From 667dbf792f466a8a0f680a457619a1e20d08a726 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 22 Jun 2023 11:28:36 +0000 Subject: [PATCH 01/34] Update dependency org.owasp.dependencycheck to v8.3.1 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 046661f7e3..6a030549e4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,7 +40,7 @@ serialization_json = "1.5.1" showkase = "1.0.0-beta18" jsoup = "1.16.1" appyx = "1.2.0" -dependencycheck = "8.2.1" +dependencycheck = "8.3.1" dependencyanalysis = "1.20.0" stem = "2.3.0" sqldelight = "1.5.5" From 874739ece9bf7e9bd31a9e9138e687b50195f2c0 Mon Sep 17 00:00:00 2001 From: bmarty Date: Mon, 26 Jun 2023 00:10:53 +0000 Subject: [PATCH 02/34] Sync Strings from Localazy --- .../src/main/res/values-sk/translations.xml | 7 + .../src/main/res/values-de/translations.xml | 2 +- .../src/main/res/values-sk/translations.xml | 14 ++ .../src/main/res/values-sk/translations.xml | 6 + .../src/main/res/values-de/translations.xml | 2 +- .../src/main/res/values-sk/translations.xml | 23 +++ .../src/main/res/values-sk/translations.xml | 8 + .../src/main/res/values-cs/translations.xml | 4 +- .../src/main/res/values-sk/translations.xml | 16 ++ .../impl/src/main/res/values/localazy.xml | 1 + .../src/main/res/values-cs/translations.xml | 1 + .../src/main/res/values-sk/translations.xml | 4 + .../src/main/res/values-sk/translations.xml | 4 + .../src/main/res/values-sk/translations.xml | 8 + .../src/main/res/values-sk/translations.xml | 28 ++++ .../src/main/res/values-sk/translations.xml | 7 + .../src/main/res/values-sk/translations.xml | 19 +++ .../src/main/res/values-sk/translations.xml | 50 ++++++ .../src/main/res/values-sk/translations.xml | 50 ++++++ .../src/main/res/values-sk/translations.xml | 9 + .../src/main/res/values-cs/translations.xml | 4 + .../src/main/res/values-de/translations.xml | 2 +- .../src/main/res/values-sk/translations.xml | 158 ++++++++++++++++++ .../src/main/res/values/localazy.xml | 3 + 24 files changed, 426 insertions(+), 4 deletions(-) create mode 100644 features/analytics/impl/src/main/res/values-sk/translations.xml create mode 100644 features/createroom/impl/src/main/res/values-sk/translations.xml create mode 100644 features/invitelist/impl/src/main/res/values-sk/translations.xml create mode 100644 features/login/impl/src/main/res/values-sk/translations.xml create mode 100644 features/logout/api/src/main/res/values-sk/translations.xml create mode 100644 features/messages/impl/src/main/res/values-sk/translations.xml create mode 100644 features/onboarding/impl/src/main/res/values-sk/translations.xml create mode 100644 features/rageshake/api/src/main/res/values-sk/translations.xml create mode 100644 features/rageshake/impl/src/main/res/values-sk/translations.xml create mode 100644 features/roomdetails/impl/src/main/res/values-sk/translations.xml create mode 100644 features/roomlist/impl/src/main/res/values-sk/translations.xml create mode 100644 features/verifysession/impl/src/main/res/values-sk/translations.xml create mode 100644 libraries/eventformatter/impl/src/main/res/values-sk/translations.xml create mode 100644 libraries/push/impl/src/main/res/values-sk/translations.xml create mode 100644 libraries/textcomposer/src/main/res/values-sk/translations.xml create mode 100644 libraries/ui-strings/src/main/res/values-sk/translations.xml diff --git a/features/analytics/impl/src/main/res/values-sk/translations.xml b/features/analytics/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..5f494909af --- /dev/null +++ b/features/analytics/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,7 @@ + + + "Nezaznamenávame ani neprofilujeme žiadne osobné údaje" + "Môžete to kedykoľvek vypnúť" + "Vaše údaje nebudeme zdieľať s tretími stranami" + "Pomôžte zlepšiť %1$s" + diff --git a/features/createroom/impl/src/main/res/values-de/translations.xml b/features/createroom/impl/src/main/res/values-de/translations.xml index 5344a7f52d..abc2ef9d71 100644 --- a/features/createroom/impl/src/main/res/values-de/translations.xml +++ b/features/createroom/impl/src/main/res/values-de/translations.xml @@ -2,7 +2,7 @@ "Neuer Raum" "Freunde zu Element einladen" - "Personen einladen" + "Personen hinzufügen" "Beim Erstellen des Raums ist ein Fehler aufgetreten" "Die Nachrichten in diesem Raum sind verschlüsselt. Die Verschlüsselung kann nicht nachträglich deaktiviert werden." "Privater Raum (nur auf Einladung)" diff --git a/features/createroom/impl/src/main/res/values-sk/translations.xml b/features/createroom/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..1b81b93561 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,14 @@ + + + "Nová miestnosť" + "Pozvať priateľov na Element" + "Pozvať ľudí" + "Pri vytváraní miestnosti došlo k chybe" + "Správy v tejto miestnosti sú šifrované. Šifrovanie už potom nie je možné vypnúť." + "Súkromná miestnosť (len pre pozvaných)" + "Správy nie sú šifrované a môže si ich prečítať ktokoľvek. Šifrovanie môžete zapnúť neskôr." + "Verejná miestnosť (ktokoľvek)" + "Názov miestnosti" + "Téma (voliteľné)" + "Vytvoriť miestnosť" + diff --git a/features/invitelist/impl/src/main/res/values-sk/translations.xml b/features/invitelist/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..fa3cf45ead --- /dev/null +++ b/features/invitelist/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,6 @@ + + + "Odmietnuť pozvanie" + "Žiadne pozvánky" + "%1$s (%2$s) vás pozval/a" + diff --git a/features/login/impl/src/main/res/values-de/translations.xml b/features/login/impl/src/main/res/values-de/translations.xml index d1393dbaf3..ef7197ac96 100644 --- a/features/login/impl/src/main/res/values-de/translations.xml +++ b/features/login/impl/src/main/res/values-de/translations.xml @@ -13,7 +13,7 @@ "Andere" "Verwende einen anderen Kontoanbieter, z. B. deinen eigenen privaten Server oder ein Arbeitskonto." "Kontoanbieter ändern" - "Wir konnten diesen Homeserver nicht erreichen. Bitte überprüfen Sie, ob Sie die Homeserver-URL korrekt eingegeben haben. Wenn die URL korrekt ist, wenden Sie sich an Ihren Homeserver-Administrator, um weitere Hilfe zu erhalten." + "Wir konnten diesen Homeserver nicht erreichen. Bitte überprüfe, dass du die Homeserver-URL korrekt eingegeben hast. Wenn die URL korrekt ist, wende dich an deinen Homeserver-Administrator für weitere Hilfe." "Dieser Server unterstützt derzeit keine Sliding Sync." "Homeserver-URL" "Du kannst dich nur mit einem existierenden Server verbinden, der Sliding Sync unterstützt. Dein Homeserver-Administrator muss es konfigurieren. %1$s" diff --git a/features/login/impl/src/main/res/values-sk/translations.xml b/features/login/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..6e1ef23a72 --- /dev/null +++ b/features/login/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,23 @@ + + + "Pokračovať" + "Adresa domovského servera" + "Adresa URL domovského servera" + "Aká je adresa vášho servera?" + "Tento účet bol deaktivovaný." + "Nesprávne používateľské meno a/alebo heslo" + "Zadajte svoje údaje" + "Kde žijú vaše rozhovory" + "Vitajte späť!" + "Prihlásiť sa do %1$s" + "Zmeniť poskytovateľa účtu" + "Súkromný server pre zamestnancov spoločnosti Element." + "Matrix je otvorená sieť pre bezpečnú a decentralizovanú komunikáciu." + "Chystáte sa prihlásiť do %1$s" + "Chystáte sa vytvoriť účet na %1$s" + "Pokračovať" + "Vyberte svoj server" + "Heslo" + "Pokračovať" + "Používateľské meno" + diff --git a/features/logout/api/src/main/res/values-sk/translations.xml b/features/logout/api/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..212f11ccbc --- /dev/null +++ b/features/logout/api/src/main/res/values-sk/translations.xml @@ -0,0 +1,8 @@ + + + "Ste si istí, že sa chcete odhlásiť?" + "Odhlásiť sa" + "Prebieha odhlasovanie…" + "Odhlásiť sa" + "Odhlásiť sa" + diff --git a/features/messages/impl/src/main/res/values-cs/translations.xml b/features/messages/impl/src/main/res/values-cs/translations.xml index 7300efe1d6..8da8200bd5 100644 --- a/features/messages/impl/src/main/res/values-cs/translations.xml +++ b/features/messages/impl/src/main/res/values-cs/translations.xml @@ -10,10 +10,12 @@ "Natočit video" "Příloha" "Knihovna fotografií a videí" + "Poloha" "Nepodařilo se načíst údaje o uživateli" "Chtěli byste je pozvat zpět?" "V tomto chatu jste sami" - "Nemáte oprávnění vkládat příspěvky do této místnosti" + "Zpráva zkopírována" + "Nemáte oprávnění zveřejňovat příspěvky v této místnosti" "Odeslat znovu" "Vaši zprávu se nepodařilo odeslat" "Nahrání média se nezdařilo, zkuste to prosím znovu." diff --git a/features/messages/impl/src/main/res/values-sk/translations.xml b/features/messages/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..68b412f979 --- /dev/null +++ b/features/messages/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,16 @@ + + + + "%1$d zmena miestnosti" + "%1$d zmeny miestnosti" + "%1$d zmien miestnosti" + + "Kamera" + "Odfotiť" + "Nahrať video" + "Príloha" + "Knižnica fotografií a videí" + "Poloha" + "Odoslať znova" + "Odstrániť" + diff --git a/features/messages/impl/src/main/res/values/localazy.xml b/features/messages/impl/src/main/res/values/localazy.xml index 508acf0e33..79d70cd4b7 100644 --- a/features/messages/impl/src/main/res/values/localazy.xml +++ b/features/messages/impl/src/main/res/values/localazy.xml @@ -9,6 +9,7 @@ "Record a video" "Attachment" "Photo & Video Library" + "Location" "Could not retrieve user details" "Would you like to invite them back?" "You are alone in this chat" diff --git a/features/onboarding/impl/src/main/res/values-cs/translations.xml b/features/onboarding/impl/src/main/res/values-cs/translations.xml index 176c446673..6b8f0eaa91 100644 --- a/features/onboarding/impl/src/main/res/values-cs/translations.xml +++ b/features/onboarding/impl/src/main/res/values-cs/translations.xml @@ -4,6 +4,7 @@ "Přihlásit se pomocí QR kódu" "Vytvořit účet" "Komunikujte a spolupracujte bezpečně" + "Vítejte u dosud nejrychlejšího Elementu. Vylepšený pro rychlost a jednoduchost." "Vítejte v %1$s. Vylepšený, pro rychlost a jednoduchost." "Buďte ve svém živlu" diff --git a/features/onboarding/impl/src/main/res/values-sk/translations.xml b/features/onboarding/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..9e4e162770 --- /dev/null +++ b/features/onboarding/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,4 @@ + + + "Vytvoriť účet" + diff --git a/features/rageshake/api/src/main/res/values-sk/translations.xml b/features/rageshake/api/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..fb9ccf4168 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-sk/translations.xml @@ -0,0 +1,4 @@ + + + "Zdá sa, že zúrivo trasiete telefónom. Chcete otvoriť obrazovku s nahlásením chýb?" + diff --git a/features/rageshake/impl/src/main/res/values-sk/translations.xml b/features/rageshake/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..20a6d81aad --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,8 @@ + + + "Priložiť snímku obrazovky" + "Upraviť snímku obrazovky" + "Popíšte chybu…" + "Ak je to možné, napíšte popis v angličtine." + "Odoslať snímku obrazovky" + diff --git a/features/roomdetails/impl/src/main/res/values-sk/translations.xml b/features/roomdetails/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..f7fc1c68a5 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,28 @@ + + + + "1 osoba" + "%1$d ľudia" + "%1$d ľudí" + + "Pridať tému" + "Upraviť miestnosť" + "Nepodarilo sa nám aktualizovať všetky informácie o tejto miestnosti." + "Nepodarilo sa aktualizovať miestnosť" + "Šifrovanie správ je zapnuté" + "Pozvať ľudí" + "Oznámenie" + "Názov miestnosti" + "Zdieľať miestnosť" + "Aktualizácia miestnosti…" + "Čaká sa" + "Členovia miestnosti" + "Zablokovať" + "Zablokovať používateľa" + "Odblokovať" + "Odblokovať používateľa" + "Opustiť miestnosť" + "Ľudia" + "Bezpečnosť" + "Téma" + diff --git a/features/roomlist/impl/src/main/res/values-sk/translations.xml b/features/roomlist/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..22e8fa193f --- /dev/null +++ b/features/roomlist/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,7 @@ + + + "Vytvorte novú konverzáciu alebo miestnosť" + "Všetky konverzácie" + "Vyzerá to tak, že používate nové zariadenie. Overte, či ste to vy, aby ste mali prístup k zašifrovaným správam." + "Získajte prístup k histórii vašich správ" + diff --git a/features/verifysession/impl/src/main/res/values-sk/translations.xml b/features/verifysession/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..275924e9ec --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,19 @@ + + + "Zdá sa, že niečo nie je v poriadku. Časový limit žiadosti vypršal alebo bola žiadosť zamietnutá." + "Skontrolujte, či sa emotikony uvedené nižšie zhodujú s emotikonmi zobrazenými vo vašej druhej relácii." + "Porovnajte emotikony" + "Vaša nová relácia je teraz overená. Má prístup k vašim zašifrovaným správam a ostatní používatelia ju budú vidieť ako dôveryhodnú." + "Dokážte, že ste to vy, aby ste získali prístup k histórii vašich zašifrovaných správ." + "Otvoriť existujúcu reláciu" + "Zopakovať overenie" + "Som pripravený/á" + "Čaká sa na zhodu" + "Porovnajte jedinečné emotikony a uistite sa, že sú zobrazené v rovnakom poradí." + "Nezhodujú sa" + "Zhodujú sa" + "Ak chcete pokračovať, prijmite žiadosť o spustenie procesu overenia vo vašej druhej relácii." + "Čaká sa na prijatie žiadosti" + "Overovanie zrušené" + "Spustiť" + diff --git a/libraries/eventformatter/impl/src/main/res/values-sk/translations.xml b/libraries/eventformatter/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..d2aae2bf98 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,50 @@ + + + "(obrázok bol tiež zmenený)" + "%1$s zmenili svoj obrázok" + "Zmenili ste svoj obrázok" + "%1$s zmenili svoje zobrazované meno z %2$s na %3$s" + "Zmenili ste si zobrazované meno z %1$s na %2$s" + "%1$s odstránili svoje zobrazované meno (predtým bolo %2$s)" + "Odstránili ste svoje zobrazované meno (predtým bolo %1$s)" + "%1$s nastavili svoje zobrazované meno na %2$s" + "Svoje zobrazované meno ste nastavili na %1$s" + "%1$s zmenil/a obrázok miestnosti" + "Zmenili ste obrázok miestnosti" + "%1$s odstránil/a obrázok miestnosti" + "Odstránili ste obrázok miestnosti" + "%1$s zakázal/a používateľa %2$s" + "Zakázali ste používateľa %1$s" + "%1$s vytvoril/a miestnosť" + "Vytvorili ste miestnosť" + "%1$s pozval/a používateľa %2$s" + "%1$s prijal/a pozvanie" + "Prijali ste pozvánku" + "Pozvali ste používateľa %1$s" + "%1$s vás pozval/a" + "%1$s sa pripojil/a do miestnosti" + "Vstúpili ste do miestnosti" + "%1$s požiadal o pripojenie" + "%1$s umožnil/a používateľovi %2$s pripojiť sa" + "%1$s vám umožnil/a pripojiť sa" + "Požiadali ste o pripojenie" + "Zrušili ste svoju žiadosť o pripojenie" + "%1$s opustil/a miestnosť" + "Opustili ste miestnosť" + "%1$s zmenil/a názov miestnosti na: %2$s" + "Zmenili ste názov miestnosti na: %1$s" + "%1$s odstránil/a názov miestnosti" + "Odstránili ste názov miestnosti" + "%1$s odmietol/a pozvánku" + "Odmietli ste pozvánku" + "%1$s odstránil/a %2$s" + "Odstránili ste %1$s" + "%1$s poslal/a pozvánku používateľovi %2$s, aby sa pripojil k miestnosti" + "Poslali ste pozvánku používateľovi %1$s, aby sa pripojil do miestnosti" + "%1$s zmenil/a tému na: %2$s" + "Zmenili ste tému na: %1$s" + "%1$s odstránil/a tému miestnosti" + "Odstránili ste tému miestnosti" + "%1$s zrušil/a zákaz pre %2$s" + "Zrušili ste zákaz pre %1$s" + diff --git a/libraries/push/impl/src/main/res/values-sk/translations.xml b/libraries/push/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..71a225140e --- /dev/null +++ b/libraries/push/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,50 @@ + + + "Zavolať" + "Tiché oznámenia" + "** Nepodarilo sa odoslať - prosím otvorte miestnosť" + "Pripojiť sa" + "Zamietnuť" + "Nové správy" + "Označiť ako prečítané" + "Prezeráte si oznámenie! Kliknite na mňa!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + "%1$s a %2$s" + "%1$s v %2$s" + "%1$s v %2$s a %3$s" + + "%1$s: %2$d správa" + "%1$s: %2$d správy" + "%1$s: %2$d správ" + + + "%d oznámenie" + "%d oznámenia" + "%d oznámení" + + + "%d pozvánka" + "%d pozvánky" + "%d pozvánok" + + + "%d nová správa" + "%d nové správy" + "%d nových správ" + + + "%d neprečítaná oznámená správa" + "%d neprečítané oznámené správy" + "%d neprečítaných oznámených správ" + + + "%d miestnosť" + "%d miestnosti" + "%d miestností" + + "Vyberte spôsob prijímania oznámení" + "Synchronizácia na pozadí" + "Služby Google" + "Rýchla odpoveď" + diff --git a/libraries/textcomposer/src/main/res/values-sk/translations.xml b/libraries/textcomposer/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..e63bd21442 --- /dev/null +++ b/libraries/textcomposer/src/main/res/values-sk/translations.xml @@ -0,0 +1,9 @@ + + + "Správa…" + "Použiť tučný formát" + "Použiť formát kurzívy" + "Použiť formát prečiarknutia" + "Použiť formát podčiarknutia" + "Nastaviť odkaz" + diff --git a/libraries/ui-strings/src/main/res/values-cs/translations.xml b/libraries/ui-strings/src/main/res/values-cs/translations.xml index 70a3c7a3ab..3fdcf39458 100644 --- a/libraries/ui-strings/src/main/res/values-cs/translations.xml +++ b/libraries/ui-strings/src/main/res/values-cs/translations.xml @@ -28,6 +28,7 @@ "Pozvat" "Pozvat přátele" "Pozvat přátele do %1$s" + "Pozvat lidi na %1$s" "Pozvánky" "Zjistit více" "Odejít" @@ -108,6 +109,7 @@ "Server není podporován" "URL serveru" "Nastavení" + "Sdílená poloha" "Zahajování chatu…" "Nálepka" "Úspěch" @@ -163,6 +165,8 @@ "Nahrání média se nezdařilo, zkuste to prosím znovu." "Nahrání média se nezdařilo, zkuste to prosím znovu." "Zaškrtněte, pokud chcete skrýt všechny aktuální a budoucí zprávy od tohoto uživatele" + "Sdílet polohu" + "Sdílet moji polohu" "Rageshake" "Práh detekce" "Obecné" diff --git a/libraries/ui-strings/src/main/res/values-de/translations.xml b/libraries/ui-strings/src/main/res/values-de/translations.xml index f8d004d624..3129b91f23 100644 --- a/libraries/ui-strings/src/main/res/values-de/translations.xml +++ b/libraries/ui-strings/src/main/res/values-de/translations.xml @@ -158,7 +158,7 @@ "Teile Analyse-Daten" "Medienauswahl fehlgeschlagen, bitte versuche es erneut." "Fehler bei der Verarbeitung von Medien zum Hochladen, bitte versuchen Sie es erneut." - "Medien hochladen fehlgeschlagen. Bitte versuchen Sie es erneut." + "Hochladen von Medien fehlgeschlagen, bitte versuchen Sie es erneut." "Prüfe, ob du alle aktuellen und zukünftigen Nachrichten dieses Benutzers ausblenden möchtest" "Rageshake" "Erkennungsschwelle" diff --git a/libraries/ui-strings/src/main/res/values-sk/translations.xml b/libraries/ui-strings/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..d7cb30a8b2 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-sk/translations.xml @@ -0,0 +1,158 @@ + + + "Skryť heslo" + "Odoslať súbory" + "Zobraziť heslo" + "Používateľské menu" + "Prijať" + "Späť" + "Zrušiť" + "Vybrať fotku" + "Vyčistiť" + "Zavrieť" + "Dokončiť overenie" + "Potvrdiť" + "Pokračovať" + "Kopírovať" + "Kopírovať odkaz" + "Kopírovať odkaz do správy" + "Vytvoriť" + "Vytvoriť miestnosť" + "Odmietnuť" + "Vypnúť" + "Hotovo" + "Upraviť" + "Povoliť" + "Zabudnuté heslo?" + "Pozvať" + "Pozvať priateľov" + "Pozvať priateľov do %1$s" + "Pozvať ľudí do %1$s" + "Pozvánky" + "Zistiť viac" + "Opustiť" + "Opustiť miestnosť" + "Ďalej" + "Nie" + "Teraz nie" + "OK" + "Otvoriť pomocou" + "Rýchla odpoveď" + "Citovať" + "Odstrániť" + "Odpovedať" + "Nahlásiť chybu" + "Nahlásiť obsah" + "Skúsiť znova" + "Opakovať dešifrovanie" + "Uložiť" + "Hľadať" + "Odoslať" + "Odoslať správu" + "Zdieľať" + "Zdieľať odkaz" + "Preskočiť" + "Spustiť" + "Začať konverzáciu" + "Spustiť overovanie" + "Ťuknutím načítate mapu" + "Urobiť fotku" + "Zobraziť zdroj" + "Áno" + "O aplikácii" + "Analytika" + "Zvuk" + "Bubliny" + "Autorské práva" + "Vytváranie miestnosti…" + "Opustil/a miestnosť" + "Chyba dešifrovania" + "Možnosti pre vývojárov" + "(upravené)" + "Upravuje sa" + "* %1$s %2$s" + "Šifrovanie zapnuté" + "Chyba" + "Súbor" + "Súbor bol uložený do priečinka Stiahnuté súbory" + "Preposlať správu" + "GIF" + "Obrázok" + "Nedokážeme overiť Matrix ID tohto používateľa. Pozvánka nemusí byť prijatá." + "Odkaz bol skopírovaný do schránky" + "Načítava sa…" + "Správa" + "Rozloženie správy" + "Správa odstránená" + "Moderné" + "Žiadne výsledky" + "Offline" + "Heslo" + "Ľudia" + "Trvalý odkaz" + "Reakcie" + "Nahlásiť chybu" + "Nahlásenie bolo odoslané" + "Názov miestnosti" + "Výsledky hľadania" + "Bezpečnosť" + "Vyberte svoj server" + "Odosiela sa…" + "Server nie je podporovaný" + "URL adresa servera" + "Nastavenia" + "Zdieľaná poloha" + "Nálepka" + "Úspech" + "Návrhy" + "Synchronizuje sa" + "Téma" + "O čom je táto miestnosť?" + "Nie je možné dešifrovať" + "Nepodporovaná udalosť" + "Používateľské meno" + "Overovanie zrušené" + "Overovanie je dokončené" + "Video" + "Čaká sa…" + "Potvrdenie" + "Upozornenie" + "Aktivity" + "Vlajky" + "Jedlo a nápoje" + "Zvieratá a príroda" + "Predmety" + "Smajlíky a ľudia" + "Cestovanie a miesta" + "Symboly" + "Načítanie správ zlyhalo" + "Niektoré správy neboli odoslané" + "Prepáčte, vyskytla sa chyba" + "🔐️ Pripojte sa ku mne na %1$s" + "Ahoj, porozprávajte sa so mnou na %1$s: %2$s" + "Ste si istí, že chcete opustiť túto miestnosť? Ste tu jediná osoba. Ak odídete, nikto sa do nej nebude môcť v budúcnosti pripojiť, vrátane vás." + "Ste si istí, že chcete opustiť túto miestnosť? Táto miestnosť nie je verejná a bez pozvania sa do nej nebudete môcť vrátiť." + "Ste si istí, že chcete opustiť miestnosť?" + "%1$s Android" + + "%1$d člen" + "%1$d členovia" + "%1$d členov" + + "Zúrivo potriasť pre nahlásenie chyby" + "Zdá sa, že zúrivo trasiete telefónom. Chcete otvoriť obrazovku s nahlásením chýb?" + "Toto je začiatok %1$s." + "Toto je začiatok tejto konverzácie." + "Nové" + "Zdieľať polohu" + "Zdieľať moju polohu" + "Zdieľajte túto polohu" + "Zúrivé potrasenie" + "Prahová hodnota detekcie" + "Všeobecné" + "Verzia: %1$s (%2$s)" + "sk" + "Chyba" + "Úspech" + "Zablokovať používateľa" + diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index bcd90cb160..b452f153e7 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -164,6 +164,9 @@ "Failed processing media to upload, please try again." "Failed uploading media, please try again." "Check if you want to hide all current and future messages from this user" + "Share location" + "Share my location" + "Share this location" "Rageshake" "Detection threshold" "General" From 4f269d37dd9025299ff0280f314c6da494f5f1c2 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 20 Jun 2023 12:46:24 +0200 Subject: [PATCH 03/34] Improve PreferenceText rendering, in particular center the test on the right vertically, add padding in add more previews. --- .../components/preferences/PreferenceText.kt | 74 ++++++++++++++----- 1 file changed, 57 insertions(+), 17 deletions(-) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt index 2172f4518a..2195be296f 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt @@ -17,6 +17,7 @@ package io.element.android.libraries.designsystem.components.preferences import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -26,7 +27,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.progressSemantics import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BugReport @@ -55,7 +55,7 @@ fun PreferenceText( tintColor: Color? = null, onClick: () -> Unit = {}, ) { - val minHeight = if (subtitle == null) preferenceMinHeightOnlyTitle else preferenceMinHeight + val minHeight = if (subtitle == null) preferenceMinHeightOnlyTitle else preferenceMinHeight Box( modifier = modifier .fillMaxWidth() @@ -69,9 +69,10 @@ fun PreferenceText( .padding(vertical = preferencePaddingVertical) ) { PreferenceIcon(icon = icon, tintColor = tintColor) - Column(modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically) + Column( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically) ) { if (title != null) { Text( @@ -92,15 +93,24 @@ fun PreferenceText( } } if (currentValue != null) { - Text(currentValue, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary) - Spacer(Modifier.width(16.dp)) + Text( + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(horizontal = 16.dp), + text = currentValue, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary, + ) } else if (loadingCurrentValue) { - CircularProgressIndicator(modifier = Modifier - .progressSemantics() - .size(20.dp), strokeWidth = 2.dp) - Spacer(Modifier.width(16.dp)) + CircularProgressIndicator( + modifier = Modifier + .progressSemantics() + .padding(horizontal = 16.dp) + .size(20.dp) + .align(Alignment.CenterVertically), + strokeWidth = 2.dp + ) } - } } } @@ -111,9 +121,39 @@ internal fun PreferenceTextPreview() = ElementThemedPreview { ContentToPreview() @Composable private fun ContentToPreview() { - PreferenceText( - title = "Title", - subtitle = "Some content", - icon = Icons.Default.BugReport, - ) + Column( + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + PreferenceText( + title = "Title", + icon = Icons.Default.BugReport, + ) + PreferenceText( + title = "Title", + subtitle = "Some content", + icon = Icons.Default.BugReport, + ) + PreferenceText( + title = "Title", + subtitle = "Some content", + icon = Icons.Default.BugReport, + currentValue = "123", + ) + PreferenceText( + title = "Title", + subtitle = "Some content", + icon = Icons.Default.BugReport, + loadingCurrentValue = true, + ) + PreferenceText( + title = "Title", + icon = Icons.Default.BugReport, + currentValue = "123", + ) + PreferenceText( + title = "Title", + icon = Icons.Default.BugReport, + loadingCurrentValue = true, + ) + } } From 9829daa70e2a28996ed74e298e92507035b00594 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 20 Jun 2023 12:46:49 +0200 Subject: [PATCH 04/34] Add logs. --- .../io/element/android/samples/minimal/RoomListScreen.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt index e4cb389d94..5d8e0bfd1c 100644 --- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt @@ -43,6 +43,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.datetime.Clock import kotlinx.datetime.TimeZone +import timber.log.Timber import java.util.Locale class RoomListScreen( @@ -106,8 +107,10 @@ class RoomListScreen( ) DisposableEffect(Unit) { + Timber.w("Start sync!") matrixClient.startSync() onDispose { + Timber.w("Stop sync!") matrixClient.stopSync() } } From f014f0a3ae71677fd03f2e4a31a38749dd69b60d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 20 Jun 2023 12:52:20 +0200 Subject: [PATCH 05/34] Add clear cache action in the developer settings (#643) --- features/preferences/impl/build.gradle.kts | 1 + .../impl/developer/DeveloperSettingsEvents.kt | 1 + .../developer/DeveloperSettingsPresenter.kt | 17 +++++++ .../impl/developer/DeveloperSettingsState.kt | 4 +- .../DeveloperSettingsStateProvider.kt | 3 ++ .../impl/developer/DeveloperSettingsView.kt | 15 +++++++ .../impl/tasks/ClearCacheUseCase.kt | 44 +++++++++++++++++++ .../DeveloperSettingsPresenterTest.kt | 33 ++++++++++++-- .../impl/tasks/FakeClearCacheUseCase.kt | 28 ++++++++++++ .../libraries/matrix/api/MatrixClient.kt | 1 + libraries/matrix/impl/build.gradle.kts | 1 + .../libraries/matrix/impl/RustMatrixClient.kt | 27 +++++++++++- .../libraries/matrix/test/FakeMatrixClient.kt | 3 ++ 13 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt create mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeClearCacheUseCase.kt diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index 631884fe47..bf8f04b61b 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -62,6 +62,7 @@ dependencies { testImplementation(projects.features.logout.impl) testImplementation(projects.features.analytics.test) testImplementation(projects.features.analytics.impl) + testImplementation(projects.tests.testutils) androidTestImplementation(libs.test.junitext) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt index b79484592f..bb3879b129 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt @@ -20,4 +20,5 @@ import io.element.android.libraries.featureflag.ui.model.FeatureUiModel sealed interface DeveloperSettingsEvents { data class UpdateEnabledFeature(val feature: FeatureUiModel, val isEnabled: Boolean) : DeveloperSettingsEvents + object ClearCache: DeveloperSettingsEvents } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt index 9f9cd636eb..cb4a5d1ec9 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt @@ -18,12 +18,17 @@ package io.element.android.features.preferences.impl.developer import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.snapshots.SnapshotStateMap +import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase +import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.execute import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.featureflag.api.Feature import io.element.android.libraries.featureflag.api.FeatureFlagService @@ -36,6 +41,7 @@ import javax.inject.Inject class DeveloperSettingsPresenter @Inject constructor( private val featureFlagService: FeatureFlagService, + private val clearCacheUseCase: ClearCacheUseCase, ) : Presenter { @Composable @@ -47,6 +53,9 @@ class DeveloperSettingsPresenter @Inject constructor( val enabledFeatures = remember { mutableStateMapOf() } + val clearCacheAction = remember { + mutableStateOf>(Async.Uninitialized) + } LaunchedEffect(Unit) { FeatureFlags.values().forEach { feature -> features[feature.key] = feature @@ -64,11 +73,13 @@ class DeveloperSettingsPresenter @Inject constructor( event.feature, event.isEnabled ) + DeveloperSettingsEvents.ClearCache -> coroutineScope.clearCache(clearCacheAction) } } return DeveloperSettingsState( features = featureUiModels.toImmutableList(), + clearCacheAction = clearCacheAction.value, eventSink = ::handleEvents ) } @@ -103,6 +114,12 @@ class DeveloperSettingsPresenter @Inject constructor( enabledFeatures[featureUiModel.key] = enabled } } + + private fun CoroutineScope.clearCache(clearCacheAction: MutableState>) = launch { + suspend { + clearCacheUseCase.execute() + }.execute(clearCacheAction) + } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt index 53ff80967e..392f83357f 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt @@ -16,10 +16,12 @@ package io.element.android.features.preferences.impl.developer +import io.element.android.libraries.architecture.Async import io.element.android.libraries.featureflag.ui.model.FeatureUiModel import kotlinx.collections.immutable.ImmutableList -data class DeveloperSettingsState( +data class DeveloperSettingsState constructor( val features: ImmutableList, + val clearCacheAction: Async, val eventSink: (DeveloperSettingsEvents) -> Unit ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt index f69f73e6e5..f600edd602 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt @@ -17,16 +17,19 @@ package io.element.android.features.preferences.impl.developer import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async import io.element.android.libraries.featureflag.ui.model.aFeatureUiModelList open class DeveloperSettingsStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aDeveloperSettingsState(), + aDeveloperSettingsState().copy(clearCacheAction = Async.Loading()), ) } fun aDeveloperSettingsState() = DeveloperSettingsState( features = aFeatureUiModelList(), + clearCacheAction = Async.Uninitialized, eventSink = {} ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt index 697081c397..9bd1a4331c 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt @@ -16,11 +16,14 @@ package io.element.android.features.preferences.impl.developer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.libraries.architecture.isLoading import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceText import io.element.android.libraries.designsystem.components.preferences.PreferenceView @@ -52,6 +55,18 @@ fun DeveloperSettingsView( onClick = onOpenShowkase ) } + PreferenceCategory(title = "Cache") { + PreferenceText( + title = "Clear cache", + icon = Icons.Default.Delete, + loadingCurrentValue = state.clearCacheAction.isLoading(), + onClick = { + if (state.clearCacheAction.isLoading().not()) { + state.eventSink(DeveloperSettingsEvents.ClearCache) + } + } + ) + } } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt new file mode 100644 index 0000000000..4ccfbb6b81 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 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 io.element.android.features.preferences.impl.tasks + +import android.content.Context +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import kotlinx.coroutines.withContext +import javax.inject.Inject + +interface ClearCacheUseCase { + suspend fun execute() +} + +@ContributesBinding(SessionScope::class) +class DefaultClearCacheUseCase @Inject constructor( + @ApplicationContext private val context: Context, + private val matrixClient: MatrixClient, + private val coroutineDispatchers: CoroutineDispatchers, +) : ClearCacheUseCase { + override suspend fun execute() = withContext(coroutineDispatchers.io) { + matrixClient.stopSync() + matrixClient.clearCache() + context.cacheDir.deleteRecursively() + matrixClient.startSync() + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt index 6b7c8c2df4..5710289217 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt @@ -20,6 +20,8 @@ import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase +import io.element.android.libraries.architecture.Async import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import kotlinx.coroutines.test.runTest @@ -29,13 +31,15 @@ class DeveloperSettingsPresenterTest { @Test fun `present - ensures initial state is correct`() = runTest { val presenter = DeveloperSettingsPresenter( - FakeFeatureFlagService() + FakeFeatureFlagService(), + FakeClearCacheUseCase() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { val initialState = awaitItem() assertThat(initialState.features).isEmpty() + assertThat(initialState.clearCacheAction).isEqualTo(Async.Uninitialized) cancelAndIgnoreRemainingEvents() } } @@ -43,7 +47,8 @@ class DeveloperSettingsPresenterTest { @Test fun `present - ensures feature list is loaded`() = runTest { val presenter = DeveloperSettingsPresenter( - FakeFeatureFlagService() + FakeFeatureFlagService(), + FakeClearCacheUseCase() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -58,7 +63,8 @@ class DeveloperSettingsPresenterTest { @Test fun `present - ensures state is updated when enabled feature event is triggered`() = runTest { val presenter = DeveloperSettingsPresenter( - FakeFeatureFlagService() + FakeFeatureFlagService(), + FakeClearCacheUseCase() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -74,4 +80,25 @@ class DeveloperSettingsPresenterTest { cancelAndIgnoreRemainingEvents() } } + + @Test + fun `present - clear cache`() = runTest { + val clearCacheUseCase = FakeClearCacheUseCase() + val presenter = DeveloperSettingsPresenter( + FakeFeatureFlagService(), + clearCacheUseCase + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(clearCacheUseCase.executeHasBeenCalled).isFalse() + initialState.eventSink(DeveloperSettingsEvents.ClearCache) + val stateAfterEvent = awaitItem() + assertThat(stateAfterEvent.clearCacheAction).isInstanceOf(Async.Loading::class.java) + assertThat(awaitItem().clearCacheAction).isInstanceOf(Async.Success::class.java) + assertThat(clearCacheUseCase.executeHasBeenCalled).isTrue() + } + } } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeClearCacheUseCase.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeClearCacheUseCase.kt new file mode 100644 index 0000000000..f5bc83c443 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeClearCacheUseCase.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 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 io.element.android.features.preferences.impl.tasks + +import io.element.android.tests.testutils.simulateLongTask + +class FakeClearCacheUseCase : ClearCacheUseCase { + var executeHasBeenCalled = false + private set + + override suspend fun execute() = simulateLongTask { + executeHasBeenCalled = true + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 4a018e18da..77e4bf810b 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -50,6 +50,7 @@ interface MatrixClient : Closeable { fun sessionVerificationService(): SessionVerificationService fun pushersService(): PushersService fun notificationService(): NotificationService + suspend fun clearCache() suspend fun logout() suspend fun loadUserDisplayName(): Result suspend fun loadUserAvatarURLString(): Result diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index 1ac5b4b507..5709b5a6d7 100644 --- a/libraries/matrix/impl/build.gradle.kts +++ b/libraries/matrix/impl/build.gradle.kts @@ -32,6 +32,7 @@ dependencies { // api(projects.libraries.rustsdk) implementation(libs.matrix.sdk) implementation(projects.libraries.di) + implementation(projects.libraries.androidutils) implementation(projects.services.toolbox.api) api(projects.libraries.matrix.api) implementation(libs.dagger) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 268e09c764..e6e49b5feb 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -18,6 +18,7 @@ package io.element.android.libraries.matrix.impl +import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.ProgressCallback @@ -336,6 +337,10 @@ class RustMatrixClient constructor( client.destroy() } + override suspend fun clearCache() { + baseDirectory.deleteSessionDirectory(userID = client.userId(), deleteCryptoDb = false) + } + override suspend fun logout() = withContext(dispatchers.io) { try { client.logout() @@ -378,11 +383,29 @@ class RustMatrixClient constructor( override fun roomMembershipObserver(): RoomMembershipObserver = roomMembershipObserver - private fun File.deleteSessionDirectory(userID: String): Boolean { + private fun File.deleteSessionDirectory( + userID: String, + deleteCryptoDb: Boolean = false, + ): Boolean { // Rust sanitises the user ID replacing invalid characters with an _ val sanitisedUserID = userID.replace(":", "_") val sessionDirectory = File(this, sanitisedUserID) - return sessionDirectory.deleteRecursively() + return if (deleteCryptoDb) { + // Delete the folder and all its content + sessionDirectory.deleteRecursively() + } else { + // Delete only the state.db file + listOf( + "matrix-sdk-state.sqlite3", + "matrix-sdk-state.sqlite3-shm", + "matrix-sdk-state.sqlite3-wal", + ).map { fileName -> + File(sessionDirectory, fileName) + }.forEach { file -> + file.safeDelete() + } + true + } } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 8b38a74457..85a3ea3a5c 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -102,6 +102,9 @@ class FakeMatrixClient( override fun stopSync() = Unit + override suspend fun clearCache() { + } + override suspend fun logout() { delay(100) logoutFailure?.let { throw it } From 2a7d252a4e79facc5426666a784c21755c8ac68d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 20 Jun 2023 13:41:04 +0200 Subject: [PATCH 06/34] Display cache size in the developer settings (#643) --- features/preferences/impl/build.gradle.kts | 1 + .../developer/DeveloperSettingsPresenter.kt | 16 ++ .../impl/developer/DeveloperSettingsState.kt | 1 + .../DeveloperSettingsStateProvider.kt | 1 + .../impl/developer/DeveloperSettingsView.kt | 7 +- .../impl/tasks/ComputeCacheSizeUseCase.kt | 45 ++++++ .../DeveloperSettingsPresenterTest.kt | 16 +- .../impl/tasks/FakeComputeCacheSizeUseCase.kt | 25 ++++ .../libraries/androidutils/file/FileUtils.kt | 137 ++++++++++++++++++ .../libraries/matrix/api/MatrixClient.kt | 1 + .../libraries/matrix/impl/RustMatrixClient.kt | 41 +++++- .../libraries/matrix/test/FakeMatrixClient.kt | 4 + 12 files changed, 283 insertions(+), 12 deletions(-) create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ComputeCacheSizeUseCase.kt create mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeComputeCacheSizeUseCase.kt create mode 100644 libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/FileUtils.kt diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index bf8f04b61b..e0ecbe9ddd 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -32,6 +32,7 @@ anvil { dependencies { implementation(projects.anvilannotations) anvil(projects.anvilcodegen) + implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt index cb4a5d1ec9..ffcbb92ba9 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.snapshots.SnapshotStateMap import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase +import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.execute @@ -41,6 +42,7 @@ import javax.inject.Inject class DeveloperSettingsPresenter @Inject constructor( private val featureFlagService: FeatureFlagService, + private val computeCacheSizeUseCase: ComputeCacheSizeUseCase, private val clearCacheUseCase: ClearCacheUseCase, ) : Presenter { @@ -53,6 +55,9 @@ class DeveloperSettingsPresenter @Inject constructor( val enabledFeatures = remember { mutableStateMapOf() } + val cacheSize = remember { + mutableStateOf>(Async.Uninitialized) + } val clearCacheAction = remember { mutableStateOf>(Async.Uninitialized) } @@ -64,6 +69,10 @@ class DeveloperSettingsPresenter @Inject constructor( } val featureUiModels = createUiModels(features, enabledFeatures) val coroutineScope = rememberCoroutineScope() + // Compute cache size each time the clear cache action value is changed + LaunchedEffect(clearCacheAction.value) { + computeCacheSize(cacheSize) + } fun handleEvents(event: DeveloperSettingsEvents) { when (event) { @@ -79,6 +88,7 @@ class DeveloperSettingsPresenter @Inject constructor( return DeveloperSettingsState( features = featureUiModels.toImmutableList(), + cacheSizeInBytes = cacheSize.value, clearCacheAction = clearCacheAction.value, eventSink = ::handleEvents ) @@ -115,6 +125,12 @@ class DeveloperSettingsPresenter @Inject constructor( } } + private fun CoroutineScope.computeCacheSize(cacheSize: MutableState>) = launch { + suspend { + computeCacheSizeUseCase.execute() + }.execute(cacheSize) + } + private fun CoroutineScope.clearCache(clearCacheAction: MutableState>) = launch { suspend { clearCacheUseCase.execute() diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt index 392f83357f..7d9bfed714 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt @@ -22,6 +22,7 @@ import kotlinx.collections.immutable.ImmutableList data class DeveloperSettingsState constructor( val features: ImmutableList, + val cacheSizeInBytes: Async, val clearCacheAction: Async, val eventSink: (DeveloperSettingsEvents) -> Unit ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt index f600edd602..92fb248142 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt @@ -30,6 +30,7 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider Boolean + +/* ========================================================================================== + * Delete + * ========================================================================================== */ + +fun deleteAllFiles(root: File) { + Timber.v("Delete ${root.absolutePath}") + recursiveActionOnFile(root, ::deleteAction) +} + +private fun deleteAction(file: File): Boolean { + if (file.exists()) { + Timber.v("deleteFile: $file") + return file.delete() + } + + return true +} + +/* ========================================================================================== + * Log + * ========================================================================================== */ + +fun lsFiles(context: Context) { + Timber.v("Content of cache dir:") + recursiveActionOnFile(context.cacheDir, ::logAction) + + Timber.v("Content of files dir:") + recursiveActionOnFile(context.filesDir, ::logAction) +} + +private fun logAction(file: File): Boolean { + if (file.isDirectory) { + Timber.v(file.toString()) + } else { + Timber.v("$file ${file.length()} bytes") + } + return true +} + +/* ========================================================================================== + * Private + * ========================================================================================== */ + +/** + * Return true in case of success. + */ +private fun recursiveActionOnFile(file: File, action: ActionOnFile): Boolean { + if (file.isDirectory) { + file.list()?.forEach { + val result = recursiveActionOnFile(File(file, it), action) + + if (!result) { + // Break the loop + return false + } + } + } + + return action.invoke(file) +} + +/** + * Get the file extension of a fileUri or a filename. + * + * @param fileUri the fileUri (can be a simple filename) + * @return the file extension, in lower case, or null is extension is not available or empty + */ +fun getFileExtension(fileUri: String): String? { + var reducedStr = fileUri + + if (reducedStr.isNotEmpty()) { + // Remove fragment + reducedStr = reducedStr.substringBeforeLast('#') + + // Remove query + reducedStr = reducedStr.substringBeforeLast('?') + + // Remove path + val filename = reducedStr.substringAfterLast('/') + + // Contrary to method MimeTypeMap.getFileExtensionFromUrl, we do not check the pattern + // See https://stackoverflow.com/questions/14320527/android-should-i-use-mimetypemap-getfileextensionfromurl-bugs + if (filename.isNotEmpty()) { + val dotPos = filename.lastIndexOf('.') + if (0 <= dotPos) { + val ext = filename.substring(dotPos + 1) + + if (ext.isNotBlank()) { + return ext.lowercase(Locale.ROOT) + } + } + } + } + + return null +} + +/* ========================================================================================== + * Size + * ========================================================================================== */ + +@WorkerThread +fun File.getSizeOfFiles(): Long { + return walkTopDown() + .onEnter { + Timber.v("Get size of ${it.absolutePath}") + true + } + .sumOf { it.length() } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 77e4bf810b..479e3169d9 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -50,6 +50,7 @@ interface MatrixClient : Closeable { fun sessionVerificationService(): SessionVerificationService fun pushersService(): PushersService fun notificationService(): NotificationService + suspend fun getCacheSize(): Long suspend fun clearCache() suspend fun logout() suspend fun loadUserDisplayName(): Result diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index e6e49b5feb..03d18abba5 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -18,6 +18,7 @@ package io.element.android.libraries.matrix.impl +import io.element.android.libraries.androidutils.file.getSizeOfFiles import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.MatrixClient @@ -337,8 +338,12 @@ class RustMatrixClient constructor( client.destroy() } + override suspend fun getCacheSize(): Long { + return baseDirectory.getCacheSize(userID = client.userId()) + } + override suspend fun clearCache() { - baseDirectory.deleteSessionDirectory(userID = client.userId(), deleteCryptoDb = false) + baseDirectory.deleteSessionDirectory(userID = client.userId()) } override suspend fun logout() = withContext(dispatchers.io) { @@ -347,7 +352,7 @@ class RustMatrixClient constructor( } catch (failure: Throwable) { Timber.e(failure, "Fail to call logout on HS. Still delete local files.") } - baseDirectory.deleteSessionDirectory(userID = client.userId()) + baseDirectory.deleteSessionDirectory(userID = client.userId(), deleteCryptoDb = true) sessionStore.removeSession(client.userId()) close() } @@ -383,14 +388,36 @@ class RustMatrixClient constructor( override fun roomMembershipObserver(): RoomMembershipObserver = roomMembershipObserver - private fun File.deleteSessionDirectory( + private suspend fun File.getCacheSize( userID: String, - deleteCryptoDb: Boolean = false, - ): Boolean { + includeCryptoDb: Boolean = false, + ): Long = withContext(dispatchers.io) { // Rust sanitises the user ID replacing invalid characters with an _ val sanitisedUserID = userID.replace(":", "_") - val sessionDirectory = File(this, sanitisedUserID) - return if (deleteCryptoDb) { + val sessionDirectory = File(this@getCacheSize, sanitisedUserID) + if (includeCryptoDb) { + sessionDirectory.getSizeOfFiles() + } else { + listOf( + "matrix-sdk-state.sqlite3", + "matrix-sdk-state.sqlite3-shm", + "matrix-sdk-state.sqlite3-wal", + ).map { fileName -> + File(sessionDirectory, fileName) + }.sumOf { file -> + file.length() + } + } + } + + private suspend fun File.deleteSessionDirectory( + userID: String, + deleteCryptoDb: Boolean = false, + ): Boolean = withContext(dispatchers.io) { + // Rust sanitises the user ID replacing invalid characters with an _ + val sanitisedUserID = userID.replace(":", "_") + val sessionDirectory = File(this@deleteSessionDirectory, sanitisedUserID) + if (deleteCryptoDb) { // Delete the folder and all its content sessionDirectory.deleteRecursively() } else { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 85a3ea3a5c..eb5e4624d6 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -102,6 +102,10 @@ class FakeMatrixClient( override fun stopSync() = Unit + override suspend fun getCacheSize(): Long { + return 0 + } + override suspend fun clearCache() { } From 529720079a21b390355c126d4ba0b57413a7cb79 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 20 Jun 2023 14:46:25 +0200 Subject: [PATCH 07/34] Move FileSizeFormatter to a new module for injectable Android utilities. --- features/messages/impl/build.gradle.kts | 2 + .../media/local/AndroidLocalMediaFactory.kt | 2 +- .../TimelineItemContentMessageFactory.kt | 2 +- .../messages/fixtures/timelineItemsFactory.kt | 2 +- libraries/androidtools/api/build.gradle.kts | 23 ++++++++++ .../androidtools/api/FileSizeFormatter.kt | 24 +++++++++++ libraries/androidtools/impl/build.gradle.kts | 42 +++++++++++++++++++ .../impl/AndroidFileSizeFormatter.kt | 10 +---- libraries/androidtools/test/build.gradle.kts | 27 ++++++++++++ .../test}/FakeFileSizeFormatter.kt | 4 +- 10 files changed, 125 insertions(+), 13 deletions(-) create mode 100644 libraries/androidtools/api/build.gradle.kts create mode 100644 libraries/androidtools/api/src/main/kotlin/io/element/android/libraries/androidtools/api/FileSizeFormatter.kt create mode 100644 libraries/androidtools/impl/build.gradle.kts rename features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/FileSizeFormatter.kt => libraries/androidtools/impl/src/main/kotlin/io/element/android/libraries/androidtools/impl/AndroidFileSizeFormatter.kt (82%) create mode 100644 libraries/androidtools/test/build.gradle.kts rename {features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline => libraries/androidtools/test/src/main/kotlin/io/element/android/libraries/androidtools/test}/FakeFileSizeFormatter.kt (84%) diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index f8377733a6..73360a0a25 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { anvil(projects.anvilcodegen) api(projects.features.messages.api) implementation(projects.libraries.androidutils) + implementation(projects.libraries.androidtools.api) implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) @@ -64,6 +65,7 @@ dependencies { testImplementation(libs.molecule.runtime) testImplementation(libs.test.truth) testImplementation(libs.test.turbine) + testImplementation(projects.libraries.androidtools.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.features.networkmonitor.test) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaFactory.kt index 7500f0d91e..c231825524 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaFactory.kt @@ -21,7 +21,7 @@ import android.net.Uri import androidx.core.net.toUri import com.squareup.anvil.annotations.ContributesBinding import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractor -import io.element.android.features.messages.impl.timeline.util.FileSizeFormatter +import io.element.android.libraries.androidtools.api.FileSizeFormatter import io.element.android.libraries.androidutils.file.getFileName import io.element.android.libraries.androidutils.file.getFileSize import io.element.android.libraries.androidutils.file.getMimeType diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index c99ab46ab1..db5ee8190e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -25,8 +25,8 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractor -import io.element.android.features.messages.impl.timeline.util.FileSizeFormatter import io.element.android.features.messages.impl.timeline.util.toHtmlDocument +import io.element.android.libraries.androidtools.api.FileSizeFormatter import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt index beb37f8e51..6e2bf33477 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt @@ -32,7 +32,7 @@ import io.element.android.features.messages.impl.timeline.factories.virtual.Time import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractorWithoutValidation -import io.element.android.features.messages.timeline.FakeFileSizeFormatter +import io.element.android.libraries.androidtools.test.FakeFileSizeFormatter import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter import io.element.android.libraries.eventformatter.api.TimelineEventFormatter import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem diff --git a/libraries/androidtools/api/build.gradle.kts b/libraries/androidtools/api/build.gradle.kts new file mode 100644 index 0000000000..aeaec3ad30 --- /dev/null +++ b/libraries/androidtools/api/build.gradle.kts @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.androidtools.api" +} diff --git a/libraries/androidtools/api/src/main/kotlin/io/element/android/libraries/androidtools/api/FileSizeFormatter.kt b/libraries/androidtools/api/src/main/kotlin/io/element/android/libraries/androidtools/api/FileSizeFormatter.kt new file mode 100644 index 0000000000..6c67d9a588 --- /dev/null +++ b/libraries/androidtools/api/src/main/kotlin/io/element/android/libraries/androidtools/api/FileSizeFormatter.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 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 io.element.android.libraries.androidtools.api + +interface FileSizeFormatter { + /** + * Formats a content size to be in the form of bytes, kilobytes, megabytes, etc. + */ + fun format(fileSize: Long): String +} diff --git a/libraries/androidtools/impl/build.gradle.kts b/libraries/androidtools/impl/build.gradle.kts new file mode 100644 index 0000000000..5b3b85036f --- /dev/null +++ b/libraries/androidtools/impl/build.gradle.kts @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2023 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. + */ + +plugins { + id("io.element.android-library") + alias(libs.plugins.ksp) + alias(libs.plugins.anvil) +} + +anvil { + generateDaggerFactories.set(true) +} + +android { + namespace = "io.element.android.libraries.androidtools.impl" + + dependencies { + anvil(projects.anvilcodegen) + implementation(libs.dagger) + implementation(projects.libraries.di) + implementation(projects.anvilannotations) + + api(projects.libraries.androidtools.api) + + testImplementation(libs.test.junit) + testImplementation(libs.test.truth) + testImplementation(projects.libraries.androidtools.test) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/FileSizeFormatter.kt b/libraries/androidtools/impl/src/main/kotlin/io/element/android/libraries/androidtools/impl/AndroidFileSizeFormatter.kt similarity index 82% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/FileSizeFormatter.kt rename to libraries/androidtools/impl/src/main/kotlin/io/element/android/libraries/androidtools/impl/AndroidFileSizeFormatter.kt index 4ede9b7f21..5a57c1d904 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/FileSizeFormatter.kt +++ b/libraries/androidtools/impl/src/main/kotlin/io/element/android/libraries/androidtools/impl/AndroidFileSizeFormatter.kt @@ -14,22 +14,16 @@ * limitations under the License. */ -package io.element.android.features.messages.impl.timeline.util +package io.element.android.libraries.androidtools.impl import android.content.Context import android.text.format.Formatter import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.androidtools.api.FileSizeFormatter import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext import javax.inject.Inject -interface FileSizeFormatter { - /** - * Formats a content size to be in the form of bytes, kilobytes, megabytes, etc. - */ - fun format(fileSize: Long): String -} - @ContributesBinding(AppScope::class) class AndroidFileSizeFormatter @Inject constructor(@ApplicationContext private val context: Context) : FileSizeFormatter { override fun format(fileSize: Long): String { diff --git a/libraries/androidtools/test/build.gradle.kts b/libraries/androidtools/test/build.gradle.kts new file mode 100644 index 0000000000..8e045d6e01 --- /dev/null +++ b/libraries/androidtools/test/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.androidtools.test" + + dependencies { + api(projects.libraries.androidtools.api) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/FakeFileSizeFormatter.kt b/libraries/androidtools/test/src/main/kotlin/io/element/android/libraries/androidtools/test/FakeFileSizeFormatter.kt similarity index 84% rename from features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/FakeFileSizeFormatter.kt rename to libraries/androidtools/test/src/main/kotlin/io/element/android/libraries/androidtools/test/FakeFileSizeFormatter.kt index 4ff65b0146..d316510ec8 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/FakeFileSizeFormatter.kt +++ b/libraries/androidtools/test/src/main/kotlin/io/element/android/libraries/androidtools/test/FakeFileSizeFormatter.kt @@ -14,9 +14,9 @@ * limitations under the License. */ -package io.element.android.features.messages.timeline +package io.element.android.libraries.androidtools.test -import io.element.android.features.messages.impl.timeline.util.FileSizeFormatter +import io.element.android.libraries.androidtools.api.FileSizeFormatter class FakeFileSizeFormatter : FileSizeFormatter { override fun format(fileSize: Long): String { From c847ac3a98e7f76b0efcd58d2db6e771ed8427a1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 20 Jun 2023 14:55:26 +0200 Subject: [PATCH 08/34] Improve FileSizeFormatter.format API. --- .../androidtools/api/FileSizeFormatter.kt | 2 +- .../impl/AndroidFileSizeFormatter.kt | 23 +++++++++++++++++-- .../test/FakeFileSizeFormatter.kt | 2 +- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/libraries/androidtools/api/src/main/kotlin/io/element/android/libraries/androidtools/api/FileSizeFormatter.kt b/libraries/androidtools/api/src/main/kotlin/io/element/android/libraries/androidtools/api/FileSizeFormatter.kt index 6c67d9a588..261b7cbecc 100644 --- a/libraries/androidtools/api/src/main/kotlin/io/element/android/libraries/androidtools/api/FileSizeFormatter.kt +++ b/libraries/androidtools/api/src/main/kotlin/io/element/android/libraries/androidtools/api/FileSizeFormatter.kt @@ -20,5 +20,5 @@ interface FileSizeFormatter { /** * Formats a content size to be in the form of bytes, kilobytes, megabytes, etc. */ - fun format(fileSize: Long): String + fun format(fileSize: Long, useShortFormat: Boolean = true): String } diff --git a/libraries/androidtools/impl/src/main/kotlin/io/element/android/libraries/androidtools/impl/AndroidFileSizeFormatter.kt b/libraries/androidtools/impl/src/main/kotlin/io/element/android/libraries/androidtools/impl/AndroidFileSizeFormatter.kt index 5a57c1d904..fe156f1635 100644 --- a/libraries/androidtools/impl/src/main/kotlin/io/element/android/libraries/androidtools/impl/AndroidFileSizeFormatter.kt +++ b/libraries/androidtools/impl/src/main/kotlin/io/element/android/libraries/androidtools/impl/AndroidFileSizeFormatter.kt @@ -17,6 +17,7 @@ package io.element.android.libraries.androidtools.impl import android.content.Context +import android.os.Build import android.text.format.Formatter import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.androidtools.api.FileSizeFormatter @@ -26,7 +27,25 @@ import javax.inject.Inject @ContributesBinding(AppScope::class) class AndroidFileSizeFormatter @Inject constructor(@ApplicationContext private val context: Context) : FileSizeFormatter { - override fun format(fileSize: Long): String { - return Formatter.formatShortFileSize(context, fileSize) + override fun format(fileSize: Long, useShortFormat: Boolean): String { + // Since Android O, the system considers that 1ko = 1000 bytes instead of 1024 bytes. + // We want to avoid that. + val normalizedSize = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N) { + fileSize + } else { + // First convert the size + when { + fileSize < 1024 -> fileSize + fileSize < 1024 * 1024 -> fileSize * 1000 / 1024 + fileSize < 1024 * 1024 * 1024 -> fileSize * 1000 / 1024 * 1000 / 1024 + else -> fileSize * 1000 / 1024 * 1000 / 1024 * 1000 / 1024 + } + } + + return if (useShortFormat) { + Formatter.formatShortFileSize(context, normalizedSize) + } else { + Formatter.formatFileSize(context, normalizedSize) + } } } diff --git a/libraries/androidtools/test/src/main/kotlin/io/element/android/libraries/androidtools/test/FakeFileSizeFormatter.kt b/libraries/androidtools/test/src/main/kotlin/io/element/android/libraries/androidtools/test/FakeFileSizeFormatter.kt index d316510ec8..183a78eaf1 100644 --- a/libraries/androidtools/test/src/main/kotlin/io/element/android/libraries/androidtools/test/FakeFileSizeFormatter.kt +++ b/libraries/androidtools/test/src/main/kotlin/io/element/android/libraries/androidtools/test/FakeFileSizeFormatter.kt @@ -19,7 +19,7 @@ package io.element.android.libraries.androidtools.test import io.element.android.libraries.androidtools.api.FileSizeFormatter class FakeFileSizeFormatter : FileSizeFormatter { - override fun format(fileSize: Long): String { + override fun format(fileSize: Long, useShortFormat: Boolean): String { return "$fileSize Bytes" } } From 573de1c16896cea00fe34eff118bb33a4f8b0f56 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 20 Jun 2023 15:26:16 +0200 Subject: [PATCH 09/34] Improve rendering of cache size (and fix compilation issue) --- features/messages/impl/build.gradle.kts | 2 +- features/preferences/impl/build.gradle.kts | 1 + .../impl/developer/DeveloperSettingsPresenter.kt | 6 +++--- .../impl/developer/DeveloperSettingsState.kt | 2 +- .../impl/developer/DeveloperSettingsStateProvider.kt | 2 +- .../impl/developer/DeveloperSettingsView.kt | 8 +++----- .../preferences/impl/tasks/ComputeCacheSizeUseCase.kt | 11 +++++++---- .../impl/developer/DeveloperSettingsPresenterTest.kt | 2 +- .../androidtools/impl/AndroidFileSizeFormatter.kt | 4 +++- .../main/kotlin/extension/DependencyHandleScope.kt | 1 + 10 files changed, 22 insertions(+), 17 deletions(-) diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 73360a0a25..882c226b6b 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -34,7 +34,7 @@ dependencies { anvil(projects.anvilcodegen) api(projects.features.messages.api) implementation(projects.libraries.androidutils) - implementation(projects.libraries.androidtools.api) + api(projects.libraries.androidtools.api) implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index e0ecbe9ddd..bb55094347 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(projects.anvilannotations) anvil(projects.anvilcodegen) implementation(projects.libraries.androidutils) + api(projects.libraries.androidtools.api) implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt index ffcbb92ba9..08a787e998 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt @@ -56,7 +56,7 @@ class DeveloperSettingsPresenter @Inject constructor( mutableStateMapOf() } val cacheSize = remember { - mutableStateOf>(Async.Uninitialized) + mutableStateOf>(Async.Uninitialized) } val clearCacheAction = remember { mutableStateOf>(Async.Uninitialized) @@ -88,7 +88,7 @@ class DeveloperSettingsPresenter @Inject constructor( return DeveloperSettingsState( features = featureUiModels.toImmutableList(), - cacheSizeInBytes = cacheSize.value, + cacheSize = cacheSize.value, clearCacheAction = clearCacheAction.value, eventSink = ::handleEvents ) @@ -125,7 +125,7 @@ class DeveloperSettingsPresenter @Inject constructor( } } - private fun CoroutineScope.computeCacheSize(cacheSize: MutableState>) = launch { + private fun CoroutineScope.computeCacheSize(cacheSize: MutableState>) = launch { suspend { computeCacheSizeUseCase.execute() }.execute(cacheSize) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt index 7d9bfed714..61205e7f7d 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt @@ -22,7 +22,7 @@ import kotlinx.collections.immutable.ImmutableList data class DeveloperSettingsState constructor( val features: ImmutableList, - val cacheSizeInBytes: Async, + val cacheSize: Async, val clearCacheAction: Async, val eventSink: (DeveloperSettingsEvents) -> Unit ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt index 92fb248142..de94bd6664 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt @@ -30,7 +30,7 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider Date: Tue, 20 Jun 2023 14:12:02 +0000 Subject: [PATCH 10/34] Update screenshots --- ...eloperSettingsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png | 4 ++-- ...eloperSettingsViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png | 3 +++ ...loperSettingsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png | 4 ++-- ...loperSettingsViewLightPreview_0_null_1,NEXUS_5,1.0,en].png | 3 +++ ...oup_RoomDetailsDarkPreview--1_1_null_0,NEXUS_5,1.0,en].png | 4 ++-- ...oup_RoomDetailsDarkPreview--1_1_null_1,NEXUS_5,1.0,en].png | 4 ++-- ...oup_RoomDetailsDarkPreview--1_1_null_2,NEXUS_5,1.0,en].png | 4 ++-- ...oup_RoomDetailsDarkPreview--1_1_null_3,NEXUS_5,1.0,en].png | 4 ++-- ...oup_RoomDetailsDarkPreview--1_1_null_4,NEXUS_5,1.0,en].png | 4 ++-- ...oup_RoomDetailsDarkPreview--1_1_null_7,NEXUS_5,1.0,en].png | 4 ++-- ...oup_RoomDetailsDarkPreview--1_1_null_8,NEXUS_5,1.0,en].png | 4 ++-- ...up_RoomDetailsLightPreview--0_0_null_0,NEXUS_5,1.0,en].png | 4 ++-- ...up_RoomDetailsLightPreview--0_0_null_1,NEXUS_5,1.0,en].png | 4 ++-- ...up_RoomDetailsLightPreview--0_0_null_2,NEXUS_5,1.0,en].png | 4 ++-- ...up_RoomDetailsLightPreview--0_0_null_3,NEXUS_5,1.0,en].png | 4 ++-- ...up_RoomDetailsLightPreview--0_0_null_4,NEXUS_5,1.0,en].png | 4 ++-- ...up_RoomDetailsLightPreview--0_0_null_7,NEXUS_5,1.0,en].png | 4 ++-- ...up_RoomDetailsLightPreview--0_0_null_8,NEXUS_5,1.0,en].png | 4 ++-- ...eferences_PreferenceTextPreview_0_null,NEXUS_5,1.0,en].png | 4 ++-- 19 files changed, 40 insertions(+), 34 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewLightPreview_0_null_1,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png index 57b4a846e2..db838e0f1f 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5d806cbab0f26fb4f471adb5fbecfc600603a7651c5391501d42b13b23617a4c -size 29301 +oid sha256:9597821bbe6b65693470b40e5f570cf318821d2dbf5bdf525d447daff7d352ae +size 35345 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..db838e0f1f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9597821bbe6b65693470b40e5f570cf318821d2dbf5bdf525d447daff7d352ae +size 35345 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png index d8d2dbc8d1..9786515e1c 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d04eb5d2ebb8b740edcaac00912994e8c164e7b5083595d4085d350af0ca6c33 -size 28482 +oid sha256:61880d7b08cc92743a12a74246495039b76e1e80e2704839f726329efb6958a0 +size 34308 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9786515e1c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:61880d7b08cc92743a12a74246495039b76e1e80e2704839f726329efb6958a0 +size 34308 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_0,NEXUS_5,1.0,en].png index 719089d8e9..cf208695d7 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:04e83ead8c23ec9160eb9cfd656aa5e9bf8c302dcf435d00b78aa2eba3243f0d -size 64181 +oid sha256:05dc60cbfa8de0e27acecc5d44f7e1841d15f1cdcb749dcf23995aa49d40d924 +size 64174 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_1,NEXUS_5,1.0,en].png index dcdf364d71..9d69f48d15 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b59df47ab2ad44752eb3441ed117fff517e42c2c08f5e0c6402168223f35daef -size 53042 +oid sha256:dc5b5b7a08b61201bde775880eadd40dc51773fe616209c85c143fc703091cef +size 53024 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_2,NEXUS_5,1.0,en].png index ef99f706a2..cfb41488ee 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:002fa211918a6d41c993339b30261fb8b564df442756bafe9f8866d2ecf51c81 -size 54256 +oid sha256:51f3ee04917b0725bc0a721ac770636b7c273c1ef6f800c5ff3087ea59906396 +size 54267 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_3,NEXUS_5,1.0,en].png index bfc84aa2bf..2c2e946b6d 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ec598fdc25acf4300e9e44a7b4913e3b9dd881cd27222393047421cd8c361217 -size 54722 +oid sha256:05e6a676f742d70c75d1e4ed35667aadbf22e0d796ef165b5265e8889c5bd062 +size 54715 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_4,NEXUS_5,1.0,en].png index c64bff1e9f..dd3e5c1a5e 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_4,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:df7c6f78b6a54d1444388aafdd76c8c00ba15be688e2ee10f0582339e74e2499 -size 67872 +oid sha256:51fb6dadd1af1bc140877f205d57270ff67a46a3b4c4508ddcdf11e2402f60e4 +size 67859 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_7,NEXUS_5,1.0,en].png index d4c96e56b4..85103e6e4d 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_7,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_7,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:872c9b5285190577292485c2c0439ceb2259b68740591f1cad7129f322b089b3 -size 57677 +oid sha256:1471ebd62c5514c04bb943e83ae1f2bf51edad409da5fb9c8f6613fbae09d333 +size 57679 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_8,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_8,NEXUS_5,1.0,en].png index 29f39d40c7..b101fabb04 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_8,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_8,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d3bdab2714bd1b232b255fbe0799209da5f40a390384fb21f56bbc1788032129 -size 64512 +oid sha256:c164ad24729e25e5b4a1031bb6bb452516e9172b65d867d584efa63e5a095355 +size 64505 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_0,NEXUS_5,1.0,en].png index a12afbc7c9..625da9d324 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a9a1a5bb3c04fb601b84a2b1373544e8eba94f688d938a990308a0e788d96445 -size 61182 +oid sha256:e651c80d07b153dde903f427b4e41e3475c51b524d877ffe55b265ffaac8c32a +size 61197 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_1,NEXUS_5,1.0,en].png index 79ebe73047..9e9b406f63 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b7ee7551b233428723d79b099d7bac4634188a3ac120ff3389b2ed33625dcc94 -size 51252 +oid sha256:cb6a8ad69606bac57df9e1c758756eaf09e887875989546c8aa628207926b79e +size 51274 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_2,NEXUS_5,1.0,en].png index d0c208aa7c..f70b484141 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:48e7307ffc1d8f7f7a885e67851314c3b135e74a4268ff5fede59758f91a3358 -size 51926 +oid sha256:75eab912dfea0e0eac42742dc131d1630270ac91bf840986be56a755765ee18a +size 51917 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_3,NEXUS_5,1.0,en].png index 2a02579eb9..faf29851e2 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:632f982253a6e6f0101fd60a0ef48601cc2c06268ab3d66db084571a5861a52c -size 52417 +oid sha256:d0d5bee6b1b4b6069a55c0f297a864979b7c1f1f212bc5bccf8806bd82004abc +size 52429 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_4,NEXUS_5,1.0,en].png index 43fe21a48a..73cdbe63be 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_4,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c49a225d80fe15170e6444271b63324f67099bb733eb1c4b989508a4d0d678ef -size 64502 +oid sha256:4c2ce12fd0e6edff9ab4a468ec9f3eab2a206b7c4c51b26fa9fa7bf5a5efdc63 +size 64472 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_7,NEXUS_5,1.0,en].png index 4975863ccb..7c14d499d2 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_7,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_7,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d8612af1823d149e503e5d47814ecb171a4b2a4be19a243751c89034a1fdb30d -size 55076 +oid sha256:2e000d7940a2fae0a350d508df8b5c95a27f76f60a865cd7372b8a32d89318f6 +size 55082 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_8,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_8,NEXUS_5,1.0,en].png index 885fc65fb2..e34c095393 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_8,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_8,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:40ecdf8314109687437223860b5e28847d0cb8ba7d546829de5f061640e58315 -size 61500 +oid sha256:86cca8af9ab505cd9cd1ae4a941d5053f2d3155eb033ed1121e7d15f92a6cb43 +size 61509 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_Preferences_PreferenceTextPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_Preferences_PreferenceTextPreview_0_null,NEXUS_5,1.0,en].png index d818c6f2a0..21db86fe46 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_Preferences_PreferenceTextPreview_0_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_Preferences_PreferenceTextPreview_0_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1fc465aa6658ace0327804f04e329cae12bf358daca27e48a8ae6bd516752a9c -size 12843 +oid sha256:197b1b5fa33ba31f4e47f70b12e4b6eaf7fb3ea30368e96b7dec08f37bdeb62c +size 28185 From 200fe59fbb654a0a272ca6c9b7959dc80819db85 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 20 Jun 2023 17:49:32 +0200 Subject: [PATCH 11/34] Ignore typo --- .idea/dictionaries/bmarty.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/.idea/dictionaries/bmarty.xml b/.idea/dictionaries/bmarty.xml index dd650c15e1..588cfdb67d 100644 --- a/.idea/dictionaries/bmarty.xml +++ b/.idea/dictionaries/bmarty.xml @@ -2,6 +2,7 @@ homeserver + showkase \ No newline at end of file From 979ebe4adbb5e22df1f71843069c5984a1c8482a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 21 Jun 2023 09:34:30 +0200 Subject: [PATCH 12/34] Close the client before deleting data, and ensure the app is restarted, using a cache Index. --- .../io/element/android/appnav/RootFlowNode.kt | 21 +++++++++++++------ .../impl/tasks/ClearCacheUseCase.kt | 5 +++-- .../libraries/matrix/api/MatrixClient.kt | 4 ++++ .../api/auth/MatrixAuthenticationService.kt | 7 +++++++ .../libraries/matrix/impl/RustMatrixClient.kt | 6 ++++-- .../auth/RustMatrixAuthenticationService.kt | 10 +++++++++ 6 files changed, 43 insertions(+), 10 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 78c39f93e6..d8847b55b3 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -54,7 +54,9 @@ import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.parcelize.Parcelize @@ -88,11 +90,17 @@ class RootFlowNode @AssistedInject constructor( private fun observeLoggedInState() { authenticationService.isLoggedIn() .distinctUntilChanged() + .combine( + authenticationService.cacheIdx().onEach { + Timber.v("cacheIdx=$it") + matrixClientsHolder.removeAll() + } + ) { isLoggedIn, cacheIdx -> isLoggedIn to cacheIdx } .onEach { isLoggedIn -> Timber.v("isLoggedIn=$isLoggedIn") - if (isLoggedIn) { + if (isLoggedIn.first) { tryToRestoreLatestSession( - onSuccess = { switchToLoggedInFlow(it) }, + onSuccess = { switchToLoggedInFlow(it, isLoggedIn.second) }, onFailure = { switchToNotLoggedInFlow() } ) } else { @@ -102,8 +110,8 @@ class RootFlowNode @AssistedInject constructor( .launchIn(lifecycleScope) } - private fun switchToLoggedInFlow(sessionId: SessionId) { - backstack.safeRoot(NavTarget.LoggedInFlow(sessionId)) + private fun switchToLoggedInFlow(sessionId: SessionId, cacheIndex: Int) { + backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, cacheIndex)) } private fun switchToNotLoggedInFlow() { @@ -163,7 +171,7 @@ class RootFlowNode @AssistedInject constructor( object NotLoggedInFlow : NavTarget @Parcelize - data class LoggedInFlow(val sessionId: SessionId) : NavTarget + data class LoggedInFlow(val sessionId: SessionId, val cacheIndex: Int) : NavTarget @Parcelize object BugReport : NavTarget @@ -235,8 +243,9 @@ class RootFlowNode @AssistedInject constructor( } private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode { + val cacheIdx = authenticationService.cacheIdx().first() return attachChild { - backstack.newRoot(NavTarget.LoggedInFlow(sessionId)) + backstack.newRoot(NavTarget.LoggedInFlow(sessionId, cacheIdx)) } } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt index 4ccfbb6b81..1624a81d64 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt @@ -22,6 +22,7 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import kotlinx.coroutines.withContext import javax.inject.Inject @@ -34,11 +35,11 @@ class DefaultClearCacheUseCase @Inject constructor( @ApplicationContext private val context: Context, private val matrixClient: MatrixClient, private val coroutineDispatchers: CoroutineDispatchers, + private val authenticationService: MatrixAuthenticationService, ) : ClearCacheUseCase { override suspend fun execute() = withContext(coroutineDispatchers.io) { - matrixClient.stopSync() matrixClient.clearCache() context.cacheDir.deleteRecursively() - matrixClient.startSync() + authenticationService.incrementCacheIdx() } } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 479e3169d9..0bd7a8ab22 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -51,6 +51,10 @@ interface MatrixClient : Closeable { fun pushersService(): PushersService fun notificationService(): NotificationService suspend fun getCacheSize(): Long + + /** + * Will close the client and delete the cache data. + */ suspend fun clearCache() suspend fun logout() suspend fun loadUserDisplayName(): Result diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt index c15153876c..8d63642eae 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt @@ -29,6 +29,13 @@ interface MatrixAuthenticationService { suspend fun setHomeserver(homeserver: String): Result suspend fun login(username: String, password: String): Result + /* + * Cache index + */ + + fun cacheIdx(): Flow + fun incrementCacheIdx() + /* * OIDC part. */ diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 03d18abba5..773dc8d03b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -339,11 +339,13 @@ class RustMatrixClient constructor( } override suspend fun getCacheSize(): Long { - return baseDirectory.getCacheSize(userID = client.userId()) + // Do not use client.userId since it can throw if client has been closed (during clear cache) + return baseDirectory.getCacheSize(userID = sessionId.value) } override suspend fun clearCache() { - baseDirectory.deleteSessionDirectory(userID = client.userId()) + close() + baseDirectory.deleteSessionDirectory(userID = sessionId.value, deleteCryptoDb = false) } override suspend fun logout() = withContext(dispatchers.io) { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index d599029923..2e3c406348 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -58,6 +58,8 @@ class RustMatrixAuthenticationService @Inject constructor( private val clock: SystemClock, ) : MatrixAuthenticationService { + private val cacheIdxState = MutableStateFlow(0) + private val authService: RustAuthenticationService = RustAuthenticationService( basePath = baseDirectory.absolutePath, passphrase = null, @@ -71,6 +73,14 @@ class RustMatrixAuthenticationService @Inject constructor( return sessionStore.isLoggedIn() } + override fun incrementCacheIdx() { + cacheIdxState.value++ + } + + override fun cacheIdx(): Flow { + return cacheIdxState + } + override suspend fun getLatestSessionId(): SessionId? = withContext(coroutineDispatchers.io) { sessionStore.getLatestSession()?.userId?.let { SessionId(it) } } From f9423fc15e272479221758ab55a4be013cea885d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 21 Jun 2023 09:35:21 +0200 Subject: [PATCH 13/34] Close the client before removing its data. Probably safer. --- .../element/android/libraries/matrix/impl/RustMatrixClient.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 773dc8d03b..776747270c 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -354,9 +354,9 @@ class RustMatrixClient constructor( } catch (failure: Throwable) { Timber.e(failure, "Fail to call logout on HS. Still delete local files.") } - baseDirectory.deleteSessionDirectory(userID = client.userId(), deleteCryptoDb = true) - sessionStore.removeSession(client.userId()) close() + baseDirectory.deleteSessionDirectory(userID = sessionId.value, deleteCryptoDb = true) + sessionStore.removeSession(sessionId.value) } override suspend fun loadUserDisplayName(): Result = withContext(dispatchers.io) { From e298670b68e589c94a11238cde75639306d2e095 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 21 Jun 2023 10:11:08 +0200 Subject: [PATCH 14/34] var -> val --- .../android/features/analytics/test/FakeAnalyticsService.kt | 6 +++--- .../features/invitelist/test/FakeSeenInvitesStore.kt | 2 +- .../libraries/matrix/test/auth/FakeAuthenticationService.kt | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/features/analytics/test/src/main/kotlin/io/element/android/features/analytics/test/FakeAnalyticsService.kt b/features/analytics/test/src/main/kotlin/io/element/android/features/analytics/test/FakeAnalyticsService.kt index 3d969567f7..7ff0f50d9c 100644 --- a/features/analytics/test/src/main/kotlin/io/element/android/features/analytics/test/FakeAnalyticsService.kt +++ b/features/analytics/test/src/main/kotlin/io/element/android/features/analytics/test/FakeAnalyticsService.kt @@ -29,9 +29,9 @@ class FakeAnalyticsService( didAskUserConsent: Boolean = false ): AnalyticsService { - private var isEnabledFlow = MutableStateFlow(isEnabled) - private var didAskUserConsentFlow = MutableStateFlow(didAskUserConsent) - var capturedEvents = mutableListOf() + private val isEnabledFlow = MutableStateFlow(isEnabled) + private val didAskUserConsentFlow = MutableStateFlow(didAskUserConsent) + val capturedEvents = mutableListOf() override fun getAvailableAnalyticsProviders(): List = emptyList() diff --git a/features/invitelist/test/src/main/kotlin/io/element/android/features/invitelist/test/FakeSeenInvitesStore.kt b/features/invitelist/test/src/main/kotlin/io/element/android/features/invitelist/test/FakeSeenInvitesStore.kt index 3716cd4456..486d3fb4a8 100644 --- a/features/invitelist/test/src/main/kotlin/io/element/android/features/invitelist/test/FakeSeenInvitesStore.kt +++ b/features/invitelist/test/src/main/kotlin/io/element/android/features/invitelist/test/FakeSeenInvitesStore.kt @@ -23,7 +23,7 @@ import kotlinx.coroutines.flow.MutableStateFlow class FakeSeenInvitesStore : SeenInvitesStore { - private var existing = MutableStateFlow(emptySet()) + private val existing = MutableStateFlow(emptySet()) private var provided: Set? = null fun publishRoomIds(invites: Set) { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt index 816bfc572a..81fa3b677c 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt @@ -31,7 +31,7 @@ import kotlinx.coroutines.flow.flowOf val A_OIDC_DATA = OidcDetails(url = "a-url") class FakeAuthenticationService : MatrixAuthenticationService { - private var homeserver = MutableStateFlow(null) + private val homeserver = MutableStateFlow(null) private var oidcError: Throwable? = null private var oidcCancelError: Throwable? = null private var loginError: Throwable? = null From 3652059a526a088bd1e8dac6b766e5bdda641d7a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 21 Jun 2023 10:11:43 +0200 Subject: [PATCH 15/34] Lazy usage of OkHttpClient --- .../rageshake/impl/reporter/DefaultBugReporter.kt | 5 +++-- .../libraries/matrix/ui/media/ImageLoaderFactories.kt | 9 +++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt index 874da06acb..91f761bda9 100755 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt @@ -52,6 +52,7 @@ import java.io.OutputStreamWriter import java.net.HttpURLConnection import java.util.Locale import javax.inject.Inject +import javax.inject.Provider /** * BugReporter creates and sends the bug reports. @@ -62,7 +63,7 @@ class DefaultBugReporter @Inject constructor( private val screenshotHolder: ScreenshotHolder, private val crashDataStore: CrashDataStore, private val coroutineDispatchers: CoroutineDispatchers, - private val okHttpClient: OkHttpClient, + private val okHttpClient: Provider, /* private val activeSessionHolder: ActiveSessionHolder, private val versionProvider: VersionProvider, @@ -339,7 +340,7 @@ class DefaultBugReporter @Inject constructor( // trigger the request try { - mBugReportCall = okHttpClient.newCall(request) + mBugReportCall = okHttpClient.get().newCall(request) response = mBugReportCall!!.execute() responseCode = response.code } catch (e: Exception) { diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt index 9038e03611..c5b7f1ed44 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt @@ -26,16 +26,17 @@ import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.matrix.api.MatrixClient import okhttp3.OkHttpClient import javax.inject.Inject +import javax.inject.Provider class LoggedInImageLoaderFactory @Inject constructor( @ApplicationContext private val context: Context, private val matrixClient: MatrixClient, - private val okHttpClient: OkHttpClient, + private val okHttpClient: Provider, ) : ImageLoaderFactory { override fun newImageLoader(): ImageLoader { return ImageLoader .Builder(context) - .okHttpClient(okHttpClient) + .okHttpClient { okHttpClient.get() } .components { // Add gif support if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { @@ -54,12 +55,12 @@ class LoggedInImageLoaderFactory @Inject constructor( class NotLoggedInImageLoaderFactory @Inject constructor( @ApplicationContext private val context: Context, - private val okHttpClient: OkHttpClient, + private val okHttpClient: Provider, ) : ImageLoaderFactory { override fun newImageLoader(): ImageLoader { return ImageLoader .Builder(context) - .okHttpClient(okHttpClient) + .okHttpClient { okHttpClient.get() } .build() } } From fb9568258fdefccf4199da615f9943b9cf3c94e2 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 21 Jun 2023 10:12:19 +0200 Subject: [PATCH 16/34] Clear cache: clear Coil and OkHttpClient cache. --- features/preferences/impl/build.gradle.kts | 2 ++ .../preferences/impl/tasks/ClearCacheUseCase.kt | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index bb55094347..e8fa540f6c 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -41,6 +41,7 @@ dependencies { implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.ui) implementation(projects.libraries.elementresources) + implementation(projects.libraries.network) implementation(projects.libraries.testtags) implementation(projects.libraries.uiStrings) implementation(projects.features.rageshake.api) @@ -49,6 +50,7 @@ dependencies { implementation(projects.features.logout.api) implementation(libs.datetime) implementation(libs.accompanist.placeholder) + implementation(libs.coil.compose) api(projects.features.preferences.api) ksp(libs.showkase.processor) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt index 1624a81d64..c351bcc127 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt @@ -14,9 +14,13 @@ * limitations under the License. */ +@file:OptIn(ExperimentalCoilApi::class) + package io.element.android.features.preferences.impl.tasks import android.content.Context +import coil.Coil +import coil.annotation.ExperimentalCoilApi import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.di.ApplicationContext @@ -24,7 +28,9 @@ import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient import javax.inject.Inject +import javax.inject.Provider interface ClearCacheUseCase { suspend fun execute() @@ -36,10 +42,21 @@ class DefaultClearCacheUseCase @Inject constructor( private val matrixClient: MatrixClient, private val coroutineDispatchers: CoroutineDispatchers, private val authenticationService: MatrixAuthenticationService, + private val okHttpClient: Provider, ) : ClearCacheUseCase { override suspend fun execute() = withContext(coroutineDispatchers.io) { + // Clear Matrix cache matrixClient.clearCache() + // Clear Coil cache + Coil.imageLoader(context).let { + it.diskCache?.clear() + it.memoryCache?.clear() + } + // Clear OkHttp cache + okHttpClient.get().cache?.delete() + // Clear app cache context.cacheDir.deleteRecursively() + // Ensure the app is restarted authenticationService.incrementCacheIdx() } } From ffa7154e43d4b23cf9c6c9228e5e4c28bb27249b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 21 Jun 2023 10:12:37 +0200 Subject: [PATCH 17/34] Implement missing methods in Fake class. --- .../matrix/test/auth/FakeAuthenticationService.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt index 81fa3b677c..95bb077252 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt @@ -65,6 +65,16 @@ class FakeAuthenticationService : MatrixAuthenticationService { loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID) } + private val cacheIdxFlow = MutableStateFlow(0) + + override fun cacheIdx(): Flow { + return cacheIdxFlow + } + + override fun incrementCacheIdx() { + cacheIdxFlow.value++ + } + override suspend fun getOidcUrl(): Result = simulateLongTask { oidcError?.let { Result.failure(it) } ?: Result.success(A_OIDC_DATA) } From 8f0834afd5210c0f14979242d8c4d285e081a163 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 21 Jun 2023 10:15:43 +0200 Subject: [PATCH 18/34] Fix compilation issue in Fake class. --- .../preferences/impl/tasks/FakeComputeCacheSizeUseCase.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeComputeCacheSizeUseCase.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeComputeCacheSizeUseCase.kt index fdf0d657ad..36f60eca7c 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeComputeCacheSizeUseCase.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeComputeCacheSizeUseCase.kt @@ -20,6 +20,6 @@ import io.element.android.tests.testutils.simulateLongTask class FakeComputeCacheSizeUseCase : ComputeCacheSizeUseCase { override suspend fun execute() = simulateLongTask { - 0L + "O kB" } } From 35dc9eebfc5c86c528c60b42b37c9fa0d7f6541c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 21 Jun 2023 10:24:05 +0200 Subject: [PATCH 19/34] Ensure all file about sqlite are deleted. --- .../libraries/matrix/impl/RustMatrixClient.kt | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 776747270c..be31855ecd 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -424,15 +424,12 @@ class RustMatrixClient constructor( sessionDirectory.deleteRecursively() } else { // Delete only the state.db file - listOf( - "matrix-sdk-state.sqlite3", - "matrix-sdk-state.sqlite3-shm", - "matrix-sdk-state.sqlite3-wal", - ).map { fileName -> - File(sessionDirectory, fileName) - }.forEach { file -> - file.safeDelete() - } + sessionDirectory.listFiles().orEmpty() + .filter { it.name.contains("matrix-sdk-state") } + .forEach { file -> + Timber.w("Deleting file ${file.name}...") + file.safeDelete() + } true } } From 48d78111db9d3979c64e6956a71f7623054d99a9 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 23 Jun 2023 15:08:44 +0200 Subject: [PATCH 20/34] Merge dict. --- .idea/dictionaries/bmarty.xml | 8 -------- .idea/dictionaries/shared.xml | 2 ++ 2 files changed, 2 insertions(+), 8 deletions(-) delete mode 100644 .idea/dictionaries/bmarty.xml diff --git a/.idea/dictionaries/bmarty.xml b/.idea/dictionaries/bmarty.xml deleted file mode 100644 index 588cfdb67d..0000000000 --- a/.idea/dictionaries/bmarty.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - homeserver - showkase - - - \ No newline at end of file diff --git a/.idea/dictionaries/shared.xml b/.idea/dictionaries/shared.xml index 7c04ccd5e7..9353e11fd9 100644 --- a/.idea/dictionaries/shared.xml +++ b/.idea/dictionaries/shared.xml @@ -2,8 +2,10 @@ backstack + homeserver kover onboarding + showkase textfields From 5b4069d9f4355490e62c4c7771d17b9defc8950f Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 23 Jun 2023 15:13:03 +0200 Subject: [PATCH 21/34] Use operator invoke. --- .../preferences/impl/developer/DeveloperSettingsPresenter.kt | 4 ++-- .../features/preferences/impl/tasks/ClearCacheUseCase.kt | 4 ++-- .../preferences/impl/tasks/ComputeCacheSizeUseCase.kt | 4 ++-- .../features/preferences/impl/tasks/FakeClearCacheUseCase.kt | 2 +- .../preferences/impl/tasks/FakeComputeCacheSizeUseCase.kt | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt index 08a787e998..d4430dd2e3 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt @@ -127,13 +127,13 @@ class DeveloperSettingsPresenter @Inject constructor( private fun CoroutineScope.computeCacheSize(cacheSize: MutableState>) = launch { suspend { - computeCacheSizeUseCase.execute() + computeCacheSizeUseCase() }.execute(cacheSize) } private fun CoroutineScope.clearCache(clearCacheAction: MutableState>) = launch { suspend { - clearCacheUseCase.execute() + clearCacheUseCase() }.execute(clearCacheAction) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt index c351bcc127..df70b6f148 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt @@ -33,7 +33,7 @@ import javax.inject.Inject import javax.inject.Provider interface ClearCacheUseCase { - suspend fun execute() + suspend operator fun invoke() } @ContributesBinding(SessionScope::class) @@ -44,7 +44,7 @@ class DefaultClearCacheUseCase @Inject constructor( private val authenticationService: MatrixAuthenticationService, private val okHttpClient: Provider, ) : ClearCacheUseCase { - override suspend fun execute() = withContext(coroutineDispatchers.io) { + override suspend fun invoke() = withContext(coroutineDispatchers.io) { // Clear Matrix cache matrixClient.clearCache() // Clear Coil cache diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ComputeCacheSizeUseCase.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ComputeCacheSizeUseCase.kt index 9da6909e5f..038694201b 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ComputeCacheSizeUseCase.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ComputeCacheSizeUseCase.kt @@ -28,7 +28,7 @@ import kotlinx.coroutines.withContext import javax.inject.Inject interface ComputeCacheSizeUseCase { - suspend fun execute(): String + suspend operator fun invoke(): String } @ContributesBinding(SessionScope::class) @@ -38,7 +38,7 @@ class DefaultComputeCacheSizeUseCase @Inject constructor( private val coroutineDispatchers: CoroutineDispatchers, private val fileSizeFormatter: FileSizeFormatter, ) : ComputeCacheSizeUseCase { - override suspend fun execute(): String = withContext(coroutineDispatchers.io) { + override suspend fun invoke(): String = withContext(coroutineDispatchers.io) { var cumulativeSize = 0L cumulativeSize += matrixClient.getCacheSize() // - 4096 to not include the size fo the folder diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeClearCacheUseCase.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeClearCacheUseCase.kt index f5bc83c443..7415e09e96 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeClearCacheUseCase.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeClearCacheUseCase.kt @@ -22,7 +22,7 @@ class FakeClearCacheUseCase : ClearCacheUseCase { var executeHasBeenCalled = false private set - override suspend fun execute() = simulateLongTask { + override suspend fun invoke() = simulateLongTask { executeHasBeenCalled = true } } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeComputeCacheSizeUseCase.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeComputeCacheSizeUseCase.kt index 36f60eca7c..fa8556630f 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeComputeCacheSizeUseCase.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeComputeCacheSizeUseCase.kt @@ -19,7 +19,7 @@ package io.element.android.features.preferences.impl.tasks import io.element.android.tests.testutils.simulateLongTask class FakeComputeCacheSizeUseCase : ComputeCacheSizeUseCase { - override suspend fun execute() = simulateLongTask { + override suspend fun invoke() = simulateLongTask { "O kB" } } From 4297cfac24037876a61b88bc223f81983cff2eff Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 23 Jun 2023 15:13:47 +0200 Subject: [PATCH 22/34] Fix typo in comment. --- .../libraries/androidtools/impl/AndroidFileSizeFormatter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/androidtools/impl/src/main/kotlin/io/element/android/libraries/androidtools/impl/AndroidFileSizeFormatter.kt b/libraries/androidtools/impl/src/main/kotlin/io/element/android/libraries/androidtools/impl/AndroidFileSizeFormatter.kt index 1437c15ed5..f31151e15c 100644 --- a/libraries/androidtools/impl/src/main/kotlin/io/element/android/libraries/androidtools/impl/AndroidFileSizeFormatter.kt +++ b/libraries/androidtools/impl/src/main/kotlin/io/element/android/libraries/androidtools/impl/AndroidFileSizeFormatter.kt @@ -30,7 +30,7 @@ class AndroidFileSizeFormatter @Inject constructor( @ApplicationContext private val context: Context, ) : FileSizeFormatter { override fun format(fileSize: Long, useShortFormat: Boolean): String { - // Since Android O, the system considers that 1ko = 1000 bytes instead of 1024 bytes. + // Since Android O, the system considers that 1kB = 1000 bytes instead of 1024 bytes. // We want to avoid that. val normalizedSize = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N) { fileSize From 145cd410c7a757eafbf778fa97efc157fbcf5ccc Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 23 Jun 2023 15:20:19 +0200 Subject: [PATCH 23/34] `Idx` -> `Index` --- .../kotlin/io/element/android/appnav/RootFlowNode.kt | 8 ++++---- .../preferences/impl/tasks/ClearCacheUseCase.kt | 2 +- .../matrix/api/auth/MatrixAuthenticationService.kt | 4 ++-- .../impl/auth/RustMatrixAuthenticationService.kt | 10 +++++----- .../matrix/test/auth/FakeAuthenticationService.kt | 10 +++++----- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index d8847b55b3..1a8118f95f 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -91,8 +91,8 @@ class RootFlowNode @AssistedInject constructor( authenticationService.isLoggedIn() .distinctUntilChanged() .combine( - authenticationService.cacheIdx().onEach { - Timber.v("cacheIdx=$it") + authenticationService.cacheIndex().onEach { + Timber.v("cacheIndex=$it") matrixClientsHolder.removeAll() } ) { isLoggedIn, cacheIdx -> isLoggedIn to cacheIdx } @@ -243,9 +243,9 @@ class RootFlowNode @AssistedInject constructor( } private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode { - val cacheIdx = authenticationService.cacheIdx().first() + val cacheIndex = authenticationService.cacheIndex().first() return attachChild { - backstack.newRoot(NavTarget.LoggedInFlow(sessionId, cacheIdx)) + backstack.newRoot(NavTarget.LoggedInFlow(sessionId, cacheIndex)) } } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt index df70b6f148..68ed41babd 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt @@ -57,6 +57,6 @@ class DefaultClearCacheUseCase @Inject constructor( // Clear app cache context.cacheDir.deleteRecursively() // Ensure the app is restarted - authenticationService.incrementCacheIdx() + authenticationService.incrementCacheIndex() } } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt index 8d63642eae..d1e2362da6 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt @@ -33,8 +33,8 @@ interface MatrixAuthenticationService { * Cache index */ - fun cacheIdx(): Flow - fun incrementCacheIdx() + fun cacheIndex(): Flow + fun incrementCacheIndex() /* * OIDC part. diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index 2e3c406348..ac239d83c1 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -58,7 +58,7 @@ class RustMatrixAuthenticationService @Inject constructor( private val clock: SystemClock, ) : MatrixAuthenticationService { - private val cacheIdxState = MutableStateFlow(0) + private val cacheIndexState = MutableStateFlow(0) private val authService: RustAuthenticationService = RustAuthenticationService( basePath = baseDirectory.absolutePath, @@ -73,12 +73,12 @@ class RustMatrixAuthenticationService @Inject constructor( return sessionStore.isLoggedIn() } - override fun incrementCacheIdx() { - cacheIdxState.value++ + override fun incrementCacheIndex() { + cacheIndexState.value++ } - override fun cacheIdx(): Flow { - return cacheIdxState + override fun cacheIndex(): Flow { + return cacheIndexState } override suspend fun getLatestSessionId(): SessionId? = withContext(coroutineDispatchers.io) { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt index 95bb077252..b40dd766ff 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt @@ -65,14 +65,14 @@ class FakeAuthenticationService : MatrixAuthenticationService { loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID) } - private val cacheIdxFlow = MutableStateFlow(0) + private val cacheIndexFlow = MutableStateFlow(0) - override fun cacheIdx(): Flow { - return cacheIdxFlow + override fun cacheIndex(): Flow { + return cacheIndexFlow } - override fun incrementCacheIdx() { - cacheIdxFlow.value++ + override fun incrementCacheIndex() { + cacheIndexFlow.value++ } override suspend fun getOidcUrl(): Result = simulateLongTask { From baf117e8fac7769ceb8a8904cbfa6bccd052173a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 23 Jun 2023 15:20:54 +0200 Subject: [PATCH 24/34] Move content to to File.kt. Also remove deleteAllFiles, we have deleteRecursively now. --- .../libraries/androidutils/file/File.kt | 98 +++++++++++++ .../libraries/androidutils/file/FileUtils.kt | 137 ------------------ 2 files changed, 98 insertions(+), 137 deletions(-) delete mode 100644 libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/FileUtils.kt diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt index 269407d3b5..ea214ff683 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt @@ -17,9 +17,11 @@ package io.element.android.libraries.androidutils.file import android.content.Context +import androidx.annotation.WorkerThread import io.element.android.libraries.core.data.tryOrNull import timber.log.Timber import java.io.File +import java.util.Locale import java.util.UUID fun File.safeDelete() { @@ -52,3 +54,99 @@ fun Context.createTmpFile(baseDir: File = cacheDir, extension: String? = null): val suffix = extension?.let { ".$extension" } return File.createTempFile(UUID.randomUUID().toString(), suffix, baseDir).apply { mkdirs() } } + +// Implementation should return true in case of success +typealias ActionOnFile = (file: File) -> Boolean + +/* ========================================================================================== + * Log + * ========================================================================================== */ + +fun lsFiles(context: Context) { + Timber.v("Content of cache dir:") + recursiveActionOnFile(context.cacheDir, ::logAction) + + Timber.v("Content of files dir:") + recursiveActionOnFile(context.filesDir, ::logAction) +} + +private fun logAction(file: File): Boolean { + if (file.isDirectory) { + Timber.v(file.toString()) + } else { + Timber.v("$file ${file.length()} bytes") + } + return true +} + +/* ========================================================================================== + * Private + * ========================================================================================== */ + +/** + * Return true in case of success. + */ +private fun recursiveActionOnFile(file: File, action: ActionOnFile): Boolean { + if (file.isDirectory) { + file.list()?.forEach { + val result = recursiveActionOnFile(File(file, it), action) + + if (!result) { + // Break the loop + return false + } + } + } + + return action.invoke(file) +} + +/** + * Get the file extension of a fileUri or a filename. + * + * @param fileUri the fileUri (can be a simple filename) + * @return the file extension, in lower case, or null is extension is not available or empty + */ +fun getFileExtension(fileUri: String): String? { + var reducedStr = fileUri + + if (reducedStr.isNotEmpty()) { + // Remove fragment + reducedStr = reducedStr.substringBeforeLast('#') + + // Remove query + reducedStr = reducedStr.substringBeforeLast('?') + + // Remove path + val filename = reducedStr.substringAfterLast('/') + + // Contrary to method MimeTypeMap.getFileExtensionFromUrl, we do not check the pattern + // See https://stackoverflow.com/questions/14320527/android-should-i-use-mimetypemap-getfileextensionfromurl-bugs + if (filename.isNotEmpty()) { + val dotPos = filename.lastIndexOf('.') + if (0 <= dotPos) { + val ext = filename.substring(dotPos + 1) + + if (ext.isNotBlank()) { + return ext.lowercase(Locale.ROOT) + } + } + } + } + + return null +} + +/* ========================================================================================== + * Size + * ========================================================================================== */ + +@WorkerThread +fun File.getSizeOfFiles(): Long { + return walkTopDown() + .onEnter { + Timber.v("Get size of ${it.absolutePath}") + true + } + .sumOf { it.length() } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/FileUtils.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/FileUtils.kt deleted file mode 100644 index dbd284d305..0000000000 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/FileUtils.kt +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright (c) 2023 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 io.element.android.libraries.androidutils.file - -import android.content.Context -import androidx.annotation.WorkerThread -import timber.log.Timber -import java.io.File -import java.util.Locale - -// Implementation should return true in case of success -typealias ActionOnFile = (file: File) -> Boolean - -/* ========================================================================================== - * Delete - * ========================================================================================== */ - -fun deleteAllFiles(root: File) { - Timber.v("Delete ${root.absolutePath}") - recursiveActionOnFile(root, ::deleteAction) -} - -private fun deleteAction(file: File): Boolean { - if (file.exists()) { - Timber.v("deleteFile: $file") - return file.delete() - } - - return true -} - -/* ========================================================================================== - * Log - * ========================================================================================== */ - -fun lsFiles(context: Context) { - Timber.v("Content of cache dir:") - recursiveActionOnFile(context.cacheDir, ::logAction) - - Timber.v("Content of files dir:") - recursiveActionOnFile(context.filesDir, ::logAction) -} - -private fun logAction(file: File): Boolean { - if (file.isDirectory) { - Timber.v(file.toString()) - } else { - Timber.v("$file ${file.length()} bytes") - } - return true -} - -/* ========================================================================================== - * Private - * ========================================================================================== */ - -/** - * Return true in case of success. - */ -private fun recursiveActionOnFile(file: File, action: ActionOnFile): Boolean { - if (file.isDirectory) { - file.list()?.forEach { - val result = recursiveActionOnFile(File(file, it), action) - - if (!result) { - // Break the loop - return false - } - } - } - - return action.invoke(file) -} - -/** - * Get the file extension of a fileUri or a filename. - * - * @param fileUri the fileUri (can be a simple filename) - * @return the file extension, in lower case, or null is extension is not available or empty - */ -fun getFileExtension(fileUri: String): String? { - var reducedStr = fileUri - - if (reducedStr.isNotEmpty()) { - // Remove fragment - reducedStr = reducedStr.substringBeforeLast('#') - - // Remove query - reducedStr = reducedStr.substringBeforeLast('?') - - // Remove path - val filename = reducedStr.substringAfterLast('/') - - // Contrary to method MimeTypeMap.getFileExtensionFromUrl, we do not check the pattern - // See https://stackoverflow.com/questions/14320527/android-should-i-use-mimetypemap-getfileextensionfromurl-bugs - if (filename.isNotEmpty()) { - val dotPos = filename.lastIndexOf('.') - if (0 <= dotPos) { - val ext = filename.substring(dotPos + 1) - - if (ext.isNotBlank()) { - return ext.lowercase(Locale.ROOT) - } - } - } - } - - return null -} - -/* ========================================================================================== - * Size - * ========================================================================================== */ - -@WorkerThread -fun File.getSizeOfFiles(): Long { - return walkTopDown() - .onEnter { - Timber.v("Get size of ${it.absolutePath}") - true - } - .sumOf { it.length() } -} From cc1c3c8f7b31d913e36b22e8922422e90bade5ce Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 23 Jun 2023 15:24:46 +0200 Subject: [PATCH 25/34] Improve readability --- .../kotlin/io/element/android/appnav/RootFlowNode.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 1a8118f95f..57aa598dc5 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -96,11 +96,13 @@ class RootFlowNode @AssistedInject constructor( matrixClientsHolder.removeAll() } ) { isLoggedIn, cacheIdx -> isLoggedIn to cacheIdx } - .onEach { isLoggedIn -> - Timber.v("isLoggedIn=$isLoggedIn") - if (isLoggedIn.first) { + .onEach { pair -> + val isLoggedIn = pair.first + val cacheIndex = pair.second + Timber.v("isLoggedIn=$isLoggedIn, cacheIndex=$cacheIndex") + if (isLoggedIn) { tryToRestoreLatestSession( - onSuccess = { switchToLoggedInFlow(it, isLoggedIn.second) }, + onSuccess = { switchToLoggedInFlow(it, cacheIndex) }, onFailure = { switchToNotLoggedInFlow() } ) } else { From b6920afb7fd6d2afd22f707f7a490f856ab213d7 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 23 Jun 2023 15:38:04 +0200 Subject: [PATCH 26/34] Move cache management to a dedicated class and in the setting module, for clarity. --- .../io/element/android/appnav/RootFlowNode.kt | 6 ++- .../features/preferences/api/CacheService.kt | 28 +++++++++++++ .../preferences/impl/DefaultCacheService.kt | 39 +++++++++++++++++++ .../impl/tasks/ClearCacheUseCase.kt | 6 +-- .../api/auth/MatrixAuthenticationService.kt | 7 ---- .../auth/RustMatrixAuthenticationService.kt | 10 ----- .../test/auth/FakeAuthenticationService.kt | 10 ----- 7 files changed, 74 insertions(+), 32 deletions(-) create mode 100644 features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/CacheService.kt create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultCacheService.kt diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 57aa598dc5..733485fcef 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -44,6 +44,7 @@ import io.element.android.appnav.root.RootPresenter import io.element.android.appnav.root.RootView import io.element.android.features.login.api.oidc.OidcAction import io.element.android.features.login.api.oidc.OidcActionFlow +import io.element.android.features.preferences.api.CacheService import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler @@ -67,6 +68,7 @@ class RootFlowNode @AssistedInject constructor( @Assisted val buildContext: BuildContext, @Assisted plugins: List, private val authenticationService: MatrixAuthenticationService, + private val cacheService: CacheService, private val matrixClientsHolder: MatrixClientsHolder, private val presenter: RootPresenter, private val bugReportEntryPoint: BugReportEntryPoint, @@ -91,7 +93,7 @@ class RootFlowNode @AssistedInject constructor( authenticationService.isLoggedIn() .distinctUntilChanged() .combine( - authenticationService.cacheIndex().onEach { + cacheService.cacheIndex().onEach { Timber.v("cacheIndex=$it") matrixClientsHolder.removeAll() } @@ -245,7 +247,7 @@ class RootFlowNode @AssistedInject constructor( } private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode { - val cacheIndex = authenticationService.cacheIndex().first() + val cacheIndex = cacheService.cacheIndex().first() return attachChild { backstack.newRoot(NavTarget.LoggedInFlow(sessionId, cacheIndex)) } diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/CacheService.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/CacheService.kt new file mode 100644 index 0000000000..0bc9285853 --- /dev/null +++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/CacheService.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 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 io.element.android.features.preferences.api + +import kotlinx.coroutines.flow.Flow + +interface CacheService { + /** + * Returns a flow of the current cache index, can let the app to know when the + * cache has been cleared, for instance to restart the app. + * Will be a flow of Int, starting from 0, and incrementing each time the cache is cleared. + */ + fun cacheIndex(): Flow +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultCacheService.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultCacheService.kt new file mode 100644 index 0000000000..7675ec3dd6 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultCacheService.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 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 io.element.android.features.preferences.impl + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.preferences.api.CacheService +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import javax.inject.Inject + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultCacheService @Inject constructor() : CacheService { + private val cacheIndexState = MutableStateFlow(0) + + override fun cacheIndex(): Flow { + return cacheIndexState + } + + fun incrementCacheIndex() { + cacheIndexState.value++ + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt index 68ed41babd..f7b0d01130 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt @@ -22,11 +22,11 @@ import android.content.Context import coil.Coil import coil.annotation.ExperimentalCoilApi import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.preferences.impl.DefaultCacheService import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import javax.inject.Inject @@ -41,7 +41,7 @@ class DefaultClearCacheUseCase @Inject constructor( @ApplicationContext private val context: Context, private val matrixClient: MatrixClient, private val coroutineDispatchers: CoroutineDispatchers, - private val authenticationService: MatrixAuthenticationService, + private val defaultCacheIndexProvider: DefaultCacheService, private val okHttpClient: Provider, ) : ClearCacheUseCase { override suspend fun invoke() = withContext(coroutineDispatchers.io) { @@ -57,6 +57,6 @@ class DefaultClearCacheUseCase @Inject constructor( // Clear app cache context.cacheDir.deleteRecursively() // Ensure the app is restarted - authenticationService.incrementCacheIndex() + defaultCacheIndexProvider.incrementCacheIndex() } } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt index d1e2362da6..c15153876c 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt @@ -29,13 +29,6 @@ interface MatrixAuthenticationService { suspend fun setHomeserver(homeserver: String): Result suspend fun login(username: String, password: String): Result - /* - * Cache index - */ - - fun cacheIndex(): Flow - fun incrementCacheIndex() - /* * OIDC part. */ diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index ac239d83c1..d599029923 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -58,8 +58,6 @@ class RustMatrixAuthenticationService @Inject constructor( private val clock: SystemClock, ) : MatrixAuthenticationService { - private val cacheIndexState = MutableStateFlow(0) - private val authService: RustAuthenticationService = RustAuthenticationService( basePath = baseDirectory.absolutePath, passphrase = null, @@ -73,14 +71,6 @@ class RustMatrixAuthenticationService @Inject constructor( return sessionStore.isLoggedIn() } - override fun incrementCacheIndex() { - cacheIndexState.value++ - } - - override fun cacheIndex(): Flow { - return cacheIndexState - } - override suspend fun getLatestSessionId(): SessionId? = withContext(coroutineDispatchers.io) { sessionStore.getLatestSession()?.userId?.let { SessionId(it) } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt index b40dd766ff..81fa3b677c 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt @@ -65,16 +65,6 @@ class FakeAuthenticationService : MatrixAuthenticationService { loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID) } - private val cacheIndexFlow = MutableStateFlow(0) - - override fun cacheIndex(): Flow { - return cacheIndexFlow - } - - override fun incrementCacheIndex() { - cacheIndexFlow.value++ - } - override suspend fun getOidcUrl(): Result = simulateLongTask { oidcError?.let { Result.failure(it) } ?: Result.success(A_OIDC_DATA) } From 36910abca4fb568c750424db2b8b4020c2dcf72a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 26 Jun 2023 11:14:53 +0200 Subject: [PATCH 27/34] Move FileSizeFormatter to module :libraries:androidutils --- features/messages/impl/build.gradle.kts | 2 - .../media/local/AndroidLocalMediaFactory.kt | 2 +- .../TimelineItemContentMessageFactory.kt | 2 +- .../messages/fixtures/timelineItemsFactory.kt | 2 +- features/preferences/impl/build.gradle.kts | 1 - .../impl/tasks/ComputeCacheSizeUseCase.kt | 2 +- libraries/androidtools/api/build.gradle.kts | 23 ---------- libraries/androidtools/impl/build.gradle.kts | 42 ------------------- libraries/androidtools/test/build.gradle.kts | 27 ------------ .../filesize}/AndroidFileSizeFormatter.kt | 3 +- .../filesize}/FakeFileSizeFormatter.kt | 4 +- .../filesize}/FileSizeFormatter.kt | 2 +- .../kotlin/extension/DependencyHandleScope.kt | 1 - 13 files changed, 7 insertions(+), 106 deletions(-) delete mode 100644 libraries/androidtools/api/build.gradle.kts delete mode 100644 libraries/androidtools/impl/build.gradle.kts delete mode 100644 libraries/androidtools/test/build.gradle.kts rename libraries/{androidtools/impl/src/main/kotlin/io/element/android/libraries/androidtools/impl => androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize}/AndroidFileSizeFormatter.kt (93%) rename libraries/{androidtools/test/src/main/kotlin/io/element/android/libraries/androidtools/test => androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize}/FakeFileSizeFormatter.kt (85%) rename libraries/{androidtools/api/src/main/kotlin/io/element/android/libraries/androidtools/api => androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize}/FileSizeFormatter.kt (93%) diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 882c226b6b..f8377733a6 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -34,7 +34,6 @@ dependencies { anvil(projects.anvilcodegen) api(projects.features.messages.api) implementation(projects.libraries.androidutils) - api(projects.libraries.androidtools.api) implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) @@ -65,7 +64,6 @@ dependencies { testImplementation(libs.molecule.runtime) testImplementation(libs.test.truth) testImplementation(libs.test.turbine) - testImplementation(projects.libraries.androidtools.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.features.networkmonitor.test) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaFactory.kt index c231825524..ff2f8aaeeb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaFactory.kt @@ -21,7 +21,7 @@ import android.net.Uri import androidx.core.net.toUri import com.squareup.anvil.annotations.ContributesBinding import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractor -import io.element.android.libraries.androidtools.api.FileSizeFormatter +import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.androidutils.file.getFileName import io.element.android.libraries.androidutils.file.getFileSize import io.element.android.libraries.androidutils.file.getMimeType diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index db5ee8190e..d9a12cf615 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -26,7 +26,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractor import io.element.android.features.messages.impl.timeline.util.toHtmlDocument -import io.element.android.libraries.androidtools.api.FileSizeFormatter +import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt index 6e2bf33477..41daff47fd 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt @@ -32,7 +32,7 @@ import io.element.android.features.messages.impl.timeline.factories.virtual.Time import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractorWithoutValidation -import io.element.android.libraries.androidtools.test.FakeFileSizeFormatter +import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter import io.element.android.libraries.eventformatter.api.TimelineEventFormatter import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index e8fa540f6c..1e76ee5c93 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -33,7 +33,6 @@ dependencies { implementation(projects.anvilannotations) anvil(projects.anvilcodegen) implementation(projects.libraries.androidutils) - api(projects.libraries.androidtools.api) implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ComputeCacheSizeUseCase.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ComputeCacheSizeUseCase.kt index 038694201b..661f6493ec 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ComputeCacheSizeUseCase.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ComputeCacheSizeUseCase.kt @@ -18,7 +18,7 @@ package io.element.android.features.preferences.impl.tasks import android.content.Context import com.squareup.anvil.annotations.ContributesBinding -import io.element.android.libraries.androidtools.api.FileSizeFormatter +import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.androidutils.file.getSizeOfFiles import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.di.ApplicationContext diff --git a/libraries/androidtools/api/build.gradle.kts b/libraries/androidtools/api/build.gradle.kts deleted file mode 100644 index aeaec3ad30..0000000000 --- a/libraries/androidtools/api/build.gradle.kts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright (c) 2023 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. - */ - -plugins { - id("io.element.android-library") -} - -android { - namespace = "io.element.android.libraries.androidtools.api" -} diff --git a/libraries/androidtools/impl/build.gradle.kts b/libraries/androidtools/impl/build.gradle.kts deleted file mode 100644 index 5b3b85036f..0000000000 --- a/libraries/androidtools/impl/build.gradle.kts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (c) 2023 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. - */ - -plugins { - id("io.element.android-library") - alias(libs.plugins.ksp) - alias(libs.plugins.anvil) -} - -anvil { - generateDaggerFactories.set(true) -} - -android { - namespace = "io.element.android.libraries.androidtools.impl" - - dependencies { - anvil(projects.anvilcodegen) - implementation(libs.dagger) - implementation(projects.libraries.di) - implementation(projects.anvilannotations) - - api(projects.libraries.androidtools.api) - - testImplementation(libs.test.junit) - testImplementation(libs.test.truth) - testImplementation(projects.libraries.androidtools.test) - } -} diff --git a/libraries/androidtools/test/build.gradle.kts b/libraries/androidtools/test/build.gradle.kts deleted file mode 100644 index 8e045d6e01..0000000000 --- a/libraries/androidtools/test/build.gradle.kts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (c) 2023 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. - */ - -plugins { - id("io.element.android-library") -} - -android { - namespace = "io.element.android.libraries.androidtools.test" - - dependencies { - api(projects.libraries.androidtools.api) - } -} diff --git a/libraries/androidtools/impl/src/main/kotlin/io/element/android/libraries/androidtools/impl/AndroidFileSizeFormatter.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize/AndroidFileSizeFormatter.kt similarity index 93% rename from libraries/androidtools/impl/src/main/kotlin/io/element/android/libraries/androidtools/impl/AndroidFileSizeFormatter.kt rename to libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize/AndroidFileSizeFormatter.kt index f31151e15c..9cd70febcc 100644 --- a/libraries/androidtools/impl/src/main/kotlin/io/element/android/libraries/androidtools/impl/AndroidFileSizeFormatter.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize/AndroidFileSizeFormatter.kt @@ -14,13 +14,12 @@ * limitations under the License. */ -package io.element.android.libraries.androidtools.impl +package io.element.android.libraries.androidutils.filesize import android.content.Context import android.os.Build import android.text.format.Formatter import com.squareup.anvil.annotations.ContributesBinding -import io.element.android.libraries.androidtools.api.FileSizeFormatter import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext import javax.inject.Inject diff --git a/libraries/androidtools/test/src/main/kotlin/io/element/android/libraries/androidtools/test/FakeFileSizeFormatter.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize/FakeFileSizeFormatter.kt similarity index 85% rename from libraries/androidtools/test/src/main/kotlin/io/element/android/libraries/androidtools/test/FakeFileSizeFormatter.kt rename to libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize/FakeFileSizeFormatter.kt index 183a78eaf1..32c0239428 100644 --- a/libraries/androidtools/test/src/main/kotlin/io/element/android/libraries/androidtools/test/FakeFileSizeFormatter.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize/FakeFileSizeFormatter.kt @@ -14,9 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.androidtools.test - -import io.element.android.libraries.androidtools.api.FileSizeFormatter +package io.element.android.libraries.androidutils.filesize class FakeFileSizeFormatter : FileSizeFormatter { override fun format(fileSize: Long, useShortFormat: Boolean): String { diff --git a/libraries/androidtools/api/src/main/kotlin/io/element/android/libraries/androidtools/api/FileSizeFormatter.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize/FileSizeFormatter.kt similarity index 93% rename from libraries/androidtools/api/src/main/kotlin/io/element/android/libraries/androidtools/api/FileSizeFormatter.kt rename to libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize/FileSizeFormatter.kt index 261b7cbecc..7be38bf9bd 100644 --- a/libraries/androidtools/api/src/main/kotlin/io/element/android/libraries/androidtools/api/FileSizeFormatter.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize/FileSizeFormatter.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.androidtools.api +package io.element.android.libraries.androidutils.filesize interface FileSizeFormatter { /** diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt index 47bdabcbcd..88f499b993 100644 --- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt +++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt @@ -77,7 +77,6 @@ private fun DependencyHandlerScope.addImplementationProjects( fun DependencyHandlerScope.allLibrariesImpl() { implementation(project(":libraries:androidutils")) implementation(project(":libraries:deeplink")) - implementation(project(":libraries:androidtools:impl")) implementation(project(":libraries:designsystem")) implementation(project(":libraries:matrix:impl")) implementation(project(":libraries:matrixui")) From 5c469765c857d86ab07e49e9428e73eaa33c647f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 26 Jun 2023 10:07:03 +0000 Subject: [PATCH 28/34] Update appyx to v1.3.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1fa483052a..480e06cfa3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,7 +39,7 @@ datetime = "0.4.0" serialization_json = "1.5.1" showkase = "1.0.0-beta18" jsoup = "1.16.1" -appyx = "1.2.0" +appyx = "1.3.0" dependencycheck = "8.3.1" dependencyanalysis = "1.20.0" stem = "2.3.0" From c5c9cd33da8f3e34dcf65033d1f8738d0fb28a29 Mon Sep 17 00:00:00 2001 From: Marco Romano Date: Mon, 26 Jun 2023 16:29:50 +0200 Subject: [PATCH 29/34] Refine handleSnackbarMessage API (#676) As per our offline convos during a PR review. --- .../messages/impl/MessagesPresenter.kt | 4 ++-- .../impl/media/viewer/MediaViewerPresenter.kt | 4 ++-- .../roomlist/impl/RoomListPresenter.kt | 4 ++-- .../libraries/designsystem/utils/Snackbar.kt | 19 +++++++++++-------- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 01c3dc12d1..d12c6ca901 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -58,7 +58,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.SnackbarMessage -import io.element.android.libraries.designsystem.utils.handleSnackbarMessage +import io.element.android.libraries.designsystem.utils.collectSnackbarMessageAsState import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MessageEventType @@ -110,7 +110,7 @@ class MessagesPresenter @AssistedInject constructor( val networkConnectionStatus by networkMonitor.connectivity.collectAsState(initial = networkMonitor.currentConnectivityStatus) - val snackbarMessage = handleSnackbarMessage(snackbarDispatcher) + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() LaunchedEffect(syncUpdateFlow) { roomAvatar.value = diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerPresenter.kt index 88bf7c91c2..5ef1bdcd47 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerPresenter.kt @@ -35,7 +35,7 @@ import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.SnackbarMessage -import io.element.android.libraries.designsystem.utils.handleSnackbarMessage +import io.element.android.libraries.designsystem.utils.collectSnackbarMessageAsState import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MediaFile import kotlinx.coroutines.CoroutineScope @@ -66,7 +66,7 @@ class MediaViewerPresenter @AssistedInject constructor( val localMedia: MutableState> = remember { mutableStateOf(Async.Uninitialized) } - val snackbarMessage = handleSnackbarMessage(snackbarDispatcher) + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() localMediaActions.Configure() DisposableEffect(loadMediaTrigger) { coroutineScope.downloadMedia(mediaFile, localMedia) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt index 9448ff4b1b..12791b8083 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt @@ -38,7 +38,7 @@ import io.element.android.libraries.core.extensions.orEmpty import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.utils.SnackbarDispatcher -import io.element.android.libraries.designsystem.utils.handleSnackbarMessage +import io.element.android.libraries.designsystem.utils.collectSnackbarMessageAsState import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId @@ -135,7 +135,7 @@ class RoomListPresenter @Inject constructor( filteredRoomSummaries.value = updateFilteredRoomSummaries(mappedRoomSummaries.value, filter) } - val snackbarMessage = handleSnackbarMessage(snackbarDispatcher) + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() return RoomListState( matrixUser = matrixUser.value, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt index 1de46c78e0..ba9f2d3121 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember @@ -28,27 +29,31 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.res.stringResource import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +/** + * A global dispatcher of [SnackbarMessage] to be displayed in [Snackbar] via a [SnackbarHostState]. + */ class SnackbarDispatcher { private val mutex = Mutex() - private val snackbarState = MutableStateFlow(null) - val snackbarMessage: Flow = snackbarState + private val _snackbarMessage = MutableStateFlow(null) + val snackbarMessage: Flow = _snackbarMessage.asStateFlow() suspend fun post(message: SnackbarMessage) { mutex.withLock { - snackbarState.update { message } + _snackbarMessage.update { message } } } suspend fun clear() { mutex.withLock { - snackbarState.update { null } + _snackbarMessage.update { null } } } } @@ -59,10 +64,8 @@ val LocalSnackbarDispatcher = compositionLocalOf { } @Composable -fun handleSnackbarMessage( - snackbarDispatcher: SnackbarDispatcher -): SnackbarMessage? { - return snackbarDispatcher.snackbarMessage.collectAsState(initial = null).value +fun SnackbarDispatcher.collectSnackbarMessageAsState(): State { + return snackbarMessage.collectAsState(initial = null) } @Composable From 32332deaf4afbb4f28dca12f6b7cfaa6624f5f36 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 27 Jun 2023 08:29:36 +0200 Subject: [PATCH 30/34] Update dependency org.matrix.rustcomponents:sdk-android to v0.1.24 (#675) * Update dependency org.matrix.rustcomponents:sdk-android to v0.1.24 * Add new `filterByPushRules`. Only existing usage of this API will set this param to `true` in order to not change the current behavior. --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Marco Romano --- gradle/libs.versions.toml | 2 +- .../libraries/matrix/api/notification/NotificationService.kt | 2 +- .../matrix/impl/notification/RustNotificationService.kt | 5 +++-- .../matrix/test/notification/FakeNotificationService.kt | 2 +- .../push/impl/notifications/NotifiableEventResolver.kt | 1 + 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 480e06cfa3..eaf01afbe7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -142,7 +142,7 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" } timber = "com.jakewharton.timber:timber:5.0.1" -matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.23" +matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.24" sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" } sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt index f52e24d79b..2046252930 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt @@ -21,5 +21,5 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId interface NotificationService { - fun getNotification(userId: SessionId, roomId: RoomId, eventId: EventId): Result + fun getNotification(userId: SessionId, roomId: RoomId, eventId: EventId, filterByPushRules: Boolean): Result } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt index 52d1598cff..99e5991719 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt @@ -32,10 +32,11 @@ class RustNotificationService( override fun getNotification( userId: SessionId, roomId: RoomId, - eventId: EventId + eventId: EventId, + filterByPushRules: Boolean, ): Result { return runCatching { - client.getNotificationItem(roomId.value, eventId.value)?.use(notificationMapper::map) + client.getNotificationItem(roomId.value, eventId.value, filterByPushRules)?.use(notificationMapper::map) } } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt index b92c9b7a92..9eb5a20ba4 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt @@ -23,7 +23,7 @@ import io.element.android.libraries.matrix.api.notification.NotificationData import io.element.android.libraries.matrix.api.notification.NotificationService class FakeNotificationService : NotificationService { - override fun getNotification(userId: SessionId, roomId: RoomId, eventId: EventId): Result { + override fun getNotification(userId: SessionId, roomId: RoomId, eventId: EventId, filterByPushRules: Boolean): Result { return Result.success(null) } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt index fb3fcfc61f..faa8eb86d8 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt @@ -59,6 +59,7 @@ class NotifiableEventResolver @Inject constructor( userId = sessionId, roomId = roomId, eventId = eventId, + filterByPushRules = true, ) }.fold( { From fe59b381a731cdca477b154f20fe4e5a14dfd201 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 27 Jun 2023 06:30:11 +0000 Subject: [PATCH 31/34] Update dependency org.matrix.rustcomponents:sdk-android to v0.1.25 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eaf01afbe7..6d22ce6ed1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -142,7 +142,7 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" } timber = "com.jakewharton.timber:timber:5.0.1" -matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.24" +matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.25" sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" } sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" } From b437360bc510f4fd3e730ff4a14dafbdccb07720 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 27 Jun 2023 08:36:25 +0200 Subject: [PATCH 32/34] Update dependency app.cash.molecule:molecule-runtime to v0.10.0 (#679) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eaf01afbe7..9a4d549f77 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ android_gradle_plugin = "8.0.2" kotlin = "1.8.21" ksp = "1.8.21-1.0.11" -molecule = "0.9.0" +molecule = "0.10.0" # AndroidX material = "1.9.0" From 4fe7bb680984d6e5645e82a1c0c46be3c5b86104 Mon Sep 17 00:00:00 2001 From: Marco Romano Date: Tue, 27 Jun 2023 09:12:17 +0200 Subject: [PATCH 33/34] Add `sendLocation` API to Rust Room (#681) Will be used by the location sharing feature. --- .../libraries/matrix/api/room/MatrixRoom.kt | 9 +++++++++ .../libraries/matrix/impl/room/RustMatrixRoom.kt | 9 +++++++++ .../libraries/matrix/test/room/FakeMatrixRoom.kt | 16 ++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index afd0e8ea25..53a80e4723 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -114,4 +114,13 @@ interface MatrixRoom : Closeable { suspend fun setTopic(topic: String): Result suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result + + /** + * Share a location message in the room. + * + * @param body A human readable textual representation of the location. + * @param geoUri A geo URI (RFC 5870) representing the location e.g. `geo:51.5008,0.1247;u=35`. + * Respectively: latitude, longitude, and (optional) uncertainty. + */ + suspend fun sendLocation(body: String, geoUri: String): Result } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index bc8525d87b..5d42a3552b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -338,4 +338,13 @@ class RustMatrixRoom( } } } + + override suspend fun sendLocation( + body: String, + geoUri: String + ): Result = withContext(coroutineDispatchers.io) { + runCatching { + innerRoom.sendLocation(body, geoUri, genTransactionId()) + } + } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index d8187b0a1d..6fab4cb053 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -77,6 +77,7 @@ class FakeMatrixRoom( private var cancelSendResult = Result.success(Unit) private var forwardEventResult = Result.success(Unit) private var reportContentResult = Result.success(Unit) + private var sendLocationResult = Result.success(Unit) var sendMediaCount = 0 private set @@ -93,6 +94,9 @@ class FakeMatrixRoom( var reportedContentCount: Int = 0 private set + var sendLocationCount: Int = 0 + private set + var isInviteAccepted: Boolean = false private set @@ -262,6 +266,14 @@ class FakeMatrixRoom( return reportContentResult } + override suspend fun sendLocation( + body: String, + geoUri: String + ): Result = simulateLongTask { + sendLocationCount++ + return sendLocationResult + } + override fun close() = Unit fun givenLeaveRoomError(throwable: Throwable?) { @@ -355,4 +367,8 @@ class FakeMatrixRoom( fun givenReportContentResult(result: Result) { reportContentResult = result } + + fun givenSendLocationResult(result: Result) { + sendLocationResult = result + } } From 882f75864cff4a6a9ee666e5aa7f83620350e8c3 Mon Sep 17 00:00:00 2001 From: Marco Romano Date: Tue, 27 Jun 2023 09:23:00 +0200 Subject: [PATCH 34/34] Set up SDK & reusable map view component (#476) Adds `libraries/map` which contains some initial building blocks that will be used by the location sharing feature. Ref: https://github.com/vector-im/element-meta/issues/1684 --- build.gradle.kts | 1 + features/location/api/build.gradle.kts | 53 ++++ .../android/features/location/api/Location.kt | 26 ++ .../android/features/location/api/MapView.kt | 297 ++++++++++++++++++ .../features/location/api/StaticMapView.kt | 135 ++++++++ .../location/api/internal/MapsUtils.kt | 80 +++++ .../api/internal/StaticMapPlaceholder.kt | 111 +++++++ .../main/res/drawable/blurred_map_dark.png | Bin 0 -> 34121 bytes .../main/res/drawable/blurred_map_light.png | Bin 0 -> 48957 bytes .../api/src/main/res/drawable/pin.xml | 23 ++ .../api/internal/BuildStaticMapsApiUrlTest.kt | 71 +++++ features/location/fake/build.gradle.kts | 52 +++ .../location/fake/LocationUpdatesFlowFake.kt | 35 +++ features/location/impl/build.gradle.kts | 54 ++++ .../impl/src/main/AndroidManifest.xml | 21 ++ .../location/impl/LocationUpdatesFlowImpl.kt | 96 ++++++ gradle/libs.versions.toml | 3 + ...erDarkPreview_0_null_0,NEXUS_5,1.0,en].png | 3 + ...erDarkPreview_0_null_1,NEXUS_5,1.0,en].png | 3 + ...rLightPreview_0_null_0,NEXUS_5,1.0,en].png | 3 + ...rLightPreview_0_null_1,NEXUS_5,1.0,en].png | 3 + ...ViewDarkPreview_0_null,NEXUS_5,1.0,en].png | 3 + ...iewLightPreview_0_null,NEXUS_5,1.0,en].png | 3 + ...ViewDarkPreview_0_null,NEXUS_5,1.0,en].png | 3 + ...iewLightPreview_0_null,NEXUS_5,1.0,en].png | 3 + 25 files changed, 1082 insertions(+) create mode 100644 features/location/api/build.gradle.kts create mode 100644 features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt create mode 100644 features/location/api/src/main/kotlin/io/element/android/features/location/api/MapView.kt create mode 100644 features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt create mode 100644 features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapsUtils.kt create mode 100644 features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt create mode 100644 features/location/api/src/main/res/drawable/blurred_map_dark.png create mode 100644 features/location/api/src/main/res/drawable/blurred_map_light.png create mode 100644 features/location/api/src/main/res/drawable/pin.xml create mode 100644 features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/BuildStaticMapsApiUrlTest.kt create mode 100644 features/location/fake/build.gradle.kts create mode 100644 features/location/fake/src/main/kotlin/io/element/android/features/location/fake/LocationUpdatesFlowFake.kt create mode 100644 features/location/impl/build.gradle.kts create mode 100644 features/location/impl/src/main/AndroidManifest.xml create mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/LocationUpdatesFlowImpl.kt create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderDarkPreview_0_null_0,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderDarkPreview_0_null_1,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderLightPreview_0_null_0,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderLightPreview_0_null_1,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_MapViewDarkPreview_0_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_MapViewLightPreview_0_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_StaticMapViewDarkPreview_0_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_StaticMapViewLightPreview_0_null,NEXUS_5,1.0,en].png diff --git a/build.gradle.kts b/build.gradle.kts index 240af6c9ae..b0bcde29a8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -247,6 +247,7 @@ koverMerged { excludes += "io.element.android.libraries.push.impl.notifications.NotificationState*" excludes += "io.element.android.features.messages.impl.media.local.pdf.PdfViewerState" excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState" + excludes += "io.element.android.features.location.api.MapState" } bound { minValue = 90 diff --git a/features/location/api/build.gradle.kts b/features/location/api/build.gradle.kts new file mode 100644 index 0000000000..a2f73e7def --- /dev/null +++ b/features/location/api/build.gradle.kts @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2022 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) +} + +android { + namespace = "io.element.android.features.location.api" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(libs.dagger) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.network) + implementation(projects.libraries.core) + implementation(projects.libraries.uiStrings) + implementation(libs.maplibre) + implementation(libs.network.retrofit) + implementation(libs.maplibre.annotation) + implementation(libs.coil.compose) + implementation(libs.serialization.json) + implementation(libs.accompanist.permission) + ksp(libs.showkase.processor) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(libs.test.truth) + testImplementation(projects.libraries.matrix.test) +} diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt new file mode 100644 index 0000000000..596bc4d1a0 --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 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 io.element.android.features.location.api + +/** + * Represents a location sample emitted by the device's location subsystem. + */ +data class Location( + val lat: Double, + val lon: Double, + val accuracy: Float, +) diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/MapView.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/MapView.kt new file mode 100644 index 0000000000..464422d713 --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/MapView.kt @@ -0,0 +1,297 @@ +/* + * Copyright (c) 2023 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 io.element.android.features.location.api + +import android.annotation.SuppressLint +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.mapbox.mapboxsdk.Mapbox +import com.mapbox.mapboxsdk.camera.CameraPosition +import com.mapbox.mapboxsdk.camera.CameraUpdateFactory +import com.mapbox.mapboxsdk.geometry.LatLng +import com.mapbox.mapboxsdk.maps.MapView +import com.mapbox.mapboxsdk.maps.MapboxMap +import com.mapbox.mapboxsdk.maps.Style +import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager +import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions +import io.element.android.features.location.api.internal.buildTileServerUrl +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.ElementTheme +import io.element.android.libraries.designsystem.theme.components.FloatingActionButton +import io.element.android.libraries.designsystem.theme.components.Icon +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import timber.log.Timber +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +/** + * Composable wrapper around MapLibre's [MapView]. + */ +@SuppressLint("MissingPermission") +@Composable +fun MapView( + modifier: Modifier = Modifier, + mapState: MapState = rememberMapState(), + darkMode: Boolean = !ElementTheme.colors.isLight, + onLocationClick: () -> Unit, +) { + // When in preview, early return a Box with the received modifier preserving layout + if (LocalInspectionMode.current) { + @Suppress("ModifierReused") // False positive, the modifier is not reused due to the early return. + Box(modifier = modifier) + return + } + + val context = LocalContext.current + val mapView = remember { + Mapbox.getInstance(context) + MapView(context) + } + var mapRefs by remember { mutableStateOf(null) } + + // Build map + LaunchedEffect(darkMode) { + mapView.awaitMap().let { map -> + map.uiSettings.apply { + isCompassEnabled = false + } + map.setStyle(buildTileServerUrl(darkMode = darkMode)) { style -> + mapRefs = MapRefs( + map = map, + symbolManager = SymbolManager(mapView, map, style).apply { + iconAllowOverlap = true + }, + style = style + ) + } + } + } + + // Update state position when moving map + DisposableEffect(mapRefs) { + var listener: MapboxMap.OnCameraIdleListener? = null + + mapRefs?.let { mapRefs -> + listener = MapboxMap.OnCameraIdleListener { + mapRefs.map.cameraPosition.target?.let { target -> + val position = MapState.CameraPosition( + lat = target.latitude, + lon = target.longitude, + zoom = mapRefs.map.cameraPosition.zoom + ) + mapState.position = position + Timber.d("Camera moved to: $position") + } + }.apply { + mapRefs.map.addOnCameraIdleListener(this) + Timber.d("Added OnCameraIdleListener $this") + } + } + + onDispose { + mapRefs?.let { mapRefs -> + listener?.let { + mapRefs.map.removeOnCameraIdleListener(it).apply { + Timber.d("Removed OnCameraIdleListener $it") + } + } + } + } + } + + // Move map to given position when state has changed + LaunchedEffect(mapRefs, mapState.position) { + mapRefs?.map?.moveCamera( + CameraUpdateFactory.newCameraPosition( + CameraPosition.Builder() + .target(LatLng(mapState.position.lat, mapState.position.lon)) + .zoom(mapState.position.zoom).build() + ) + ) + Timber.d("Camera position updated to: ${mapState.position}") + } + + // Draw pin + LaunchedEffect(mapRefs, mapState.location) { + mapRefs?.let { mapRefs -> + mapState.location?.let { location -> + context.getDrawable(R.drawable.pin)?.let { mapRefs.style.addImage("pin", it) } + mapRefs.symbolManager.create( + SymbolOptions() + .withLatLng(LatLng(location.lat, location.lon)) + .withIconImage("pin") + .withIconSize(1.3f) + ) + Timber.d("Shown pin at location: $location") + } + } + + } + + // Draw markers + LaunchedEffect(mapRefs, mapState.markers) { + mapRefs?.let { mapRefs -> + mapState.markers.forEachIndexed { index, marker -> + context.getDrawable(marker.drawable)?.let { mapRefs.style.addImage("marker_$index", it) } + mapRefs.symbolManager.create( + SymbolOptions() + .withLatLng(LatLng(marker.lat, marker.lon)) + .withIconImage("marker_$index") + .withIconSize(1.0f) + ) + Timber.d("Shown marker at location: $marker") + } + } + } + + @Suppress("ModifierReused") + Box(modifier = modifier) { + AndroidView(factory = { mapView }) + FloatingActionButton( + onClick = onLocationClick, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + ) { + Icon( + imageVector = Icons.Filled.LocationOn, + contentDescription = null, // TODO + ) + } + } +} + +@Composable +fun rememberMapState( + position: MapState.CameraPosition = MapState.CameraPosition(lat = 0.0, lon = 0.0, zoom = 0.0), + location: Location? = null, + markers: ImmutableList = emptyList().toImmutableList(), +): MapState = remember { + MapState( + position = position, + location = location, + markers = markers, + ) +} // TODO(Use remember saveable with Parcelable custom saver) + +@Stable +class MapState( + position: CameraPosition, // The position of the camera, it's what will be shared + location: Location? = null, // The location retrieved by the location subsystem, if any. + markers: ImmutableList = emptyList().toImmutableList(), // The pin's location, if any. +) { + var position: CameraPosition by mutableStateOf(position) + var location: Location? by mutableStateOf(location) + var markers: ImmutableList by mutableStateOf(markers) + + override fun toString(): String { + return "MapState(position=$position, location=$location, markers=$markers)" + } + + @Stable + data class CameraPosition( + val lat: Double, + val lon: Double, + val zoom: Double, + ) + + @Stable + data class Marker( + @DrawableRes val drawable: Int, + val lat: Double, + val lon: Double, + ) +} + +private class MapRefs( + val map: MapboxMap, + val symbolManager: SymbolManager, + val style: Style +) + +/** + * A suspending function that provides an instance of [MapboxMap] from this [MapView]. This is + * an alternative to [MapView.getMapAsync] by using coroutines to obtain the [MapboxMap]. + * + * Inspired from [com.google.maps.android.ktx.awaitMap] + * + * @return the [MapboxMap] instance + */ +private suspend inline fun MapView.awaitMap(): MapboxMap = + suspendCoroutine { continuation -> + getMapAsync { + continuation.resume(it) + } + } + +@Preview +@Composable +fun MapViewLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +fun MapViewDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + MapView( + modifier = Modifier.size(400.dp), + mapState = rememberMapState( + position = MapState.CameraPosition( + lat = 0.0, + lon = 0.0, + zoom = 0.0, + ), + location = Location( + lat = 0.0, + lon = 0.0, + accuracy = 0.0f, + ), + markers = listOf( + MapState.Marker( + drawable = R.drawable.pin, + lat = 0.0, + lon = 0.0, + ) + ).toImmutableList() + ), + onLocationClick = {}, + ) +} diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt new file mode 100644 index 0000000000..e68c42368f --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2023 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 io.element.android.features.location.api + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImagePainter +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.ElementTheme +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.features.location.api.internal.StaticMapPlaceholder +import io.element.android.features.location.api.internal.buildStaticMapsApiUrl +import timber.log.Timber + +/** + * Shows a static map image downloaded via a third party service's static maps API. + */ +@Composable +fun StaticMapView( + lat: Double, + lon: Double, + zoom: Double, + contentDescription: String?, + modifier: Modifier = Modifier, + darkMode: Boolean = !ElementTheme.colors.isLight, +) { + // Using BoxWithConstraints to: + // 1) Size the inner Image to the same Dp size of the outer BoxWithConstraints. + // 2) Request the static map image of the exact required size in Px to fill the AsyncImage. + BoxWithConstraints( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + var retryHash by remember { mutableStateOf(0) } + val painter = rememberAsyncImagePainter( + model = if (constraints.isZero) { + // Avoid building a URL if any of the size constraints is zero (else it will thrown an exception). + null + } else { + ImageRequest.Builder(LocalContext.current) + .data( + buildStaticMapsApiUrl( + lat = lat, + lon = lon, + desiredZoom = zoom, + desiredWidth = constraints.maxWidth, + desiredHeight = constraints.maxHeight, + darkMode = darkMode, + ) + ) + .size(width = constraints.maxWidth, height = constraints.maxHeight) + .setParameter("retry_hash", retryHash, memoryCacheKey = null) + .build() + }.apply { + Timber.d("Static map image request: ${this?.data}") + } + ) + + if (painter.state is AsyncImagePainter.State.Success) { + Image( + painter = painter, + contentDescription = contentDescription, + modifier = Modifier.size(width = maxWidth, height = maxHeight), + // The returned image can be smaller than the requested size due to the static maps API having + // a max width and height of 2048 px. See buildStaticMapsApiUrl() for more details. + // We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case. + contentScale = ContentScale.Fit, + ) + Icon( + resourceId = R.drawable.pin, + contentDescription = null, + tint = Color.Unspecified + ) + } else { + StaticMapPlaceholder( + showProgress = painter.state is AsyncImagePainter.State.Loading, + contentDescription = contentDescription, + modifier = Modifier.size(width = maxWidth, height = maxHeight), + darkMode = darkMode, + onLoadMapClick = { retryHash++ } + ) + } + } +} + +@Preview +@Composable +fun StaticMapViewLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +fun StaticMapViewDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + StaticMapView( + lat = 0.0, + lon = 0.0, + zoom = 0.0, + contentDescription = null, + modifier = Modifier.size(400.dp), + ) +} diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapsUtils.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapsUtils.kt new file mode 100644 index 0000000000..f5a15e46c6 --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapsUtils.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2023 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 io.element.android.features.location.api.internal + +import kotlin.math.roundToInt + +private const val API_KEY = "fU3vlMsMn4Jb6dnEIFsx" +private const val BASE_URL = "https://api.maptiler.com" +private const val LIGHT_MAP_ID = "9bc819c8-e627-474a-a348-ec144fe3d810" +private const val DARK_MAP_ID = "dea61faf-292b-4774-9660-58fcef89a7f3" +private const val STATIC_MAP_FORMAT = "webp" +private const val STATIC_MAP_SCALE = "" // Either "" (empty string) for normal image or "@2x" for retina images. +private const val STATIC_MAP_MAX_WIDTH_HEIGHT = 2048 +private const val STATIC_MAP_MAX_ZOOM = 22.0 + +internal fun buildTileServerUrl( + darkMode: Boolean +): String = if (!darkMode) { + "$BASE_URL/maps/$LIGHT_MAP_ID/style.json?key=$API_KEY" +} else { + "$BASE_URL/maps/$DARK_MAP_ID/style.json?key=$API_KEY" +} + +/** + * Builds a valid URL for maptiler.com static map api based on the given params. + * + * Coerces width and height to the API maximum of 2048 keeping the requested aspect ratio. + * Coerces zoom to the API maximum of 22. + * + * NB: This will throw if either width or height are <= 0. You need to handle this case upstream + * (hint: views can't have negative width or height but can have 0 width or height sometimes). + */ +internal fun buildStaticMapsApiUrl( + lat: Double, + lon: Double, + desiredZoom: Double, + desiredWidth: Int, + desiredHeight: Int, + darkMode: Boolean +): String { + require(desiredWidth > 0 && desiredHeight > 0) { + "Width ($desiredHeight) and height ($desiredHeight) must be > 0" + } + require(desiredZoom >= 0) { "Zoom ($desiredZoom) must be >= 0" } + val zoom = desiredZoom.coerceAtMost(STATIC_MAP_MAX_ZOOM) // API will error if outside 0-22 range. + val width: Int + val height: Int + if (desiredWidth <= STATIC_MAP_MAX_WIDTH_HEIGHT && desiredHeight <= STATIC_MAP_MAX_WIDTH_HEIGHT) { + width = desiredWidth + height = desiredHeight + } else { + val aspectRatio = desiredWidth.toDouble() / desiredHeight.toDouble() + if (desiredWidth >= desiredHeight) { + width = desiredWidth.coerceAtMost(STATIC_MAP_MAX_WIDTH_HEIGHT) + height = (width / aspectRatio).roundToInt() + } else { + height = desiredHeight.coerceAtMost(STATIC_MAP_MAX_WIDTH_HEIGHT) + width = (height * aspectRatio).roundToInt() + } + } + return if (!darkMode) { + "$BASE_URL/maps/$LIGHT_MAP_ID/static/${lon},${lat},${zoom}/${width}x${height}$STATIC_MAP_SCALE.$STATIC_MAP_FORMAT?key=$API_KEY" + } else { + "$BASE_URL/maps/$DARK_MAP_ID/static/${lon},${lat},${zoom}/${width}x${height}$STATIC_MAP_SCALE.$STATIC_MAP_FORMAT?key=$API_KEY" + } +} diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt new file mode 100644 index 0000000000..d39bfd7d15 --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2023 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 io.element.android.features.location.api.internal + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.ElementTheme +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.features.location.api.R +import io.element.android.libraries.ui.strings.R as StringsR + +@Composable +internal fun StaticMapPlaceholder( + showProgress: Boolean, + contentDescription: String?, + modifier: Modifier = Modifier, + darkMode: Boolean = !ElementTheme.colors.isLight, + onLoadMapClick: () -> Unit, +) { + Box( + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource( + id = if (darkMode) R.drawable.blurred_map_dark + else R.drawable.blurred_map_light + ), + contentDescription = contentDescription, + modifier = modifier, + contentScale = ContentScale.FillBounds, + ) + if (showProgress) { + CircularProgressIndicator() + } else { + Box( + modifier = modifier.clickable(onClick = onLoadMapClick), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null + ) + Text(text = stringResource(id = StringsR.string.action_static_map_load)) + } + } + } + } +} + +@Preview +@Composable +fun StaticMapPlaceholderLightPreview( + @PreviewParameter(BooleanParameterProvider::class) values: Boolean +) = ElementPreviewLight { ContentToPreview(values) } + +@Preview +@Composable +fun StaticMapPlaceholderDarkPreview( + @PreviewParameter(BooleanParameterProvider::class) values: Boolean +) = ElementPreviewDark { ContentToPreview(values) } + +@Composable +private fun ContentToPreview(showProgress: Boolean) { + StaticMapPlaceholder( + showProgress = showProgress, + contentDescription = null, + modifier = Modifier.size(400.dp), + onLoadMapClick = {}, + ) +} + +internal class BooleanParameterProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf(true, false) +} diff --git a/features/location/api/src/main/res/drawable/blurred_map_dark.png b/features/location/api/src/main/res/drawable/blurred_map_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..7e90d568f15f8b687e25265f4a3a27ceaaded09c GIT binary patch literal 34121 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

cEIOjJ1jTpQ0$F>yZKoJ-(9kbycKypd$!wK z=dWxja%~Z1KHs%+)XCvm0uwVNbbfJ!?34!u^ZXcL)FABEm~qnxW7S*K>Z~jv?O1^@@h|t_c5l-GEz|bf!(8XXa!`*YQX~GajF_ z4H}LPM~wPN)qUY$kA3rClx!#YC>g{$&^tl^r%l@>aY)*nyP!j+Q+N$TqXUtwiM-S` z^vCsF^i5mkNKkSKYkpy|Nl7j^26h(bD+~XB+KSMKfc-L+ppJK_tpoBSH@%?uMu1> z39#2wD+%z^JHJhgmrnTf9G6BwyN#}7Bi1to?(e!=uXbl53L?sD@|69`$9F2OwzV^w z@fr)`S|ljg;9RdK;u~*M=CA|U1hQy z!3A=ug9%}^L(6r7$t4hhYEu3bC8>~>3B104etiS|+xszZ^ex`Mts&a$HJ?Xz6tCgh znHjB@qaS|@q}~r8TC3dGQ1|t>kmGz>?b!eJ{>?tlHC*5AnDyQnlEv$l5DQ~I-j4aD zqy3LWs8@+2Ka2BoGa39|0M&FjZh0Mj)pT-|YmpU>%k0IhW*4HyZlpBAC;n{az4W4Gv2_hjxlRPCQ-*yPqa!H+vj%6)+vzCJ+*A z$BbIenexNHLOOJQ29`W{XNO;fca*qp5AOFLEpXC>j&$zp&vSUjUnrq0U>-*>DWVWwK?Dw_)=*JJwRqXHI-p==1 z`RbhWQ+s)f_;U@cY`rf1z^gSZTTd?^p!DOMP2Z9^k722@R&tqzkyQnGuT4Av*_+q233NO@bRcmp<~KF1IOi) d`vCj){{gDE`>8sOoXP+I002ovPDHLkV1hAOWcdI9 literal 0 HcmV?d00001 diff --git a/features/location/api/src/main/res/drawable/blurred_map_light.png b/features/location/api/src/main/res/drawable/blurred_map_light.png new file mode 100644 index 0000000000000000000000000000000000000000..365cf96786fc9c5c5bd2ea584748bedcf87c46c7 GIT binary patch literal 48957 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 tqg6X^4U-?=6{Zppcz9w}r(FQi{{W&4_1?z0 + + + + + + + + + + diff --git a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/BuildStaticMapsApiUrlTest.kt b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/BuildStaticMapsApiUrlTest.kt new file mode 100644 index 0000000000..023c7be365 --- /dev/null +++ b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/BuildStaticMapsApiUrlTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2023 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 io.element.android.features.location.api.internal + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.location.api.internal.buildStaticMapsApiUrl +import org.junit.Test + +class BuildStaticMapsApiUrlTest { + @Test + fun `buildStaticMapsApiUrl builds light mode url`() { + assertThat( + buildStaticMapsApiUrl( + lat = 1.234, + lon = 5.678, + desiredZoom = 1.2, + desiredWidth = 100, + desiredHeight = 200, + darkMode = false + ) + ).isEqualTo( + "https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,1.2/100x200.webp?key=fU3vlMsMn4Jb6dnEIFsx" + ) + } + + @Test + fun `buildStaticMapsApiUrl builds dark mode url`() { + assertThat( + buildStaticMapsApiUrl( + lat = 1.234, + lon = 5.678, + desiredZoom = 1.2, + desiredWidth = 100, + desiredHeight = 200, + darkMode = true + ) + ).isEqualTo( + "https://api.maptiler.com/maps/dea61faf-292b-4774-9660-58fcef89a7f3/static/5.678,1.234,1.2/100x200.webp?key=fU3vlMsMn4Jb6dnEIFsx" + ) + } + + @Test + fun `buildStaticMapsApiUrl coerces zoom at 22 and width and height at max 2048 keeping aspect ratio`() { + assertThat( + buildStaticMapsApiUrl( + lat = 1.234, + lon = 5.678, + desiredZoom = 100.0, + desiredWidth = 8192, + desiredHeight = 4096, + darkMode = false + ) + ).isEqualTo( + "https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,22.0/2048x1024.webp?key=fU3vlMsMn4Jb6dnEIFsx" + ) + } +} diff --git a/features/location/fake/build.gradle.kts b/features/location/fake/build.gradle.kts new file mode 100644 index 0000000000..cceab3f2b7 --- /dev/null +++ b/features/location/fake/build.gradle.kts @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2022 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "io.element.android.features.location.fake" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(libs.dagger) + api(projects.features.location.api) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.di) + implementation(projects.libraries.network) + implementation(projects.libraries.core) + implementation(libs.maplibre) + implementation(libs.network.retrofit) + implementation(libs.maplibre.annotation) + implementation(libs.coil.compose) + implementation(libs.serialization.json) + implementation(libs.accompanist.permission) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(libs.test.truth) + testImplementation(projects.libraries.matrix.test) +} diff --git a/features/location/fake/src/main/kotlin/io/element/android/features/location/fake/LocationUpdatesFlowFake.kt b/features/location/fake/src/main/kotlin/io/element/android/features/location/fake/LocationUpdatesFlowFake.kt new file mode 100644 index 0000000000..c3f070acbb --- /dev/null +++ b/features/location/fake/src/main/kotlin/io/element/android/features/location/fake/LocationUpdatesFlowFake.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023 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 io.element.android.features.location.fake + +import io.element.android.features.location.api.Location +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +fun fakeLocationUpdatesFlow(): Flow = flow { + while (true) { + delay(1_000) + emit(aLocation()) + } +} + +private fun aLocation() = Location( + lat = 51.49404, + lon = -0.25484, + accuracy = 5f +) diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts new file mode 100644 index 0000000000..66d29fd6bb --- /dev/null +++ b/features/location/impl/build.gradle.kts @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2022 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) +} + +android { + namespace = "io.element.android.features.location.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(libs.dagger) + api(projects.features.location.api) + implementation(projects.libraries.di) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.network) + implementation(projects.libraries.core) + implementation(libs.maplibre) + implementation(libs.network.retrofit) + implementation(libs.maplibre.annotation) + implementation(libs.coil.compose) + implementation(libs.serialization.json) + implementation(libs.accompanist.permission) + ksp(libs.showkase.processor) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(libs.test.truth) + testImplementation(projects.libraries.matrix.test) +} diff --git a/features/location/impl/src/main/AndroidManifest.xml b/features/location/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..b4f5d8f271 --- /dev/null +++ b/features/location/impl/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/LocationUpdatesFlowImpl.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/LocationUpdatesFlowImpl.kt new file mode 100644 index 0000000000..11b1e2a02d --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/LocationUpdatesFlowImpl.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2023 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 io.element.android.features.location.impl + +import android.Manifest +import android.content.Context +import android.location.LocationManager +import androidx.annotation.RequiresPermission +import androidx.core.content.getSystemService +import androidx.core.location.LocationListenerCompat +import androidx.core.location.LocationManagerCompat +import androidx.core.location.LocationRequestCompat +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.features.location.api.Location +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +/** + * Returns a cold [Flow] that, once collected, emits [Location] updates every second. + */ +@RequiresPermission( + anyOf = [ + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION + ] +) +fun locationUpdatesFlow( + context: Context, + coroutineDispatchers: CoroutineDispatchers, +): Flow = callbackFlow { + val locationManager: LocationManager = checkNotNull(context.getSystemService()) + val provider = locationManager.bestAvailableProvider() + // Try to eagerly emit the last known location as fast as possible + locationManager.getLastKnownLocation(provider)?.let { location -> + trySendBlocking( + Location( + lat = location.latitude, + lon = location.longitude, + accuracy = location.accuracy + ) + ) + } + val locationListener = LocationListenerCompat { location -> + trySendBlocking( + Location( + lat = location.latitude, + lon = location.longitude, + accuracy = location.accuracy + ) + ) + } + LocationManagerCompat.requestLocationUpdates( + locationManager, + provider, + buildLocationRequest(), + coroutineDispatchers.io.asExecutor(), + locationListener, + ) + awaitClose { + LocationManagerCompat.removeUpdates(locationManager, locationListener) + } +} + +private fun LocationManager.bestAvailableProvider(): String = + checkNotNull(getProviders(true).maxByOrNull { providerPriority(it) }) { + "No location provider available" + } + +private fun providerPriority(provider: String): Int = when (provider) { + LocationManager.FUSED_PROVIDER -> 4 + LocationManager.GPS_PROVIDER -> 3 + LocationManager.NETWORK_PROVIDER -> 2 + LocationManager.PASSIVE_PROVIDER -> 1 + else -> 0 +} + +private fun buildLocationRequest() = LocationRequestCompat.Builder(1_000).apply { + setMinUpdateIntervalMillis(1_000) +}.build() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9a4d549f77..2bf327ed58 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -155,6 +155,8 @@ vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0" vanniktech_emoji = "com.vanniktech:emoji-google:0.16.0" telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" } statemachine = "com.freeletics.flowredux:compose:1.1.0" +maplibre = "org.maplibre.gl:android-sdk:10.2.0" +maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:1.0.0" # Analytics posthog = "com.posthog.android:posthog:2.0.3" @@ -186,6 +188,7 @@ android_application = { id = "com.android.application", version.ref = "android_g android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" } kotlin_android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin_jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin_serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } anvil = { id = "com.squareup.anvil", version.ref = "anvil" } diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..04f26d7d81 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:407a17fca1405575861b7c8861222a5d529778eeaeb904c3058fe19ff9f809fc +size 143328 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6aebd55728 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95068257a39fc8a693adce87c54b605be395de5fd639ee00b7d34dde5bce568a +size 147055 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..97787cadcd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:819c585286d2ef5c7064766d537b9da62be54b67340a5a7a44e94dcf53b1caf4 +size 277318 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7efba0b598 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c0e68e68e1cf4f735671b5abcfb4e0c936ca1a00ead817e8f010d39f5b753252 +size 280801 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_MapViewDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_MapViewDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..78ba79757e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_MapViewDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5d34ac54f8c46bc3752366adeefb0952b23f052f9359b48864a6a43455334a6d +size 4965 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_MapViewLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_MapViewLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..665c8811ac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_MapViewLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b +size 4457 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_StaticMapViewDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_StaticMapViewDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..04f26d7d81 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_StaticMapViewDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:407a17fca1405575861b7c8861222a5d529778eeaeb904c3058fe19ff9f809fc +size 143328 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_StaticMapViewLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_StaticMapViewLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..97787cadcd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_StaticMapViewLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:819c585286d2ef5c7064766d537b9da62be54b67340a5a7a44e94dcf53b1caf4 +size 277318