diff --git a/changelog.d/7864.sdk b/changelog.d/7864.sdk new file mode 100644 index 0000000000..b7c6a5b339 --- /dev/null +++ b/changelog.d/7864.sdk @@ -0,0 +1 @@ +[Poll] Adding PollHistoryService diff --git a/changelog.d/7864.wip b/changelog.d/7864.wip new file mode 100644 index 0000000000..da04806b8b --- /dev/null +++ b/changelog.d/7864.wip @@ -0,0 +1 @@ +[Poll] History list: unmock data diff --git a/changelog.d/8005.sdk b/changelog.d/8005.sdk new file mode 100644 index 0000000000..1849776d50 --- /dev/null +++ b/changelog.d/8005.sdk @@ -0,0 +1 @@ +[Push rules] Call /actions api before /enabled api diff --git a/dependencies.gradle b/dependencies.gradle index 5d7286ab1a..bab9229b3b 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -27,7 +27,7 @@ def jjwt = "0.11.5" // Temporary version to unblock #6929. Once 0.16.0 is released we should use it, and revert // the whole commit which set version 0.16.0-SNAPSHOT def vanniktechEmoji = "0.16.0-SNAPSHOT" -def sentry = "6.12.1" +def sentry = "6.13.0" // Use 1.6.0 alpha to fix issue with test def fragment = "1.6.0-alpha04" // Testing diff --git a/fastlane/metadata/android/ru-RU/changelogs/40105110.txt b/fastlane/metadata/android/ru-RU/changelogs/40105110.txt new file mode 100644 index 0000000000..3de0ce886e --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40105110.txt @@ -0,0 +1,2 @@ +Главные изменения в этой версии: Новый полноэкранный режим в улучшенном редакторе текста и исправления багов. +Полный список: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/ru-RU/changelogs/40105140.txt b/fastlane/metadata/android/ru-RU/changelogs/40105140.txt new file mode 100644 index 0000000000..9700aef76f --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40105140.txt @@ -0,0 +1,2 @@ +Главные изменения в этой версии: Обсуждения включены по умолчанию. +Полный список: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/ru-RU/changelogs/40105160.txt b/fastlane/metadata/android/ru-RU/changelogs/40105160.txt new file mode 100644 index 0000000000..9700aef76f --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40105160.txt @@ -0,0 +1,2 @@ +Главные изменения в этой версии: Обсуждения включены по умолчанию. +Полный список: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/ru-RU/changelogs/40105180.txt b/fastlane/metadata/android/ru-RU/changelogs/40105180.txt new file mode 100644 index 0000000000..9700aef76f --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40105180.txt @@ -0,0 +1,2 @@ +Главные изменения в этой версии: Обсуждения включены по умолчанию. +Полный список: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/ru-RU/changelogs/40105200.txt b/fastlane/metadata/android/ru-RU/changelogs/40105200.txt new file mode 100644 index 0000000000..9ee0bb9656 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40105200.txt @@ -0,0 +1,2 @@ +Главные изменения в этой версии: Устранения багов! +Полный список: https://github.com/vector-im/element-android/releases diff --git a/library/ui-strings/src/main/res/values-cs/strings.xml b/library/ui-strings/src/main/res/values-cs/strings.xml index c9d697f560..c122de7798 100644 --- a/library/ui-strings/src/main/res/values-cs/strings.xml +++ b/library/ui-strings/src/main/res/values-cs/strings.xml @@ -2978,4 +2978,5 @@ Hlasovou zprávu nelze spustit, protože právě nahráváte živé vysílání. Ukončete prosím živé vysílání, abyste mohli začít nahrávat hlasovou zprávu Nelze spustit hlasovou zprávu - + Chyba připojení - nahrávání pozastaveno + \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-de/strings.xml b/library/ui-strings/src/main/res/values-de/strings.xml index f0e5a7bb8d..17fa9b6e44 100644 --- a/library/ui-strings/src/main/res/values-de/strings.xml +++ b/library/ui-strings/src/main/res/values-de/strings.xml @@ -2917,4 +2917,5 @@ Du kannst keine Sprachnachricht beginnen, da du im Moment eine Echtzeitübertragung aufzeichnest. Bitte beende deine Sprachübertragung, um ein Gespräch zu beginnen Kann Sprachnachricht nicht beginnen - + Verbindungsfehler − Aufnahme pausiert + \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-et/strings.xml b/library/ui-strings/src/main/res/values-et/strings.xml index 1d7b96d2f9..f33ade2a7e 100644 --- a/library/ui-strings/src/main/res/values-et/strings.xml +++ b/library/ui-strings/src/main/res/values-et/strings.xml @@ -2909,4 +2909,5 @@ Viga küsitluste laadimisel. Häälsõnumi esitamine ei õnnestu Kuna sa hetkel salvestad ringhäälingukõnet, siis häälsõnumi salvestamine või esitamine ei õnnestu. Selleks palun lõpeta ringhäälingukõne - + Viga võrguühenduses - salvestamine on peatatud + \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-fa/strings.xml b/library/ui-strings/src/main/res/values-fa/strings.xml index 1b726a2428..d498f4a51b 100644 --- a/library/ui-strings/src/main/res/values-fa/strings.xml +++ b/library/ui-strings/src/main/res/values-fa/strings.xml @@ -2918,4 +2918,5 @@ از آن‌جا که در حال ضبط پخشی زنده‌اید، نمی‌توانید پیامی صوتی را آغاز کنید. لطفاً برای آغاز ضبط یک پیام صوتی، پخش زنده‌تان را پایان دهید نمی‌توان پخش صوتی را آغاز کرد - + خطای اتّصال - ضبط مکث شد + \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-fr/strings.xml b/library/ui-strings/src/main/res/values-fr/strings.xml index e45211b61a..d62d208e43 100644 --- a/library/ui-strings/src/main/res/values-fr/strings.xml +++ b/library/ui-strings/src/main/res/values-fr/strings.xml @@ -2918,4 +2918,5 @@ Vous ne pouvez pas commencer un message vocal car vous êtes en train d’enregistrer une diffusion en direct. Veuillez terminer cette diffusion pour commencer un message vocal Impossible de démarrer un message vocal - + Erreur de connexion – Enregistrement en pause + \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-hu/strings.xml b/library/ui-strings/src/main/res/values-hu/strings.xml index 0aa70cea55..c265b79969 100644 --- a/library/ui-strings/src/main/res/values-hu/strings.xml +++ b/library/ui-strings/src/main/res/values-hu/strings.xml @@ -2918,4 +2918,5 @@ A Visszaállítási Kulcsot tartsd biztonságos helyen, mint pl. egy jelszókeze Nem lehet hang üzenetet indítani élő közvetítés felvétele közben. Az élő közvetítés bejezése szükséges a hang üzenet indításához Hang üzenetet nem lehet elindítani - + Kapcsolódási hiba – Felvétel szüneteltetve + \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-in/strings.xml b/library/ui-strings/src/main/res/values-in/strings.xml index 4c524df727..8a05481fd5 100644 --- a/library/ui-strings/src/main/res/values-in/strings.xml +++ b/library/ui-strings/src/main/res/values-in/strings.xml @@ -2858,4 +2858,7 @@ Di masa mendatang proses verifikasi ini akan dimutakhirkan. Tidak ada pemungutan suara aktif %1$d hari terakhir. \nMuat lebih banyak pemungutan suara untuk melihat pemungutan suara untuk hari sebelumnya. - + Kesalahan koneksi - Perekaman dijeda + Anda tidak dapat memulai sebuah pesan suara karena Anda saat ini merekam sebuah siaran langsung. Silakan mengakhiri siaran langsung Anda untuk memulai merekam sebuah pesan suara + Tidak dapat memulai pesan suara + \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-ja/strings.xml b/library/ui-strings/src/main/res/values-ja/strings.xml index d893156f6e..0b987ce683 100644 --- a/library/ui-strings/src/main/res/values-ja/strings.xml +++ b/library/ui-strings/src/main/res/values-ja/strings.xml @@ -198,7 +198,7 @@ メインアドレスとして設定 メインアドレスとしての設定を解除 セッションID - 文字の大きさ + フォントの大きさ とても小さい 小さい 標準 @@ -2391,7 +2391,7 @@ 招待 プッシュ通知 セッション名 - セッションを改名 + セッション名を変更 IPアドレス オペレーティングシステム 形式 @@ -2487,4 +2487,17 @@ %1$dを選択しました - + 有効にすると、このアプリケーションを使用している際にも、他のユーザーにオフラインとして表示されます。 + 最近のチャットをシステムの共有メニューに表示 + システムの既定値を使用 + 手動で設定 + 自動的に設定 + フォントの大きさを選択 + ⚠ 未認証の端末がこのルームにあります。あなたが送信するメッセージを復号化することはできません。 + このルームの未認証のセッションに暗号化されたメッセージを送信しない。 + あなたのホームサーバーはスレッドの一覧表示をまだサポートしていません。 + ここに新しいリクエストと招待が表示されます。 + リッチテキストエディターを試してみる(プレーンテキストモードは近日公開) + タブを使用してElementの表示をシンプルにする + セッションの詳細 + \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-pl/strings.xml b/library/ui-strings/src/main/res/values-pl/strings.xml index 0aad400340..4419187ba5 100644 --- a/library/ui-strings/src/main/res/values-pl/strings.xml +++ b/library/ui-strings/src/main/res/values-pl/strings.xml @@ -345,7 +345,7 @@ Importuj klucze z lokalnego pliku Importuj Szyfruj wiadomości tylko do zaufanych sesji - Nigdy nie wysyłaj szyfrowanych wiadomości do sesji (np urządzeń innych użytkowników) które nie zostały zweryfikowane. + Nigdy nie wysyłaj szyfrowanych wiadomości do niezweryfikowanych sesji (bez zielonej tarczy) z tego urządzenia. Aby sprawdzić czy ta sesja jest zaufana, skontaktuj się z jej właścicielem używając innych form (np. osobiście lub telefonicznie) i zapytaj czy klucz, który widzą w ustawieniach użytkownika dla tego urządzenia pasuje do klucza poniżej: Jeśli klucz pasuje, potwierdź to przyciskiem poniżej. Jeśli nie, to ktoś inny najprawdopodobniej przejmuje lub podszywa się pod tą sesję i powinieneś dodać tę sesję do czarnej listy. W przyszłości proces weryfikacji będzie bardziej skomplikowany. Wyślij naklejkę @@ -1115,7 +1115,7 @@ \nKlucze nie są zaufane Podpis krzyżowy nie jest aktywowany Aktywne Sesje - Pokaż wszystkie Sesje + Pokaż wszystkie sesje Zarządzaj Sesjami Wyloguj z tej sesji Brak dostępnej informacji o kryptografii @@ -1242,7 +1242,7 @@ Zapisz Klucz Bezpieczeństwa Użyj Frazy Bezpieczeństwa Użyj klucza bezpieczeństwa - Zabezpiecza przeciwko utracie dostępu do zaszyfrowanych wiadomości oraz danych poprzez zapisanie zaszyfrowanych kluczy na Twoim serwerze. + Zabezpiecza przed utratą dostępu do zaszyfrowanych wiadomości poprzez zapisanie kluczy szyfrujących na twoim serwerze. Włącz aparat Wyłącz aparat Wyłącz wyciszenie mikrofonu @@ -1493,7 +1493,7 @@ Integracje są zablokowane To zastąpi obecny Klucz bądź Hasło. Wygeneruj nowy klucz bezpieczeństwa albo hasło dla istniejącej kopii zapasowej. - Zabezpiecza przeciwko utracie dostępu do zaszyfrowanych wiadomości oraz danych poprzez zapisanie zaszyfrowanych kluczy na Twoim serwerze. + Zabezpiecza przed utratą dostępu do zaszyfrowanych wiadomości poprzez zapisanie kluczy szyfrujących na twoim serwerze. Powiadomienie zostało kliknięte! Proszę kliknąć na powiadomieniu, Jeżeli nie widzisz powiadomienia, sprawdź ustawienia systemowe. Widzisz powiadomienia! Kliknij na mnie! @@ -2795,4 +2795,36 @@ Rozumiem Zwiń %s pokojów Rozwiń %s pokojów - + Nieaktywne sesje + Ta sesja jest gotowa do bezpiecznego przesyłania wiadomości. + Twoja bieżąca sesja jest gotowa do bezpiecznego przesyłania wiadomości. + Kontakt + Lokalizacja + Aparat + Transmisja głosowa + Rozpocznij transmisję głosową + Ostatnie ankiety + W tym pokoju nie ma aktywnych ankiet + Aktywne ankiety + Niektóre głosy mogą nie zostać policzone z powodu błędów w odszyfrowaniu + Zakończono ankietę. + Błąd połączenia - Nagrywanie wstrzymane + Nie można odtworzyć tej transmisji głosowej. + Jesteś już w trakcie nagrywania transmisji głosowej. Proszę zakończyć bieżącą transmisję, aby rozpocząć nową. + Ktoś inny nagrywa już transmisję głosową. Aby rozpocząć nową transmisję, należy poczekać na jej zakończenie. + Nie masz wymaganych uprawnień do rozpoczęcia transmisji głosowej w tym pokoju. Skontaktuj się z administratorem pokoju, aby przyznał ci uprawnienia. + Nie można rozpocząć nowej transmisji głosowej + Buforowanie… + Nie można rozpocząć wiadomości głosowej + Masz niezweryfikowane sesje + Autentyczność tej zaszyfrowanej wiadomości nie może być zagwarantowana na tym urządzeniu. + Historia ankiet + Dodaje (╯°□°)╯︵ ┻━┻ do wiadomości tekstowej + Skanuj kod QR + %1$s zakończył(a) transmisję głosową. + Zarządaj od systemu Android aby klawiatura nie zapisywała żadnych danych takich jak historia pisania lub słownik. Pamiętaj, nie niektóre klawiatury mogą nie zastosować się do tego ustawienia. + Klawiatura incognito + Witaj w ${app_name}, +\n%s. + Wszechstronna, bezpieczna aplikacja do czatowania dla zespołów, przyjaciół i organizacji. Utwórz czat lub dołącz do istniejącego pokoju, aby rozpocząć. + \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-ru/strings.xml b/library/ui-strings/src/main/res/values-ru/strings.xml index 1255776c1f..5938200c1e 100644 --- a/library/ui-strings/src/main/res/values-ru/strings.xml +++ b/library/ui-strings/src/main/res/values-ru/strings.xml @@ -6,7 +6,7 @@ %1$s вошёл(шла) в комнату %1$s покинул(а) комнату %1$s отклонил(а) приглашение - %1$s выгнан %2$s + %1$s выгнал %2$s %1$s разблокировал(а) %2$s %1$s заблокировал(а) %2$s %1$s отозвал(а) приглашение %2$s @@ -65,7 +65,7 @@ %1$s отклонил приглашение. Причина: %2$s %1$s выгнали %2$s. Причина: %3$s %1$s разблокировано %2$s. Причина: %3$s - %1$s забанен %2$s. Причина: %3$s + %1$s забанил %2$s. Причина: %3$s %1$s принял приглашение для %2$s. Причина: %3$s %1$s отозвал приглашение %2$s. Причина: %3$s %1$s создал(а) комнату @@ -1420,7 +1420,7 @@ Посылает сообщение, окрашенное в цвет радуги Посылает данную эмоцию, окрашенную в цвет радуги Редактор сообщений - Включаем сквозное шифрование… + Включить сквозное шифрование… Включить шифрование\? После включения шифрование для комнаты нельзя отключить. Сообщения отправленные в зашифрованной комнате не будут видны серверу, только участникам комнаты. Включение шифрования может помешать правильной работе многих ботов и мостов. Включить шифрование @@ -2433,7 +2433,7 @@ Не удалось загрузить карту Карта Примечание: приложение будет перезапущено - Обсуждения сообщений + Включить обсуждения сообщений Подключиться к серверу Хотите присоединиться к существующему серверу\? Пропустить вопрос @@ -2540,7 +2540,7 @@ Домашний сервер не принимает имя пользователя, состоящее только из цифр. Пропустить этот шаг Сохранить и продолжить - Ваши предпочтения были сохранены. + Зайдите в настройки чтобы изменить Ваш профиль Выглядит хорошо! ${app_name} также отлично подходит для работы. Ему доверяют самые надёжные организации в мире. Резервная копия имеет действительную подпись для данного пользователя. @@ -2791,7 +2791,7 @@ Рассмотрите возможность выхода из старых сеансов (%1$d дней или дольше), которые вы более не используете. Голосовая трансляция - Голосовые трансляции + Включить голосовые трансляции Записывает название клиента, версию и URL-адрес для более лёгкого распознавания сеансов в менеджере сеансов. Записывать информацию о клиенте Галерея @@ -2824,9 +2824,9 @@ Развернуть дочерние элементы %s Выбрано %1$d - Выбрано %1$d - Выбрано %1$d - Выбрано %1$d + Выбраны %1$d + Выбраны %1$d + Выбраны %1$d Войти в полноэкранный режим Применить форматирование подчёркиванием @@ -2970,4 +2970,58 @@ Этот сеанс не поддерживает шифрование и поэтому не может быть заверен. %1$s завершил(а) голосовую трансляцию. Вы завершили голосовую трансляцию. - + + Нет активных опросов за %1$d день. +\nЗагрузите больше чтобы показать опросы за прошедшие дни. + Нет активных опросов за %1$d дней. +\nЗагрузите больше чтобы показать опросы за прошедшие дни. + Нет активных опросов за %1$d дней. +\nЗагрузите больше чтобы показать опросы за прошедшие дни. + Нет активных опросов за %1$d дней. +\nЗагрузите больше чтобы показать опросы за прошедшие дни. + + + Нет завершённых опросов за день %1$d. +\nЗагрузите больше чтобы показать опросы за предыдущие дни. + Нет завершённых опросов за %1$d дней +\nЗагрузите больше чтобы показать опросы за предыдущие дни. + Нет завершённых опросов за %1$d дней +\nЗагрузите больше чтобы показать опросы за предыдущие дни. + Нет завершённых опросов за %1$d дней +\nЗагрузите больше чтобы показать опросы за предыдущие дни. + + Токен доступа даёт полный доступ к аккаунту. Не делитесь им ни с кем. + Токен доступа + Завершённый опрос + Опрос + завершённый опрос. + Изменить ссылку + Создать ссылку + Ссылка + Текст + Список + Пронумерованный список + Ссылка + Ошибка считывания опросов. + Загрузить больше опросов + Показываем опросы + Нет завершённых опросов + Завершённые опросы + Нет активных опросов + Активные опросы + Из-за ошибок расшифровки, некоторые голоса могут быть не засчитаны + Опрос завершён. + Вы уверены что хотите завершить голосовую трансляцию\? Это завершит трансляцию и полная запись будет доступна в чате. + Завершить голосовую трансляцию\? + Ошибка подключения - Запись приостановлена + Невозможно прослушать голосовую трансляцию. + Голосовая трансляция + Вы не можете записать голосовое сообщение, потому-что Вы записываете голосовую трансляцию. Завершите голосовую трансляцию, чтобы записать голосовое сообщение + Не удалось записать голосовое сообщение + Убедиться что Ваш аккаунт в безопасности + Получить последнюю сборку (у вас могут быть проблемы со входом) + История опроса + Голосовая трансляция начата + Ваш домашний сервер не поддерживает список обсуждений. + Остановить + \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-sk/strings.xml b/library/ui-strings/src/main/res/values-sk/strings.xml index ed3f47f9d3..c9e92d323b 100644 --- a/library/ui-strings/src/main/res/values-sk/strings.xml +++ b/library/ui-strings/src/main/res/values-sk/strings.xml @@ -2978,4 +2978,5 @@ Nemôžete spustiť hlasovú správu, pretože práve nahrávate živé vysielanie. Ukončite prosím živé vysielanie, aby ste mohli začať nahrávať hlasovú správu Nemožno spustiť hlasovú správu - + Chyba pripojenia - nahrávanie pozastavené + \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values-uk/strings.xml b/library/ui-strings/src/main/res/values-uk/strings.xml index 6294526be2..0f6027903f 100644 --- a/library/ui-strings/src/main/res/values-uk/strings.xml +++ b/library/ui-strings/src/main/res/values-uk/strings.xml @@ -3038,4 +3038,5 @@ Показ опитувань Ви не можете розпочати запис голосового повідомлення, оскільки ви записуєте трансляцію наживо. Будь ласка, заверште її, щоб розпочати запис голосового повідомлення Не вдалося розпочати запис голосового повідомлення - + Помилка з\'єднання - Запис призупинено + \ No newline at end of file diff --git a/library/ui-strings/src/main/res/values/donottranslate.xml b/library/ui-strings/src/main/res/values/donottranslate.xml index bfe751ef5a..910ce31c41 100755 --- a/library/ui-strings/src/main/res/values/donottranslate.xml +++ b/library/ui-strings/src/main/res/values/donottranslate.xml @@ -10,6 +10,8 @@ Cut the slack from teams. + Crash the application. + © MapTiler © OpenStreetMap contributors diff --git a/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowSession.kt b/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowSession.kt index 80ed311901..64cb0acb2d 100644 --- a/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowSession.kt +++ b/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowSession.kt @@ -80,6 +80,9 @@ class FlowSession(private val session: Session) { fun liveSyncState(): Flow { return session.syncService().getSyncStateLive().asFlow() + .startWith(session.coroutineDispatchers.io) { + session.syncService().getSyncState() + } } fun livePushers(): Flow> { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt index 8031fcaeea..de360c89c6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt @@ -28,6 +28,7 @@ import org.matrix.android.sdk.api.session.room.model.LocalRoomSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.relation.RelationService import org.matrix.android.sdk.api.session.room.notification.RoomPushRuleService +import org.matrix.android.sdk.api.session.room.poll.PollHistoryService import org.matrix.android.sdk.api.session.room.read.ReadService import org.matrix.android.sdk.api.session.room.reporting.ReportingService import org.matrix.android.sdk.api.session.room.send.DraftService @@ -181,4 +182,9 @@ interface Room { * Get the LocationSharingService associated to this Room. */ fun locationSharingService(): LocationSharingService + + /** + * Get the PollHistoryService associated to this Room. + */ + fun pollHistoryService(): PollHistoryService } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/poll/LoadedPollsStatus.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/poll/LoadedPollsStatus.kt new file mode 100644 index 0000000000..02a7667ebf --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/poll/LoadedPollsStatus.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.session.room.poll + +/** + * Represent the status of the loaded polls for a room. + */ +data class LoadedPollsStatus( + /** + * Indicate whether more polls can be loaded from timeline. + * A false value would mean the start of the timeline has been reached. + */ + val canLoadMore: Boolean, + + /** + * Number of days of timeline events currently synced (fetched and stored in local). + */ + val daysSynced: Int, + + /** + * Indicate whether a sync of timeline events has been completely done in backward. It would + * mean timeline events have been synced for at least a number of days defined by [PollHistoryService.loadingPeriodInDays]. + */ + val hasCompletedASyncBackward: Boolean, +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/poll/PollHistoryService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/poll/PollHistoryService.kt new file mode 100644 index 0000000000..62706af86a --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/poll/PollHistoryService.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.session.room.poll + +import androidx.lifecycle.LiveData +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent + +/** + * Expose methods to get history of polls in rooms. + */ +interface PollHistoryService { + + /** + * The number of days covered when requesting to load more polls. + */ + val loadingPeriodInDays: Int + + /** + * This must be called when you don't need the service anymore. + * It ensures the underlying database get closed. + */ + fun dispose() + + /** + * Ask to load more polls starting from last loaded polls for a period defined by + * [loadingPeriodInDays]. + */ + suspend fun loadMore(): LoadedPollsStatus + + /** + * Get the current status of the loaded polls. + */ + suspend fun getLoadedPollsStatus(): LoadedPollsStatus + + /** + * Sync polls from last loaded polls until now. + */ + suspend fun syncPolls() + + /** + * Get currently loaded list of poll events. See [loadMore]. + */ + fun getPollEvents(): LiveData> +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt index fe55beb997..45bcd792c2 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt @@ -66,6 +66,7 @@ import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo046 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo047 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo048 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo049 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo050 import org.matrix.android.sdk.internal.util.Normalizer import org.matrix.android.sdk.internal.util.database.MatrixRealmMigration import javax.inject.Inject @@ -74,7 +75,7 @@ internal class RealmSessionStoreMigration @Inject constructor( private val normalizer: Normalizer ) : MatrixRealmMigration( dbName = "Session", - schemaVersion = 49L, + schemaVersion = 50L, ) { /** * Forces all RealmSessionStoreMigration instances to be equal. @@ -133,5 +134,6 @@ internal class RealmSessionStoreMigration @Inject constructor( if (oldVersion < 47) MigrateSessionTo047(realm).perform() if (oldVersion < 48) MigrateSessionTo048(realm).perform() if (oldVersion < 49) MigrateSessionTo049(realm).perform() + if (oldVersion < 50) MigrateSessionTo050(realm).perform() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo050.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo050.kt new file mode 100644 index 0000000000..dfbfdc8da7 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo050.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2023 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.database.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +/** + * Adding new entity PollHistoryStatusEntity. + */ +internal class MigrateSessionTo050(realm: DynamicRealm) : RealmMigrator(realm, 50) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.create("PollHistoryStatusEntity") + .addField(PollHistoryStatusEntityFields.ROOM_ID, String::class.java) + .addPrimaryKey(PollHistoryStatusEntityFields.ROOM_ID) + .setRequired(PollHistoryStatusEntityFields.ROOM_ID, true) + .addField(PollHistoryStatusEntityFields.CURRENT_TIMESTAMP_TARGET_BACKWARD_MS, Long::class.java) + .setNullable(PollHistoryStatusEntityFields.CURRENT_TIMESTAMP_TARGET_BACKWARD_MS, true) + .addField(PollHistoryStatusEntityFields.OLDEST_TIMESTAMP_TARGET_REACHED_MS, Long::class.java) + .setNullable(PollHistoryStatusEntityFields.OLDEST_TIMESTAMP_TARGET_REACHED_MS, true) + .addField(PollHistoryStatusEntityFields.OLDEST_EVENT_ID_REACHED, String::class.java) + .addField(PollHistoryStatusEntityFields.MOST_RECENT_EVENT_ID_REACHED, String::class.java) + .addField(PollHistoryStatusEntityFields.IS_END_OF_POLLS_BACKWARD, Boolean::class.java) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/PollHistoryStatusEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/PollHistoryStatusEntity.kt new file mode 100644 index 0000000000..35075ffa0e --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/PollHistoryStatusEntity.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2023 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.database.model + +import io.realm.RealmObject +import io.realm.annotations.PrimaryKey +import org.matrix.android.sdk.internal.session.room.poll.PollConstants + +/** + * Keeps track of the loading process of the poll history. + */ +internal open class PollHistoryStatusEntity( + /** + * The related room id. + */ + @PrimaryKey + var roomId: String = "", + + /** + * Timestamp of the in progress poll sync target in backward direction in milliseconds. + */ + var currentTimestampTargetBackwardMs: Long? = null, + + /** + * Timestamp of the oldest event synced once target has been reached in milliseconds. + */ + var oldestTimestampTargetReachedMs: Long? = null, + + /** + * Id of the oldest event synced. + */ + var oldestEventIdReached: String? = null, + + /** + * Id of the most recent event synced. + */ + var mostRecentEventIdReached: String? = null, + + /** + * Indicate whether all polls in a room have been synced in backward direction. + */ + var isEndOfPollsBackward: Boolean = false, +) : RealmObject() { + + companion object + + /** + * Create a new instance of the entity with the same content. + */ + fun copy(): PollHistoryStatusEntity { + return PollHistoryStatusEntity( + roomId = roomId, + currentTimestampTargetBackwardMs = currentTimestampTargetBackwardMs, + oldestTimestampTargetReachedMs = oldestTimestampTargetReachedMs, + oldestEventIdReached = oldestEventIdReached, + mostRecentEventIdReached = mostRecentEventIdReached, + isEndOfPollsBackward = isEndOfPollsBackward, + ) + } + + /** + * Indicate whether at least one poll sync has been fully completed backward for the given room. + */ + val hasCompletedASyncBackward: Boolean + get() = oldestTimestampTargetReachedMs != null + + /** + * Indicate whether all polls in a room have been synced for the current timestamp target in backward direction. + */ + val currentTimestampTargetBackwardReached: Boolean + get() = checkIfCurrentTimestampTargetBackwardIsReached() + + private fun checkIfCurrentTimestampTargetBackwardIsReached(): Boolean { + val currentTarget = currentTimestampTargetBackwardMs + val lastTarget = oldestTimestampTargetReachedMs + // last timestamp target should be older or equal to the current target + return currentTarget != null && lastTarget != null && lastTarget <= currentTarget + } + + /** + * Compute the number of days of history currently synced. + */ + fun getNbSyncedDays(currentMs: Long): Int { + val oldestTimestamp = oldestTimestampTargetReachedMs + return if (oldestTimestamp == null) { + 0 + } else { + ((currentMs - oldestTimestamp).coerceAtLeast(0) / PollConstants.MILLISECONDS_PER_DAY).toInt() + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/PollResponseAggregatedSummaryEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/PollResponseAggregatedSummaryEntity.kt index 906e329f6f..e74f8e2ce9 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/PollResponseAggregatedSummaryEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/PollResponseAggregatedSummaryEntity.kt @@ -36,7 +36,4 @@ internal open class PollResponseAggregatedSummaryEntity( var sourceLocalEchoEvents: RealmList = RealmList(), // list of related event ids which are encrypted due to decryption failure var encryptedRelatedEventIds: RealmList = RealmList(), -) : RealmObject() { - - companion object -} +) : RealmObject() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt index 93fe1bd1d2..af8dfd7ece 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt @@ -73,6 +73,7 @@ import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntit UserPresenceEntity::class, ThreadSummaryEntity::class, ThreadListPageEntity::class, + PollHistoryStatusEntity::class, ] ) internal class SessionRealmModule diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/PollHistoryStatusEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/PollHistoryStatusEntityQueries.kt new file mode 100644 index 0000000000..1396eb897b --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/PollHistoryStatusEntityQueries.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.database.query + +import io.realm.Realm +import io.realm.kotlin.createObject +import io.realm.kotlin.where +import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntity +import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntityFields + +internal fun PollHistoryStatusEntity.Companion.get(realm: Realm, roomId: String): PollHistoryStatusEntity? { + return realm.where().equalTo(PollHistoryStatusEntityFields.ROOM_ID, roomId).findFirst() +} + +internal fun PollHistoryStatusEntity.Companion.getOrCreate(realm: Realm, roomId: String): PollHistoryStatusEntity { + return get(realm, roomId) ?: realm.createObject(roomId) +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/UpdatePushRuleActionsTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/UpdatePushRuleActionsTask.kt index 454b9cdd80..ec6b5d5268 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/UpdatePushRuleActionsTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushers/UpdatePushRuleActionsTask.kt @@ -34,10 +34,16 @@ internal interface UpdatePushRuleActionsTask : Task { + data class Params( + val roomId: String, + val events: List, + val filterPredicate: (Event) -> Boolean, + ) +} + +internal class DefaultFilterAndStoreEventsTask @Inject constructor( + @SessionDatabase private val monarchy: Monarchy, + private val clock: Clock, + private val eventDecryptor: EventDecryptor, +) : FilterAndStoreEventsTask { + + override suspend fun execute(params: FilterAndStoreEventsTask.Params) { + val filteredEvents = params.events + .map { decryptEventIfNeeded(it) } + // we also filter in the encrypted events since it means there was decryption error for them + // and they may be decrypted later + .filter { params.filterPredicate(it) || it.getClearType() == EventType.ENCRYPTED } + + addMissingEventsInDB(params.roomId, filteredEvents) + } + + private suspend fun addMissingEventsInDB(roomId: String, events: List) { + monarchy.awaitTransaction { realm -> + val eventIdsToCheck = events.mapNotNull { it.eventId }.filter { it.isNotEmpty() } + if (eventIdsToCheck.isNotEmpty()) { + val existingIds = EventEntity.where(realm, eventIdsToCheck).findAll().toList().map { it.eventId } + + events.filterNot { it.eventId in existingIds } + .map { it.toEntity(roomId = roomId, sendState = SendState.SYNCED, ageLocalTs = computeLocalTs(it)) } + .forEach { it.copyToRealmOrIgnore(realm, EventInsertType.PAGINATION) } + } + } + } + + private suspend fun decryptEventIfNeeded(event: Event): Event { + if (event.isEncrypted()) { + eventDecryptor.decryptEventAndSaveResult(event, timeline = "") + } + + event.ageLocalTs = computeLocalTs(event) + + return event + } + + private fun computeLocalTs(event: Event) = clock.epochMillis() - (event.unsignedData?.age ?: 0) +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/poll/DefaultPollHistoryService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/poll/DefaultPollHistoryService.kt new file mode 100644 index 0000000000..28a857e6fa --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/poll/DefaultPollHistoryService.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2023 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.poll + +import androidx.lifecycle.LiveData +import androidx.lifecycle.Transformations +import com.zhuinden.monarchy.Monarchy +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.realm.kotlin.where +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus +import org.matrix.android.sdk.api.session.room.poll.PollHistoryService +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.session.room.timeline.TimelineService +import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings +import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper +import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntity +import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntityFields +import org.matrix.android.sdk.internal.database.model.TimelineEventEntity +import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields +import org.matrix.android.sdk.internal.di.SessionDatabase +import org.matrix.android.sdk.internal.util.time.Clock + +private const val LOADING_PERIOD_IN_DAYS = 30 +private const val EVENTS_PAGE_SIZE = 250 + +internal class DefaultPollHistoryService @AssistedInject constructor( + @Assisted private val roomId: String, + @Assisted private val timelineService: TimelineService, + @SessionDatabase private val monarchy: Monarchy, + private val clock: Clock, + private val loadMorePollsTask: LoadMorePollsTask, + private val getLoadedPollsStatusTask: GetLoadedPollsStatusTask, + private val syncPollsTask: SyncPollsTask, + private val timelineEventMapper: TimelineEventMapper, +) : PollHistoryService { + + @AssistedFactory + interface Factory { + fun create(roomId: String, timelineService: TimelineService): DefaultPollHistoryService + } + + override val loadingPeriodInDays: Int + get() = LOADING_PERIOD_IN_DAYS + + private val timeline by lazy { + val settings = TimelineSettings( + initialSize = EVENTS_PAGE_SIZE, + buildReadReceipts = false, + rootThreadEventId = null, + useLiveSenderInfo = false, + ) + timelineService.createTimeline(eventId = null, settings = settings).also { it.start() } + } + private val timelineMutex = Mutex() + + override fun dispose() { + timeline.dispose() + } + + override suspend fun loadMore(): LoadedPollsStatus { + return timelineMutex.withLock { + val params = LoadMorePollsTask.Params( + timeline = timeline, + roomId = roomId, + currentTimestampMs = clock.epochMillis(), + loadingPeriodInDays = loadingPeriodInDays, + eventsPageSize = EVENTS_PAGE_SIZE, + ) + loadMorePollsTask.execute(params) + } + } + + override suspend fun getLoadedPollsStatus(): LoadedPollsStatus { + val params = GetLoadedPollsStatusTask.Params( + roomId = roomId, + currentTimestampMs = clock.epochMillis(), + ) + return getLoadedPollsStatusTask.execute(params) + } + + override suspend fun syncPolls() { + timelineMutex.withLock { + val params = SyncPollsTask.Params( + timeline = timeline, + roomId = roomId, + currentTimestampMs = clock.epochMillis(), + eventsPageSize = EVENTS_PAGE_SIZE, + ) + syncPollsTask.execute(params) + } + } + + override fun getPollEvents(): LiveData> { + val pollHistoryStatusLiveData = getPollHistoryStatus() + + return Transformations.switchMap(pollHistoryStatusLiveData) { results -> + val oldestTimestamp = results.firstOrNull()?.oldestTimestampTargetReachedMs ?: clock.epochMillis() + getPollStartEventsAfter(oldestTimestamp) + } + } + + private fun getPollStartEventsAfter(timestampMs: Long): LiveData> { + val eventsLiveData = monarchy.findAllMappedWithChanges( + { realm -> + val pollTypes = (EventType.POLL_START.values + EventType.ENCRYPTED).toTypedArray() + realm.where() + .equalTo(TimelineEventEntityFields.ROOM_ID, roomId) + .`in`(TimelineEventEntityFields.ROOT.TYPE, pollTypes) + .greaterThan(TimelineEventEntityFields.ROOT.ORIGIN_SERVER_TS, timestampMs) + }, + { result -> + timelineEventMapper.map(result, buildReadReceipts = false) + } + ) + + return Transformations.map(eventsLiveData) { events -> + events.filter { it.root.getClearType() in EventType.POLL_START.values } + .distinctBy { it.eventId } + } + } + + private fun getPollHistoryStatus(): LiveData> { + return monarchy.findAllMappedWithChanges( + { realm -> + realm.where() + .equalTo(PollHistoryStatusEntityFields.ROOM_ID, roomId) + }, + { result -> + // make a copy of the Realm object since it will be used in another transformations + result.copy() + } + ) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/poll/GetLoadedPollsStatusTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/poll/GetLoadedPollsStatusTask.kt new file mode 100644 index 0000000000..5bdb52d04c --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/poll/GetLoadedPollsStatusTask.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2023 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.poll + +import com.zhuinden.monarchy.Monarchy +import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus +import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntity +import org.matrix.android.sdk.internal.database.query.getOrCreate +import org.matrix.android.sdk.internal.di.SessionDatabase +import org.matrix.android.sdk.internal.task.Task +import org.matrix.android.sdk.internal.util.awaitTransaction +import javax.inject.Inject + +internal interface GetLoadedPollsStatusTask : Task { + data class Params( + val roomId: String, + val currentTimestampMs: Long, + ) +} + +internal class DefaultGetLoadedPollsStatusTask @Inject constructor( + @SessionDatabase private val monarchy: Monarchy, +) : GetLoadedPollsStatusTask { + + override suspend fun execute(params: GetLoadedPollsStatusTask.Params): LoadedPollsStatus { + return monarchy.awaitTransaction { realm -> + val status = PollHistoryStatusEntity + .getOrCreate(realm, params.roomId) + .copy() + LoadedPollsStatus( + canLoadMore = status.isEndOfPollsBackward.not(), + daysSynced = status.getNbSyncedDays(params.currentTimestampMs), + hasCompletedASyncBackward = status.hasCompletedASyncBackward, + ) + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/poll/LoadMorePollsTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/poll/LoadMorePollsTask.kt new file mode 100644 index 0000000000..50dbeb763e --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/poll/LoadMorePollsTask.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2023 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.poll + +import com.zhuinden.monarchy.Monarchy +import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus +import org.matrix.android.sdk.api.session.room.timeline.Timeline +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntity +import org.matrix.android.sdk.internal.database.query.getOrCreate +import org.matrix.android.sdk.internal.di.SessionDatabase +import org.matrix.android.sdk.internal.session.room.poll.PollConstants.MILLISECONDS_PER_DAY +import org.matrix.android.sdk.internal.task.Task +import org.matrix.android.sdk.internal.util.awaitTransaction +import javax.inject.Inject + +internal interface LoadMorePollsTask : Task { + data class Params( + val timeline: Timeline, + val roomId: String, + val currentTimestampMs: Long, + val loadingPeriodInDays: Int, + val eventsPageSize: Int, + ) +} + +internal class DefaultLoadMorePollsTask @Inject constructor( + @SessionDatabase private val monarchy: Monarchy, +) : LoadMorePollsTask { + + override suspend fun execute(params: LoadMorePollsTask.Params): LoadedPollsStatus { + var currentPollHistoryStatus = updatePollHistoryStatus(params) + + params.timeline.restartWithEventId(eventId = currentPollHistoryStatus.oldestEventIdReached) + + while (shouldFetchMoreEventsBackward(currentPollHistoryStatus)) { + currentPollHistoryStatus = fetchMorePollEventsBackward(params) + } + + return LoadedPollsStatus( + canLoadMore = currentPollHistoryStatus.isEndOfPollsBackward.not(), + daysSynced = currentPollHistoryStatus.getNbSyncedDays(params.currentTimestampMs), + hasCompletedASyncBackward = currentPollHistoryStatus.hasCompletedASyncBackward, + ) + } + + private fun shouldFetchMoreEventsBackward(status: PollHistoryStatusEntity): Boolean { + return status.currentTimestampTargetBackwardReached.not() && status.isEndOfPollsBackward.not() + } + + private suspend fun updatePollHistoryStatus(params: LoadMorePollsTask.Params): PollHistoryStatusEntity { + return monarchy.awaitTransaction { realm -> + val status = PollHistoryStatusEntity.getOrCreate(realm, params.roomId) + val currentTargetTimestampMs = status.currentTimestampTargetBackwardMs + val lastTargetTimestampMs = status.oldestTimestampTargetReachedMs + val loadingPeriodMs: Long = MILLISECONDS_PER_DAY * params.loadingPeriodInDays.toLong() + if (currentTargetTimestampMs == null) { + // first load, compute the target timestamp + status.currentTimestampTargetBackwardMs = params.currentTimestampMs - loadingPeriodMs + } else if (lastTargetTimestampMs != null && status.currentTimestampTargetBackwardReached) { + // previous load has finished, update the target timestamp + status.currentTimestampTargetBackwardMs = lastTargetTimestampMs - loadingPeriodMs + } + // return a copy of the Realm object + status.copy() + } + } + + private suspend fun fetchMorePollEventsBackward(params: LoadMorePollsTask.Params): PollHistoryStatusEntity { + val events = params.timeline.awaitPaginate( + direction = Timeline.Direction.BACKWARDS, + count = params.eventsPageSize, + ) + + val paginationState = params.timeline.getPaginationState(direction = Timeline.Direction.BACKWARDS) + + return updatePollHistoryStatus( + roomId = params.roomId, + events = events, + paginationState = paginationState, + ) + } + + private suspend fun updatePollHistoryStatus( + roomId: String, + events: List, + paginationState: Timeline.PaginationState, + ): PollHistoryStatusEntity { + return monarchy.awaitTransaction { realm -> + val status = PollHistoryStatusEntity.getOrCreate(realm, roomId) + val mostRecentEventIdReached = status.mostRecentEventIdReached + + if (mostRecentEventIdReached == null) { + // save it for next forward pagination + val mostRecentEvent = events + .maxByOrNull { it.root.originServerTs ?: Long.MIN_VALUE } + ?.root + status.mostRecentEventIdReached = mostRecentEvent?.eventId + } + + val oldestEvent = events + .minByOrNull { it.root.originServerTs ?: Long.MAX_VALUE } + ?.root + val oldestEventTimestamp = oldestEvent?.originServerTs + val oldestEventId = oldestEvent?.eventId + + val currentTargetTimestamp = status.currentTimestampTargetBackwardMs + + if (paginationState.hasMoreToLoad.not()) { + // start of the timeline is reached, there are no more events + status.isEndOfPollsBackward = true + + if (oldestEventTimestamp != null && oldestEventTimestamp > 0) { + status.oldestTimestampTargetReachedMs = oldestEventTimestamp + } + } else if (oldestEventTimestamp != null && currentTargetTimestamp != null && oldestEventTimestamp <= currentTargetTimestamp) { + // target has been reached + status.oldestTimestampTargetReachedMs = oldestEventTimestamp + } + + if (oldestEventId != null) { + // save it for next backward pagination + status.oldestEventIdReached = oldestEventId + } + + // return a copy of the Realm object + status.copy() + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/poll/PollConstants.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/poll/PollConstants.kt new file mode 100644 index 0000000000..bbc230610c --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/poll/PollConstants.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.poll + +object PollConstants { + const val MILLISECONDS_PER_DAY = 24 * 60 * 60_000 +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/poll/SyncPollsTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/poll/SyncPollsTask.kt new file mode 100644 index 0000000000..fff24288b4 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/poll/SyncPollsTask.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2023 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.poll + +import com.zhuinden.monarchy.Monarchy +import org.matrix.android.sdk.api.session.room.timeline.Timeline +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntity +import org.matrix.android.sdk.internal.database.query.getOrCreate +import org.matrix.android.sdk.internal.di.SessionDatabase +import org.matrix.android.sdk.internal.task.Task +import org.matrix.android.sdk.internal.util.awaitTransaction +import javax.inject.Inject + +internal interface SyncPollsTask : Task { + data class Params( + val timeline: Timeline, + val roomId: String, + val currentTimestampMs: Long, + val eventsPageSize: Int, + ) +} + +internal class DefaultSyncPollsTask @Inject constructor( + @SessionDatabase private val monarchy: Monarchy, +) : SyncPollsTask { + + override suspend fun execute(params: SyncPollsTask.Params) { + val currentPollHistoryStatus = getCurrentPollHistoryStatus(params.roomId) + + params.timeline.restartWithEventId(currentPollHistoryStatus.mostRecentEventIdReached) + + var loadStatus = LoadStatus(shouldLoadMore = true) + while (loadStatus.shouldLoadMore) { + loadStatus = fetchMorePollEventsForward(params) + } + + params.timeline.restartWithEventId(currentPollHistoryStatus.oldestEventIdReached) + } + + private suspend fun getCurrentPollHistoryStatus(roomId: String): PollHistoryStatusEntity { + return monarchy.awaitTransaction { realm -> + PollHistoryStatusEntity + .getOrCreate(realm, roomId) + .copy() + } + } + + private suspend fun fetchMorePollEventsForward(params: SyncPollsTask.Params): LoadStatus { + val events = params.timeline.awaitPaginate( + direction = Timeline.Direction.FORWARDS, + count = params.eventsPageSize, + ) + + val paginationState = params.timeline.getPaginationState(direction = Timeline.Direction.FORWARDS) + + return updatePollHistoryStatus( + roomId = params.roomId, + currentTimestampMs = params.currentTimestampMs, + events = events, + paginationState = paginationState, + ) + } + + private suspend fun updatePollHistoryStatus( + roomId: String, + currentTimestampMs: Long, + events: List, + paginationState: Timeline.PaginationState, + ): LoadStatus { + return monarchy.awaitTransaction { realm -> + val status = PollHistoryStatusEntity.getOrCreate(realm, roomId) + val mostRecentEvent = events + .maxByOrNull { it.root.originServerTs ?: Long.MIN_VALUE } + ?.root + val mostRecentEventIdReached = mostRecentEvent?.eventId + + if (mostRecentEventIdReached != null) { + // save it for next forward pagination + status.mostRecentEventIdReached = mostRecentEventIdReached + } + + val mostRecentTimestamp = mostRecentEvent?.originServerTs + + val shouldLoadMore = paginationState.hasMoreToLoad && + (mostRecentTimestamp == null || mostRecentTimestamp < currentTimestampMs) + + LoadStatus(shouldLoadMore = shouldLoadMore) + } + } + + private class LoadStatus( + val shouldLoadMore: Boolean, + ) +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/poll/FetchPollResponseEventsTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/poll/FetchPollResponseEventsTask.kt index e7dd8c57eb..347c9fbf12 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/poll/FetchPollResponseEventsTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/poll/FetchPollResponseEventsTask.kt @@ -17,25 +17,14 @@ package org.matrix.android.sdk.internal.session.room.relation.poll import androidx.annotation.VisibleForTesting -import com.zhuinden.monarchy.Monarchy -import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.events.model.isPollResponse -import org.matrix.android.sdk.api.session.room.send.SendState -import org.matrix.android.sdk.internal.crypto.EventDecryptor -import org.matrix.android.sdk.internal.database.mapper.toEntity -import org.matrix.android.sdk.internal.database.model.EventEntity -import org.matrix.android.sdk.internal.database.model.EventInsertType -import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore -import org.matrix.android.sdk.internal.database.query.where -import org.matrix.android.sdk.internal.di.SessionDatabase import org.matrix.android.sdk.internal.network.GlobalErrorReceiver import org.matrix.android.sdk.internal.network.executeRequest import org.matrix.android.sdk.internal.session.room.RoomAPI +import org.matrix.android.sdk.internal.session.room.event.FilterAndStoreEventsTask import org.matrix.android.sdk.internal.session.room.relation.RelationsResponse import org.matrix.android.sdk.internal.task.Task -import org.matrix.android.sdk.internal.util.awaitTransaction -import org.matrix.android.sdk.internal.util.time.Clock import javax.inject.Inject @VisibleForTesting @@ -54,10 +43,9 @@ internal interface FetchPollResponseEventsTask : Task = runCatching { var nextBatch: String? = fetchAndProcessRelatedEventsFrom(params) @@ -70,11 +58,12 @@ internal class DefaultFetchPollResponseEventsTask @Inject constructor( private suspend fun fetchAndProcessRelatedEventsFrom(params: FetchPollResponseEventsTask.Params, from: String? = null): String? { val response = getRelatedEvents(params, from) - val filteredEvents = response.chunks - .map { decryptEventIfNeeded(it) } - .filter { it.isPollResponse() } - - addMissingEventsInDB(params.roomId, filteredEvents) + val filterTaskParams = FilterAndStoreEventsTask.Params( + roomId = params.roomId, + events = response.chunks, + filterPredicate = { it.isPollResponse() } + ) + filterAndStoreEventsTask.execute(filterTaskParams) return response.nextBatch } @@ -90,29 +79,4 @@ internal class DefaultFetchPollResponseEventsTask @Inject constructor( ) } } - - private suspend fun addMissingEventsInDB(roomId: String, events: List) { - monarchy.awaitTransaction { realm -> - val eventIdsToCheck = events.mapNotNull { it.eventId }.filter { it.isNotEmpty() } - if (eventIdsToCheck.isNotEmpty()) { - val existingIds = EventEntity.where(realm, eventIdsToCheck).findAll().toList().map { it.eventId } - - events.filterNot { it.eventId in existingIds } - .map { it.toEntity(roomId = roomId, sendState = SendState.SYNCED, ageLocalTs = computeLocalTs(it)) } - .forEach { it.copyToRealmOrIgnore(realm, EventInsertType.PAGINATION) } - } - } - } - - private suspend fun decryptEventIfNeeded(event: Event): Event { - if (event.isEncrypted()) { - eventDecryptor.decryptEventAndSaveResult(event, timeline = "") - } - - event.ageLocalTs = computeLocalTs(event) - - return event - } - - private fun computeLocalTs(event: Event) = clock.epochMillis() - (event.unsignedData?.age ?: 0) } diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/event/DefaultFilterAndStoreEventsTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/event/DefaultFilterAndStoreEventsTaskTest.kt new file mode 100644 index 0000000000..81e43c7c03 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/event/DefaultFilterAndStoreEventsTaskTest.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2023 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.event + +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.room.send.SendState +import org.matrix.android.sdk.internal.database.mapper.toEntity +import org.matrix.android.sdk.internal.database.model.EventEntity +import org.matrix.android.sdk.internal.database.model.EventEntityFields +import org.matrix.android.sdk.internal.database.model.EventInsertType +import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore +import org.matrix.android.sdk.test.fakes.FakeClock +import org.matrix.android.sdk.test.fakes.FakeEventDecryptor +import org.matrix.android.sdk.test.fakes.FakeMonarchy +import org.matrix.android.sdk.test.fakes.givenFindAll +import org.matrix.android.sdk.test.fakes.givenIn + +@OptIn(ExperimentalCoroutinesApi::class) +internal class DefaultFilterAndStoreEventsTaskTest { + + private val fakeMonarchy = FakeMonarchy() + private val fakeClock = FakeClock() + private val fakeEventDecryptor = FakeEventDecryptor() + + private val defaultFilterAndStoreEventsTask = DefaultFilterAndStoreEventsTask( + monarchy = fakeMonarchy.instance, + clock = fakeClock, + eventDecryptor = fakeEventDecryptor.instance, + ) + + @Before + fun setup() { + mockkStatic("org.matrix.android.sdk.api.session.events.model.EventKt") + mockkStatic("org.matrix.android.sdk.internal.database.mapper.EventMapperKt") + mockkStatic("org.matrix.android.sdk.internal.database.query.EventEntityQueriesKt") + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given a room and list of events when execute then filter in using given predicate and store them in local if needed`() = runTest { + // Given + val aRoomId = "roomId" + val anEventId1 = "eventId1" + val anEventId2 = "eventId2" + val anEventId3 = "eventId3" + val anEventId4 = "eventId4" + val event1 = givenAnEvent(eventId = anEventId1, isEncrypted = true, clearType = EventType.ENCRYPTED) + val event2 = givenAnEvent(eventId = anEventId2, isEncrypted = true, clearType = EventType.MESSAGE) + val event3 = givenAnEvent(eventId = anEventId3, isEncrypted = false, clearType = EventType.MESSAGE) + val event4 = givenAnEvent(eventId = anEventId4, isEncrypted = false, clearType = EventType.MESSAGE) + val events = listOf(event1, event2, event3, event4) + val filterPredicate = { event: Event -> event == event2 } + val params = givenTaskParams(roomId = aRoomId, events = events, predicate = filterPredicate) + fakeEventDecryptor.givenDecryptEventAndSaveResultSuccess(event1) + fakeEventDecryptor.givenDecryptEventAndSaveResultSuccess(event2) + fakeClock.givenEpoch(123) + givenExistingEventEntities(eventIdsToCheck = listOf(anEventId1, anEventId2), existingIds = listOf(anEventId1)) + val eventEntityToSave = EventEntity(eventId = anEventId2) + every { event2.toEntity(any(), any(), any()) } returns eventEntityToSave + every { eventEntityToSave.copyToRealmOrIgnore(any(), any()) } returns eventEntityToSave + + // When + defaultFilterAndStoreEventsTask.execute(params) + + // Then + fakeEventDecryptor.verifyDecryptEventAndSaveResult(event1, timeline = "") + fakeEventDecryptor.verifyDecryptEventAndSaveResult(event2, timeline = "") + // Check we save in DB the event2 which is a non stored poll response + verify { + event2.toEntity(aRoomId, SendState.SYNCED, any()) + eventEntityToSave.copyToRealmOrIgnore(fakeMonarchy.fakeRealm.instance, EventInsertType.PAGINATION) + } + } + + private fun givenTaskParams(roomId: String, events: List, predicate: (Event) -> Boolean) = FilterAndStoreEventsTask.Params( + roomId = roomId, + events = events, + filterPredicate = predicate, + ) + + private fun givenAnEvent( + eventId: String, + isEncrypted: Boolean, + clearType: String, + ): Event { + val event = mockk(relaxed = true) + every { event.eventId } returns eventId + every { event.isEncrypted() } returns isEncrypted + every { event.getClearType() } returns clearType + return event + } + + private fun givenExistingEventEntities(eventIdsToCheck: List, existingIds: List) { + val eventEntities = existingIds.map { EventEntity(eventId = it) } + fakeMonarchy.givenWhere() + .givenIn(EventEntityFields.EVENT_ID, eventIdsToCheck) + .givenFindAll(eventEntities) + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/poll/DefaultGetLoadedPollsStatusTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/poll/DefaultGetLoadedPollsStatusTaskTest.kt new file mode 100644 index 0000000000..9c3093897d --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/poll/DefaultGetLoadedPollsStatusTaskTest.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2023 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.poll + +import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.After +import org.junit.Test +import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus +import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntity +import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntityFields +import org.matrix.android.sdk.test.fakes.FakeMonarchy +import org.matrix.android.sdk.test.fakes.givenEqualTo +import org.matrix.android.sdk.test.fakes.givenFindFirst + +private const val A_ROOM_ID = "room-id" + +/** + * Timestamp in milliseconds corresponding to 2023/01/26. + */ +private const val A_CURRENT_TIMESTAMP = 1674737619290L + +/** + * Timestamp in milliseconds corresponding to 2023/01/20. + */ +private const val AN_EVENT_TIMESTAMP = 1674169200000L + +@OptIn(ExperimentalCoroutinesApi::class) +internal class DefaultGetLoadedPollsStatusTaskTest { + + private val fakeMonarchy = FakeMonarchy() + + private val defaultGetLoadedPollsStatusTask = DefaultGetLoadedPollsStatusTask( + monarchy = fakeMonarchy.instance, + ) + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given poll history status exists in db with an oldestTimestamp reached when execute then the computed status is returned`() = runTest { + // Given + val params = givenTaskParams() + val pollHistoryStatus = aPollHistoryStatusEntity( + isEndOfPollsBackward = false, + oldestTimestampReached = AN_EVENT_TIMESTAMP, + ) + fakeMonarchy.fakeRealm + .givenWhere() + .givenEqualTo(PollHistoryStatusEntityFields.ROOM_ID, A_ROOM_ID) + .givenFindFirst(pollHistoryStatus) + val expectedStatus = LoadedPollsStatus( + canLoadMore = true, + daysSynced = 6, + hasCompletedASyncBackward = true, + ) + + // When + val result = defaultGetLoadedPollsStatusTask.execute(params) + + // Then + result shouldBeEqualTo expectedStatus + } + + @Test + fun `given poll history status exists in db and no oldestTimestamp reached when execute then the computed status is returned`() = runTest { + // Given + val params = givenTaskParams() + val pollHistoryStatus = aPollHistoryStatusEntity( + isEndOfPollsBackward = false, + oldestTimestampReached = null, + ) + fakeMonarchy.fakeRealm + .givenWhere() + .givenEqualTo(PollHistoryStatusEntityFields.ROOM_ID, A_ROOM_ID) + .givenFindFirst(pollHistoryStatus) + val expectedStatus = LoadedPollsStatus( + canLoadMore = true, + daysSynced = 0, + hasCompletedASyncBackward = false, + ) + + // When + val result = defaultGetLoadedPollsStatusTask.execute(params) + + // Then + result shouldBeEqualTo expectedStatus + } + + private fun givenTaskParams(): GetLoadedPollsStatusTask.Params { + return GetLoadedPollsStatusTask.Params( + roomId = A_ROOM_ID, + currentTimestampMs = A_CURRENT_TIMESTAMP, + ) + } + + private fun aPollHistoryStatusEntity( + isEndOfPollsBackward: Boolean, + oldestTimestampReached: Long?, + ): PollHistoryStatusEntity { + return PollHistoryStatusEntity( + roomId = A_ROOM_ID, + isEndOfPollsBackward = isEndOfPollsBackward, + oldestTimestampTargetReachedMs = oldestTimestampReached, + ) + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/poll/DefaultLoadMorePollsTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/poll/DefaultLoadMorePollsTaskTest.kt new file mode 100644 index 0000000000..489a32b198 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/poll/DefaultLoadMorePollsTaskTest.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2023 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.poll + +import io.mockk.coVerifyOrder +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.After +import org.junit.Test +import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus +import org.matrix.android.sdk.api.session.room.timeline.Timeline +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntity +import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntityFields +import org.matrix.android.sdk.test.fakes.FakeMonarchy +import org.matrix.android.sdk.test.fakes.FakeTimeline +import org.matrix.android.sdk.test.fakes.givenEqualTo +import org.matrix.android.sdk.test.fakes.givenFindFirst + +private const val A_ROOM_ID = "room-id" + +/** + * Timestamp in milliseconds corresponding to 2023/01/26. + */ +private const val A_CURRENT_TIMESTAMP = 1674737619290L + +/** + * Timestamp in milliseconds corresponding to 2023/01/20. + */ +private const val AN_EVENT_TIMESTAMP = 1674169200000L +private const val A_PERIOD_IN_DAYS = 3 +private const val A_PAGE_SIZE = 200 + +@OptIn(ExperimentalCoroutinesApi::class) +internal class DefaultLoadMorePollsTaskTest { + + private val fakeMonarchy = FakeMonarchy() + private val fakeTimeline = FakeTimeline() + + private val defaultLoadMorePollsTask = DefaultLoadMorePollsTask( + monarchy = fakeMonarchy.instance, + ) + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given timeline when execute then more events are fetched in backward direction until has no more to load`() = runTest { + // Given + val params = givenTaskParams() + val oldestEventId = "oldest" + val pollHistoryStatus = aPollHistoryStatusEntity( + oldestEventIdReached = oldestEventId, + ) + fakeMonarchy.fakeRealm + .givenWhere() + .givenEqualTo(PollHistoryStatusEntityFields.ROOM_ID, A_ROOM_ID) + .givenFindFirst(pollHistoryStatus) + fakeTimeline.givenRestartWithEventIdSuccess(oldestEventId) + val anEventId = "event-id" + val aTimelineEvent = aTimelineEvent(anEventId, AN_EVENT_TIMESTAMP) + fakeTimeline.givenAwaitPaginateReturns( + events = listOf(aTimelineEvent), + direction = Timeline.Direction.BACKWARDS, + count = params.eventsPageSize, + ) + val aPaginationState = aPaginationState(hasMoreToLoad = false) + fakeTimeline.givenGetPaginationStateReturns( + paginationState = aPaginationState, + direction = Timeline.Direction.BACKWARDS, + ) + val expectedLoadStatus = LoadedPollsStatus( + canLoadMore = false, + daysSynced = 6, + hasCompletedASyncBackward = true, + ) + + // When + val result = defaultLoadMorePollsTask.execute(params) + + // Then + coVerifyOrder { + fakeTimeline.instance.restartWithEventId(oldestEventId) + fakeTimeline.instance.awaitPaginate(direction = Timeline.Direction.BACKWARDS, count = params.eventsPageSize) + fakeTimeline.instance.getPaginationState(direction = Timeline.Direction.BACKWARDS) + } + pollHistoryStatus.mostRecentEventIdReached shouldBeEqualTo anEventId + pollHistoryStatus.oldestEventIdReached shouldBeEqualTo anEventId + pollHistoryStatus.isEndOfPollsBackward shouldBeEqualTo true + pollHistoryStatus.oldestTimestampTargetReachedMs shouldBeEqualTo AN_EVENT_TIMESTAMP + result shouldBeEqualTo expectedLoadStatus + } + + @Test + fun `given timeline when execute then more events are fetched in backward direction until current target is reached`() = runTest { + // Given + val params = givenTaskParams() + val oldestEventId = "oldest" + val pollHistoryStatus = aPollHistoryStatusEntity( + oldestEventIdReached = oldestEventId, + ) + fakeMonarchy.fakeRealm + .givenWhere() + .givenEqualTo(PollHistoryStatusEntityFields.ROOM_ID, A_ROOM_ID) + .givenFindFirst(pollHistoryStatus) + fakeTimeline.givenRestartWithEventIdSuccess(oldestEventId) + val anEventId = "event-id" + val aTimelineEvent = aTimelineEvent(anEventId, AN_EVENT_TIMESTAMP) + fakeTimeline.givenAwaitPaginateReturns( + events = listOf(aTimelineEvent), + direction = Timeline.Direction.BACKWARDS, + count = params.eventsPageSize, + ) + val aPaginationState = aPaginationState(hasMoreToLoad = true) + fakeTimeline.givenGetPaginationStateReturns( + paginationState = aPaginationState, + direction = Timeline.Direction.BACKWARDS, + ) + val expectedLoadStatus = LoadedPollsStatus( + canLoadMore = true, + daysSynced = 6, + hasCompletedASyncBackward = true, + ) + + // When + val result = defaultLoadMorePollsTask.execute(params) + + // Then + coVerifyOrder { + fakeTimeline.instance.restartWithEventId(oldestEventId) + fakeTimeline.instance.awaitPaginate(direction = Timeline.Direction.BACKWARDS, count = params.eventsPageSize) + fakeTimeline.instance.getPaginationState(direction = Timeline.Direction.BACKWARDS) + } + pollHistoryStatus.mostRecentEventIdReached shouldBeEqualTo anEventId + pollHistoryStatus.oldestEventIdReached shouldBeEqualTo anEventId + pollHistoryStatus.isEndOfPollsBackward shouldBeEqualTo false + pollHistoryStatus.oldestTimestampTargetReachedMs shouldBeEqualTo AN_EVENT_TIMESTAMP + result shouldBeEqualTo expectedLoadStatus + } + + private fun givenTaskParams(): LoadMorePollsTask.Params { + return LoadMorePollsTask.Params( + timeline = fakeTimeline.instance, + roomId = A_ROOM_ID, + currentTimestampMs = A_CURRENT_TIMESTAMP, + loadingPeriodInDays = A_PERIOD_IN_DAYS, + eventsPageSize = A_PAGE_SIZE, + ) + } + + private fun aPollHistoryStatusEntity( + oldestEventIdReached: String, + ): PollHistoryStatusEntity { + return PollHistoryStatusEntity( + roomId = A_ROOM_ID, + oldestEventIdReached = oldestEventIdReached, + ) + } + + private fun aTimelineEvent(eventId: String, timestamp: Long): TimelineEvent { + val event = mockk() + every { event.root.originServerTs } returns timestamp + every { event.root.eventId } returns eventId + return event + } + + private fun aPaginationState(hasMoreToLoad: Boolean): Timeline.PaginationState { + return Timeline.PaginationState( + hasMoreToLoad = hasMoreToLoad, + ) + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/poll/DefaultSyncPollsTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/poll/DefaultSyncPollsTaskTest.kt new file mode 100644 index 0000000000..8a95a2f131 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/poll/DefaultSyncPollsTaskTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2023 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.poll + +import io.mockk.coVerifyOrder +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.After +import org.junit.Test +import org.matrix.android.sdk.api.session.room.timeline.Timeline +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntity +import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntityFields +import org.matrix.android.sdk.test.fakes.FakeMonarchy +import org.matrix.android.sdk.test.fakes.FakeTimeline +import org.matrix.android.sdk.test.fakes.givenEqualTo +import org.matrix.android.sdk.test.fakes.givenFindFirst + +private const val A_ROOM_ID = "room-id" +private const val A_TIMESTAMP = 123L +private const val A_PAGE_SIZE = 200 + +@OptIn(ExperimentalCoroutinesApi::class) +internal class DefaultSyncPollsTaskTest { + + private val fakeMonarchy = FakeMonarchy() + private val fakeTimeline = FakeTimeline() + + private val defaultSyncPollsTask = DefaultSyncPollsTask( + monarchy = fakeMonarchy.instance, + ) + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given timeline when execute then more events are fetched in forward direction after the most recent event id reached`() = runTest { + // Given + val params = givenTaskParams() + val mostRecentEventId = "most-recent" + val oldestEventId = "oldest" + val pollHistoryStatus = aPollHistoryStatusEntity( + mostRecentEventIdReached = mostRecentEventId, + oldestEventIdReached = oldestEventId, + ) + fakeMonarchy.fakeRealm + .givenWhere() + .givenEqualTo(PollHistoryStatusEntityFields.ROOM_ID, A_ROOM_ID) + .givenFindFirst(pollHistoryStatus) + fakeTimeline.givenRestartWithEventIdSuccess(mostRecentEventId) + fakeTimeline.givenRestartWithEventIdSuccess(oldestEventId) + val anEventId = "event-id" + val aTimelineEvent = aTimelineEvent(anEventId) + fakeTimeline.givenAwaitPaginateReturns( + events = listOf(aTimelineEvent), + direction = Timeline.Direction.FORWARDS, + count = params.eventsPageSize, + ) + fakeTimeline.givenGetPaginationStateReturns( + paginationState = aPaginationState(), + direction = Timeline.Direction.FORWARDS, + ) + + // When + defaultSyncPollsTask.execute(params) + + // Then + coVerifyOrder { + fakeTimeline.instance.restartWithEventId(mostRecentEventId) + fakeTimeline.instance.awaitPaginate(direction = Timeline.Direction.FORWARDS, count = params.eventsPageSize) + fakeTimeline.instance.getPaginationState(direction = Timeline.Direction.FORWARDS) + fakeTimeline.instance.restartWithEventId(oldestEventId) + } + pollHistoryStatus.mostRecentEventIdReached shouldBeEqualTo anEventId + } + + private fun givenTaskParams(): SyncPollsTask.Params { + return SyncPollsTask.Params( + timeline = fakeTimeline.instance, + roomId = A_ROOM_ID, + currentTimestampMs = A_TIMESTAMP, + eventsPageSize = A_PAGE_SIZE, + ) + } + + private fun aPollHistoryStatusEntity( + mostRecentEventIdReached: String, + oldestEventIdReached: String, + ): PollHistoryStatusEntity { + return PollHistoryStatusEntity( + roomId = A_ROOM_ID, + mostRecentEventIdReached = mostRecentEventIdReached, + oldestEventIdReached = oldestEventIdReached, + ) + } + + private fun aTimelineEvent(eventId: String): TimelineEvent { + val event = mockk() + every { event.root.originServerTs } returns 123L + every { event.root.eventId } returns eventId + return event + } + + private fun aPaginationState(): Timeline.PaginationState { + return Timeline.PaginationState( + hasMoreToLoad = false, + ) + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/relation/poll/DefaultFetchPollResponseEventsTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/relation/poll/DefaultFetchPollResponseEventsTaskTest.kt index 8d50bac38f..238a4fa626 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/relation/poll/DefaultFetchPollResponseEventsTaskTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/relation/poll/DefaultFetchPollResponseEventsTaskTest.kt @@ -16,11 +16,12 @@ package org.matrix.android.sdk.internal.session.room.relation.poll +import io.mockk.coJustRun +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkAll -import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.After @@ -29,41 +30,28 @@ import org.junit.Test import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.events.model.isPollResponse -import org.matrix.android.sdk.api.session.room.send.SendState -import org.matrix.android.sdk.internal.database.mapper.toEntity -import org.matrix.android.sdk.internal.database.model.EventEntity -import org.matrix.android.sdk.internal.database.model.EventEntityFields -import org.matrix.android.sdk.internal.database.model.EventInsertType -import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore +import org.matrix.android.sdk.internal.session.room.event.FilterAndStoreEventsTask import org.matrix.android.sdk.internal.session.room.relation.RelationsResponse -import org.matrix.android.sdk.test.fakes.FakeClock -import org.matrix.android.sdk.test.fakes.FakeEventDecryptor import org.matrix.android.sdk.test.fakes.FakeGlobalErrorReceiver -import org.matrix.android.sdk.test.fakes.FakeMonarchy import org.matrix.android.sdk.test.fakes.FakeRoomApi -import org.matrix.android.sdk.test.fakes.givenFindAll -import org.matrix.android.sdk.test.fakes.givenIn @OptIn(ExperimentalCoroutinesApi::class) internal class DefaultFetchPollResponseEventsTaskTest { private val fakeRoomAPI = FakeRoomApi() private val fakeGlobalErrorReceiver = FakeGlobalErrorReceiver() - private val fakeMonarchy = FakeMonarchy() - private val fakeClock = FakeClock() - private val fakeEventDecryptor = FakeEventDecryptor() + private val filterAndStoreEventsTask = mockk() private val defaultFetchPollResponseEventsTask = DefaultFetchPollResponseEventsTask( roomAPI = fakeRoomAPI.instance, globalErrorReceiver = fakeGlobalErrorReceiver, - monarchy = fakeMonarchy.instance, - clock = fakeClock, - eventDecryptor = fakeEventDecryptor.instance, + filterAndStoreEventsTask = filterAndStoreEventsTask, ) @Before fun setup() { mockkStatic("org.matrix.android.sdk.api.session.events.model.EventKt") + mockkStatic("org.matrix.android.sdk.internal.database.mapper.EventMapperKt") mockkStatic("org.matrix.android.sdk.internal.database.query.EventEntityQueriesKt") } @@ -74,7 +62,7 @@ internal class DefaultFetchPollResponseEventsTaskTest { } @Test - fun `given a room and a poll when execute then fetch related events and store them in local if needed`() = runTest { + fun `given a room and a poll when execute then fetch related events and store them in local`() = runTest { // Given val aRoomId = "roomId" val aPollEventId = "eventId" @@ -94,13 +82,7 @@ internal class DefaultFetchPollResponseEventsTaskTest { fakeRoomAPI.givenGetRelationsReturns(from = null, relationsResponse = firstResponse) val secondResponse = givenARelationsResponse(events = secondEvents, nextBatch = null) fakeRoomAPI.givenGetRelationsReturns(from = aNextBatchToken, relationsResponse = secondResponse) - fakeEventDecryptor.givenDecryptEventAndSaveResultSuccess(event1) - fakeEventDecryptor.givenDecryptEventAndSaveResultSuccess(event2) - fakeClock.givenEpoch(123) - givenExistingEventEntities(eventIdsToCheck = listOf(anEventId1, anEventId2), existingIds = listOf(anEventId1)) - val eventEntityToSave = EventEntity(eventId = anEventId2) - every { event2.toEntity(any(), any(), any()) } returns eventEntityToSave - every { eventEntityToSave.copyToRealmOrIgnore(any(), any()) } returns eventEntityToSave + coJustRun { filterAndStoreEventsTask.execute(any()) } // When defaultFetchPollResponseEventsTask.execute(params) @@ -111,21 +93,22 @@ internal class DefaultFetchPollResponseEventsTaskTest { eventId = params.startPollEventId, relationType = RelationType.REFERENCE, from = null, - limit = FETCH_RELATED_EVENTS_LIMIT + limit = FETCH_RELATED_EVENTS_LIMIT, ) fakeRoomAPI.verifyGetRelations( roomId = params.roomId, eventId = params.startPollEventId, relationType = RelationType.REFERENCE, from = aNextBatchToken, - limit = FETCH_RELATED_EVENTS_LIMIT + limit = FETCH_RELATED_EVENTS_LIMIT, ) - fakeEventDecryptor.verifyDecryptEventAndSaveResult(event1, timeline = "") - fakeEventDecryptor.verifyDecryptEventAndSaveResult(event2, timeline = "") - // Check we save in DB the event2 which is a non stored poll response - verify { - event2.toEntity(aRoomId, SendState.SYNCED, any()) - eventEntityToSave.copyToRealmOrIgnore(fakeMonarchy.fakeRealm.instance, EventInsertType.PAGINATION) + coVerify { + filterAndStoreEventsTask.execute(match { + it.roomId == aRoomId && it.events == firstEvents + }) + filterAndStoreEventsTask.execute(match { + it.roomId == aRoomId && it.events == secondEvents + }) } } @@ -153,11 +136,4 @@ internal class DefaultFetchPollResponseEventsTaskTest { every { event.isEncrypted() } returns isEncrypted return event } - - private fun givenExistingEventEntities(eventIdsToCheck: List, existingIds: List) { - val eventEntities = existingIds.map { EventEntity(eventId = it) } - fakeMonarchy.givenWhere() - .givenIn(EventEntityFields.EVENT_ID, eventIdsToCheck) - .givenFindAll(eventEntities) - } } diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeTimeline.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeTimeline.kt new file mode 100644 index 0000000000..68b80c7e8f --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeTimeline.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.test.fakes + +import io.mockk.coEvery +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import org.matrix.android.sdk.api.session.room.timeline.Timeline +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent + +class FakeTimeline { + val instance: Timeline = mockk() + + fun givenRestartWithEventIdSuccess(eventId: String) { + justRun { instance.restartWithEventId(eventId) } + } + + fun givenAwaitPaginateReturns(events: List, direction: Timeline.Direction, count: Int) { + coEvery { instance.awaitPaginate(direction, count) } returns events + } + + fun givenGetPaginationStateReturns(paginationState: Timeline.PaginationState, direction: Timeline.Direction) { + every { instance.getPaginationState(direction) } returns paginationState + } +} diff --git a/vector/src/main/java/im/vector/app/core/session/EnsureSessionSyncingUseCase.kt b/vector/src/main/java/im/vector/app/core/session/EnsureSessionSyncingUseCase.kt new file mode 100644 index 0000000000..c53795d18d --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/session/EnsureSessionSyncingUseCase.kt @@ -0,0 +1,38 @@ +/* + * 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. + */ + +package im.vector.app.core.session + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.core.extensions.startSyncing +import org.matrix.android.sdk.api.session.sync.SyncState +import timber.log.Timber +import javax.inject.Inject + +class EnsureSessionSyncingUseCase @Inject constructor( + @ApplicationContext private val context: Context, + private val activeSessionHolder: ActiveSessionHolder, +) { + fun execute() { + val session = activeSessionHolder.getSafeActiveSession() ?: return + if (session.syncService().getSyncState() == SyncState.Idle) { + Timber.w("EnsureSessionSyncingUseCase: start syncing") + session.startSyncing(context) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/command/Command.kt b/vector/src/main/java/im/vector/app/features/command/Command.kt index 324029c45b..b112751f68 100644 --- a/vector/src/main/java/im/vector/app/features/command/Command.kt +++ b/vector/src/main/java/im/vector/app/features/command/Command.kt @@ -32,6 +32,7 @@ enum class Command( val isDevCommand: Boolean, val isThreadCommand: Boolean ) { + CRASH_APP("/crash", null, "", R.string.command_description_crash_application, true, true), EMOTE("/me", null, "", R.string.command_description_emote, false, true), BAN_USER("/ban", null, " [reason]", R.string.command_description_ban_user, false, false), UNBAN_USER("/unban", null, " [reason]", R.string.command_description_unban_user, false, false), diff --git a/vector/src/main/java/im/vector/app/features/command/CommandParser.kt b/vector/src/main/java/im/vector/app/features/command/CommandParser.kt index e08bc9fb64..298387c324 100644 --- a/vector/src/main/java/im/vector/app/features/command/CommandParser.kt +++ b/vector/src/main/java/im/vector/app/features/command/CommandParser.kt @@ -20,13 +20,16 @@ import im.vector.app.core.extensions.isEmail import im.vector.app.core.extensions.isMsisdn import im.vector.app.core.extensions.orEmpty import im.vector.app.features.home.room.detail.ChatEffect +import im.vector.app.features.settings.VectorPreferences import org.matrix.android.sdk.api.MatrixPatterns import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl import org.matrix.android.sdk.api.session.identity.ThreePid import timber.log.Timber import javax.inject.Inject -class CommandParser @Inject constructor() { +class CommandParser @Inject constructor( + private val vectorPreferences: VectorPreferences +) { /** * Convert the text message into a Slash command. @@ -404,6 +407,9 @@ class CommandParser @Inject constructor() { ParsedCommand.ErrorSyntax(Command.UPGRADE_ROOM) } } + Command.CRASH_APP.matches(slashCommand) && vectorPreferences.developerMode() -> { + throw RuntimeException("Application crashed from user demand") + } else -> { // Unknown command ParsedCommand.ErrorUnknownSlashCommand(slashCommand) diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt index ca9bed5d8b..2a055b23b9 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt @@ -31,6 +31,7 @@ import im.vector.app.core.pushers.EnsureFcmTokenIsRetrievedUseCase import im.vector.app.core.pushers.PushersManager import im.vector.app.core.pushers.RegisterUnifiedPushUseCase import im.vector.app.core.pushers.UnregisterUnifiedPushUseCase +import im.vector.app.core.session.EnsureSessionSyncingUseCase import im.vector.app.features.analytics.AnalyticsConfig import im.vector.app.features.analytics.AnalyticsTracker import im.vector.app.features.analytics.extensions.toAnalyticsType @@ -93,7 +94,8 @@ class HomeActivityViewModel @AssistedInject constructor( private val registerUnifiedPushUseCase: RegisterUnifiedPushUseCase, private val unregisterUnifiedPushUseCase: UnregisterUnifiedPushUseCase, private val ensureFcmTokenIsRetrievedUseCase: EnsureFcmTokenIsRetrievedUseCase, - private val coroutineDispatchers: CoroutineDispatchers + private val ensureSessionSyncingUseCase: EnsureSessionSyncingUseCase, + private val coroutineDispatchers: CoroutineDispatchers, ) : VectorViewModel(initialState) { @AssistedFactory @@ -117,6 +119,8 @@ class HomeActivityViewModel @AssistedInject constructor( private fun initialize() { if (isInitialized) return isInitialized = true + // Ensure Session is syncing + ensureSessionSyncingUseCase.execute() registerUnifiedPushIfNeeded() viewModelScope.launch(coroutineDispatchers.io) { cleanupFiles() diff --git a/vector/src/main/java/im/vector/app/features/home/HomeDetailViewState.kt b/vector/src/main/java/im/vector/app/features/home/HomeDetailViewState.kt index 7fbd5b2bf6..dcf4d87894 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeDetailViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeDetailViewState.kt @@ -38,8 +38,8 @@ data class HomeDetailViewState( val notificationCountRooms: Int = 0, val notificationHighlightRooms: Boolean = false, val hasUnreadMessages: Boolean = false, - val syncState: SyncState = SyncState.Idle, - val incrementalSyncRequestState: SyncRequestState.IncrementalSyncRequestState = SyncRequestState.IncrementalSyncIdle, + val syncState: SyncState? = null, + val incrementalSyncRequestState: SyncRequestState.IncrementalSyncRequestState? = null, val pushCounter: Int = 0, val pstnSupportFlag: Boolean = false, val forceDialPadTab: Boolean = false diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt index 897594ffad..f4919a5906 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt @@ -60,8 +60,8 @@ data class RoomDetailViewState( val formattedTypingUsers: String? = null, val tombstoneEvent: Event? = null, val joinUpgradedRoomAsync: Async = Uninitialized, - val syncState: SyncState = SyncState.Idle, - val incrementalSyncRequestState: SyncRequestState.IncrementalSyncRequestState = SyncRequestState.IncrementalSyncIdle, + val syncState: SyncState? = null, + val incrementalSyncRequestState: SyncRequestState.IncrementalSyncRequestState? = null, val pushCounter: Int = 0, val highlightedEventId: String? = null, val unreadState: UnreadState = UnreadState.Unknown, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/PollItemViewStateFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/PollItemViewStateFactory.kt index 7abc51fa51..3c1a1cfd85 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/PollItemViewStateFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/PollItemViewStateFactory.kt @@ -19,7 +19,6 @@ package im.vector.app.features.home.room.detail.timeline.factory import im.vector.app.R import im.vector.app.core.resources.StringProvider import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData -import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState import im.vector.app.features.home.room.detail.timeline.item.PollResponseData import im.vector.app.features.poll.PollViewState import org.matrix.android.sdk.api.extensions.orFalse @@ -29,6 +28,7 @@ import javax.inject.Inject class PollItemViewStateFactory @Inject constructor( private val stringProvider: StringProvider, + private val pollOptionViewStateFactory: PollOptionViewStateFactory, ) { fun create( @@ -40,7 +40,6 @@ class PollItemViewStateFactory @Inject constructor( val question = pollCreationInfo?.question?.getBestQuestion().orEmpty() val pollResponseSummary = informationData.pollResponseAggregatedSummary - val winnerVoteCount = pollResponseSummary?.winnerVoteCount val totalVotes = pollResponseSummary?.totalVotes ?: 0 return when { @@ -48,7 +47,7 @@ class PollItemViewStateFactory @Inject constructor( createSendingPollViewState(question, pollCreationInfo) } informationData.pollResponseAggregatedSummary?.isClosed.orFalse() -> { - createEndedPollViewState(question, pollCreationInfo, pollResponseSummary, totalVotes, winnerVoteCount) + createEndedPollViewState(question, pollCreationInfo, pollResponseSummary, totalVotes) } pollContent.getBestPollCreationInfo()?.isUndisclosed().orFalse() -> { createUndisclosedPollViewState(question, pollCreationInfo, pollResponseSummary) @@ -67,12 +66,7 @@ class PollItemViewStateFactory @Inject constructor( question = question, votesStatus = stringProvider.getString(R.string.poll_no_votes_cast), canVote = false, - optionViewStates = pollCreationInfo?.answers?.map { answer -> - PollOptionViewState.PollSending( - optionId = answer.id ?: "", - optionAnswer = answer.getBestAnswer() ?: "" - ) - }, + optionViewStates = pollOptionViewStateFactory.createPollSendingOptions(pollCreationInfo), ) } @@ -81,7 +75,6 @@ class PollItemViewStateFactory @Inject constructor( pollCreationInfo: PollCreationInfo?, pollResponseSummary: PollResponseData?, totalVotes: Int, - winnerVoteCount: Int?, ): PollViewState { val totalVotesText = if (pollResponseSummary?.hasEncryptedRelatedEvents.orFalse()) { stringProvider.getString(R.string.unable_to_decrypt_some_events_in_poll) @@ -92,16 +85,7 @@ class PollItemViewStateFactory @Inject constructor( question = question, votesStatus = totalVotesText, canVote = false, - optionViewStates = pollCreationInfo?.answers?.map { answer -> - val voteSummary = pollResponseSummary?.getVoteSummaryOfAnOption(answer.id ?: "") - PollOptionViewState.PollEnded( - optionId = answer.id ?: "", - optionAnswer = answer.getBestAnswer() ?: "", - voteCount = voteSummary?.total ?: 0, - votePercentage = voteSummary?.percentage ?: 0.0, - isWinner = winnerVoteCount != 0 && voteSummary?.total == winnerVoteCount - ) - }, + optionViewStates = pollOptionViewStateFactory.createPollEndedOptions(pollCreationInfo, pollResponseSummary), ) } @@ -114,14 +98,7 @@ class PollItemViewStateFactory @Inject constructor( question = question, votesStatus = stringProvider.getString(R.string.poll_undisclosed_not_ended), canVote = true, - optionViewStates = pollCreationInfo?.answers?.map { answer -> - val isMyVote = pollResponseSummary?.myVote == answer.id - PollOptionViewState.PollUndisclosed( - optionId = answer.id ?: "", - optionAnswer = answer.getBestAnswer() ?: "", - isSelected = isMyVote - ) - }, + optionViewStates = pollOptionViewStateFactory.createPollUndisclosedOptions(pollCreationInfo, pollResponseSummary), ) } @@ -140,17 +117,7 @@ class PollItemViewStateFactory @Inject constructor( question = question, votesStatus = totalVotesText, canVote = true, - optionViewStates = pollCreationInfo?.answers?.map { answer -> - val isMyVote = pollResponseSummary?.myVote == answer.id - val voteSummary = pollResponseSummary?.getVoteSummaryOfAnOption(answer.id ?: "") - PollOptionViewState.PollVoted( - optionId = answer.id ?: "", - optionAnswer = answer.getBestAnswer() ?: "", - voteCount = voteSummary?.total ?: 0, - votePercentage = voteSummary?.percentage ?: 0.0, - isSelected = isMyVote - ) - }, + optionViewStates = pollOptionViewStateFactory.createPollVotedOptions(pollCreationInfo, pollResponseSummary), ) } @@ -168,12 +135,7 @@ class PollItemViewStateFactory @Inject constructor( question = question, votesStatus = totalVotesText, canVote = true, - optionViewStates = pollCreationInfo?.answers?.map { answer -> - PollOptionViewState.PollReady( - optionId = answer.id ?: "", - optionAnswer = answer.getBestAnswer() ?: "" - ) - }, + optionViewStates = pollOptionViewStateFactory.createPollReadyOptions(pollCreationInfo), ) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/PollOptionViewStateFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/PollOptionViewStateFactory.kt new file mode 100644 index 0000000000..875675745c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/PollOptionViewStateFactory.kt @@ -0,0 +1,82 @@ +/* + * 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 im.vector.app.features.home.room.detail.timeline.factory + +import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState +import im.vector.app.features.home.room.detail.timeline.item.PollResponseData +import org.matrix.android.sdk.api.session.room.model.message.PollCreationInfo +import javax.inject.Inject + +class PollOptionViewStateFactory @Inject constructor() { + + fun createPollEndedOptions(pollCreationInfo: PollCreationInfo?, pollResponseData: PollResponseData?): List { + val winnerVoteCount = pollResponseData?.winnerVoteCount + return pollCreationInfo?.answers?.map { answer -> + val voteSummary = pollResponseData?.getVoteSummaryOfAnOption(answer.id ?: "") + PollOptionViewState.PollEnded( + optionId = answer.id.orEmpty(), + optionAnswer = answer.getBestAnswer().orEmpty(), + voteCount = voteSummary?.total ?: 0, + votePercentage = voteSummary?.percentage ?: 0.0, + isWinner = winnerVoteCount != 0 && voteSummary?.total == winnerVoteCount + ) + } ?: emptyList() + } + + fun createPollSendingOptions(pollCreationInfo: PollCreationInfo?): List { + return pollCreationInfo?.answers?.map { answer -> + PollOptionViewState.PollSending( + optionId = answer.id.orEmpty(), + optionAnswer = answer.getBestAnswer().orEmpty(), + ) + } ?: emptyList() + } + + fun createPollUndisclosedOptions(pollCreationInfo: PollCreationInfo?, pollResponseData: PollResponseData?): List { + return pollCreationInfo?.answers?.map { answer -> + val isMyVote = pollResponseData?.myVote == answer.id + PollOptionViewState.PollUndisclosed( + optionId = answer.id.orEmpty(), + optionAnswer = answer.getBestAnswer().orEmpty(), + isSelected = isMyVote + ) + } ?: emptyList() + } + + fun createPollVotedOptions(pollCreationInfo: PollCreationInfo?, pollResponseData: PollResponseData?): List { + return pollCreationInfo?.answers?.map { answer -> + val isMyVote = pollResponseData?.myVote == answer.id + val voteSummary = pollResponseData?.getVoteSummaryOfAnOption(answer.id ?: "") + PollOptionViewState.PollVoted( + optionId = answer.id.orEmpty(), + optionAnswer = answer.getBestAnswer().orEmpty(), + voteCount = voteSummary?.total ?: 0, + votePercentage = voteSummary?.percentage ?: 0.0, + isSelected = isMyVote + ) + } ?: emptyList() + } + + fun createPollReadyOptions(pollCreationInfo: PollCreationInfo?): List { + return pollCreationInfo?.answers?.map { answer -> + PollOptionViewState.PollReady( + optionId = answer.id ?: "", + optionAnswer = answer.getBestAnswer() ?: "" + ) + } ?: emptyList() + } +} diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt index 51885dbf39..91f57d33e9 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt @@ -207,6 +207,7 @@ class RoomProfileFragment : } override fun onDestroyView() { + roomProfileController.callback = null views.matrixProfileAppBarLayout.removeOnOffsetChangedListener(appBarStateChangeListener) views.matrixProfileRecyclerView.cleanup() appBarStateChangeListener = null diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsViewModel.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsViewModel.kt index b634881f70..2beda47816 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsViewModel.kt @@ -23,20 +23,23 @@ import dagger.assisted.AssistedInject import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.platform.VectorViewModel -import im.vector.app.features.roomprofile.polls.list.domain.GetLoadedPollsStatusUseCase +import im.vector.app.features.roomprofile.polls.list.domain.DisposePollHistoryUseCase import im.vector.app.features.roomprofile.polls.list.domain.GetPollsUseCase import im.vector.app.features.roomprofile.polls.list.domain.LoadMorePollsUseCase import im.vector.app.features.roomprofile.polls.list.domain.SyncPollsUseCase +import im.vector.app.features.roomprofile.polls.list.ui.PollSummaryMapper import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch class RoomPollsViewModel @AssistedInject constructor( @Assisted initialState: RoomPollsViewState, private val getPollsUseCase: GetPollsUseCase, - private val getLoadedPollsStatusUseCase: GetLoadedPollsStatusUseCase, private val loadMorePollsUseCase: LoadMorePollsUseCase, private val syncPollsUseCase: SyncPollsUseCase, + private val disposePollHistoryUseCase: DisposePollHistoryUseCase, + private val pollSummaryMapper: PollSummaryMapper, ) : VectorViewModel(initialState) { @AssistedFactory @@ -48,26 +51,26 @@ class RoomPollsViewModel @AssistedInject constructor( init { val roomId = initialState.roomId - updateLoadedPollStatus(roomId) syncPolls(roomId) observePolls(roomId) } - private fun updateLoadedPollStatus(roomId: String) { - val loadedPollsStatus = getLoadedPollsStatusUseCase.execute(roomId) - setState { - copy( - canLoadMore = loadedPollsStatus.canLoadMore, - nbLoadedDays = loadedPollsStatus.nbLoadedDays - ) - } + override fun onCleared() { + withState { disposePollHistoryUseCase.execute(it.roomId) } + super.onCleared() } private fun syncPolls(roomId: String) { viewModelScope.launch { setState { copy(isSyncing = true) } val result = runCatching { - syncPollsUseCase.execute(roomId) + val loadedPollsStatus = syncPollsUseCase.execute(roomId) + setState { + copy( + canLoadMore = loadedPollsStatus.canLoadMore, + nbSyncedDays = loadedPollsStatus.daysSynced, + ) + } } if (result.isFailure) { _viewEvents.post(RoomPollsViewEvent.LoadingError) @@ -78,6 +81,7 @@ class RoomPollsViewModel @AssistedInject constructor( private fun observePolls(roomId: String) { getPollsUseCase.execute(roomId) + .map { it.mapNotNull { event -> pollSummaryMapper.map(event) } } .onEach { setState { copy(polls = it) } } .launchIn(viewModelScope) } @@ -96,7 +100,7 @@ class RoomPollsViewModel @AssistedInject constructor( setState { copy( canLoadMore = status.canLoadMore, - nbLoadedDays = status.nbLoadedDays, + nbSyncedDays = status.daysSynced, ) } } diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsViewState.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsViewState.kt index fa985c5c76..4a5c138b6a 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsViewState.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsViewState.kt @@ -25,7 +25,7 @@ data class RoomPollsViewState( val polls: List = emptyList(), val isLoadingMore: Boolean = false, val canLoadMore: Boolean = true, - val nbLoadedDays: Int = 0, + val nbSyncedDays: Int = 0, val isSyncing: Boolean = false, ) : MavericksState { diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/data/LoadedPollsStatus.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/data/PollHistoryError.kt similarity index 87% rename from vector/src/main/java/im/vector/app/features/roomprofile/polls/list/data/LoadedPollsStatus.kt rename to vector/src/main/java/im/vector/app/features/roomprofile/polls/list/data/PollHistoryError.kt index c3971bb289..67d59faebd 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/data/LoadedPollsStatus.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/data/PollHistoryError.kt @@ -16,7 +16,6 @@ package im.vector.app.features.roomprofile.polls.list.data -data class LoadedPollsStatus( - val canLoadMore: Boolean, - val nbLoadedDays: Int, -) +sealed class PollHistoryError : Exception() { + object UnknownRoomError : PollHistoryError() +} diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/data/RoomPollDataSource.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/data/RoomPollDataSource.kt index c0efb1efa1..3a65297fde 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/data/RoomPollDataSource.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/data/RoomPollDataSource.kt @@ -16,159 +16,44 @@ package im.vector.app.features.roomprofile.polls.list.data -import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState -import im.vector.app.features.roomprofile.polls.list.ui.PollSummary -import kotlinx.coroutines.delay +import androidx.lifecycle.asFlow +import im.vector.app.core.di.ActiveSessionHolder import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import timber.log.Timber +import org.matrix.android.sdk.api.session.getRoom +import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus +import org.matrix.android.sdk.api.session.room.poll.PollHistoryService +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import javax.inject.Inject -import javax.inject.Singleton -@Singleton -class RoomPollDataSource @Inject constructor() { +class RoomPollDataSource @Inject constructor( + private val activeSessionHolder: ActiveSessionHolder, +) { - private val pollsFlow = MutableSharedFlow>(replay = 1) - private val polls = mutableListOf() - private var fakeLoadCounter = 0 - - // TODO - // unmock using SDK service + add unit tests - // after unmock, expose domain layer model (entity) and do the mapping to PollSummary in the UI layer - fun getPolls(roomId: String): Flow> { - Timber.d("roomId=$roomId") - return pollsFlow.asSharedFlow() + private fun getPollHistoryService(roomId: String): PollHistoryService { + return activeSessionHolder + .getSafeActiveSession() + ?.getRoom(roomId) + ?.pollHistoryService() + ?: throw PollHistoryError.UnknownRoomError } - fun getLoadedPollsStatus(roomId: String): LoadedPollsStatus { - Timber.d("roomId=$roomId") - return LoadedPollsStatus( - canLoadMore = canLoadMore(), - nbLoadedDays = fakeLoadCounter * 30, - ) + fun dispose(roomId: String) { + getPollHistoryService(roomId).dispose() } - private fun canLoadMore(): Boolean { - return fakeLoadCounter < 2 + fun getPolls(roomId: String): Flow> { + return getPollHistoryService(roomId).getPollEvents().asFlow() + } + + suspend fun getLoadedPollsStatus(roomId: String): LoadedPollsStatus { + return getPollHistoryService(roomId).getLoadedPollsStatus() } suspend fun loadMorePolls(roomId: String): LoadedPollsStatus { - // TODO - // unmock using SDK service + add unit tests - delay(3000) - fakeLoadCounter++ - when (fakeLoadCounter) { - 1 -> polls.addAll(getActivePollsPart1() + getEndedPollsPart1()) - 2 -> polls.addAll(getActivePollsPart2() + getEndedPollsPart2()) - else -> Unit - } - pollsFlow.emit(polls) - return getLoadedPollsStatus(roomId) - } - - private fun getActivePollsPart1(): List { - return listOf( - PollSummary.ActivePoll( - id = "id1", - // 2022/06/28 UTC+1 - creationTimestamp = 1656367200000, - title = "Which charity would you like to support?" - ), - PollSummary.ActivePoll( - id = "id2", - // 2022/06/26 UTC+1 - creationTimestamp = 1656194400000, - title = "Which sport should the pupils do this year?" - ), - ) - } - - private fun getActivePollsPart2(): List { - return listOf( - PollSummary.ActivePoll( - id = "id3", - // 2022/06/24 UTC+1 - creationTimestamp = 1656021600000, - title = "What type of food should we have at the party?" - ), - PollSummary.ActivePoll( - id = "id4", - // 2022/06/22 UTC+1 - creationTimestamp = 1655848800000, - title = "What film should we show at the end of the year party?" - ), - ) - } - - private fun getEndedPollsPart1(): List { - return listOf( - PollSummary.EndedPoll( - id = "id1-ended", - // 2022/06/28 UTC+1 - creationTimestamp = 1656367200000, - title = "Which charity would you like to support?", - totalVotes = 22, - winnerOptions = listOf( - PollOptionViewState.PollEnded( - optionId = "id1", - optionAnswer = "Cancer research", - voteCount = 13, - votePercentage = 13 / 22.0, - isWinner = true, - ) - ), - ), - ) - } - - private fun getEndedPollsPart2(): List { - return listOf( - PollSummary.EndedPoll( - id = "id2-ended", - // 2022/06/26 UTC+1 - creationTimestamp = 1656194400000, - title = "Where should we do the offsite?", - totalVotes = 92, - winnerOptions = listOf( - PollOptionViewState.PollEnded( - optionId = "id1", - optionAnswer = "Hawaii", - voteCount = 43, - votePercentage = 43 / 92.0, - isWinner = true, - ) - ), - ), - PollSummary.EndedPoll( - id = "id3-ended", - // 2022/06/24 UTC+1 - creationTimestamp = 1656021600000, - title = "What type of food should we have at the party?", - totalVotes = 22, - winnerOptions = listOf( - PollOptionViewState.PollEnded( - optionId = "id1", - optionAnswer = "Brazilian", - voteCount = 13, - votePercentage = 13 / 22.0, - isWinner = true, - ) - ), - ), - ) + return getPollHistoryService(roomId).loadMore() } suspend fun syncPolls(roomId: String) { - Timber.d("roomId=$roomId") - // TODO - // unmock using SDK service + add unit tests - if (fakeLoadCounter == 0) { - // fake first load - loadMorePolls(roomId) - } else { - // fake sync - delay(3000) - } + getPollHistoryService(roomId).syncPolls() } } diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/data/RoomPollRepository.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/data/RoomPollRepository.kt index d3577df6c1..d993302fb7 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/data/RoomPollRepository.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/data/RoomPollRepository.kt @@ -16,20 +16,24 @@ package im.vector.app.features.roomprofile.polls.list.data -import im.vector.app.features.roomprofile.polls.list.ui.PollSummary import kotlinx.coroutines.flow.Flow +import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import javax.inject.Inject class RoomPollRepository @Inject constructor( private val roomPollDataSource: RoomPollDataSource, ) { - // TODO after unmock, expose domain layer model (entity) and do the mapping to PollSummary in the UI layer - fun getPolls(roomId: String): Flow> { + fun dispose(roomId: String) { + roomPollDataSource.dispose(roomId) + } + + fun getPolls(roomId: String): Flow> { return roomPollDataSource.getPolls(roomId) } - fun getLoadedPollsStatus(roomId: String): LoadedPollsStatus { + suspend fun getLoadedPollsStatus(roomId: String): LoadedPollsStatus { return roomPollDataSource.getLoadedPollsStatus(roomId) } diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/DisposePollHistoryUseCase.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/DisposePollHistoryUseCase.kt new file mode 100644 index 0000000000..f1cf031f73 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/DisposePollHistoryUseCase.kt @@ -0,0 +1,29 @@ +/* + * 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 im.vector.app.features.roomprofile.polls.list.domain + +import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository +import javax.inject.Inject + +class DisposePollHistoryUseCase @Inject constructor( + private val roomPollRepository: RoomPollRepository, +) { + + fun execute(roomId: String) { + roomPollRepository.dispose(roomId) + } +} diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/GetLoadedPollsStatusUseCase.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/GetLoadedPollsStatusUseCase.kt index 55324b253f..d37e27ff03 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/GetLoadedPollsStatusUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/GetLoadedPollsStatusUseCase.kt @@ -16,15 +16,15 @@ package im.vector.app.features.roomprofile.polls.list.domain -import im.vector.app.features.roomprofile.polls.list.data.LoadedPollsStatus import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository +import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus import javax.inject.Inject class GetLoadedPollsStatusUseCase @Inject constructor( private val roomPollRepository: RoomPollRepository, ) { - fun execute(roomId: String): LoadedPollsStatus { + suspend fun execute(roomId: String): LoadedPollsStatus { return roomPollRepository.getLoadedPollsStatus(roomId) } } diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/GetPollsUseCase.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/GetPollsUseCase.kt index be2afb226f..0f6316efde 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/GetPollsUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/GetPollsUseCase.kt @@ -17,17 +17,17 @@ package im.vector.app.features.roomprofile.polls.list.domain import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository -import im.vector.app.features.roomprofile.polls.list.ui.PollSummary import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import javax.inject.Inject class GetPollsUseCase @Inject constructor( private val roomPollRepository: RoomPollRepository, ) { - fun execute(roomId: String): Flow> { + fun execute(roomId: String): Flow> { return roomPollRepository.getPolls(roomId) - .map { it.sortedByDescending { poll -> poll.creationTimestamp } } + .map { it.sortedByDescending { event -> event.root.originServerTs } } } } diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/LoadMorePollsUseCase.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/LoadMorePollsUseCase.kt index df3270552d..fce222cae6 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/LoadMorePollsUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/LoadMorePollsUseCase.kt @@ -16,8 +16,8 @@ package im.vector.app.features.roomprofile.polls.list.domain -import im.vector.app.features.roomprofile.polls.list.data.LoadedPollsStatus import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository +import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus import javax.inject.Inject class LoadMorePollsUseCase @Inject constructor( diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/SyncPollsUseCase.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/SyncPollsUseCase.kt index b6a344f7f8..7d58fb7694 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/SyncPollsUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/SyncPollsUseCase.kt @@ -17,16 +17,26 @@ package im.vector.app.features.roomprofile.polls.list.domain import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository +import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus import javax.inject.Inject /** - * Sync the polls of a given room from last manual loading (see LoadMorePollsUseCase) until now. + * Sync the polls of a given room from last manual loading if any (see LoadMorePollsUseCase) until now. + * Resume or start loading more to have at least a complete load. */ class SyncPollsUseCase @Inject constructor( private val roomPollRepository: RoomPollRepository, + private val getLoadedPollsStatusUseCase: GetLoadedPollsStatusUseCase, + private val loadMorePollsUseCase: LoadMorePollsUseCase, ) { - suspend fun execute(roomId: String) { + suspend fun execute(roomId: String): LoadedPollsStatus { roomPollRepository.syncPolls(roomId) + val loadedStatus = getLoadedPollsStatusUseCase.execute(roomId) + return if (loadedStatus.hasCompletedASyncBackward) { + loadedStatus + } else { + loadMorePollsUseCase.execute(roomId) + } } } diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/ui/PollSummaryMapper.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/ui/PollSummaryMapper.kt new file mode 100644 index 0000000000..64c712e61f --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/ui/PollSummaryMapper.kt @@ -0,0 +1,82 @@ +/* + * 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 im.vector.app.features.roomprofile.polls.list.ui + +import im.vector.app.core.extensions.getVectorLastMessageContent +import im.vector.app.features.home.room.detail.timeline.factory.PollOptionViewStateFactory +import im.vector.app.features.home.room.detail.timeline.helper.PollResponseDataFactory +import im.vector.app.features.home.room.detail.timeline.item.PollResponseData +import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import timber.log.Timber +import javax.inject.Inject + +class PollSummaryMapper @Inject constructor( + private val pollResponseDataFactory: PollResponseDataFactory, + private val pollOptionViewStateFactory: PollOptionViewStateFactory, +) { + + fun map(timelineEvent: TimelineEvent): PollSummary? { + val eventId = timelineEvent.root.eventId.orEmpty() + val result = runCatching { + val content = timelineEvent.getVectorLastMessageContent() + val pollResponseData = pollResponseDataFactory.create(timelineEvent) + val creationTimestamp = timelineEvent.root.originServerTs ?: 0 + return if (eventId.isNotEmpty() && creationTimestamp > 0 && content is MessagePollContent) { + convertToPollSummary( + eventId = eventId, + creationTimestamp = creationTimestamp, + messagePollContent = content, + pollResponseData = pollResponseData + ) + } else { + Timber.w("missing mandatory info about poll event with id=$eventId") + null + } + } + + if (result.isFailure) { + Timber.w("failed to map event with id $eventId") + } + return result.getOrNull() + } + + private fun convertToPollSummary( + eventId: String, + creationTimestamp: Long, + messagePollContent: MessagePollContent, + pollResponseData: PollResponseData? + ): PollSummary { + val pollCreationInfo = messagePollContent.getBestPollCreationInfo() + val pollTitle = pollCreationInfo?.question?.getBestQuestion().orEmpty() + return if (pollResponseData?.isClosed == true) { + PollSummary.EndedPoll( + id = eventId, + creationTimestamp = creationTimestamp, + title = pollTitle, + totalVotes = pollResponseData.totalVotes, + winnerOptions = pollOptionViewStateFactory.createPollEndedOptions(pollCreationInfo, pollResponseData) + ) + } else { + PollSummary.ActivePoll( + id = eventId, + creationTimestamp = creationTimestamp, + title = pollTitle, + ) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/ui/RoomPollsListFragment.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/ui/RoomPollsListFragment.kt index 5920eb046e..1c33959824 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/ui/RoomPollsListFragment.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/ui/RoomPollsListFragment.kt @@ -78,7 +78,7 @@ abstract class RoomPollsListFragment : views.roomPollsList.configureWith(roomPollsController) views.roomPollsEmptyTitle.text = getEmptyListTitle( canLoadMore = viewState.canLoadMore, - nbLoadedDays = viewState.nbLoadedDays, + nbLoadedDays = viewState.nbSyncedDays, ) } @@ -117,7 +117,7 @@ abstract class RoomPollsListFragment : roomPollsController.setData(viewState) views.roomPollsEmptyTitle.text = getEmptyListTitle( canLoadMore = viewState.canLoadMore, - nbLoadedDays = viewState.nbLoadedDays, + nbLoadedDays = viewState.nbSyncedDays, ) views.roomPollsEmptyTitle.isVisible = !viewState.isSyncing && viewState.hasNoPolls() views.roomPollsLoadMoreWhenEmpty.isVisible = viewState.hasNoPollsAndCanLoadMore() diff --git a/vector/src/main/java/im/vector/app/features/sync/widget/SyncStateView.kt b/vector/src/main/java/im/vector/app/features/sync/widget/SyncStateView.kt index a71f445859..a40b70b8a6 100755 --- a/vector/src/main/java/im/vector/app/features/sync/widget/SyncStateView.kt +++ b/vector/src/main/java/im/vector/app/features/sync/widget/SyncStateView.kt @@ -40,8 +40,8 @@ class SyncStateView @JvmOverloads constructor(context: Context, attrs: Attribute @SuppressLint("SetTextI18n") fun render( - newState: SyncState, - incrementalSyncRequestState: SyncRequestState.IncrementalSyncRequestState, + newState: SyncState?, + incrementalSyncRequestState: SyncRequestState.IncrementalSyncRequestState?, pushCounter: Int, showDebugInfo: Boolean ) { @@ -64,8 +64,9 @@ class SyncStateView @JvmOverloads constructor(context: Context, attrs: Attribute } } - private fun SyncState.toHumanReadable(): String { + private fun SyncState?.toHumanReadable(): String { return when (this) { + null -> "Unknown" SyncState.Idle -> "Idle" SyncState.InvalidToken -> "InvalidToken" SyncState.Killed -> "Killed" @@ -76,8 +77,9 @@ class SyncStateView @JvmOverloads constructor(context: Context, attrs: Attribute } } - private fun SyncRequestState.IncrementalSyncRequestState.toHumanReadable(): String { + private fun SyncRequestState.IncrementalSyncRequestState?.toHumanReadable(): String { return when (this) { + null -> "Unknown" SyncRequestState.IncrementalSyncIdle -> "Idle" is SyncRequestState.IncrementalSyncParsing -> "Parsing ${this.rooms} room(s) ${this.toDevice} toDevice(s)" SyncRequestState.IncrementalSyncError -> "Error" diff --git a/vector/src/main/res/layout/fragment_timeline.xml b/vector/src/main/res/layout/fragment_timeline.xml index 6e83dbe8fd..a022ad2744 100644 --- a/vector/src/main/res/layout/fragment_timeline.xml +++ b/vector/src/main/res/layout/fragment_timeline.xml @@ -17,7 +17,8 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="48dp" - android:visibility="gone" /> + android:visibility="gone" + tools:visibility="visible" /> + android:layout_height="match_parent"> + app:layout_constraintTop_toBottomOf="@id/removeJitsiWidgetView" + tools:visibility="visible" /> + + - - + android:translationZ="10dp" + android:visibility="visible" /> diff --git a/vector/src/test/java/im/vector/app/features/command/CommandParserTest.kt b/vector/src/test/java/im/vector/app/features/command/CommandParserTest.kt index f502db85ca..ef6a99aa49 100644 --- a/vector/src/test/java/im/vector/app/features/command/CommandParserTest.kt +++ b/vector/src/test/java/im/vector/app/features/command/CommandParserTest.kt @@ -16,12 +16,15 @@ package im.vector.app.features.command +import im.vector.app.test.fakes.FakeVectorPreferences import org.amshove.kluent.shouldBeEqualTo import org.junit.Test private const val A_SPACE_ID = "!my-space-id" class CommandParserTest { + private val fakeVectorPreferences = FakeVectorPreferences() + @Test fun parseSlashCommandEmpty() { test("/", ParsedCommand.ErrorEmptySlashCommand) @@ -70,7 +73,7 @@ class CommandParserTest { } private fun test(message: String, expectedResult: ParsedCommand) { - val commandParser = CommandParser() + val commandParser = CommandParser(fakeVectorPreferences.instance) val result = commandParser.parseSlashCommand(message, null, false) result shouldBeEqualTo expectedResult } diff --git a/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/factory/PollItemViewStateFactoryTest.kt b/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/factory/PollItemViewStateFactoryTest.kt index 8ee55d5b6e..512f7c8a17 100644 --- a/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/factory/PollItemViewStateFactoryTest.kt +++ b/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/factory/PollItemViewStateFactoryTest.kt @@ -17,127 +17,71 @@ package im.vector.app.features.home.room.detail.timeline.factory import im.vector.app.R -import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState -import im.vector.app.features.home.room.detail.timeline.item.PollResponseData import im.vector.app.features.home.room.detail.timeline.item.PollVoteSummaryData -import im.vector.app.features.home.room.detail.timeline.item.ReactionsSummaryData -import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout import im.vector.app.features.poll.PollViewState import im.vector.app.test.fakes.FakeStringProvider +import im.vector.app.test.fixtures.PollFixture.A_MESSAGE_INFORMATION_DATA +import im.vector.app.test.fixtures.PollFixture.A_POLL_CONTENT +import im.vector.app.test.fixtures.PollFixture.A_POLL_OPTION_IDS +import im.vector.app.test.fixtures.PollFixture.A_POLL_RESPONSE_DATA +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify import org.amshove.kluent.shouldBeEqualTo import org.junit.Test -import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent -import org.matrix.android.sdk.api.session.room.model.message.PollAnswer -import org.matrix.android.sdk.api.session.room.model.message.PollCreationInfo -import org.matrix.android.sdk.api.session.room.model.message.PollQuestion import org.matrix.android.sdk.api.session.room.model.message.PollType import org.matrix.android.sdk.api.session.room.send.SendState -private val A_MESSAGE_INFORMATION_DATA = MessageInformationData( - eventId = "eventId", - senderId = "senderId", - ageLocalTS = 0, - avatarUrl = "", - sendState = SendState.SENT, - messageLayout = TimelineMessageLayout.Default(showAvatar = true, showDisplayName = true, showTimestamp = true), - reactionsSummary = ReactionsSummaryData(), - sentByMe = true, -) - -private val A_POLL_RESPONSE_DATA = PollResponseData( - myVote = null, - votes = emptyMap(), -) - -private val A_POLL_OPTION_IDS = listOf("5ef5f7b0-c9a1-49cf-a0b3-374729a43e76", "ec1a4db0-46d8-4d7a-9bb6-d80724715938", "3677ca8e-061b-40ab-bffe-b22e4e88fcad") - -private val A_POLL_CONTENT = MessagePollContent( - unstablePollCreationInfo = PollCreationInfo( - question = PollQuestion( - unstableQuestion = "What is your favourite coffee?" - ), - kind = PollType.UNDISCLOSED_UNSTABLE, - maxSelections = 1, - answers = listOf( - PollAnswer( - id = A_POLL_OPTION_IDS[0], - unstableAnswer = "Double Espresso" - ), - PollAnswer( - id = A_POLL_OPTION_IDS[1], - unstableAnswer = "Macchiato" - ), - PollAnswer( - id = A_POLL_OPTION_IDS[2], - unstableAnswer = "Iced Coffee" - ), - ) - ) -) - class PollItemViewStateFactoryTest { + private val fakeStringProvider = FakeStringProvider() + private val fakePollOptionViewStateFactory = mockk() + + private val pollItemViewStateFactory = PollItemViewStateFactory( + stringProvider = fakeStringProvider.instance, + pollOptionViewStateFactory = fakePollOptionViewStateFactory, + ) + @Test fun `given a sending poll state then poll is not votable and option states are PollSending`() { - val stringProvider = FakeStringProvider() - val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance) - + // Given val sendingPollInformationData = A_MESSAGE_INFORMATION_DATA.copy(sendState = SendState.SENDING) + val optionViewStates = listOf(PollOptionViewState.PollSending(optionId = "", optionAnswer = "")) + every { fakePollOptionViewStateFactory.createPollSendingOptions(A_POLL_CONTENT.getBestPollCreationInfo()) } returns optionViewStates + + // When val pollViewState = pollItemViewStateFactory.create( pollContent = A_POLL_CONTENT, informationData = sendingPollInformationData, ) + // Then pollViewState shouldBeEqualTo PollViewState( question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "", - votesStatus = stringProvider.instance.getString(R.string.poll_no_votes_cast), + votesStatus = fakeStringProvider.instance.getString(R.string.poll_no_votes_cast), canVote = false, - optionViewStates = A_POLL_CONTENT.getBestPollCreationInfo()?.answers?.map { answer -> - PollOptionViewState.PollSending( - optionId = answer.id ?: "", - optionAnswer = answer.getBestAnswer() ?: "" - ) - }, + optionViewStates = optionViewStates, ) + verify { fakePollOptionViewStateFactory.createPollSendingOptions(A_POLL_CONTENT.getBestPollCreationInfo()) } } @Test fun `given a sent poll state when poll is closed then poll is not votable and option states are Ended`() { - val stringProvider = FakeStringProvider() - val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance) - + // Given val closedPollSummary = A_POLL_RESPONSE_DATA.copy(isClosed = true) val closedPollInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = closedPollSummary) - - val pollViewState = pollItemViewStateFactory.create( - pollContent = A_POLL_CONTENT, - informationData = closedPollInformationData, + val optionViewStates = listOf( + PollOptionViewState.PollEnded( + optionId = "", optionAnswer = "", voteCount = 0, votePercentage = 0.0, isWinner = false + ) ) - - pollViewState shouldBeEqualTo PollViewState( - question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "", - votesStatus = stringProvider.instance.getQuantityString(R.plurals.poll_total_vote_count_after_ended, 0, 0), - canVote = false, - optionViewStates = A_POLL_CONTENT.getBestPollCreationInfo()?.answers?.map { answer -> - PollOptionViewState.PollEnded( - optionId = answer.id ?: "", - optionAnswer = answer.getBestAnswer() ?: "", - voteCount = 0, - votePercentage = 0.0, - isWinner = false - ) - }, - ) - } - - @Test - fun `given a sent poll state with some decryption error when poll is closed then warning message is displayed`() { - // Given - val stringProvider = FakeStringProvider() - val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance) - val closedPollSummary = A_POLL_RESPONSE_DATA.copy(isClosed = true, hasEncryptedRelatedEvents = true) - val closedPollInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = closedPollSummary) + every { + fakePollOptionViewStateFactory.createPollEndedOptions( + A_POLL_CONTENT.getBestPollCreationInfo(), + closedPollInformationData.pollResponseAggregatedSummary, + ) + } returns optionViewStates // When val pollViewState = pollItemViewStateFactory.create( @@ -146,42 +90,90 @@ class PollItemViewStateFactoryTest { ) // Then - pollViewState.votesStatus shouldBeEqualTo stringProvider.instance.getString(R.string.unable_to_decrypt_some_events_in_poll) + pollViewState shouldBeEqualTo PollViewState( + question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "", + votesStatus = fakeStringProvider.instance.getQuantityString(R.plurals.poll_total_vote_count_after_ended, 0, 0), + canVote = false, + optionViewStates = optionViewStates, + ) + verify { + fakePollOptionViewStateFactory.createPollEndedOptions( + A_POLL_CONTENT.getBestPollCreationInfo(), + closedPollInformationData.pollResponseAggregatedSummary, + ) + } + } + + @Test + fun `given a sent poll state with some decryption error when poll is closed then warning message is displayed`() { + // Given + val closedPollSummary = A_POLL_RESPONSE_DATA.copy(isClosed = true, hasEncryptedRelatedEvents = true) + val closedPollInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = closedPollSummary) + val optionViewStates = listOf( + PollOptionViewState.PollEnded( + optionId = "", optionAnswer = "", voteCount = 0, votePercentage = 0.0, isWinner = false + ) + ) + every { + fakePollOptionViewStateFactory.createPollEndedOptions( + A_POLL_CONTENT.getBestPollCreationInfo(), + closedPollInformationData.pollResponseAggregatedSummary, + ) + } returns optionViewStates + + // When + val pollViewState = pollItemViewStateFactory.create( + pollContent = A_POLL_CONTENT, + informationData = closedPollInformationData, + ) + + // Then + pollViewState.votesStatus shouldBeEqualTo fakeStringProvider.instance.getString(R.string.unable_to_decrypt_some_events_in_poll) } @Test fun `given a sent poll when undisclosed poll type is selected then poll is votable and option states are PollUndisclosed`() { - val stringProvider = FakeStringProvider() - val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance) + // Given + val optionViewStates = listOf( + PollOptionViewState.PollUndisclosed( + optionId = "", + optionAnswer = "", + isSelected = false, + ) + ) + every { + fakePollOptionViewStateFactory.createPollUndisclosedOptions( + A_POLL_CONTENT.getBestPollCreationInfo(), + A_MESSAGE_INFORMATION_DATA.pollResponseAggregatedSummary, + ) + } returns optionViewStates + // When val pollViewState = pollItemViewStateFactory.create( pollContent = A_POLL_CONTENT, informationData = A_MESSAGE_INFORMATION_DATA, ) + // Then pollViewState shouldBeEqualTo PollViewState( question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "", - votesStatus = stringProvider.instance.getString(R.string.poll_undisclosed_not_ended), + votesStatus = fakeStringProvider.instance.getString(R.string.poll_undisclosed_not_ended), canVote = true, - optionViewStates = A_POLL_CONTENT.getBestPollCreationInfo()?.answers?.map { answer -> - PollOptionViewState.PollUndisclosed( - optionId = answer.id ?: "", - optionAnswer = answer.getBestAnswer() ?: "", - isSelected = false - ) - }, + optionViewStates = optionViewStates, ) + verify { + fakePollOptionViewStateFactory.createPollUndisclosedOptions( + A_POLL_CONTENT.getBestPollCreationInfo(), + A_MESSAGE_INFORMATION_DATA.pollResponseAggregatedSummary, + ) + } } @Test fun `given a sent poll when my vote exists then poll is still votable and options states are PollVoted`() { - val stringProvider = FakeStringProvider() - val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance) - + // Given val votedPollData = A_POLL_RESPONSE_DATA.copy( - totalVotes = 1, - myVote = A_POLL_OPTION_IDS[0], - votes = mapOf(A_POLL_OPTION_IDS[0] to PollVoteSummaryData(total = 1, percentage = 1.0)) + totalVotes = 1, myVote = A_POLL_OPTION_IDS[0], votes = mapOf(A_POLL_OPTION_IDS[0] to PollVoteSummaryData(total = 1, percentage = 1.0)) ) val disclosedPollContent = A_POLL_CONTENT.copy( unstablePollCreationInfo = A_POLL_CONTENT.getBestPollCreationInfo()?.copy( @@ -189,33 +181,46 @@ class PollItemViewStateFactoryTest { ), ) val votedInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = votedPollData) + val optionViewStates = listOf( + PollOptionViewState.PollVoted( + optionId = "", + optionAnswer = "", + voteCount = 0, + votePercentage = 0.0, + isSelected = false, + ) + ) + every { + fakePollOptionViewStateFactory.createPollVotedOptions( + disclosedPollContent.getBestPollCreationInfo(), + votedInformationData.pollResponseAggregatedSummary, + ) + } returns optionViewStates + // When val pollViewState = pollItemViewStateFactory.create( pollContent = disclosedPollContent, informationData = votedInformationData, ) + // Then pollViewState shouldBeEqualTo PollViewState( question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "", - votesStatus = stringProvider.instance.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_voted, 1, 1), + votesStatus = fakeStringProvider.instance.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_voted, 1, 1), canVote = true, - optionViewStates = A_POLL_CONTENT.getBestPollCreationInfo()?.answers?.mapIndexed { index, answer -> - PollOptionViewState.PollVoted( - optionId = answer.id ?: "", - optionAnswer = answer.getBestAnswer() ?: "", - voteCount = if (index == 0) 1 else 0, - votePercentage = if (index == 0) 1.0 else 0.0, - isSelected = index == 0 - ) - }, + optionViewStates = optionViewStates, ) + verify { + fakePollOptionViewStateFactory.createPollVotedOptions( + disclosedPollContent.getBestPollCreationInfo(), + votedInformationData.pollResponseAggregatedSummary, + ) + } } @Test fun `given a sent poll with decryption failure when my vote exists then a warning message is displayed`() { // Given - val stringProvider = FakeStringProvider() - val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance) val votedPollData = A_POLL_RESPONSE_DATA.copy( totalVotes = 1, myVote = A_POLL_OPTION_IDS[0], @@ -228,6 +233,21 @@ class PollItemViewStateFactoryTest { ), ) val votedInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = votedPollData) + val optionViewStates = listOf( + PollOptionViewState.PollVoted( + optionId = "", + optionAnswer = "", + voteCount = 0, + votePercentage = 0.0, + isSelected = false, + ) + ) + every { + fakePollOptionViewStateFactory.createPollVotedOptions( + disclosedPollContent.getBestPollCreationInfo(), + votedInformationData.pollResponseAggregatedSummary, + ) + } returns optionViewStates // When val pollViewState = pollItemViewStateFactory.create( @@ -236,34 +256,46 @@ class PollItemViewStateFactoryTest { ) // Then - pollViewState.votesStatus shouldBeEqualTo stringProvider.instance.getString(R.string.unable_to_decrypt_some_events_in_poll) + pollViewState.votesStatus shouldBeEqualTo fakeStringProvider.instance.getString(R.string.unable_to_decrypt_some_events_in_poll) } @Test fun `given a sent poll when poll type is disclosed then poll is votable and option view states are PollReady`() { - val stringProvider = FakeStringProvider() - val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance) - + // Given val disclosedPollContent = A_POLL_CONTENT.copy( unstablePollCreationInfo = A_POLL_CONTENT.getBestPollCreationInfo()?.copy( kind = PollType.DISCLOSED_UNSTABLE ) ) + val optionViewStates = listOf( + PollOptionViewState.PollReady( + optionId = "", + optionAnswer = "", + ) + ) + every { + fakePollOptionViewStateFactory.createPollReadyOptions( + disclosedPollContent.getBestPollCreationInfo(), + ) + } returns optionViewStates + + // When val pollViewState = pollItemViewStateFactory.create( pollContent = disclosedPollContent, informationData = A_MESSAGE_INFORMATION_DATA, ) + // Then pollViewState shouldBeEqualTo PollViewState( question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "", - votesStatus = stringProvider.instance.getString(R.string.poll_no_votes_cast), + votesStatus = fakeStringProvider.instance.getString(R.string.poll_no_votes_cast), canVote = true, - optionViewStates = A_POLL_CONTENT.getBestPollCreationInfo()?.answers?.map { answer -> - PollOptionViewState.PollReady( - optionId = answer.id ?: "", - optionAnswer = answer.getBestAnswer() ?: "" - ) - }, + optionViewStates = optionViewStates, ) + verify { + fakePollOptionViewStateFactory.createPollReadyOptions( + disclosedPollContent.getBestPollCreationInfo(), + ) + } } } diff --git a/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/factory/PollOptionViewStateFactoryTest.kt b/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/factory/PollOptionViewStateFactoryTest.kt new file mode 100644 index 0000000000..285cff7d63 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/factory/PollOptionViewStateFactoryTest.kt @@ -0,0 +1,157 @@ +/* + * 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 im.vector.app.features.home.room.detail.timeline.factory + +import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState +import im.vector.app.features.home.room.detail.timeline.item.PollVoteSummaryData +import im.vector.app.test.fixtures.PollFixture.A_POLL_CONTENT +import im.vector.app.test.fixtures.PollFixture.A_POLL_OPTION_IDS +import im.vector.app.test.fixtures.PollFixture.A_POLL_RESPONSE_DATA +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import org.matrix.android.sdk.api.session.room.model.message.PollType + +internal class PollOptionViewStateFactoryTest { + + private val pollOptionViewStateFactory = PollOptionViewStateFactory() + + @Test + fun `given poll data when creating ended poll options then correct options are returned`() { + // Given + val winnerVotesCount = 0 + val pollResponseData = A_POLL_RESPONSE_DATA.copy( + isClosed = true, + winnerVoteCount = winnerVotesCount, + ) + val pollCreationInfo = A_POLL_CONTENT.getBestPollCreationInfo() + val expectedOptions = pollCreationInfo?.answers?.map { answer -> + PollOptionViewState.PollEnded( + optionId = answer.id.orEmpty(), + optionAnswer = answer.getBestAnswer().orEmpty(), + voteCount = 0, + votePercentage = 0.0, + isWinner = false, + ) + } + + // When + val result = pollOptionViewStateFactory.createPollEndedOptions( + pollCreationInfo = pollCreationInfo, + pollResponseData = pollResponseData, + ) + + // Then + result shouldBeEqualTo expectedOptions + } + + @Test + fun `given poll data when creating sending poll options then correct options are returned`() { + // Given + val pollCreationInfo = A_POLL_CONTENT.getBestPollCreationInfo() + val expectedOptions = pollCreationInfo?.answers?.map { answer -> + PollOptionViewState.PollSending( + optionId = answer.id.orEmpty(), + optionAnswer = answer.getBestAnswer().orEmpty(), + ) + } + + // When + val result = pollOptionViewStateFactory.createPollSendingOptions( + pollCreationInfo = pollCreationInfo, + ) + + // Then + result shouldBeEqualTo expectedOptions + } + + @Test + fun `given poll data when creating undisclosed poll options then correct options are returned`() { + // Given + val pollResponseData = A_POLL_RESPONSE_DATA + val pollCreationInfo = A_POLL_CONTENT.getBestPollCreationInfo() + val expectedOptions = pollCreationInfo?.answers?.map { answer -> + PollOptionViewState.PollUndisclosed( + optionId = answer.id.orEmpty(), + optionAnswer = answer.getBestAnswer().orEmpty(), + isSelected = false, + ) + } + + // When + val result = pollOptionViewStateFactory.createPollUndisclosedOptions( + pollCreationInfo = pollCreationInfo, + pollResponseData = pollResponseData, + ) + + // Then + result shouldBeEqualTo expectedOptions + } + + @Test + fun `given poll data when creating voted poll options then correct options are returned`() { + // Given + val pollResponseData = A_POLL_RESPONSE_DATA.copy( + totalVotes = 1, + myVote = A_POLL_OPTION_IDS[0], + votes = mapOf(A_POLL_OPTION_IDS[0] to PollVoteSummaryData(total = 1, percentage = 1.0)), + ) + val disclosedPollContent = A_POLL_CONTENT.copy( + unstablePollCreationInfo = A_POLL_CONTENT.getBestPollCreationInfo()?.copy( + kind = PollType.DISCLOSED_UNSTABLE, + ), + ) + val pollCreationInfo = disclosedPollContent.getBestPollCreationInfo() + val expectedOptions = pollCreationInfo?.answers?.mapIndexed { index, answer -> + PollOptionViewState.PollVoted( + optionId = answer.id.orEmpty(), + optionAnswer = answer.getBestAnswer().orEmpty(), + voteCount = if (index == 0) 1 else 0, + votePercentage = if (index == 0) 1.0 else 0.0, + isSelected = index == 0, + ) + } + + // When + val result = pollOptionViewStateFactory.createPollVotedOptions( + pollCreationInfo = pollCreationInfo, + pollResponseData = pollResponseData, + ) + + // Then + result shouldBeEqualTo expectedOptions + } + + @Test + fun `given poll data when creating ready poll options then correct options are returned`() { + // Given + val pollCreationInfo = A_POLL_CONTENT.getBestPollCreationInfo() + val expectedOptions = pollCreationInfo?.answers?.map { answer -> + PollOptionViewState.PollReady( + optionId = answer.id.orEmpty(), + optionAnswer = answer.getBestAnswer().orEmpty(), + ) + } + + // When + val result = pollOptionViewStateFactory.createPollReadyOptions( + pollCreationInfo = pollCreationInfo, + ) + + // Then + result shouldBeEqualTo expectedOptions + } +} diff --git a/vector/src/test/java/im/vector/app/features/roomprofile/polls/RoomPollsViewModelTest.kt b/vector/src/test/java/im/vector/app/features/roomprofile/polls/RoomPollsViewModelTest.kt index efb905c97f..20471637e6 100644 --- a/vector/src/test/java/im/vector/app/features/roomprofile/polls/RoomPollsViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/roomprofile/polls/RoomPollsViewModelTest.kt @@ -17,23 +17,26 @@ package im.vector.app.features.roomprofile.polls import com.airbnb.mvrx.test.MavericksTestRule -import im.vector.app.features.roomprofile.polls.list.data.LoadedPollsStatus -import im.vector.app.features.roomprofile.polls.list.domain.GetLoadedPollsStatusUseCase +import im.vector.app.features.roomprofile.polls.list.domain.DisposePollHistoryUseCase import im.vector.app.features.roomprofile.polls.list.domain.GetPollsUseCase import im.vector.app.features.roomprofile.polls.list.domain.LoadMorePollsUseCase import im.vector.app.features.roomprofile.polls.list.domain.SyncPollsUseCase import im.vector.app.features.roomprofile.polls.list.ui.PollSummary +import im.vector.app.features.roomprofile.polls.list.ui.PollSummaryMapper import im.vector.app.test.test import im.vector.app.test.testDispatcher import io.mockk.coEvery -import io.mockk.coJustRun import io.mockk.coVerify import io.mockk.every +import io.mockk.justRun import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf import org.junit.Rule import org.junit.Test +import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent private const val A_ROOM_ID = "room-id" @@ -42,33 +45,37 @@ class RoomPollsViewModelTest { @get:Rule val mavericksTestRule = MavericksTestRule(testDispatcher = testDispatcher) + private val initialState = RoomPollsViewState(A_ROOM_ID) private val fakeGetPollsUseCase = mockk() - private val fakeGetLoadedPollsStatusUseCase = mockk() private val fakeLoadMorePollsUseCase = mockk() private val fakeSyncPollsUseCase = mockk() - private val initialState = RoomPollsViewState(A_ROOM_ID) + private val fakeDisposePollHistoryUseCase = mockk() + private val fakePollSummaryMapper = mockk() private fun createViewModel(): RoomPollsViewModel { return RoomPollsViewModel( initialState = initialState, getPollsUseCase = fakeGetPollsUseCase, - getLoadedPollsStatusUseCase = fakeGetLoadedPollsStatusUseCase, loadMorePollsUseCase = fakeLoadMorePollsUseCase, syncPollsUseCase = fakeSyncPollsUseCase, + disposePollHistoryUseCase = fakeDisposePollHistoryUseCase, + pollSummaryMapper = fakePollSummaryMapper, ) } @Test fun `given viewModel when created then polls list is observed, sync is launched and viewState is updated`() { // Given - val loadedPollsStatus = givenGetLoadedPollsStatusSuccess() - givenSyncPollsWithSuccess() - val polls = listOf(givenAPollSummary()) + val loadedPollsStatus = givenSyncPollsWithSuccess() + val aPollEvent = givenAPollEvent() + val aPollSummary = givenAPollSummary() + val polls = listOf(aPollEvent) every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns flowOf(polls) + every { fakePollSummaryMapper.map(aPollEvent) } returns aPollSummary val expectedViewState = initialState.copy( - polls = polls, + polls = listOf(aPollSummary), canLoadMore = loadedPollsStatus.canLoadMore, - nbLoadedDays = loadedPollsStatus.nbLoadedDays, + nbSyncedDays = loadedPollsStatus.daysSynced, ) // When @@ -81,6 +88,7 @@ class RoomPollsViewModelTest { .finish() verify { fakeGetPollsUseCase.execute(A_ROOM_ID) + fakePollSummaryMapper.map(aPollEvent) } coVerify { fakeSyncPollsUseCase.execute(A_ROOM_ID) } } @@ -88,10 +96,8 @@ class RoomPollsViewModelTest { @Test fun `given viewModel and error during sync process when created then error is raised in view event`() { // Given - givenGetLoadedPollsStatusSuccess() givenSyncPollsWithError(Exception()) - val polls = listOf(givenAPollSummary()) - every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns flowOf(polls) + every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns emptyFlow() // When val viewModel = createViewModel() @@ -104,19 +110,32 @@ class RoomPollsViewModelTest { coVerify { fakeSyncPollsUseCase.execute(A_ROOM_ID) } } + @Test + fun `given viewModel when calling onCleared then poll history is disposed`() { + // Given + givenSyncPollsWithSuccess() + every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns emptyFlow() + justRun { fakeDisposePollHistoryUseCase.execute(A_ROOM_ID) } + val viewModel = createViewModel() + + // When + viewModel.onCleared() + + // Then + verify { fakeDisposePollHistoryUseCase.execute(A_ROOM_ID) } + } + @Test fun `given viewModel when handle load more action then viewState is updated`() { // Given - val loadedPollsStatus = givenGetLoadedPollsStatusSuccess() - givenSyncPollsWithSuccess() - val polls = listOf(givenAPollSummary()) - every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns flowOf(polls) + val loadedPollsStatus = givenSyncPollsWithSuccess() + every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns emptyFlow() val newLoadedPollsStatus = givenLoadMoreWithSuccess() val viewModel = createViewModel() val stateAfterInit = initialState.copy( - polls = polls, + polls = emptyList(), canLoadMore = loadedPollsStatus.canLoadMore, - nbLoadedDays = loadedPollsStatus.nbLoadedDays, + nbSyncedDays = loadedPollsStatus.daysSynced, ) // When @@ -128,7 +147,7 @@ class RoomPollsViewModelTest { .assertStatesChanges( stateAfterInit, { copy(isLoadingMore = true) }, - { copy(canLoadMore = newLoadedPollsStatus.canLoadMore, nbLoadedDays = newLoadedPollsStatus.nbLoadedDays) }, + { copy(canLoadMore = newLoadedPollsStatus.canLoadMore, nbSyncedDays = newLoadedPollsStatus.daysSynced) }, { copy(isLoadingMore = false) }, ) .finish() @@ -139,8 +158,14 @@ class RoomPollsViewModelTest { return mockk() } - private fun givenSyncPollsWithSuccess() { - coJustRun { fakeSyncPollsUseCase.execute(A_ROOM_ID) } + private fun givenAPollEvent(): TimelineEvent { + return mockk() + } + + private fun givenSyncPollsWithSuccess(): LoadedPollsStatus { + val loadedPollsStatus = givenALoadedPollsStatus() + coEvery { fakeSyncPollsUseCase.execute(A_ROOM_ID) } returns loadedPollsStatus + return loadedPollsStatus } private fun givenSyncPollsWithError(error: Exception) { @@ -148,20 +173,15 @@ class RoomPollsViewModelTest { } private fun givenLoadMoreWithSuccess(): LoadedPollsStatus { - val loadedPollsStatus = givenALoadedPollsStatus(canLoadMore = false, nbLoadedDays = 20) + val loadedPollsStatus = givenALoadedPollsStatus(canLoadMore = false, nbSyncedDays = 20) coEvery { fakeLoadMorePollsUseCase.execute(A_ROOM_ID) } returns loadedPollsStatus return loadedPollsStatus } - private fun givenGetLoadedPollsStatusSuccess(): LoadedPollsStatus { - val loadedPollsStatus = givenALoadedPollsStatus() - every { fakeGetLoadedPollsStatusUseCase.execute(A_ROOM_ID) } returns loadedPollsStatus - return loadedPollsStatus - } - - private fun givenALoadedPollsStatus(canLoadMore: Boolean = true, nbLoadedDays: Int = 10) = + private fun givenALoadedPollsStatus(canLoadMore: Boolean = true, nbSyncedDays: Int = 10) = LoadedPollsStatus( canLoadMore = canLoadMore, - nbLoadedDays = nbLoadedDays, + daysSynced = nbSyncedDays, + hasCompletedASyncBackward = false, ) } diff --git a/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/data/RoomPollDataSourceTest.kt b/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/data/RoomPollDataSourceTest.kt new file mode 100644 index 0000000000..89fde7b9df --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/data/RoomPollDataSourceTest.kt @@ -0,0 +1,130 @@ +/* + * 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 im.vector.app.features.roomprofile.polls.list.data + +import im.vector.app.test.fakes.FakeActiveSessionHolder +import im.vector.app.test.fakes.FakeFlowLiveDataConversions +import im.vector.app.test.fakes.FakePollHistoryService +import im.vector.app.test.fakes.givenAsFlow +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent + +private const val A_ROOM_ID = "room-id" + +internal class RoomPollDataSourceTest { + + private val fakeActiveSessionHolder = FakeActiveSessionHolder() + + private val roomPollDataSource = RoomPollDataSource( + activeSessionHolder = fakeActiveSessionHolder.instance, + ) + + @Test + fun `given poll history service when dispose then correct method of service is called`() { + // Given + val fakePollHistoryService = givenPollHistoryService() + fakePollHistoryService.givenDispose() + + // When + roomPollDataSource.dispose(A_ROOM_ID) + + // Then + fakePollHistoryService.verifyDispose() + } + + @Test + fun `given poll history service when get polls then correct method of service is called and correct result is returned`() = runTest { + // Given + val fakeFlowLiveDataConversions = FakeFlowLiveDataConversions() + fakeFlowLiveDataConversions.setup() + val fakePollHistoryService = givenPollHistoryService() + val pollEvents = listOf() + fakePollHistoryService + .givenGetPollsReturns(pollEvents) + .givenAsFlow() + + // When + val result = roomPollDataSource.getPolls(A_ROOM_ID).firstOrNull() + + // Then + result shouldBeEqualTo pollEvents + fakePollHistoryService.verifyGetPolls() + unmockkAll() + } + + @Test + fun `given poll history service when get loaded polls then correct method of service is called and correct result is returned`() = runTest { + // Given + val fakePollHistoryService = givenPollHistoryService() + val aLoadedPollsStatus = givenALoadedPollsStatus() + fakePollHistoryService.givenGetLoadedPollsStatusReturns(aLoadedPollsStatus) + + // When + val result = roomPollDataSource.getLoadedPollsStatus(A_ROOM_ID) + + // Then + result shouldBeEqualTo aLoadedPollsStatus + fakePollHistoryService.verifyGetLoadedPollsStatus() + } + + @Test + fun `given poll history service when load more then correct method of service is called and correct result is returned`() = runTest { + // Given + val fakePollHistoryService = givenPollHistoryService() + val aLoadedPollsStatus = givenALoadedPollsStatus() + fakePollHistoryService.givenLoadMoreReturns(aLoadedPollsStatus) + + // When + val result = roomPollDataSource.loadMorePolls(A_ROOM_ID) + + // Then + result shouldBeEqualTo aLoadedPollsStatus + fakePollHistoryService.verifyLoadMore() + } + + @Test + fun `given poll history service when sync polls then correct method of service is called`() = runTest { + // Given + val fakePollHistoryService = givenPollHistoryService() + fakePollHistoryService.givenSyncPollsSuccess() + + // When + roomPollDataSource.syncPolls(A_ROOM_ID) + + // Then + fakePollHistoryService.verifySyncPolls() + } + + private fun givenPollHistoryService(): FakePollHistoryService { + return fakeActiveSessionHolder + .fakeSession + .fakeRoomService + .getRoom(A_ROOM_ID) + .pollHistoryService() + } + + private fun givenALoadedPollsStatus() = LoadedPollsStatus( + canLoadMore = true, + daysSynced = 10, + hasCompletedASyncBackward = true, + ) +} diff --git a/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/data/RoomPollRepositoryTest.kt b/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/data/RoomPollRepositoryTest.kt index 49d9623c04..f27335b844 100644 --- a/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/data/RoomPollRepositoryTest.kt +++ b/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/data/RoomPollRepositoryTest.kt @@ -16,10 +16,11 @@ package im.vector.app.features.roomprofile.polls.list.data -import im.vector.app.features.roomprofile.polls.list.ui.PollSummary +import io.mockk.coEvery import io.mockk.coJustRun import io.mockk.coVerify import io.mockk.every +import io.mockk.justRun import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.flow.firstOrNull @@ -27,6 +28,8 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBeEqualTo import org.junit.Test +import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent private const val A_ROOM_ID = "room-id" @@ -38,10 +41,22 @@ class RoomPollRepositoryTest { roomPollDataSource = fakeRoomPollDataSource, ) + @Test + fun `given data source when dispose then correct method of data source is called`() { + // Given + justRun { fakeRoomPollDataSource.dispose(A_ROOM_ID) } + + // When + roomPollRepository.dispose(A_ROOM_ID) + + // Then + verify { fakeRoomPollDataSource.dispose(A_ROOM_ID) } + } + @Test fun `given data source when getting polls then correct method of data source is called`() = runTest { // Given - val expectedPolls = listOf() + val expectedPolls = listOf() every { fakeRoomPollDataSource.getPolls(A_ROOM_ID) } returns flowOf(expectedPolls) // When @@ -53,20 +68,21 @@ class RoomPollRepositoryTest { } @Test - fun `given data source when getting loaded polls status then correct method of data source is called`() { + fun `given data source when getting loaded polls status then correct method of data source is called`() = runTest { // Given val expectedStatus = LoadedPollsStatus( canLoadMore = true, - nbLoadedDays = 10, + daysSynced = 10, + hasCompletedASyncBackward = false, ) - every { fakeRoomPollDataSource.getLoadedPollsStatus(A_ROOM_ID) } returns expectedStatus + coEvery { fakeRoomPollDataSource.getLoadedPollsStatus(A_ROOM_ID) } returns expectedStatus // When val result = roomPollRepository.getLoadedPollsStatus(A_ROOM_ID) // Then result shouldBeEqualTo expectedStatus - verify { fakeRoomPollDataSource.getLoadedPollsStatus(A_ROOM_ID) } + coVerify { fakeRoomPollDataSource.getLoadedPollsStatus(A_ROOM_ID) } } @Test diff --git a/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/domain/DisposePollHistoryUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/domain/DisposePollHistoryUseCaseTest.kt new file mode 100644 index 0000000000..0063d9bfd5 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/domain/DisposePollHistoryUseCaseTest.kt @@ -0,0 +1,45 @@ +/* + * 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 im.vector.app.features.roomprofile.polls.list.domain + +import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository +import io.mockk.coVerify +import io.mockk.justRun +import io.mockk.mockk +import org.junit.Test + +internal class DisposePollHistoryUseCaseTest { + + private val fakeRoomPollRepository = mockk() + + private val disposePollHistoryUseCase = DisposePollHistoryUseCase( + roomPollRepository = fakeRoomPollRepository, + ) + + @Test + fun `given repo when execute then correct method of repo is called`() { + // Given + val aRoomId = "roomId" + justRun { fakeRoomPollRepository.dispose(aRoomId) } + + // When + disposePollHistoryUseCase.execute(aRoomId) + + // Then + coVerify { fakeRoomPollRepository.dispose(aRoomId) } + } +} diff --git a/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/domain/GetLoadedPollsStatusUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/domain/GetLoadedPollsStatusUseCaseTest.kt index c87a15fb02..2b3d731b3b 100644 --- a/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/domain/GetLoadedPollsStatusUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/domain/GetLoadedPollsStatusUseCaseTest.kt @@ -16,13 +16,14 @@ package im.vector.app.features.roomprofile.polls.list.domain -import im.vector.app.features.roomprofile.polls.list.data.LoadedPollsStatus import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository -import io.mockk.every +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.mockk -import io.mockk.verify +import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBeEqualTo import org.junit.Test +import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus class GetLoadedPollsStatusUseCaseTest { @@ -33,20 +34,21 @@ class GetLoadedPollsStatusUseCaseTest { ) @Test - fun `given repo when execute then correct method of repo is called`() { + fun `given repo when execute then correct method of repo is called`() = runTest { // Given val aRoomId = "roomId" val expectedStatus = LoadedPollsStatus( canLoadMore = true, - nbLoadedDays = 10, + daysSynced = 10, + hasCompletedASyncBackward = true, ) - every { fakeRoomPollRepository.getLoadedPollsStatus(aRoomId) } returns expectedStatus + coEvery { fakeRoomPollRepository.getLoadedPollsStatus(aRoomId) } returns expectedStatus // When val status = getLoadedPollsStatusUseCase.execute(aRoomId) // Then status shouldBeEqualTo expectedStatus - verify { fakeRoomPollRepository.getLoadedPollsStatus(aRoomId) } + coVerify { fakeRoomPollRepository.getLoadedPollsStatus(aRoomId) } } } diff --git a/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/domain/GetPollsUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/domain/GetPollsUseCaseTest.kt index e69b9287f8..f29a4844d7 100644 --- a/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/domain/GetPollsUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/domain/GetPollsUseCaseTest.kt @@ -17,8 +17,6 @@ package im.vector.app.features.roomprofile.polls.list.domain import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository -import im.vector.app.features.roomprofile.polls.list.ui.PollSummary -import im.vector.app.test.fixtures.RoomPollFixture import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -27,6 +25,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBeEqualTo import org.junit.Test +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent class GetPollsUseCaseTest { private val fakeRoomPollRepository = mockk() @@ -39,16 +38,16 @@ class GetPollsUseCaseTest { fun `given repo when execute then correct method of repo is called and polls are sorted most recent first`() = runTest { // Given val aRoomId = "roomId" - val poll1 = RoomPollFixture.anActivePollSummary(timestamp = 1) - val poll2 = RoomPollFixture.anActivePollSummary(timestamp = 2) - val poll3 = RoomPollFixture.anActivePollSummary(timestamp = 3) - val polls = listOf( + val poll1 = givenTimelineEvent(timestamp = 1) + val poll2 = givenTimelineEvent(timestamp = 2) + val poll3 = givenTimelineEvent(timestamp = 3) + val polls = listOf( poll1, poll2, poll3, ) every { fakeRoomPollRepository.getPolls(aRoomId) } returns flowOf(polls) - val expectedPolls = listOf( + val expectedPolls = listOf( poll3, poll2, poll1, @@ -60,4 +59,10 @@ class GetPollsUseCaseTest { result shouldBeEqualTo expectedPolls verify { fakeRoomPollRepository.getPolls(aRoomId) } } + + private fun givenTimelineEvent(timestamp: Long): TimelineEvent { + return mockk().also { + every { it.root.originServerTs } returns timestamp + } + } } diff --git a/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/domain/LoadMorePollsUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/domain/LoadMorePollsUseCaseTest.kt index 16405d98c3..c1ae0a3a3f 100644 --- a/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/domain/LoadMorePollsUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/domain/LoadMorePollsUseCaseTest.kt @@ -17,11 +17,13 @@ package im.vector.app.features.roomprofile.polls.list.domain import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository -import io.mockk.coJustRun +import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo import org.junit.Test +import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus class LoadMorePollsUseCaseTest { @@ -35,12 +37,18 @@ class LoadMorePollsUseCaseTest { fun `given repo when execute then correct method of repo is called`() = runTest { // Given val aRoomId = "roomId" - coJustRun { fakeRoomPollRepository.loadMorePolls(aRoomId) } + val loadedPollsStatus = LoadedPollsStatus( + canLoadMore = true, + daysSynced = 10, + hasCompletedASyncBackward = true, + ) + coEvery { fakeRoomPollRepository.loadMorePolls(aRoomId) } returns loadedPollsStatus // When - loadMorePollsUseCase.execute(aRoomId) + val result = loadMorePollsUseCase.execute(aRoomId) // Then + result shouldBeEqualTo loadedPollsStatus coVerify { fakeRoomPollRepository.loadMorePolls(aRoomId) } } } diff --git a/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/domain/SyncPollsUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/domain/SyncPollsUseCaseTest.kt index 040514e301..9dee8e6170 100644 --- a/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/domain/SyncPollsUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/domain/SyncPollsUseCaseTest.kt @@ -17,30 +17,81 @@ package im.vector.app.features.roomprofile.polls.list.domain import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository +import io.mockk.coEvery import io.mockk.coJustRun import io.mockk.coVerify +import io.mockk.coVerifyOrder import io.mockk.mockk import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo import org.junit.Test +import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus class SyncPollsUseCaseTest { private val fakeRoomPollRepository = mockk() + private val fakeGetLoadedPollsStatusUseCase = mockk() + private val fakeLoadMorePollsUseCase = mockk() private val syncPollsUseCase = SyncPollsUseCase( roomPollRepository = fakeRoomPollRepository, + getLoadedPollsStatusUseCase = fakeGetLoadedPollsStatusUseCase, + loadMorePollsUseCase = fakeLoadMorePollsUseCase, ) @Test - fun `given repo when execute then correct method of repo is called`() = runTest { + fun `given it has completed a sync backward when execute then only sync process is called`() = runTest { // Given val aRoomId = "roomId" + val aLoadedStatus = LoadedPollsStatus( + canLoadMore = true, + daysSynced = 10, + hasCompletedASyncBackward = true, + ) coJustRun { fakeRoomPollRepository.syncPolls(aRoomId) } + coEvery { fakeGetLoadedPollsStatusUseCase.execute(aRoomId) } returns aLoadedStatus // When - syncPollsUseCase.execute(aRoomId) + val result = syncPollsUseCase.execute(aRoomId) // Then - coVerify { fakeRoomPollRepository.syncPolls(aRoomId) } + result shouldBeEqualTo aLoadedStatus + coVerifyOrder { + fakeRoomPollRepository.syncPolls(aRoomId) + fakeGetLoadedPollsStatusUseCase.execute(aRoomId) + } + coVerify(inverse = true) { + fakeLoadMorePollsUseCase.execute(any()) + } + } + + @Test + fun `given it has not completed a sync backward when execute then sync process and load more is called`() = runTest { + // Given + val aRoomId = "roomId" + val aLoadedStatus = LoadedPollsStatus( + canLoadMore = true, + daysSynced = 10, + hasCompletedASyncBackward = false, + ) + val anUpdatedLoadedStatus = LoadedPollsStatus( + canLoadMore = true, + daysSynced = 10, + hasCompletedASyncBackward = true, + ) + coJustRun { fakeRoomPollRepository.syncPolls(aRoomId) } + coEvery { fakeGetLoadedPollsStatusUseCase.execute(aRoomId) } returns aLoadedStatus + coEvery { fakeLoadMorePollsUseCase.execute(aRoomId) } returns anUpdatedLoadedStatus + + // When + val result = syncPollsUseCase.execute(aRoomId) + + // Then + result shouldBeEqualTo anUpdatedLoadedStatus + coVerifyOrder { + fakeRoomPollRepository.syncPolls(aRoomId) + fakeGetLoadedPollsStatusUseCase.execute(aRoomId) + fakeLoadMorePollsUseCase.execute(aRoomId) + } } } diff --git a/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/ui/PollSummaryMapperTest.kt b/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/ui/PollSummaryMapperTest.kt new file mode 100644 index 0000000000..b523365970 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/roomprofile/polls/list/ui/PollSummaryMapperTest.kt @@ -0,0 +1,201 @@ +/* + * 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 im.vector.app.features.roomprofile.polls.list.ui + +import im.vector.app.core.extensions.getVectorLastMessageContent +import im.vector.app.features.home.room.detail.timeline.factory.PollOptionViewStateFactory +import im.vector.app.features.home.room.detail.timeline.helper.PollResponseDataFactory +import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState +import im.vector.app.features.home.room.detail.timeline.item.PollResponseData +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.amshove.kluent.shouldBe +import org.amshove.kluent.shouldBeEqualTo +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.matrix.android.sdk.api.session.room.model.message.MessageContent +import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent +import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent +import org.matrix.android.sdk.api.session.room.model.message.PollCreationInfo +import org.matrix.android.sdk.api.session.room.model.message.PollQuestion +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent + +private const val AN_EVENT_ID = "event-id" +private const val AN_EVENT_TIMESTAMP = 123L +private const val A_POLL_TITLE = "poll-title" + +internal class PollSummaryMapperTest { + + private val fakePollResponseDataFactory = mockk() + private val fakePollOptionViewStateFactory = mockk() + + private val pollSummaryMapper = PollSummaryMapper( + pollResponseDataFactory = fakePollResponseDataFactory, + pollOptionViewStateFactory = fakePollOptionViewStateFactory, + ) + + @Before + fun setup() { + mockkStatic("im.vector.app.core.extensions.TimelineEventKt") + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given a not ended poll event when mapping to model then result is active poll`() { + // Given + val pollStartedEvent = givenAPollTimelineEvent( + eventId = AN_EVENT_ID, + creationTimestamp = AN_EVENT_TIMESTAMP, + pollTitle = A_POLL_TITLE, + isClosed = false, + ) + val expectedResult = PollSummary.ActivePoll( + id = AN_EVENT_ID, + creationTimestamp = AN_EVENT_TIMESTAMP, + title = A_POLL_TITLE, + ) + + // When + val result = pollSummaryMapper.map(pollStartedEvent) + + // Then + result shouldBeEqualTo expectedResult + } + + @Test + fun `given an ended poll event when mapping to model then result is ended poll`() { + // Given + val totalVotes = 10 + val winnerOptions = listOf() + val endedPollEvent = givenAPollTimelineEvent( + eventId = AN_EVENT_ID, + creationTimestamp = AN_EVENT_TIMESTAMP, + pollTitle = A_POLL_TITLE, + isClosed = true, + totalVotes = totalVotes, + winnerOptions = winnerOptions, + ) + val expectedResult = PollSummary.EndedPoll( + id = AN_EVENT_ID, + creationTimestamp = AN_EVENT_TIMESTAMP, + title = A_POLL_TITLE, + totalVotes = totalVotes, + winnerOptions = winnerOptions, + ) + + // When + val result = pollSummaryMapper.map(endedPollEvent) + + // Then + result shouldBeEqualTo expectedResult + } + + @Test + fun `given missing data in event when mapping to model then result is null`() { + // Given + val noIdPollEvent = givenAPollTimelineEvent( + eventId = "", + creationTimestamp = AN_EVENT_TIMESTAMP, + pollTitle = A_POLL_TITLE, + isClosed = false, + ) + val noTimestampPollEvent = givenAPollTimelineEvent( + eventId = AN_EVENT_ID, + creationTimestamp = 0, + pollTitle = A_POLL_TITLE, + isClosed = false, + ) + val notAPollEvent = givenATimelineEvent( + eventId = AN_EVENT_ID, + creationTimestamp = 0, + content = mockk() + ) + + // When + val result1 = pollSummaryMapper.map(noIdPollEvent) + val result2 = pollSummaryMapper.map(noTimestampPollEvent) + val result3 = pollSummaryMapper.map(notAPollEvent) + + // Then + result1 shouldBe null + result2 shouldBe null + result3 shouldBe null + } + + private fun givenATimelineEvent( + eventId: String, + creationTimestamp: Long, + content: MessageContent, + ): TimelineEvent { + val timelineEvent = mockk() + every { timelineEvent.root.eventId } returns eventId + every { timelineEvent.root.originServerTs } returns creationTimestamp + every { timelineEvent.getVectorLastMessageContent() } returns content + return timelineEvent + } + + private fun givenAPollTimelineEvent( + eventId: String, + creationTimestamp: Long, + pollTitle: String, + isClosed: Boolean, + totalVotes: Int = 0, + winnerOptions: List = emptyList(), + ): TimelineEvent { + val pollCreationInfo = givenPollCreationInfo(pollTitle) + val messageContent = givenAMessagePollContent(pollCreationInfo) + val timelineEvent = givenATimelineEvent(eventId, creationTimestamp, messageContent) + val pollResponseData = givenAPollResponseData(isClosed, totalVotes) + every { fakePollResponseDataFactory.create(timelineEvent) } returns pollResponseData + every { + fakePollOptionViewStateFactory.createPollEndedOptions( + pollCreationInfo, + pollResponseData + ) + } returns winnerOptions + + return timelineEvent + } + + private fun givenAMessagePollContent(pollCreationInfo: PollCreationInfo): MessagePollContent { + return MessagePollContent( + unstablePollCreationInfo = pollCreationInfo, + ) + } + + private fun givenPollCreationInfo(pollTitle: String): PollCreationInfo { + return PollCreationInfo( + question = PollQuestion(unstableQuestion = pollTitle), + ) + } + + private fun givenAPollResponseData(isClosed: Boolean, totalVotes: Int): PollResponseData { + return PollResponseData( + myVote = "", + votes = emptyMap(), + isClosed = isClosed, + totalVotes = totalVotes, + ) + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakePollHistoryService.kt b/vector/src/test/java/im/vector/app/test/fakes/FakePollHistoryService.kt new file mode 100644 index 0000000000..c934c3acde --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakePollHistoryService.kt @@ -0,0 +1,75 @@ +/* + * 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 im.vector.app.test.fakes + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.mockk.coEvery +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.verify +import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus +import org.matrix.android.sdk.api.session.room.poll.PollHistoryService +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent + +class FakePollHistoryService : PollHistoryService by mockk() { + + fun givenDispose() { + justRun { dispose() } + } + + fun verifyDispose() { + verify { dispose() } + } + + fun givenGetPollsReturns(events: List): LiveData> { + return MutableLiveData(events).also { + every { getPollEvents() } returns it + } + } + + fun verifyGetPolls() { + verify { getPollEvents() } + } + + fun givenGetLoadedPollsStatusReturns(status: LoadedPollsStatus) { + coEvery { getLoadedPollsStatus() } returns status + } + + fun verifyGetLoadedPollsStatus() { + coVerify { getLoadedPollsStatus() } + } + + fun givenLoadMoreReturns(status: LoadedPollsStatus) { + coEvery { loadMore() } returns status + } + + fun verifyLoadMore() { + coVerify { loadMore() } + } + + fun givenSyncPollsSuccess() { + coJustRun { syncPolls() } + } + + fun verifySyncPolls() { + coVerify { syncPolls() } + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeRoom.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeRoom.kt index 7835c314ef..d3703f11c4 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeRoom.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeRoom.kt @@ -25,6 +25,7 @@ class FakeRoom( private val fakeTimelineService: FakeTimelineService = FakeTimelineService(), private val fakeRelationService: FakeRelationService = FakeRelationService(), private val fakeStateService: FakeStateService = FakeStateService(), + private val fakePollHistoryService: FakePollHistoryService = FakePollHistoryService(), ) : Room by mockk() { override fun locationSharingService() = fakeLocationSharingService @@ -36,4 +37,6 @@ class FakeRoom( override fun relationService() = fakeRelationService override fun stateService() = fakeStateService + + override fun pollHistoryService() = fakePollHistoryService } diff --git a/vector/src/test/java/im/vector/app/test/fixtures/PollFixture.kt b/vector/src/test/java/im/vector/app/test/fixtures/PollFixture.kt new file mode 100644 index 0000000000..24e037b299 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fixtures/PollFixture.kt @@ -0,0 +1,67 @@ +/* + * 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 im.vector.app.test.fixtures + +import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData +import im.vector.app.features.home.room.detail.timeline.item.PollResponseData +import im.vector.app.features.home.room.detail.timeline.item.ReactionsSummaryData +import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout +import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent +import org.matrix.android.sdk.api.session.room.model.message.PollAnswer +import org.matrix.android.sdk.api.session.room.model.message.PollCreationInfo +import org.matrix.android.sdk.api.session.room.model.message.PollQuestion +import org.matrix.android.sdk.api.session.room.model.message.PollType +import org.matrix.android.sdk.api.session.room.send.SendState + +object PollFixture { + + val A_MESSAGE_INFORMATION_DATA = MessageInformationData( + eventId = "eventId", + senderId = "senderId", + ageLocalTS = 0, + avatarUrl = "", + sendState = SendState.SENT, + messageLayout = TimelineMessageLayout.Default(showAvatar = true, showDisplayName = true, showTimestamp = true), + reactionsSummary = ReactionsSummaryData(), + sentByMe = true, + ) + + val A_POLL_RESPONSE_DATA = PollResponseData( + myVote = null, + votes = emptyMap(), + ) + + val A_POLL_OPTION_IDS = listOf("5ef5f7b0-c9a1-49cf-a0b3-374729a43e76", "ec1a4db0-46d8-4d7a-9bb6-d80724715938", "3677ca8e-061b-40ab-bffe-b22e4e88fcad") + + val A_POLL_CONTENT = MessagePollContent( + unstablePollCreationInfo = PollCreationInfo( + question = PollQuestion( + unstableQuestion = "What is your favourite coffee?" + ), kind = PollType.UNDISCLOSED_UNSTABLE, maxSelections = 1, answers = listOf( + PollAnswer( + id = A_POLL_OPTION_IDS[0], unstableAnswer = "Double Espresso" + ), + PollAnswer( + id = A_POLL_OPTION_IDS[1], unstableAnswer = "Macchiato" + ), + PollAnswer( + id = A_POLL_OPTION_IDS[2], unstableAnswer = "Iced Coffee" + ), + ) + ) + ) +} diff --git a/vector/src/test/java/im/vector/app/test/fixtures/RoomPollFixture.kt b/vector/src/test/java/im/vector/app/test/fixtures/RoomPollFixture.kt deleted file mode 100644 index 4ccd9fa35a..0000000000 --- a/vector/src/test/java/im/vector/app/test/fixtures/RoomPollFixture.kt +++ /dev/null @@ -1,47 +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 im.vector.app.test.fixtures - -import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState -import im.vector.app.features.roomprofile.polls.list.ui.PollSummary - -object RoomPollFixture { - - fun anActivePollSummary( - id: String = "", - timestamp: Long, - title: String = "", - ) = PollSummary.ActivePoll( - id = id, - creationTimestamp = timestamp, - title = title, - ) - - fun anEndedPollSummary( - id: String = "", - timestamp: Long, - title: String = "", - totalVotes: Int, - winnerOptions: List - ) = PollSummary.EndedPoll( - id = id, - creationTimestamp = timestamp, - title = title, - totalVotes = totalVotes, - winnerOptions = winnerOptions, - ) -}