1
0
mirror of https://github.com/vector-im/element-android.git synced 2025-07-31 07:04:23 +03:00

Merge branch 'develop' into feature/bca/rust_flavor

This commit is contained in:
valere
2023-01-30 16:46:54 +01:00
88 changed files with 3040 additions and 611 deletions

1
changelog.d/7864.sdk Normal file
View File

@ -0,0 +1 @@
[Poll] Adding PollHistoryService

1
changelog.d/7864.wip Normal file
View File

@ -0,0 +1 @@
[Poll] History list: unmock data

1
changelog.d/8005.sdk Normal file
View File

@ -0,0 +1 @@
[Push rules] Call /actions api before /enabled api

View File

@ -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 // 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 // the whole commit which set version 0.16.0-SNAPSHOT
def vanniktechEmoji = "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 // Use 1.6.0 alpha to fix issue with test
def fragment = "1.6.0-alpha04" def fragment = "1.6.0-alpha04"
// Testing // Testing

View File

@ -0,0 +1,2 @@
Главные изменения в этой версии: Новый полноэкранный режим в улучшенном редакторе текста и исправления багов.
Полный список: https://github.com/vector-im/element-android/releases

View File

@ -0,0 +1,2 @@
Главные изменения в этой версии: Обсуждения включены по умолчанию.
Полный список: https://github.com/vector-im/element-android/releases

View File

@ -0,0 +1,2 @@
Главные изменения в этой версии: Обсуждения включены по умолчанию.
Полный список: https://github.com/vector-im/element-android/releases

View File

@ -0,0 +1,2 @@
Главные изменения в этой версии: Обсуждения включены по умолчанию.
Полный список: https://github.com/vector-im/element-android/releases

View File

@ -0,0 +1,2 @@
Главные изменения в этой версии: Устранения багов!
Полный список: https://github.com/vector-im/element-android/releases

View File

@ -2978,4 +2978,5 @@
</plurals> </plurals>
<string name="error_voice_message_broadcast_in_progress_message">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</string> <string name="error_voice_message_broadcast_in_progress_message">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</string>
<string name="error_voice_message_broadcast_in_progress">Nelze spustit hlasovou zprávu</string> <string name="error_voice_message_broadcast_in_progress">Nelze spustit hlasovou zprávu</string>
<string name="error_voice_broadcast_no_connection_recording">Chyba připojení - nahrávání pozastaveno</string>
</resources> </resources>

View File

@ -2917,4 +2917,5 @@
</plurals> </plurals>
<string name="error_voice_message_broadcast_in_progress_message">Du kannst keine Sprachnachricht beginnen, da du im Moment eine Echtzeitübertragung aufzeichnest. Bitte beende deine Sprachübertragung, um ein Gespräch zu beginnen</string> <string name="error_voice_message_broadcast_in_progress_message">Du kannst keine Sprachnachricht beginnen, da du im Moment eine Echtzeitübertragung aufzeichnest. Bitte beende deine Sprachübertragung, um ein Gespräch zu beginnen</string>
<string name="error_voice_message_broadcast_in_progress">Kann Sprachnachricht nicht beginnen</string> <string name="error_voice_message_broadcast_in_progress">Kann Sprachnachricht nicht beginnen</string>
<string name="error_voice_broadcast_no_connection_recording">Verbindungsfehler Aufnahme pausiert</string>
</resources> </resources>

View File

@ -2909,4 +2909,5 @@
<string name="room_polls_loading_error">Viga küsitluste laadimisel.</string> <string name="room_polls_loading_error">Viga küsitluste laadimisel.</string>
<string name="error_voice_message_broadcast_in_progress">Häälsõnumi esitamine ei õnnestu</string> <string name="error_voice_message_broadcast_in_progress">Häälsõnumi esitamine ei õnnestu</string>
<string name="error_voice_message_broadcast_in_progress_message">Kuna sa hetkel salvestad ringhäälingukõnet, siis häälsõnumi salvestamine või esitamine ei õnnestu. Selleks palun lõpeta ringhäälingukõne</string> <string name="error_voice_message_broadcast_in_progress_message">Kuna sa hetkel salvestad ringhäälingukõnet, siis häälsõnumi salvestamine või esitamine ei õnnestu. Selleks palun lõpeta ringhäälingukõne</string>
<string name="error_voice_broadcast_no_connection_recording">Viga võrguühenduses - salvestamine on peatatud</string>
</resources> </resources>

View File

@ -2918,4 +2918,5 @@
</plurals> </plurals>
<string name="error_voice_message_broadcast_in_progress_message">از آن‌جا که در حال ضبط پخشی زنده‌اید، نمی‌توانید پیامی صوتی را آغاز کنید. لطفاً برای آغاز ضبط یک پیام صوتی، پخش زنده‌تان را پایان دهید</string> <string name="error_voice_message_broadcast_in_progress_message">از آن‌جا که در حال ضبط پخشی زنده‌اید، نمی‌توانید پیامی صوتی را آغاز کنید. لطفاً برای آغاز ضبط یک پیام صوتی، پخش زنده‌تان را پایان دهید</string>
<string name="error_voice_message_broadcast_in_progress">نمی‌توان پخش صوتی را آغاز کرد</string> <string name="error_voice_message_broadcast_in_progress">نمی‌توان پخش صوتی را آغاز کرد</string>
<string name="error_voice_broadcast_no_connection_recording">خطای اتّصال - ضبط مکث شد</string>
</resources> </resources>

View File

@ -2918,4 +2918,5 @@
</plurals> </plurals>
<string name="error_voice_message_broadcast_in_progress_message">Vous ne pouvez pas commencer un message vocal car vous êtes en train denregistrer une diffusion en direct. Veuillez terminer cette diffusion pour commencer un message vocal</string> <string name="error_voice_message_broadcast_in_progress_message">Vous ne pouvez pas commencer un message vocal car vous êtes en train denregistrer une diffusion en direct. Veuillez terminer cette diffusion pour commencer un message vocal</string>
<string name="error_voice_message_broadcast_in_progress">Impossible de démarrer un message vocal</string> <string name="error_voice_message_broadcast_in_progress">Impossible de démarrer un message vocal</string>
<string name="error_voice_broadcast_no_connection_recording">Erreur de connexion Enregistrement en pause</string>
</resources> </resources>

View File

@ -2918,4 +2918,5 @@ A Visszaállítási Kulcsot tartsd biztonságos helyen, mint pl. egy jelszókeze
</plurals> </plurals>
<string name="error_voice_message_broadcast_in_progress_message">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</string> <string name="error_voice_message_broadcast_in_progress_message">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</string>
<string name="error_voice_message_broadcast_in_progress">Hang üzenetet nem lehet elindítani</string> <string name="error_voice_message_broadcast_in_progress">Hang üzenetet nem lehet elindítani</string>
<string name="error_voice_broadcast_no_connection_recording">Kapcsolódási hiba Felvétel szüneteltetve</string>
</resources> </resources>

View File

@ -2858,4 +2858,7 @@ Di masa mendatang proses verifikasi ini akan dimutakhirkan.</string>
<item quantity="other">Tidak ada pemungutan suara aktif %1$d hari terakhir. <item quantity="other">Tidak ada pemungutan suara aktif %1$d hari terakhir.
\nMuat lebih banyak pemungutan suara untuk melihat pemungutan suara untuk hari sebelumnya.</item> \nMuat lebih banyak pemungutan suara untuk melihat pemungutan suara untuk hari sebelumnya.</item>
</plurals> </plurals>
<string name="error_voice_broadcast_no_connection_recording">Kesalahan koneksi - Perekaman dijeda</string>
<string name="error_voice_message_broadcast_in_progress_message">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</string>
<string name="error_voice_message_broadcast_in_progress">Tidak dapat memulai pesan suara</string>
</resources> </resources>

View File

@ -198,7 +198,7 @@
<string name="room_settings_set_main_address">メインアドレスとして設定</string> <string name="room_settings_set_main_address">メインアドレスとして設定</string>
<string name="room_settings_unset_main_address">メインアドレスとしての設定を解除</string> <string name="room_settings_unset_main_address">メインアドレスとしての設定を解除</string>
<string name="device_manager_session_details_session_id">セッションID</string> <string name="device_manager_session_details_session_id">セッションID</string>
<string name="font_size">文字の大きさ</string> <string name="font_size">フォントの大きさ</string>
<string name="tiny">とても小さい</string> <string name="tiny">とても小さい</string>
<string name="small">小さい</string> <string name="small">小さい</string>
<string name="normal">標準</string> <string name="normal">標準</string>
@ -2391,7 +2391,7 @@
<string name="invites_title">招待</string> <string name="invites_title">招待</string>
<string name="device_manager_push_notifications_title">プッシュ通知</string> <string name="device_manager_push_notifications_title">プッシュ通知</string>
<string name="device_manager_session_rename_edit_hint">セッション名</string> <string name="device_manager_session_rename_edit_hint">セッション名</string>
<string name="device_manager_session_rename">セッションを改</string> <string name="device_manager_session_rename">セッション名を変更</string>
<string name="device_manager_session_details_device_ip_address">IPアドレス</string> <string name="device_manager_session_details_device_ip_address">IPアドレス</string>
<string name="device_manager_session_details_device_operating_system">オペレーティングシステム</string> <string name="device_manager_session_details_device_operating_system">オペレーティングシステム</string>
<string name="device_manager_session_details_device_model">形式</string> <string name="device_manager_session_details_device_model">形式</string>
@ -2487,4 +2487,17 @@
<plurals name="x_selected"> <plurals name="x_selected">
<item quantity="other">%1$dを選択しました</item> <item quantity="other">%1$dを選択しました</item>
</plurals> </plurals>
<string name="settings_presence_user_always_appears_offline_summary">有効にすると、このアプリケーションを使用している際にも、他のユーザーにオフラインとして表示されます。</string>
<string name="settings_enable_direct_share_summary">最近のチャットをシステムの共有メニューに表示</string>
<string name="font_size_use_system">システムの既定値を使用</string>
<string name="font_size_section_manually">手動で設定</string>
<string name="font_size_section_auto">自動的に設定</string>
<string name="font_size_title">フォントの大きさを選択</string>
<string name="some_devices_will_not_be_able_to_decrypt">⚠ 未認証の端末がこのルームにあります。あなたが送信するメッセージを復号化することはできません。</string>
<string name="encryption_never_send_to_unverified_devices_in_room">このルームの未認証のセッションに暗号化されたメッセージを送信しない。</string>
<string name="thread_list_not_available">あなたのホームサーバーはスレッドの一覧表示をまだサポートしていません。</string>
<string name="invites_empty_message">ここに新しいリクエストと招待が表示されます。</string>
<string name="labs_enable_rich_text_editor_summary">リッチテキストエディターを試してみる(プレーンテキストモードは近日公開)</string>
<string name="labs_enable_new_app_layout_summary">タブを使用してElementの表示をシンプルにする</string>
<string name="device_manager_session_details_title">セッションの詳細</string>
</resources> </resources>

View File

@ -345,7 +345,7 @@
<string name="encryption_import_room_keys_summary">Importuj klucze z lokalnego pliku</string> <string name="encryption_import_room_keys_summary">Importuj klucze z lokalnego pliku</string>
<string name="encryption_import_import">Importuj</string> <string name="encryption_import_import">Importuj</string>
<string name="encryption_never_send_to_unverified_devices_title">Szyfruj wiadomości tylko do zaufanych sesji</string> <string name="encryption_never_send_to_unverified_devices_title">Szyfruj wiadomości tylko do zaufanych sesji</string>
<string name="encryption_never_send_to_unverified_devices_summary">Nigdy nie wysyłaj szyfrowanych wiadomości do sesji (np urządzeń innych użytkowników) które nie zostały zweryfikowane.</string> <string name="encryption_never_send_to_unverified_devices_summary">Nigdy nie wysyłaj szyfrowanych wiadomości do niezweryfikowanych sesji (bez zielonej tarczy) z tego urządzenia.</string>
<string name="encryption_information_verify_device_warning">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:</string> <string name="encryption_information_verify_device_warning">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:</string>
<string name="encryption_information_verify_device_warning2">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.</string> <string name="encryption_information_verify_device_warning2">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.</string>
<string name="title_activity_choose_sticker">Wyślij naklejkę</string> <string name="title_activity_choose_sticker">Wyślij naklejkę</string>
@ -1115,7 +1115,7 @@
\nKlucze nie są zaufane</string> \nKlucze nie są zaufane</string>
<string name="encryption_information_dg_xsigning_disabled">Podpis krzyżowy nie jest aktywowany</string> <string name="encryption_information_dg_xsigning_disabled">Podpis krzyżowy nie jest aktywowany</string>
<string name="settings_active_sessions_list">Aktywne Sesje</string> <string name="settings_active_sessions_list">Aktywne Sesje</string>
<string name="settings_active_sessions_show_all">Pokaż wszystkie Sesje</string> <string name="settings_active_sessions_show_all">Pokaż wszystkie sesje</string>
<string name="settings_active_sessions_manage">Zarządzaj Sesjami</string> <string name="settings_active_sessions_manage">Zarządzaj Sesjami</string>
<string name="settings_active_sessions_signout_device">Wyloguj z tej sesji</string> <string name="settings_active_sessions_signout_device">Wyloguj z tej sesji</string>
<string name="settings_failed_to_get_crypto_device_info">Brak dostępnej informacji o kryptografii</string> <string name="settings_failed_to_get_crypto_device_info">Brak dostępnej informacji o kryptografii</string>
@ -1242,7 +1242,7 @@
<string name="bottom_sheet_save_your_recovery_key_title">Zapisz Klucz Bezpieczeństwa</string> <string name="bottom_sheet_save_your_recovery_key_title">Zapisz Klucz Bezpieczeństwa</string>
<string name="bottom_sheet_setup_secure_backup_security_phrase_title">Użyj Frazy Bezpieczeństwa</string> <string name="bottom_sheet_setup_secure_backup_security_phrase_title">Użyj Frazy Bezpieczeństwa</string>
<string name="bottom_sheet_setup_secure_backup_security_key_title">Użyj klucza bezpieczeństwa</string> <string name="bottom_sheet_setup_secure_backup_security_key_title">Użyj klucza bezpieczeństwa</string>
<string name="bottom_sheet_setup_secure_backup_subtitle">Zabezpiecza przeciwko utracie dostępu do zaszyfrowanych wiadomości oraz danych poprzez zapisanie zaszyfrowanych kluczy na Twoim serwerze.</string> <string name="bottom_sheet_setup_secure_backup_subtitle">Zabezpiecza przed utra dostępu do zaszyfrowanych wiadomości poprzez zapisanie kluczy szyfrujących na twoim serwerze.</string>
<string name="a11y_start_camera">Włącz aparat</string> <string name="a11y_start_camera">Włącz aparat</string>
<string name="a11y_stop_camera">Wyłącz aparat</string> <string name="a11y_stop_camera">Wyłącz aparat</string>
<string name="a11y_unmute_microphone">Wyłącz wyciszenie mikrofonu</string> <string name="a11y_unmute_microphone">Wyłącz wyciszenie mikrofonu</string>
@ -1493,7 +1493,7 @@
<string name="disabled_integration_dialog_title">Integracje są zablokowane</string> <string name="disabled_integration_dialog_title">Integracje są zablokowane</string>
<string name="reset_secure_backup_warning">To zastąpi obecny Klucz bądź Hasło.</string> <string name="reset_secure_backup_warning">To zastąpi obecny Klucz bądź Hasło.</string>
<string name="reset_secure_backup_title">Wygeneruj nowy klucz bezpieczeństwa albo hasło dla istniejącej kopii zapasowej.</string> <string name="reset_secure_backup_title">Wygeneruj nowy klucz bezpieczeństwa albo hasło dla istniejącej kopii zapasowej.</string>
<string name="settings_secure_backup_section_info">Zabezpiecza przeciwko utracie dostępu do zaszyfrowanych wiadomości oraz danych poprzez zapisanie zaszyfrowanych kluczy na Twoim serwerze.</string> <string name="settings_secure_backup_section_info">Zabezpiecza przed utra dostępu do zaszyfrowanych wiadomości poprzez zapisanie kluczy szyfrujących na twoim serwerze.</string>
<string name="settings_troubleshoot_test_notification_notification_clicked">Powiadomienie zostało kliknięte!</string> <string name="settings_troubleshoot_test_notification_notification_clicked">Powiadomienie zostało kliknięte!</string>
<string name="settings_troubleshoot_test_notification_notice">Proszę kliknąć na powiadomieniu, Jeżeli nie widzisz powiadomienia, sprawdź ustawienia systemowe.</string> <string name="settings_troubleshoot_test_notification_notice">Proszę kliknąć na powiadomieniu, Jeżeli nie widzisz powiadomienia, sprawdź ustawienia systemowe.</string>
<string name="settings_troubleshoot_test_push_notification_content">Widzisz powiadomienia! Kliknij na mnie!</string> <string name="settings_troubleshoot_test_push_notification_content">Widzisz powiadomienia! Kliknij na mnie!</string>
@ -2795,4 +2795,36 @@
<string name="action_got_it">Rozumiem</string> <string name="action_got_it">Rozumiem</string>
<string name="a11y_collapse_space_children">Zwiń %s pokojów</string> <string name="a11y_collapse_space_children">Zwiń %s pokojów</string>
<string name="a11y_expand_space_children">Rozwiń %s pokojów</string> <string name="a11y_expand_space_children">Rozwiń %s pokojów</string>
<string name="device_manager_inactive_sessions_title">Nieaktywne sesje</string>
<string name="device_manager_verification_status_detail_other_session_verified">Ta sesja jest gotowa do bezpiecznego przesyłania wiadomości.</string>
<string name="device_manager_verification_status_detail_current_session_verified">Twoja bieżąca sesja jest gotowa do bezpiecznego przesyłania wiadomości.</string>
<string name="attachment_type_selector_contact">Kontakt</string>
<string name="attachment_type_selector_location">Lokalizacja</string>
<string name="attachment_type_selector_camera">Aparat</string>
<string name="attachment_type_selector_voice_broadcast">Transmisja głosowa</string>
<string name="tooltip_attachment_voice_broadcast">Rozpocznij transmisję głosową</string>
<string name="room_polls_ended">Ostatnie ankiety</string>
<string name="room_polls_active_no_item">W tym pokoju nie ma aktywnych ankiet</string>
<string name="room_polls_active">Aktywne ankiety</string>
<string name="unable_to_decrypt_some_events_in_poll">Niektóre głosy mogą nie zostać policzone z powodu błędów w odszyfrowaniu</string>
<string name="ended_poll_indicator">Zakończono ankietę.</string>
<string name="error_voice_broadcast_no_connection_recording">Błąd połączenia - Nagrywanie wstrzymane</string>
<string name="error_voice_broadcast_unable_to_play">Nie można odtworzyć tej transmisji głosowej.</string>
<string name="error_voice_broadcast_already_in_progress_message">Jesteś już w trakcie nagrywania transmisji głosowej. Proszę zakończyć bieżącą transmisję, aby rozpocząć nową.</string>
<string name="error_voice_broadcast_blocked_by_someone_else_message">Ktoś inny nagrywa już transmisję głosową. Aby rozpocząć nową transmisję, należy poczekać na jej zakończenie.</string>
<string name="error_voice_broadcast_permission_denied_message">Nie masz wymaganych uprawnień do rozpoczęcia transmisji głosowej w tym pokoju. Skontaktuj się z administratorem pokoju, aby przyznał ci uprawnienia.</string>
<string name="error_voice_broadcast_unauthorized_title">Nie można rozpocząć nowej transmisji głosowej</string>
<string name="voice_broadcast_buffering">Buforowanie…</string>
<string name="error_voice_message_broadcast_in_progress">Nie można rozpocząć wiadomości głosowej</string>
<string name="review_unverified_sessions_title">Masz niezweryfikowane sesje</string>
<string name="key_authenticity_not_guaranteed">Autentyczność tej zaszyfrowanej wiadomości nie może być zagwarantowana na tym urządzeniu.</string>
<string name="room_profile_section_more_polls">Historia ankiet</string>
<string name="command_description_table_flip">Dodaje (╯°□°)╯︵ ┻━┻ do wiadomości tekstowej</string>
<string name="login_scan_qr_code">Skanuj kod QR</string>
<string name="notice_voice_broadcast_ended">%1$s zakończył(a) transmisję głosową.</string>
<string name="settings_security_incognito_keyboard_summary">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.</string>
<string name="settings_security_incognito_keyboard_title">Klawiatura incognito</string>
<string name="home_empty_no_rooms_title">Witaj w ${app_name},
\n%s.</string>
<string name="home_empty_no_rooms_message">Wszechstronna, bezpieczna aplikacja do czatowania dla zespołów, przyjaciół i organizacji. Utwórz czat lub dołącz do istniejącego pokoju, aby rozpocząć.</string>
</resources> </resources>

View File

@ -6,7 +6,7 @@
<string name="notice_room_join">%1$s вошёл(шла) в комнату</string> <string name="notice_room_join">%1$s вошёл(шла) в комнату</string>
<string name="notice_room_leave">%1$s покинул(а) комнату</string> <string name="notice_room_leave">%1$s покинул(а) комнату</string>
<string name="notice_room_reject">%1$s отклонил(а) приглашение</string> <string name="notice_room_reject">%1$s отклонил(а) приглашение</string>
<string name="notice_room_remove">%1$s выгнан %2$s</string> <string name="notice_room_remove">%1$s выгнал %2$s</string>
<string name="notice_room_unban">%1$s разблокировал(а) %2$s</string> <string name="notice_room_unban">%1$s разблокировал(а) %2$s</string>
<string name="notice_room_ban">%1$s заблокировал(а) %2$s</string> <string name="notice_room_ban">%1$s заблокировал(а) %2$s</string>
<string name="notice_room_withdraw">%1$s отозвал(а) приглашение %2$s</string> <string name="notice_room_withdraw">%1$s отозвал(а) приглашение %2$s</string>
@ -65,7 +65,7 @@
<string name="notice_room_reject_with_reason">%1$s отклонил приглашение. Причина: %2$s</string> <string name="notice_room_reject_with_reason">%1$s отклонил приглашение. Причина: %2$s</string>
<string name="notice_room_remove_with_reason">%1$s выгнали %2$s. Причина: %3$s</string> <string name="notice_room_remove_with_reason">%1$s выгнали %2$s. Причина: %3$s</string>
<string name="notice_room_unban_with_reason">%1$s разблокировано %2$s. Причина: %3$s</string> <string name="notice_room_unban_with_reason">%1$s разблокировано %2$s. Причина: %3$s</string>
<string name="notice_room_ban_with_reason">%1$s забанен %2$s. Причина: %3$s</string> <string name="notice_room_ban_with_reason">%1$s забанил %2$s. Причина: %3$s</string>
<string name="notice_room_third_party_registered_invite_with_reason">%1$s принял приглашение для %2$s. Причина: %3$s</string> <string name="notice_room_third_party_registered_invite_with_reason">%1$s принял приглашение для %2$s. Причина: %3$s</string>
<string name="notice_room_withdraw_with_reason">%1$s отозвал приглашение %2$s. Причина: %3$s</string> <string name="notice_room_withdraw_with_reason">%1$s отозвал приглашение %2$s. Причина: %3$s</string>
<string name="notice_room_created">%1$s создал(а) комнату</string> <string name="notice_room_created">%1$s создал(а) комнату</string>
@ -1420,7 +1420,7 @@
<string name="command_description_rainbow">Посылает сообщение, окрашенное в цвет радуги</string> <string name="command_description_rainbow">Посылает сообщение, окрашенное в цвет радуги</string>
<string name="command_description_rainbow_emote">Посылает данную эмоцию, окрашенную в цвет радуги</string> <string name="command_description_rainbow_emote">Посылает данную эмоцию, окрашенную в цвет радуги</string>
<string name="settings_category_composer">Редактор сообщений</string> <string name="settings_category_composer">Редактор сообщений</string>
<string name="room_settings_enable_encryption">Включаем сквозное шифрование…</string> <string name="room_settings_enable_encryption">Включить сквозное шифрование…</string>
<string name="room_settings_enable_encryption_dialog_title">Включить шифрование\?</string> <string name="room_settings_enable_encryption_dialog_title">Включить шифрование\?</string>
<string name="room_settings_enable_encryption_dialog_content">После включения шифрование для комнаты нельзя отключить. Сообщения отправленные в зашифрованной комнате не будут видны серверу, только участникам комнаты. Включение шифрования может помешать правильной работе многих ботов и мостов.</string> <string name="room_settings_enable_encryption_dialog_content">После включения шифрование для комнаты нельзя отключить. Сообщения отправленные в зашифрованной комнате не будут видны серверу, только участникам комнаты. Включение шифрования может помешать правильной работе многих ботов и мостов.</string>
<string name="room_settings_enable_encryption_dialog_submit">Включить шифрование</string> <string name="room_settings_enable_encryption_dialog_submit">Включить шифрование</string>
@ -2433,7 +2433,7 @@
<string name="location_timeline_failed_to_load_map">Не удалось загрузить карту</string> <string name="location_timeline_failed_to_load_map">Не удалось загрузить карту</string>
<string name="a11y_static_map_image">Карта</string> <string name="a11y_static_map_image">Карта</string>
<string name="labs_enable_thread_messages_desc">Примечание: приложение будет перезапущено</string> <string name="labs_enable_thread_messages_desc">Примечание: приложение будет перезапущено</string>
<string name="labs_enable_thread_messages">Обсуждения сообщений</string> <string name="labs_enable_thread_messages">Включить обсуждения сообщений</string>
<string name="ftue_auth_use_case_connect_to_server">Подключиться к серверу</string> <string name="ftue_auth_use_case_connect_to_server">Подключиться к серверу</string>
<string name="ftue_auth_use_case_join_existing_server">Хотите присоединиться к существующему серверу\?</string> <string name="ftue_auth_use_case_join_existing_server">Хотите присоединиться к существующему серверу\?</string>
<string name="ftue_auth_use_case_skip_partial">Пропустить вопрос</string> <string name="ftue_auth_use_case_skip_partial">Пропустить вопрос</string>
@ -2540,7 +2540,7 @@
<string name="error_forbidden_digits_only_username">Домашний сервер не принимает имя пользователя, состоящее только из цифр.</string> <string name="error_forbidden_digits_only_username">Домашний сервер не принимает имя пользователя, состоящее только из цифр.</string>
<string name="ftue_personalize_skip_this_step">Пропустить этот шаг</string> <string name="ftue_personalize_skip_this_step">Пропустить этот шаг</string>
<string name="ftue_personalize_submit">Сохранить и продолжить</string> <string name="ftue_personalize_submit">Сохранить и продолжить</string>
<string name="ftue_personalize_complete_subtitle">Ваши предпочтения были сохранены.</string> <string name="ftue_personalize_complete_subtitle">Зайдите в настройки чтобы изменить Ваш профиль</string>
<string name="ftue_personalize_complete_title">Выглядит хорошо!</string> <string name="ftue_personalize_complete_title">Выглядит хорошо!</string>
<string name="ftue_auth_carousel_workplace_body">${app_name} также отлично подходит для работы. Ему доверяют самые надёжные организации в мире.</string> <string name="ftue_auth_carousel_workplace_body">${app_name} также отлично подходит для работы. Ему доверяют самые надёжные организации в мире.</string>
<string name="keys_backup_settings_signature_from_this_user">Резервная копия имеет действительную подпись для данного пользователя.</string> <string name="keys_backup_settings_signature_from_this_user">Резервная копия имеет действительную подпись для данного пользователя.</string>
@ -2791,7 +2791,7 @@
<item quantity="other">Рассмотрите возможность выхода из старых сеансов (%1$d дней или дольше), которые вы более не используете.</item> <item quantity="other">Рассмотрите возможность выхода из старых сеансов (%1$d дней или дольше), которые вы более не используете.</item>
</plurals> </plurals>
<string name="attachment_type_voice_broadcast">Голосовая трансляция</string> <string name="attachment_type_voice_broadcast">Голосовая трансляция</string>
<string name="labs_enable_voice_broadcast_title">Голосовые трансляции</string> <string name="labs_enable_voice_broadcast_title">Включить голосовые трансляции</string>
<string name="labs_enable_client_info_recording_summary">Записывает название клиента, версию и URL-адрес для более лёгкого распознавания сеансов в менеджере сеансов.</string> <string name="labs_enable_client_info_recording_summary">Записывает название клиента, версию и URL-адрес для более лёгкого распознавания сеансов в менеджере сеансов.</string>
<string name="labs_enable_client_info_recording_title">Записывать информацию о клиенте</string> <string name="labs_enable_client_info_recording_title">Записывать информацию о клиенте</string>
<string name="attachment_type_selector_gallery">Галерея</string> <string name="attachment_type_selector_gallery">Галерея</string>
@ -2824,9 +2824,9 @@
<string name="a11y_expand_space_children">Развернуть дочерние элементы %s</string> <string name="a11y_expand_space_children">Развернуть дочерние элементы %s</string>
<plurals name="x_selected"> <plurals name="x_selected">
<item quantity="one">Выбрано %1$d</item> <item quantity="one">Выбрано %1$d</item>
<item quantity="few">Выбрано %1$d</item> <item quantity="few">Выбраны %1$d</item>
<item quantity="many">Выбрано %1$d</item> <item quantity="many">Выбраны %1$d</item>
<item quantity="other">Выбрано %1$d</item> <item quantity="other">Выбраны %1$d</item>
</plurals> </plurals>
<string name="rich_text_editor_full_screen_toggle">Войти в полноэкранный режим</string> <string name="rich_text_editor_full_screen_toggle">Войти в полноэкранный режим</string>
<string name="rich_text_editor_format_underline">Применить форматирование подчёркиванием</string> <string name="rich_text_editor_format_underline">Применить форматирование подчёркиванием</string>
@ -2970,4 +2970,58 @@
<string name="device_manager_verification_status_detail_session_encryption_not_supported">Этот сеанс не поддерживает шифрование и поэтому не может быть заверен.</string> <string name="device_manager_verification_status_detail_session_encryption_not_supported">Этот сеанс не поддерживает шифрование и поэтому не может быть заверен.</string>
<string name="notice_voice_broadcast_ended">%1$s завершил(а) голосовую трансляцию.</string> <string name="notice_voice_broadcast_ended">%1$s завершил(а) голосовую трансляцию.</string>
<string name="notice_voice_broadcast_ended_by_you">Вы завершили голосовую трансляцию.</string> <string name="notice_voice_broadcast_ended_by_you">Вы завершили голосовую трансляцию.</string>
<plurals name="room_polls_active_no_item_for_loaded_period">
<item quantity="one">Нет активных опросов за %1$d день.
\nЗагрузите больше чтобы показать опросы за прошедшие дни.</item>
<item quantity="few">Нет активных опросов за %1$d дней.
\nЗагрузите больше чтобы показать опросы за прошедшие дни.</item>
<item quantity="many">Нет активных опросов за %1$d дней.
\nЗагрузите больше чтобы показать опросы за прошедшие дни.</item>
<item quantity="other">Нет активных опросов за %1$d дней.
\nЗагрузите больше чтобы показать опросы за прошедшие дни.</item>
</plurals>
<plurals name="room_polls_ended_no_item_for_loaded_period">
<item quantity="one">Нет завершённых опросов за день %1$d.
\nЗагрузите больше чтобы показать опросы за предыдущие дни.</item>
<item quantity="few">Нет завершённых опросов за %1$d дней
\nЗагрузите больше чтобы показать опросы за предыдущие дни.</item>
<item quantity="many">Нет завершённых опросов за %1$d дней
\nЗагрузите больше чтобы показать опросы за предыдущие дни.</item>
<item quantity="other">Нет завершённых опросов за %1$d дней
\nЗагрузите больше чтобы показать опросы за предыдущие дни.</item>
</plurals>
<string name="settings_access_token_summary">Токен доступа даёт полный доступ к аккаунту. Не делитесь им ни с кем.</string>
<string name="settings_access_token">Токен доступа</string>
<string name="message_reply_to_ended_poll_preview">Завершённый опрос</string>
<string name="message_reply_to_poll_preview">Опрос</string>
<string name="message_reply_to_sender_ended_poll">завершённый опрос.</string>
<string name="set_link_edit">Изменить ссылку</string>
<string name="set_link_create">Создать ссылку</string>
<string name="set_link_link">Ссылка</string>
<string name="set_link_text">Текст</string>
<string name="rich_text_editor_bullet_list">Список</string>
<string name="rich_text_editor_numbered_list">Пронумерованный список</string>
<string name="rich_text_editor_link">Ссылка</string>
<string name="room_polls_loading_error">Ошибка считывания опросов.</string>
<string name="room_polls_load_more">Загрузить больше опросов</string>
<string name="room_polls_wait_for_display">Показываем опросы</string>
<string name="room_polls_ended_no_item">Нет завершённых опросов</string>
<string name="room_polls_ended">Завершённые опросы</string>
<string name="room_polls_active_no_item">Нет активных опросов</string>
<string name="room_polls_active">Активные опросы</string>
<string name="unable_to_decrypt_some_events_in_poll">Из-за ошибок расшифровки, некоторые голоса могут быть не засчитаны</string>
<string name="ended_poll_indicator">Опрос завершён.</string>
<string name="stop_voice_broadcast_content">Вы уверены что хотите завершить голосовую трансляцию\? Это завершит трансляцию и полная запись будет доступна в чате.</string>
<string name="stop_voice_broadcast_dialog_title">Завершить голосовую трансляцию\?</string>
<string name="error_voice_broadcast_no_connection_recording">Ошибка подключения - Запись приостановлена</string>
<string name="error_voice_broadcast_unable_to_play">Невозможно прослушать голосовую трансляцию.</string>
<string name="voice_broadcast_live_broadcast">Голосовая трансляция</string>
<string name="error_voice_message_broadcast_in_progress_message">Вы не можете записать голосовое сообщение, потому-что Вы записываете голосовую трансляцию. Завершите голосовую трансляцию, чтобы записать голосовое сообщение</string>
<string name="error_voice_message_broadcast_in_progress">Не удалось записать голосовое сообщение</string>
<string name="review_unverified_sessions_description">Убедиться что Ваш аккаунт в безопасности</string>
<string name="settings_nightly_build_update">Получить последнюю сборку (у вас могут быть проблемы со входом)</string>
<string name="room_profile_section_more_polls">История опроса</string>
<string name="started_a_voice_broadcast">Голосовая трансляция начата</string>
<string name="thread_list_not_available">Ваш домашний сервер не поддерживает список обсуждений.</string>
<string name="action_stop">Остановить</string>
</resources> </resources>

View File

@ -2978,4 +2978,5 @@
</plurals> </plurals>
<string name="error_voice_message_broadcast_in_progress_message">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</string> <string name="error_voice_message_broadcast_in_progress_message">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</string>
<string name="error_voice_message_broadcast_in_progress">Nemožno spustiť hlasovú správu</string> <string name="error_voice_message_broadcast_in_progress">Nemožno spustiť hlasovú správu</string>
<string name="error_voice_broadcast_no_connection_recording">Chyba pripojenia - nahrávanie pozastavené</string>
</resources> </resources>

View File

@ -3038,4 +3038,5 @@
<string name="room_polls_wait_for_display">Показ опитувань</string> <string name="room_polls_wait_for_display">Показ опитувань</string>
<string name="error_voice_message_broadcast_in_progress_message">Ви не можете розпочати запис голосового повідомлення, оскільки ви записуєте трансляцію наживо. Будь ласка, заверште її, щоб розпочати запис голосового повідомлення</string> <string name="error_voice_message_broadcast_in_progress_message">Ви не можете розпочати запис голосового повідомлення, оскільки ви записуєте трансляцію наживо. Будь ласка, заверште її, щоб розпочати запис голосового повідомлення</string>
<string name="error_voice_message_broadcast_in_progress">Не вдалося розпочати запис голосового повідомлення</string> <string name="error_voice_message_broadcast_in_progress">Не вдалося розпочати запис голосового повідомлення</string>
<string name="error_voice_broadcast_no_connection_recording">Помилка з\'єднання - Запис призупинено</string>
</resources> </resources>

View File

@ -10,6 +10,8 @@
<!-- onboarding english only word play --> <!-- onboarding english only word play -->
<string name="cut_the_slack_from_teams" translatable="false">Cut the slack from teams.</string> <string name="cut_the_slack_from_teams" translatable="false">Cut the slack from teams.</string>
<string name="command_description_crash_application" translatable="false">Crash the application.</string>
<!-- WIP --> <!-- WIP -->
<string name="location_map_view_copyright" translatable="false">© MapTiler © OpenStreetMap contributors</string> <string name="location_map_view_copyright" translatable="false">© MapTiler © OpenStreetMap contributors</string>
</resources> </resources>

View File

@ -80,6 +80,9 @@ class FlowSession(private val session: Session) {
fun liveSyncState(): Flow<SyncState> { fun liveSyncState(): Flow<SyncState> {
return session.syncService().getSyncStateLive().asFlow() return session.syncService().getSyncStateLive().asFlow()
.startWith(session.coroutineDispatchers.io) {
session.syncService().getSyncState()
}
} }
fun livePushers(): Flow<List<Pusher>> { fun livePushers(): Flow<List<Pusher>> {

View File

@ -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.RoomSummary
import org.matrix.android.sdk.api.session.room.model.relation.RelationService 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.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.read.ReadService
import org.matrix.android.sdk.api.session.room.reporting.ReportingService import org.matrix.android.sdk.api.session.room.reporting.ReportingService
import org.matrix.android.sdk.api.session.room.send.DraftService import org.matrix.android.sdk.api.session.room.send.DraftService
@ -181,4 +182,9 @@ interface Room {
* Get the LocationSharingService associated to this Room. * Get the LocationSharingService associated to this Room.
*/ */
fun locationSharingService(): LocationSharingService fun locationSharingService(): LocationSharingService
/**
* Get the PollHistoryService associated to this Room.
*/
fun pollHistoryService(): PollHistoryService
} }

View File

@ -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,
)

View File

@ -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<List<TimelineEvent>>
}

View File

@ -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.MigrateSessionTo047
import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo048 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.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.Normalizer
import org.matrix.android.sdk.internal.util.database.MatrixRealmMigration import org.matrix.android.sdk.internal.util.database.MatrixRealmMigration
import javax.inject.Inject import javax.inject.Inject
@ -74,7 +75,7 @@ internal class RealmSessionStoreMigration @Inject constructor(
private val normalizer: Normalizer private val normalizer: Normalizer
) : MatrixRealmMigration( ) : MatrixRealmMigration(
dbName = "Session", dbName = "Session",
schemaVersion = 49L, schemaVersion = 50L,
) { ) {
/** /**
* Forces all RealmSessionStoreMigration instances to be equal. * Forces all RealmSessionStoreMigration instances to be equal.
@ -133,5 +134,6 @@ internal class RealmSessionStoreMigration @Inject constructor(
if (oldVersion < 47) MigrateSessionTo047(realm).perform() if (oldVersion < 47) MigrateSessionTo047(realm).perform()
if (oldVersion < 48) MigrateSessionTo048(realm).perform() if (oldVersion < 48) MigrateSessionTo048(realm).perform()
if (oldVersion < 49) MigrateSessionTo049(realm).perform() if (oldVersion < 49) MigrateSessionTo049(realm).perform()
if (oldVersion < 50) MigrateSessionTo050(realm).perform()
} }
} }

View File

@ -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)
}
}

View File

@ -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()
}
}
}

View File

@ -36,7 +36,4 @@ internal open class PollResponseAggregatedSummaryEntity(
var sourceLocalEchoEvents: RealmList<String> = RealmList(), var sourceLocalEchoEvents: RealmList<String> = RealmList(),
// list of related event ids which are encrypted due to decryption failure // list of related event ids which are encrypted due to decryption failure
var encryptedRelatedEventIds: RealmList<String> = RealmList(), var encryptedRelatedEventIds: RealmList<String> = RealmList(),
) : RealmObject() { ) : RealmObject()
companion object
}

View File

@ -73,6 +73,7 @@ import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntit
UserPresenceEntity::class, UserPresenceEntity::class,
ThreadSummaryEntity::class, ThreadSummaryEntity::class,
ThreadListPageEntity::class, ThreadListPageEntity::class,
PollHistoryStatusEntity::class,
] ]
) )
internal class SessionRealmModule internal class SessionRealmModule

View File

@ -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<PollHistoryStatusEntity>().equalTo(PollHistoryStatusEntityFields.ROOM_ID, roomId).findFirst()
}
internal fun PollHistoryStatusEntity.Companion.getOrCreate(realm: Realm, roomId: String): PollHistoryStatusEntity {
return get(realm, roomId) ?: realm.createObject(roomId)
}

View File

@ -34,10 +34,16 @@ internal interface UpdatePushRuleActionsTask : Task<UpdatePushRuleActionsTask.Pa
internal class DefaultUpdatePushRuleActionsTask @Inject constructor( internal class DefaultUpdatePushRuleActionsTask @Inject constructor(
private val pushRulesApi: PushRulesApi, private val pushRulesApi: PushRulesApi,
private val globalErrorReceiver: GlobalErrorReceiver private val globalErrorReceiver: GlobalErrorReceiver,
) : UpdatePushRuleActionsTask { ) : UpdatePushRuleActionsTask {
override suspend fun execute(params: UpdatePushRuleActionsTask.Params) { override suspend fun execute(params: UpdatePushRuleActionsTask.Params) {
if (params.actions != null) {
val body = mapOf("actions" to params.actions.toJson())
executeRequest(globalErrorReceiver) {
pushRulesApi.updateRuleActions(params.kind.value, params.ruleId, body)
}
}
executeRequest(globalErrorReceiver) { executeRequest(globalErrorReceiver) {
pushRulesApi.updateEnableRuleStatus( pushRulesApi.updateEnableRuleStatus(
params.kind.value, params.kind.value,
@ -45,11 +51,5 @@ internal class DefaultUpdatePushRuleActionsTask @Inject constructor(
EnabledBody(params.enable) EnabledBody(params.enable)
) )
} }
if (params.actions != null) {
val body = mapOf("actions" to params.actions.toJson())
executeRequest(globalErrorReceiver) {
pushRulesApi.updateRuleActions(params.kind.value, params.ruleId, body)
}
}
} }
} }

View File

@ -30,6 +30,7 @@ import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.model.RoomType import org.matrix.android.sdk.api.session.room.model.RoomType
import org.matrix.android.sdk.api.session.room.model.relation.RelationService 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.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.read.ReadService
import org.matrix.android.sdk.api.session.room.reporting.ReportingService import org.matrix.android.sdk.api.session.room.reporting.ReportingService
import org.matrix.android.sdk.api.session.room.send.DraftService import org.matrix.android.sdk.api.session.room.send.DraftService
@ -72,6 +73,7 @@ internal class DefaultRoom(
private val roomVersionService: RoomVersionService, private val roomVersionService: RoomVersionService,
private val viaParameterFinder: ViaParameterFinder, private val viaParameterFinder: ViaParameterFinder,
private val locationSharingService: LocationSharingService, private val locationSharingService: LocationSharingService,
private val pollHistoryService: PollHistoryService,
override val coroutineDispatchers: MatrixCoroutineDispatchers override val coroutineDispatchers: MatrixCoroutineDispatchers
) : Room { ) : Room {
@ -116,4 +118,5 @@ internal class DefaultRoom(
override fun roomAccountDataService() = roomAccountDataService override fun roomAccountDataService() = roomAccountDataService
override fun roomVersionService() = roomVersionService override fun roomVersionService() = roomVersionService
override fun locationSharingService() = locationSharingService override fun locationSharingService() = locationSharingService
override fun pollHistoryService() = pollHistoryService
} }

View File

@ -28,6 +28,7 @@ import org.matrix.android.sdk.internal.session.room.draft.DefaultDraftService
import org.matrix.android.sdk.internal.session.room.location.DefaultLocationSharingService import org.matrix.android.sdk.internal.session.room.location.DefaultLocationSharingService
import org.matrix.android.sdk.internal.session.room.membership.DefaultMembershipService import org.matrix.android.sdk.internal.session.room.membership.DefaultMembershipService
import org.matrix.android.sdk.internal.session.room.notification.DefaultRoomPushRuleService import org.matrix.android.sdk.internal.session.room.notification.DefaultRoomPushRuleService
import org.matrix.android.sdk.internal.session.room.poll.DefaultPollHistoryService
import org.matrix.android.sdk.internal.session.room.read.DefaultReadService import org.matrix.android.sdk.internal.session.room.read.DefaultReadService
import org.matrix.android.sdk.internal.session.room.relation.DefaultRelationService import org.matrix.android.sdk.internal.session.room.relation.DefaultRelationService
import org.matrix.android.sdk.internal.session.room.reporting.DefaultReportingService import org.matrix.android.sdk.internal.session.room.reporting.DefaultReportingService
@ -71,15 +72,17 @@ internal class DefaultRoomFactory @Inject constructor(
private val roomAccountDataServiceFactory: DefaultRoomAccountDataService.Factory, private val roomAccountDataServiceFactory: DefaultRoomAccountDataService.Factory,
private val viaParameterFinder: ViaParameterFinder, private val viaParameterFinder: ViaParameterFinder,
private val locationSharingServiceFactory: DefaultLocationSharingService.Factory, private val locationSharingServiceFactory: DefaultLocationSharingService.Factory,
private val pollHistoryServiceFactory: DefaultPollHistoryService.Factory,
private val coroutineDispatchers: MatrixCoroutineDispatchers private val coroutineDispatchers: MatrixCoroutineDispatchers
) : RoomFactory { ) : RoomFactory {
override fun create(roomId: String): Room { override fun create(roomId: String): Room {
val timelineService = timelineServiceFactory.create(roomId)
return DefaultRoom( return DefaultRoom(
roomId = roomId, roomId = roomId,
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
roomCryptoService = roomCryptoServiceFactory.create(roomId), roomCryptoService = roomCryptoServiceFactory.create(roomId),
timelineService = timelineServiceFactory.create(roomId), timelineService = timelineService,
threadsService = threadsServiceFactory.create(roomId), threadsService = threadsServiceFactory.create(roomId),
threadsLocalService = threadsLocalServiceFactory.create(roomId), threadsLocalService = threadsLocalServiceFactory.create(roomId),
sendService = sendServiceFactory.create(roomId), sendService = sendServiceFactory.create(roomId),
@ -99,6 +102,7 @@ internal class DefaultRoomFactory @Inject constructor(
roomVersionService = roomVersionServiceFactory.create(roomId), roomVersionService = roomVersionServiceFactory.create(roomId),
viaParameterFinder = viaParameterFinder, viaParameterFinder = viaParameterFinder,
locationSharingService = locationSharingServiceFactory.create(roomId), locationSharingService = locationSharingServiceFactory.create(roomId),
pollHistoryService = pollHistoryServiceFactory.create(roomId, timelineService),
coroutineDispatchers = coroutineDispatchers coroutineDispatchers = coroutineDispatchers
) )
} }

View File

@ -59,6 +59,8 @@ import org.matrix.android.sdk.internal.session.room.directory.DefaultSetRoomDire
import org.matrix.android.sdk.internal.session.room.directory.GetPublicRoomTask import org.matrix.android.sdk.internal.session.room.directory.GetPublicRoomTask
import org.matrix.android.sdk.internal.session.room.directory.GetRoomDirectoryVisibilityTask import org.matrix.android.sdk.internal.session.room.directory.GetRoomDirectoryVisibilityTask
import org.matrix.android.sdk.internal.session.room.directory.SetRoomDirectoryVisibilityTask import org.matrix.android.sdk.internal.session.room.directory.SetRoomDirectoryVisibilityTask
import org.matrix.android.sdk.internal.session.room.event.DefaultFilterAndStoreEventsTask
import org.matrix.android.sdk.internal.session.room.event.FilterAndStoreEventsTask
import org.matrix.android.sdk.internal.session.room.location.CheckIfExistingActiveLiveTask import org.matrix.android.sdk.internal.session.room.location.CheckIfExistingActiveLiveTask
import org.matrix.android.sdk.internal.session.room.location.DefaultCheckIfExistingActiveLiveTask import org.matrix.android.sdk.internal.session.room.location.DefaultCheckIfExistingActiveLiveTask
import org.matrix.android.sdk.internal.session.room.location.DefaultGetActiveBeaconInfoForUserTask import org.matrix.android.sdk.internal.session.room.location.DefaultGetActiveBeaconInfoForUserTask
@ -89,6 +91,12 @@ import org.matrix.android.sdk.internal.session.room.peeking.DefaultPeekRoomTask
import org.matrix.android.sdk.internal.session.room.peeking.DefaultResolveRoomStateTask import org.matrix.android.sdk.internal.session.room.peeking.DefaultResolveRoomStateTask
import org.matrix.android.sdk.internal.session.room.peeking.PeekRoomTask import org.matrix.android.sdk.internal.session.room.peeking.PeekRoomTask
import org.matrix.android.sdk.internal.session.room.peeking.ResolveRoomStateTask import org.matrix.android.sdk.internal.session.room.peeking.ResolveRoomStateTask
import org.matrix.android.sdk.internal.session.room.poll.DefaultGetLoadedPollsStatusTask
import org.matrix.android.sdk.internal.session.room.poll.DefaultLoadMorePollsTask
import org.matrix.android.sdk.internal.session.room.poll.DefaultSyncPollsTask
import org.matrix.android.sdk.internal.session.room.poll.GetLoadedPollsStatusTask
import org.matrix.android.sdk.internal.session.room.poll.LoadMorePollsTask
import org.matrix.android.sdk.internal.session.room.poll.SyncPollsTask
import org.matrix.android.sdk.internal.session.room.read.DefaultMarkAllRoomsReadTask import org.matrix.android.sdk.internal.session.room.read.DefaultMarkAllRoomsReadTask
import org.matrix.android.sdk.internal.session.room.read.DefaultSetReadMarkersTask import org.matrix.android.sdk.internal.session.room.read.DefaultSetReadMarkersTask
import org.matrix.android.sdk.internal.session.room.read.MarkAllRoomsReadTask import org.matrix.android.sdk.internal.session.room.read.MarkAllRoomsReadTask
@ -359,4 +367,16 @@ internal abstract class RoomModule {
@Binds @Binds
abstract fun bindFetchPollResponseEventsTask(task: DefaultFetchPollResponseEventsTask): FetchPollResponseEventsTask abstract fun bindFetchPollResponseEventsTask(task: DefaultFetchPollResponseEventsTask): FetchPollResponseEventsTask
@Binds
abstract fun bindLoadMorePollsTask(task: DefaultLoadMorePollsTask): LoadMorePollsTask
@Binds
abstract fun bindGetLoadedPollsStatusTask(task: DefaultGetLoadedPollsStatusTask): GetLoadedPollsStatusTask
@Binds
abstract fun bindFilterAndStoreEventsTask(task: DefaultFilterAndStoreEventsTask): FilterAndStoreEventsTask
@Binds
abstract fun bindSyncPollsTask(task: DefaultSyncPollsTask): SyncPollsTask
} }

View File

@ -0,0 +1,83 @@
/*
* 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 com.zhuinden.monarchy.Monarchy
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.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.task.Task
import org.matrix.android.sdk.internal.util.awaitTransaction
import org.matrix.android.sdk.internal.util.time.Clock
import javax.inject.Inject
internal interface FilterAndStoreEventsTask : Task<FilterAndStoreEventsTask.Params, Unit> {
data class Params(
val roomId: String,
val events: List<Event>,
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<Event>) {
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)
}

View File

@ -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<List<TimelineEvent>> {
val pollHistoryStatusLiveData = getPollHistoryStatus()
return Transformations.switchMap(pollHistoryStatusLiveData) { results ->
val oldestTimestamp = results.firstOrNull()?.oldestTimestampTargetReachedMs ?: clock.epochMillis()
getPollStartEventsAfter(oldestTimestamp)
}
}
private fun getPollStartEventsAfter(timestampMs: Long): LiveData<List<TimelineEvent>> {
val eventsLiveData = monarchy.findAllMappedWithChanges(
{ realm ->
val pollTypes = (EventType.POLL_START.values + EventType.ENCRYPTED).toTypedArray()
realm.where<TimelineEventEntity>()
.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<List<PollHistoryStatusEntity>> {
return monarchy.findAllMappedWithChanges(
{ realm ->
realm.where<PollHistoryStatusEntity>()
.equalTo(PollHistoryStatusEntityFields.ROOM_ID, roomId)
},
{ result ->
// make a copy of the Realm object since it will be used in another transformations
result.copy()
}
)
}
}

View File

@ -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<GetLoadedPollsStatusTask.Params, LoadedPollsStatus> {
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,
)
}
}
}

View File

@ -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<LoadMorePollsTask.Params, LoadedPollsStatus> {
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<TimelineEvent>,
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()
}
}
}

View File

@ -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
}

View File

@ -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<SyncPollsTask.Params, Unit> {
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<TimelineEvent>,
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,
)
}

View File

@ -17,25 +17,14 @@
package org.matrix.android.sdk.internal.session.room.relation.poll package org.matrix.android.sdk.internal.session.room.relation.poll
import androidx.annotation.VisibleForTesting 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.RelationType
import org.matrix.android.sdk.api.session.events.model.isPollResponse 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.GlobalErrorReceiver
import org.matrix.android.sdk.internal.network.executeRequest 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.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.session.room.relation.RelationsResponse
import org.matrix.android.sdk.internal.task.Task 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 import javax.inject.Inject
@VisibleForTesting @VisibleForTesting
@ -54,9 +43,8 @@ internal interface FetchPollResponseEventsTask : Task<FetchPollResponseEventsTas
internal class DefaultFetchPollResponseEventsTask @Inject constructor( internal class DefaultFetchPollResponseEventsTask @Inject constructor(
private val roomAPI: RoomAPI, private val roomAPI: RoomAPI,
private val globalErrorReceiver: GlobalErrorReceiver, private val globalErrorReceiver: GlobalErrorReceiver,
@SessionDatabase private val monarchy: Monarchy, private val filterAndStoreEventsTask: FilterAndStoreEventsTask,
private val clock: Clock,
private val eventDecryptor: EventDecryptor,
) : FetchPollResponseEventsTask { ) : FetchPollResponseEventsTask {
override suspend fun execute(params: FetchPollResponseEventsTask.Params): Result<Unit> = runCatching { override suspend fun execute(params: FetchPollResponseEventsTask.Params): Result<Unit> = runCatching {
@ -70,11 +58,12 @@ internal class DefaultFetchPollResponseEventsTask @Inject constructor(
private suspend fun fetchAndProcessRelatedEventsFrom(params: FetchPollResponseEventsTask.Params, from: String? = null): String? { private suspend fun fetchAndProcessRelatedEventsFrom(params: FetchPollResponseEventsTask.Params, from: String? = null): String? {
val response = getRelatedEvents(params, from) val response = getRelatedEvents(params, from)
val filteredEvents = response.chunks val filterTaskParams = FilterAndStoreEventsTask.Params(
.map { decryptEventIfNeeded(it) } roomId = params.roomId,
.filter { it.isPollResponse() } events = response.chunks,
filterPredicate = { it.isPollResponse() }
addMissingEventsInDB(params.roomId, filteredEvents) )
filterAndStoreEventsTask.execute(filterTaskParams)
return response.nextBatch return response.nextBatch
} }
@ -90,29 +79,4 @@ internal class DefaultFetchPollResponseEventsTask @Inject constructor(
) )
} }
} }
private suspend fun addMissingEventsInDB(roomId: String, events: List<Event>) {
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)
} }

View File

@ -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<Event>, predicate: (Event) -> Boolean) = FilterAndStoreEventsTask.Params(
roomId = roomId,
events = events,
filterPredicate = predicate,
)
private fun givenAnEvent(
eventId: String,
isEncrypted: Boolean,
clearType: String,
): Event {
val event = mockk<Event>(relaxed = true)
every { event.eventId } returns eventId
every { event.isEncrypted() } returns isEncrypted
every { event.getClearType() } returns clearType
return event
}
private fun givenExistingEventEntities(eventIdsToCheck: List<String>, existingIds: List<String>) {
val eventEntities = existingIds.map { EventEntity(eventId = it) }
fakeMonarchy.givenWhere<EventEntity>()
.givenIn(EventEntityFields.EVENT_ID, eventIdsToCheck)
.givenFindAll(eventEntities)
}
}

View File

@ -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<PollHistoryStatusEntity>()
.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<PollHistoryStatusEntity>()
.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,
)
}
}

View File

@ -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<PollHistoryStatusEntity>()
.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<PollHistoryStatusEntity>()
.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<TimelineEvent>()
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,
)
}
}

View File

@ -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<PollHistoryStatusEntity>()
.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<TimelineEvent>()
every { event.root.originServerTs } returns 123L
every { event.root.eventId } returns eventId
return event
}
private fun aPaginationState(): Timeline.PaginationState {
return Timeline.PaginationState(
hasMoreToLoad = false,
)
}
}

View File

@ -16,11 +16,12 @@
package org.matrix.android.sdk.internal.session.room.relation.poll 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.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.unmockkAll import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.After 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.Event
import org.matrix.android.sdk.api.session.events.model.RelationType 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.events.model.isPollResponse
import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.internal.session.room.event.FilterAndStoreEventsTask
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.relation.RelationsResponse 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.FakeGlobalErrorReceiver
import org.matrix.android.sdk.test.fakes.FakeMonarchy
import org.matrix.android.sdk.test.fakes.FakeRoomApi 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) @OptIn(ExperimentalCoroutinesApi::class)
internal class DefaultFetchPollResponseEventsTaskTest { internal class DefaultFetchPollResponseEventsTaskTest {
private val fakeRoomAPI = FakeRoomApi() private val fakeRoomAPI = FakeRoomApi()
private val fakeGlobalErrorReceiver = FakeGlobalErrorReceiver() private val fakeGlobalErrorReceiver = FakeGlobalErrorReceiver()
private val fakeMonarchy = FakeMonarchy() private val filterAndStoreEventsTask = mockk<FilterAndStoreEventsTask>()
private val fakeClock = FakeClock()
private val fakeEventDecryptor = FakeEventDecryptor()
private val defaultFetchPollResponseEventsTask = DefaultFetchPollResponseEventsTask( private val defaultFetchPollResponseEventsTask = DefaultFetchPollResponseEventsTask(
roomAPI = fakeRoomAPI.instance, roomAPI = fakeRoomAPI.instance,
globalErrorReceiver = fakeGlobalErrorReceiver, globalErrorReceiver = fakeGlobalErrorReceiver,
monarchy = fakeMonarchy.instance, filterAndStoreEventsTask = filterAndStoreEventsTask,
clock = fakeClock,
eventDecryptor = fakeEventDecryptor.instance,
) )
@Before @Before
fun setup() { fun setup() {
mockkStatic("org.matrix.android.sdk.api.session.events.model.EventKt") 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.mapper.EventMapperKt")
mockkStatic("org.matrix.android.sdk.internal.database.query.EventEntityQueriesKt") mockkStatic("org.matrix.android.sdk.internal.database.query.EventEntityQueriesKt")
} }
@ -74,7 +62,7 @@ internal class DefaultFetchPollResponseEventsTaskTest {
} }
@Test @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 // Given
val aRoomId = "roomId" val aRoomId = "roomId"
val aPollEventId = "eventId" val aPollEventId = "eventId"
@ -94,13 +82,7 @@ internal class DefaultFetchPollResponseEventsTaskTest {
fakeRoomAPI.givenGetRelationsReturns(from = null, relationsResponse = firstResponse) fakeRoomAPI.givenGetRelationsReturns(from = null, relationsResponse = firstResponse)
val secondResponse = givenARelationsResponse(events = secondEvents, nextBatch = null) val secondResponse = givenARelationsResponse(events = secondEvents, nextBatch = null)
fakeRoomAPI.givenGetRelationsReturns(from = aNextBatchToken, relationsResponse = secondResponse) fakeRoomAPI.givenGetRelationsReturns(from = aNextBatchToken, relationsResponse = secondResponse)
fakeEventDecryptor.givenDecryptEventAndSaveResultSuccess(event1) coJustRun { filterAndStoreEventsTask.execute(any()) }
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 // When
defaultFetchPollResponseEventsTask.execute(params) defaultFetchPollResponseEventsTask.execute(params)
@ -111,21 +93,22 @@ internal class DefaultFetchPollResponseEventsTaskTest {
eventId = params.startPollEventId, eventId = params.startPollEventId,
relationType = RelationType.REFERENCE, relationType = RelationType.REFERENCE,
from = null, from = null,
limit = FETCH_RELATED_EVENTS_LIMIT limit = FETCH_RELATED_EVENTS_LIMIT,
) )
fakeRoomAPI.verifyGetRelations( fakeRoomAPI.verifyGetRelations(
roomId = params.roomId, roomId = params.roomId,
eventId = params.startPollEventId, eventId = params.startPollEventId,
relationType = RelationType.REFERENCE, relationType = RelationType.REFERENCE,
from = aNextBatchToken, from = aNextBatchToken,
limit = FETCH_RELATED_EVENTS_LIMIT limit = FETCH_RELATED_EVENTS_LIMIT,
) )
fakeEventDecryptor.verifyDecryptEventAndSaveResult(event1, timeline = "") coVerify {
fakeEventDecryptor.verifyDecryptEventAndSaveResult(event2, timeline = "") filterAndStoreEventsTask.execute(match {
// Check we save in DB the event2 which is a non stored poll response it.roomId == aRoomId && it.events == firstEvents
verify { })
event2.toEntity(aRoomId, SendState.SYNCED, any()) filterAndStoreEventsTask.execute(match {
eventEntityToSave.copyToRealmOrIgnore(fakeMonarchy.fakeRealm.instance, EventInsertType.PAGINATION) it.roomId == aRoomId && it.events == secondEvents
})
} }
} }
@ -153,11 +136,4 @@ internal class DefaultFetchPollResponseEventsTaskTest {
every { event.isEncrypted() } returns isEncrypted every { event.isEncrypted() } returns isEncrypted
return event return event
} }
private fun givenExistingEventEntities(eventIdsToCheck: List<String>, existingIds: List<String>) {
val eventEntities = existingIds.map { EventEntity(eventId = it) }
fakeMonarchy.givenWhere<EventEntity>()
.givenIn(EventEntityFields.EVENT_ID, eventIdsToCheck)
.givenFindAll(eventEntities)
}
} }

View File

@ -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<TimelineEvent>, 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
}
}

View File

@ -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)
}
}
}

View File

@ -32,6 +32,7 @@ enum class Command(
val isDevCommand: Boolean, val isDevCommand: Boolean,
val isThreadCommand: Boolean val isThreadCommand: Boolean
) { ) {
CRASH_APP("/crash", null, "", R.string.command_description_crash_application, true, true),
EMOTE("/me", null, "<message>", R.string.command_description_emote, false, true), EMOTE("/me", null, "<message>", R.string.command_description_emote, false, true),
BAN_USER("/ban", null, "<user-id> [reason]", R.string.command_description_ban_user, false, false), BAN_USER("/ban", null, "<user-id> [reason]", R.string.command_description_ban_user, false, false),
UNBAN_USER("/unban", null, "<user-id> [reason]", R.string.command_description_unban_user, false, false), UNBAN_USER("/unban", null, "<user-id> [reason]", R.string.command_description_unban_user, false, false),

View File

@ -20,13 +20,16 @@ import im.vector.app.core.extensions.isEmail
import im.vector.app.core.extensions.isMsisdn import im.vector.app.core.extensions.isMsisdn
import im.vector.app.core.extensions.orEmpty import im.vector.app.core.extensions.orEmpty
import im.vector.app.features.home.room.detail.ChatEffect 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.MatrixPatterns
import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl
import org.matrix.android.sdk.api.session.identity.ThreePid import org.matrix.android.sdk.api.session.identity.ThreePid
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
class CommandParser @Inject constructor() { class CommandParser @Inject constructor(
private val vectorPreferences: VectorPreferences
) {
/** /**
* Convert the text message into a Slash command. * Convert the text message into a Slash command.
@ -404,6 +407,9 @@ class CommandParser @Inject constructor() {
ParsedCommand.ErrorSyntax(Command.UPGRADE_ROOM) ParsedCommand.ErrorSyntax(Command.UPGRADE_ROOM)
} }
} }
Command.CRASH_APP.matches(slashCommand) && vectorPreferences.developerMode() -> {
throw RuntimeException("Application crashed from user demand")
}
else -> { else -> {
// Unknown command // Unknown command
ParsedCommand.ErrorUnknownSlashCommand(slashCommand) ParsedCommand.ErrorUnknownSlashCommand(slashCommand)

View File

@ -31,6 +31,7 @@ import im.vector.app.core.pushers.EnsureFcmTokenIsRetrievedUseCase
import im.vector.app.core.pushers.PushersManager import im.vector.app.core.pushers.PushersManager
import im.vector.app.core.pushers.RegisterUnifiedPushUseCase import im.vector.app.core.pushers.RegisterUnifiedPushUseCase
import im.vector.app.core.pushers.UnregisterUnifiedPushUseCase 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.AnalyticsConfig
import im.vector.app.features.analytics.AnalyticsTracker import im.vector.app.features.analytics.AnalyticsTracker
import im.vector.app.features.analytics.extensions.toAnalyticsType import im.vector.app.features.analytics.extensions.toAnalyticsType
@ -93,7 +94,8 @@ class HomeActivityViewModel @AssistedInject constructor(
private val registerUnifiedPushUseCase: RegisterUnifiedPushUseCase, private val registerUnifiedPushUseCase: RegisterUnifiedPushUseCase,
private val unregisterUnifiedPushUseCase: UnregisterUnifiedPushUseCase, private val unregisterUnifiedPushUseCase: UnregisterUnifiedPushUseCase,
private val ensureFcmTokenIsRetrievedUseCase: EnsureFcmTokenIsRetrievedUseCase, private val ensureFcmTokenIsRetrievedUseCase: EnsureFcmTokenIsRetrievedUseCase,
private val coroutineDispatchers: CoroutineDispatchers private val ensureSessionSyncingUseCase: EnsureSessionSyncingUseCase,
private val coroutineDispatchers: CoroutineDispatchers,
) : VectorViewModel<HomeActivityViewState, HomeActivityViewActions, HomeActivityViewEvents>(initialState) { ) : VectorViewModel<HomeActivityViewState, HomeActivityViewActions, HomeActivityViewEvents>(initialState) {
@AssistedFactory @AssistedFactory
@ -117,6 +119,8 @@ class HomeActivityViewModel @AssistedInject constructor(
private fun initialize() { private fun initialize() {
if (isInitialized) return if (isInitialized) return
isInitialized = true isInitialized = true
// Ensure Session is syncing
ensureSessionSyncingUseCase.execute()
registerUnifiedPushIfNeeded() registerUnifiedPushIfNeeded()
viewModelScope.launch(coroutineDispatchers.io) { viewModelScope.launch(coroutineDispatchers.io) {
cleanupFiles() cleanupFiles()

View File

@ -38,8 +38,8 @@ data class HomeDetailViewState(
val notificationCountRooms: Int = 0, val notificationCountRooms: Int = 0,
val notificationHighlightRooms: Boolean = false, val notificationHighlightRooms: Boolean = false,
val hasUnreadMessages: Boolean = false, val hasUnreadMessages: Boolean = false,
val syncState: SyncState = SyncState.Idle, val syncState: SyncState? = null,
val incrementalSyncRequestState: SyncRequestState.IncrementalSyncRequestState = SyncRequestState.IncrementalSyncIdle, val incrementalSyncRequestState: SyncRequestState.IncrementalSyncRequestState? = null,
val pushCounter: Int = 0, val pushCounter: Int = 0,
val pstnSupportFlag: Boolean = false, val pstnSupportFlag: Boolean = false,
val forceDialPadTab: Boolean = false val forceDialPadTab: Boolean = false

View File

@ -60,8 +60,8 @@ data class RoomDetailViewState(
val formattedTypingUsers: String? = null, val formattedTypingUsers: String? = null,
val tombstoneEvent: Event? = null, val tombstoneEvent: Event? = null,
val joinUpgradedRoomAsync: Async<String> = Uninitialized, val joinUpgradedRoomAsync: Async<String> = Uninitialized,
val syncState: SyncState = SyncState.Idle, val syncState: SyncState? = null,
val incrementalSyncRequestState: SyncRequestState.IncrementalSyncRequestState = SyncRequestState.IncrementalSyncIdle, val incrementalSyncRequestState: SyncRequestState.IncrementalSyncRequestState? = null,
val pushCounter: Int = 0, val pushCounter: Int = 0,
val highlightedEventId: String? = null, val highlightedEventId: String? = null,
val unreadState: UnreadState = UnreadState.Unknown, val unreadState: UnreadState = UnreadState.Unknown,

View File

@ -19,7 +19,6 @@ package im.vector.app.features.home.room.detail.timeline.factory
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.resources.StringProvider 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.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.PollResponseData
import im.vector.app.features.poll.PollViewState import im.vector.app.features.poll.PollViewState
import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.extensions.orFalse
@ -29,6 +28,7 @@ import javax.inject.Inject
class PollItemViewStateFactory @Inject constructor( class PollItemViewStateFactory @Inject constructor(
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val pollOptionViewStateFactory: PollOptionViewStateFactory,
) { ) {
fun create( fun create(
@ -40,7 +40,6 @@ class PollItemViewStateFactory @Inject constructor(
val question = pollCreationInfo?.question?.getBestQuestion().orEmpty() val question = pollCreationInfo?.question?.getBestQuestion().orEmpty()
val pollResponseSummary = informationData.pollResponseAggregatedSummary val pollResponseSummary = informationData.pollResponseAggregatedSummary
val winnerVoteCount = pollResponseSummary?.winnerVoteCount
val totalVotes = pollResponseSummary?.totalVotes ?: 0 val totalVotes = pollResponseSummary?.totalVotes ?: 0
return when { return when {
@ -48,7 +47,7 @@ class PollItemViewStateFactory @Inject constructor(
createSendingPollViewState(question, pollCreationInfo) createSendingPollViewState(question, pollCreationInfo)
} }
informationData.pollResponseAggregatedSummary?.isClosed.orFalse() -> { informationData.pollResponseAggregatedSummary?.isClosed.orFalse() -> {
createEndedPollViewState(question, pollCreationInfo, pollResponseSummary, totalVotes, winnerVoteCount) createEndedPollViewState(question, pollCreationInfo, pollResponseSummary, totalVotes)
} }
pollContent.getBestPollCreationInfo()?.isUndisclosed().orFalse() -> { pollContent.getBestPollCreationInfo()?.isUndisclosed().orFalse() -> {
createUndisclosedPollViewState(question, pollCreationInfo, pollResponseSummary) createUndisclosedPollViewState(question, pollCreationInfo, pollResponseSummary)
@ -67,12 +66,7 @@ class PollItemViewStateFactory @Inject constructor(
question = question, question = question,
votesStatus = stringProvider.getString(R.string.poll_no_votes_cast), votesStatus = stringProvider.getString(R.string.poll_no_votes_cast),
canVote = false, canVote = false,
optionViewStates = pollCreationInfo?.answers?.map { answer -> optionViewStates = pollOptionViewStateFactory.createPollSendingOptions(pollCreationInfo),
PollOptionViewState.PollSending(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: ""
)
},
) )
} }
@ -81,7 +75,6 @@ class PollItemViewStateFactory @Inject constructor(
pollCreationInfo: PollCreationInfo?, pollCreationInfo: PollCreationInfo?,
pollResponseSummary: PollResponseData?, pollResponseSummary: PollResponseData?,
totalVotes: Int, totalVotes: Int,
winnerVoteCount: Int?,
): PollViewState { ): PollViewState {
val totalVotesText = if (pollResponseSummary?.hasEncryptedRelatedEvents.orFalse()) { val totalVotesText = if (pollResponseSummary?.hasEncryptedRelatedEvents.orFalse()) {
stringProvider.getString(R.string.unable_to_decrypt_some_events_in_poll) stringProvider.getString(R.string.unable_to_decrypt_some_events_in_poll)
@ -92,16 +85,7 @@ class PollItemViewStateFactory @Inject constructor(
question = question, question = question,
votesStatus = totalVotesText, votesStatus = totalVotesText,
canVote = false, canVote = false,
optionViewStates = pollCreationInfo?.answers?.map { answer -> optionViewStates = pollOptionViewStateFactory.createPollEndedOptions(pollCreationInfo, pollResponseSummary),
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
)
},
) )
} }
@ -114,14 +98,7 @@ class PollItemViewStateFactory @Inject constructor(
question = question, question = question,
votesStatus = stringProvider.getString(R.string.poll_undisclosed_not_ended), votesStatus = stringProvider.getString(R.string.poll_undisclosed_not_ended),
canVote = true, canVote = true,
optionViewStates = pollCreationInfo?.answers?.map { answer -> optionViewStates = pollOptionViewStateFactory.createPollUndisclosedOptions(pollCreationInfo, pollResponseSummary),
val isMyVote = pollResponseSummary?.myVote == answer.id
PollOptionViewState.PollUndisclosed(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: "",
isSelected = isMyVote
)
},
) )
} }
@ -140,17 +117,7 @@ class PollItemViewStateFactory @Inject constructor(
question = question, question = question,
votesStatus = totalVotesText, votesStatus = totalVotesText,
canVote = true, canVote = true,
optionViewStates = pollCreationInfo?.answers?.map { answer -> optionViewStates = pollOptionViewStateFactory.createPollVotedOptions(pollCreationInfo, pollResponseSummary),
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
)
},
) )
} }
@ -168,12 +135,7 @@ class PollItemViewStateFactory @Inject constructor(
question = question, question = question,
votesStatus = totalVotesText, votesStatus = totalVotesText,
canVote = true, canVote = true,
optionViewStates = pollCreationInfo?.answers?.map { answer -> optionViewStates = pollOptionViewStateFactory.createPollReadyOptions(pollCreationInfo),
PollOptionViewState.PollReady(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: ""
)
},
) )
} }
} }

View File

@ -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<PollOptionViewState.PollEnded> {
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<PollOptionViewState.PollSending> {
return pollCreationInfo?.answers?.map { answer ->
PollOptionViewState.PollSending(
optionId = answer.id.orEmpty(),
optionAnswer = answer.getBestAnswer().orEmpty(),
)
} ?: emptyList()
}
fun createPollUndisclosedOptions(pollCreationInfo: PollCreationInfo?, pollResponseData: PollResponseData?): List<PollOptionViewState.PollUndisclosed> {
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<PollOptionViewState.PollVoted> {
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<PollOptionViewState.PollReady> {
return pollCreationInfo?.answers?.map { answer ->
PollOptionViewState.PollReady(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: ""
)
} ?: emptyList()
}
}

View File

@ -207,6 +207,7 @@ class RoomProfileFragment :
} }
override fun onDestroyView() { override fun onDestroyView() {
roomProfileController.callback = null
views.matrixProfileAppBarLayout.removeOnOffsetChangedListener(appBarStateChangeListener) views.matrixProfileAppBarLayout.removeOnOffsetChangedListener(appBarStateChangeListener)
views.matrixProfileRecyclerView.cleanup() views.matrixProfileRecyclerView.cleanup()
appBarStateChangeListener = null appBarStateChangeListener = null

View File

@ -23,20 +23,23 @@ import dagger.assisted.AssistedInject
import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.VectorViewModel 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.GetPollsUseCase
import im.vector.app.features.roomprofile.polls.list.domain.LoadMorePollsUseCase 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.domain.SyncPollsUseCase
import im.vector.app.features.roomprofile.polls.list.ui.PollSummaryMapper
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class RoomPollsViewModel @AssistedInject constructor( class RoomPollsViewModel @AssistedInject constructor(
@Assisted initialState: RoomPollsViewState, @Assisted initialState: RoomPollsViewState,
private val getPollsUseCase: GetPollsUseCase, private val getPollsUseCase: GetPollsUseCase,
private val getLoadedPollsStatusUseCase: GetLoadedPollsStatusUseCase,
private val loadMorePollsUseCase: LoadMorePollsUseCase, private val loadMorePollsUseCase: LoadMorePollsUseCase,
private val syncPollsUseCase: SyncPollsUseCase, private val syncPollsUseCase: SyncPollsUseCase,
private val disposePollHistoryUseCase: DisposePollHistoryUseCase,
private val pollSummaryMapper: PollSummaryMapper,
) : VectorViewModel<RoomPollsViewState, RoomPollsAction, RoomPollsViewEvent>(initialState) { ) : VectorViewModel<RoomPollsViewState, RoomPollsAction, RoomPollsViewEvent>(initialState) {
@AssistedFactory @AssistedFactory
@ -48,26 +51,26 @@ class RoomPollsViewModel @AssistedInject constructor(
init { init {
val roomId = initialState.roomId val roomId = initialState.roomId
updateLoadedPollStatus(roomId)
syncPolls(roomId) syncPolls(roomId)
observePolls(roomId) observePolls(roomId)
} }
private fun updateLoadedPollStatus(roomId: String) { override fun onCleared() {
val loadedPollsStatus = getLoadedPollsStatusUseCase.execute(roomId) withState { disposePollHistoryUseCase.execute(it.roomId) }
setState { super.onCleared()
copy(
canLoadMore = loadedPollsStatus.canLoadMore,
nbLoadedDays = loadedPollsStatus.nbLoadedDays
)
}
} }
private fun syncPolls(roomId: String) { private fun syncPolls(roomId: String) {
viewModelScope.launch { viewModelScope.launch {
setState { copy(isSyncing = true) } setState { copy(isSyncing = true) }
val result = runCatching { val result = runCatching {
syncPollsUseCase.execute(roomId) val loadedPollsStatus = syncPollsUseCase.execute(roomId)
setState {
copy(
canLoadMore = loadedPollsStatus.canLoadMore,
nbSyncedDays = loadedPollsStatus.daysSynced,
)
}
} }
if (result.isFailure) { if (result.isFailure) {
_viewEvents.post(RoomPollsViewEvent.LoadingError) _viewEvents.post(RoomPollsViewEvent.LoadingError)
@ -78,6 +81,7 @@ class RoomPollsViewModel @AssistedInject constructor(
private fun observePolls(roomId: String) { private fun observePolls(roomId: String) {
getPollsUseCase.execute(roomId) getPollsUseCase.execute(roomId)
.map { it.mapNotNull { event -> pollSummaryMapper.map(event) } }
.onEach { setState { copy(polls = it) } } .onEach { setState { copy(polls = it) } }
.launchIn(viewModelScope) .launchIn(viewModelScope)
} }
@ -96,7 +100,7 @@ class RoomPollsViewModel @AssistedInject constructor(
setState { setState {
copy( copy(
canLoadMore = status.canLoadMore, canLoadMore = status.canLoadMore,
nbLoadedDays = status.nbLoadedDays, nbSyncedDays = status.daysSynced,
) )
} }
} }

View File

@ -25,7 +25,7 @@ data class RoomPollsViewState(
val polls: List<PollSummary> = emptyList(), val polls: List<PollSummary> = emptyList(),
val isLoadingMore: Boolean = false, val isLoadingMore: Boolean = false,
val canLoadMore: Boolean = true, val canLoadMore: Boolean = true,
val nbLoadedDays: Int = 0, val nbSyncedDays: Int = 0,
val isSyncing: Boolean = false, val isSyncing: Boolean = false,
) : MavericksState { ) : MavericksState {

View File

@ -16,7 +16,6 @@
package im.vector.app.features.roomprofile.polls.list.data package im.vector.app.features.roomprofile.polls.list.data
data class LoadedPollsStatus( sealed class PollHistoryError : Exception() {
val canLoadMore: Boolean, object UnknownRoomError : PollHistoryError()
val nbLoadedDays: Int, }
)

View File

@ -16,159 +16,44 @@
package im.vector.app.features.roomprofile.polls.list.data package im.vector.app.features.roomprofile.polls.list.data
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState import androidx.lifecycle.asFlow
import im.vector.app.features.roomprofile.polls.list.ui.PollSummary import im.vector.app.core.di.ActiveSessionHolder
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import org.matrix.android.sdk.api.session.getRoom
import kotlinx.coroutines.flow.asSharedFlow import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
import timber.log.Timber 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.Inject
import javax.inject.Singleton
@Singleton class RoomPollDataSource @Inject constructor(
class RoomPollDataSource @Inject constructor() { private val activeSessionHolder: ActiveSessionHolder,
) {
private val pollsFlow = MutableSharedFlow<List<PollSummary>>(replay = 1) private fun getPollHistoryService(roomId: String): PollHistoryService {
private val polls = mutableListOf<PollSummary>() return activeSessionHolder
private var fakeLoadCounter = 0 .getSafeActiveSession()
?.getRoom(roomId)
// TODO ?.pollHistoryService()
// unmock using SDK service + add unit tests ?: throw PollHistoryError.UnknownRoomError
// after unmock, expose domain layer model (entity) and do the mapping to PollSummary in the UI layer
fun getPolls(roomId: String): Flow<List<PollSummary>> {
Timber.d("roomId=$roomId")
return pollsFlow.asSharedFlow()
} }
fun getLoadedPollsStatus(roomId: String): LoadedPollsStatus { fun dispose(roomId: String) {
Timber.d("roomId=$roomId") getPollHistoryService(roomId).dispose()
return LoadedPollsStatus(
canLoadMore = canLoadMore(),
nbLoadedDays = fakeLoadCounter * 30,
)
} }
private fun canLoadMore(): Boolean { fun getPolls(roomId: String): Flow<List<TimelineEvent>> {
return fakeLoadCounter < 2 return getPollHistoryService(roomId).getPollEvents().asFlow()
}
suspend fun getLoadedPollsStatus(roomId: String): LoadedPollsStatus {
return getPollHistoryService(roomId).getLoadedPollsStatus()
} }
suspend fun loadMorePolls(roomId: String): LoadedPollsStatus { suspend fun loadMorePolls(roomId: String): LoadedPollsStatus {
// TODO return getPollHistoryService(roomId).loadMore()
// 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<PollSummary.ActivePoll> {
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<PollSummary.ActivePoll> {
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<PollSummary.EndedPoll> {
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<PollSummary.EndedPoll> {
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,
)
),
),
)
} }
suspend fun syncPolls(roomId: String) { suspend fun syncPolls(roomId: String) {
Timber.d("roomId=$roomId") getPollHistoryService(roomId).syncPolls()
// TODO
// unmock using SDK service + add unit tests
if (fakeLoadCounter == 0) {
// fake first load
loadMorePolls(roomId)
} else {
// fake sync
delay(3000)
}
} }
} }

View File

@ -16,20 +16,24 @@
package im.vector.app.features.roomprofile.polls.list.data 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 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 import javax.inject.Inject
class RoomPollRepository @Inject constructor( class RoomPollRepository @Inject constructor(
private val roomPollDataSource: RoomPollDataSource, private val roomPollDataSource: RoomPollDataSource,
) { ) {
// TODO after unmock, expose domain layer model (entity) and do the mapping to PollSummary in the UI layer fun dispose(roomId: String) {
fun getPolls(roomId: String): Flow<List<PollSummary>> { roomPollDataSource.dispose(roomId)
}
fun getPolls(roomId: String): Flow<List<TimelineEvent>> {
return roomPollDataSource.getPolls(roomId) return roomPollDataSource.getPolls(roomId)
} }
fun getLoadedPollsStatus(roomId: String): LoadedPollsStatus { suspend fun getLoadedPollsStatus(roomId: String): LoadedPollsStatus {
return roomPollDataSource.getLoadedPollsStatus(roomId) return roomPollDataSource.getLoadedPollsStatus(roomId)
} }

View File

@ -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)
}
}

View File

@ -16,15 +16,15 @@
package im.vector.app.features.roomprofile.polls.list.domain 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 im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
import javax.inject.Inject import javax.inject.Inject
class GetLoadedPollsStatusUseCase @Inject constructor( class GetLoadedPollsStatusUseCase @Inject constructor(
private val roomPollRepository: RoomPollRepository, private val roomPollRepository: RoomPollRepository,
) { ) {
fun execute(roomId: String): LoadedPollsStatus { suspend fun execute(roomId: String): LoadedPollsStatus {
return roomPollRepository.getLoadedPollsStatus(roomId) return roomPollRepository.getLoadedPollsStatus(roomId)
} }
} }

View File

@ -17,17 +17,17 @@
package im.vector.app.features.roomprofile.polls.list.domain 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.data.RoomPollRepository
import im.vector.app.features.roomprofile.polls.list.ui.PollSummary
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import javax.inject.Inject import javax.inject.Inject
class GetPollsUseCase @Inject constructor( class GetPollsUseCase @Inject constructor(
private val roomPollRepository: RoomPollRepository, private val roomPollRepository: RoomPollRepository,
) { ) {
fun execute(roomId: String): Flow<List<PollSummary>> { fun execute(roomId: String): Flow<List<TimelineEvent>> {
return roomPollRepository.getPolls(roomId) return roomPollRepository.getPolls(roomId)
.map { it.sortedByDescending { poll -> poll.creationTimestamp } } .map { it.sortedByDescending { event -> event.root.originServerTs } }
} }
} }

View File

@ -16,8 +16,8 @@
package im.vector.app.features.roomprofile.polls.list.domain 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 im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
import javax.inject.Inject import javax.inject.Inject
class LoadMorePollsUseCase @Inject constructor( class LoadMorePollsUseCase @Inject constructor(

View File

@ -17,16 +17,26 @@
package im.vector.app.features.roomprofile.polls.list.domain 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.data.RoomPollRepository
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
import javax.inject.Inject 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( class SyncPollsUseCase @Inject constructor(
private val roomPollRepository: RoomPollRepository, 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) roomPollRepository.syncPolls(roomId)
val loadedStatus = getLoadedPollsStatusUseCase.execute(roomId)
return if (loadedStatus.hasCompletedASyncBackward) {
loadedStatus
} else {
loadMorePollsUseCase.execute(roomId)
}
} }
} }

View File

@ -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,
)
}
}
}

View File

@ -78,7 +78,7 @@ abstract class RoomPollsListFragment :
views.roomPollsList.configureWith(roomPollsController) views.roomPollsList.configureWith(roomPollsController)
views.roomPollsEmptyTitle.text = getEmptyListTitle( views.roomPollsEmptyTitle.text = getEmptyListTitle(
canLoadMore = viewState.canLoadMore, canLoadMore = viewState.canLoadMore,
nbLoadedDays = viewState.nbLoadedDays, nbLoadedDays = viewState.nbSyncedDays,
) )
} }
@ -117,7 +117,7 @@ abstract class RoomPollsListFragment :
roomPollsController.setData(viewState) roomPollsController.setData(viewState)
views.roomPollsEmptyTitle.text = getEmptyListTitle( views.roomPollsEmptyTitle.text = getEmptyListTitle(
canLoadMore = viewState.canLoadMore, canLoadMore = viewState.canLoadMore,
nbLoadedDays = viewState.nbLoadedDays, nbLoadedDays = viewState.nbSyncedDays,
) )
views.roomPollsEmptyTitle.isVisible = !viewState.isSyncing && viewState.hasNoPolls() views.roomPollsEmptyTitle.isVisible = !viewState.isSyncing && viewState.hasNoPolls()
views.roomPollsLoadMoreWhenEmpty.isVisible = viewState.hasNoPollsAndCanLoadMore() views.roomPollsLoadMoreWhenEmpty.isVisible = viewState.hasNoPollsAndCanLoadMore()

View File

@ -40,8 +40,8 @@ class SyncStateView @JvmOverloads constructor(context: Context, attrs: Attribute
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
fun render( fun render(
newState: SyncState, newState: SyncState?,
incrementalSyncRequestState: SyncRequestState.IncrementalSyncRequestState, incrementalSyncRequestState: SyncRequestState.IncrementalSyncRequestState?,
pushCounter: Int, pushCounter: Int,
showDebugInfo: Boolean 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) { return when (this) {
null -> "Unknown"
SyncState.Idle -> "Idle" SyncState.Idle -> "Idle"
SyncState.InvalidToken -> "InvalidToken" SyncState.InvalidToken -> "InvalidToken"
SyncState.Killed -> "Killed" 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) { return when (this) {
null -> "Unknown"
SyncRequestState.IncrementalSyncIdle -> "Idle" SyncRequestState.IncrementalSyncIdle -> "Idle"
is SyncRequestState.IncrementalSyncParsing -> "Parsing ${this.rooms} room(s) ${this.toDevice} toDevice(s)" is SyncRequestState.IncrementalSyncParsing -> "Parsing ${this.rooms} room(s) ${this.toDevice} toDevice(s)"
SyncRequestState.IncrementalSyncError -> "Error" SyncRequestState.IncrementalSyncError -> "Error"

View File

@ -17,7 +17,8 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:minHeight="48dp" android:minHeight="48dp"
android:visibility="gone" /> android:visibility="gone"
tools:visibility="visible" />
<com.google.android.material.appbar.MaterialToolbar <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/roomToolbar" android:id="@+id/roomToolbar"
@ -38,9 +39,9 @@
</com.google.android.material.appbar.AppBarLayout> </com.google.android.material.appbar.AppBarLayout>
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/rootConstraintLayout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent">
android:id="@+id/rootConstraintLayout">
<im.vector.app.features.sync.widget.SyncStateView <im.vector.app.features.sync.widget.SyncStateView
android:id="@+id/syncStateView" android:id="@+id/syncStateView"
@ -75,7 +76,7 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
android:overScrollMode="always" android:overScrollMode="always"
app:layout_constraintBottom_toTopOf="@id/notificationAreaView" app:layout_constraintBottom_toTopOf="@id/failedMessagesWarningStub"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/removeJitsiWidgetView" app:layout_constraintTop_toBottomOf="@id/removeJitsiWidgetView"
@ -95,7 +96,19 @@
app:closeIcon="@drawable/ic_close_24dp" app:closeIcon="@drawable/ic_close_24dp"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/removeJitsiWidgetView" /> app:layout_constraintTop_toBottomOf="@id/removeJitsiWidgetView"
tools:visibility="visible" />
<ViewStub
android:id="@+id/failedMessagesWarningStub"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inflatedId="@+id/failedMessagesWarningStub"
android:layout="@layout/view_stub_failed_message_warning_layout"
app:layout_constraintBottom_toTopOf="@id/notificationAreaView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:layout_height="300dp" />
<im.vector.app.core.ui.views.NotificationAreaView <im.vector.app.core.ui.views.NotificationAreaView
android:id="@+id/notificationAreaView" android:id="@+id/notificationAreaView"
@ -107,17 +120,6 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
tools:visibility="visible" /> tools:visibility="visible" />
<ViewStub
android:id="@+id/failedMessagesWarningStub"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inflatedId="@+id/failedMessagesWarningStub"
android:layout="@layout/view_stub_failed_message_warning_layout"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:layout_height="300dp" />
<ViewStub <ViewStub
android:id="@+id/inviteViewStub" android:id="@+id/inviteViewStub"
android:layout_width="0dp" android:layout_width="0dp"
@ -208,8 +210,8 @@
android:id="@+id/composerContainer" android:id="@+id/composerContainer"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:translationZ="10dp"
android:background="@android:color/transparent" android:background="@android:color/transparent"
android:translationZ="10dp"
app:layout_behavior="im.vector.app.core.utils.ExpandingBottomSheetBehavior" /> app:layout_behavior="im.vector.app.core.utils.ExpandingBottomSheetBehavior" />
<FrameLayout <FrameLayout
@ -217,7 +219,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="bottom" android:layout_gravity="bottom"
android:visibility="visible" android:translationZ="10dp"
android:translationZ="10dp" /> android:visibility="visible" />
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -16,12 +16,15 @@
package im.vector.app.features.command package im.vector.app.features.command
import im.vector.app.test.fakes.FakeVectorPreferences
import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test import org.junit.Test
private const val A_SPACE_ID = "!my-space-id" private const val A_SPACE_ID = "!my-space-id"
class CommandParserTest { class CommandParserTest {
private val fakeVectorPreferences = FakeVectorPreferences()
@Test @Test
fun parseSlashCommandEmpty() { fun parseSlashCommandEmpty() {
test("/", ParsedCommand.ErrorEmptySlashCommand) test("/", ParsedCommand.ErrorEmptySlashCommand)
@ -70,7 +73,7 @@ class CommandParserTest {
} }
private fun test(message: String, expectedResult: ParsedCommand) { private fun test(message: String, expectedResult: ParsedCommand) {
val commandParser = CommandParser() val commandParser = CommandParser(fakeVectorPreferences.instance)
val result = commandParser.parseSlashCommand(message, null, false) val result = commandParser.parseSlashCommand(message, null, false)
result shouldBeEqualTo expectedResult result shouldBeEqualTo expectedResult
} }

View File

@ -17,127 +17,71 @@
package im.vector.app.features.home.room.detail.timeline.factory package im.vector.app.features.home.room.detail.timeline.factory
import im.vector.app.R 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.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.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.features.poll.PollViewState
import im.vector.app.test.fakes.FakeStringProvider 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.amshove.kluent.shouldBeEqualTo
import org.junit.Test 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.model.message.PollType
import org.matrix.android.sdk.api.session.room.send.SendState 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 { class PollItemViewStateFactoryTest {
private val fakeStringProvider = FakeStringProvider()
private val fakePollOptionViewStateFactory = mockk<PollOptionViewStateFactory>()
private val pollItemViewStateFactory = PollItemViewStateFactory(
stringProvider = fakeStringProvider.instance,
pollOptionViewStateFactory = fakePollOptionViewStateFactory,
)
@Test @Test
fun `given a sending poll state then poll is not votable and option states are PollSending`() { fun `given a sending poll state then poll is not votable and option states are PollSending`() {
val stringProvider = FakeStringProvider() // Given
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance)
val sendingPollInformationData = A_MESSAGE_INFORMATION_DATA.copy(sendState = SendState.SENDING) 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( val pollViewState = pollItemViewStateFactory.create(
pollContent = A_POLL_CONTENT, pollContent = A_POLL_CONTENT,
informationData = sendingPollInformationData, informationData = sendingPollInformationData,
) )
// Then
pollViewState shouldBeEqualTo PollViewState( pollViewState shouldBeEqualTo PollViewState(
question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "", 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, canVote = false,
optionViewStates = A_POLL_CONTENT.getBestPollCreationInfo()?.answers?.map { answer -> optionViewStates = optionViewStates,
PollOptionViewState.PollSending(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: ""
)
},
) )
verify { fakePollOptionViewStateFactory.createPollSendingOptions(A_POLL_CONTENT.getBestPollCreationInfo()) }
} }
@Test @Test
fun `given a sent poll state when poll is closed then poll is not votable and option states are Ended`() { fun `given a sent poll state when poll is closed then poll is not votable and option states are Ended`() {
val stringProvider = FakeStringProvider() // Given
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance)
val closedPollSummary = A_POLL_RESPONSE_DATA.copy(isClosed = true) val closedPollSummary = A_POLL_RESPONSE_DATA.copy(isClosed = true)
val closedPollInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = closedPollSummary) val closedPollInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = closedPollSummary)
val optionViewStates = listOf(
val pollViewState = pollItemViewStateFactory.create(
pollContent = A_POLL_CONTENT,
informationData = closedPollInformationData,
)
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( PollOptionViewState.PollEnded(
optionId = answer.id ?: "", optionId = "", optionAnswer = "", voteCount = 0, votePercentage = 0.0, isWinner = false
optionAnswer = answer.getBestAnswer() ?: "",
voteCount = 0,
votePercentage = 0.0,
isWinner = false
) )
},
) )
} every {
fakePollOptionViewStateFactory.createPollEndedOptions(
@Test A_POLL_CONTENT.getBestPollCreationInfo(),
fun `given a sent poll state with some decryption error when poll is closed then warning message is displayed`() { closedPollInformationData.pollResponseAggregatedSummary,
// Given )
val stringProvider = FakeStringProvider() } returns optionViewStates
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)
// When // When
val pollViewState = pollItemViewStateFactory.create( val pollViewState = pollItemViewStateFactory.create(
@ -146,42 +90,90 @@ class PollItemViewStateFactoryTest {
) )
// Then // 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 @Test
fun `given a sent poll when undisclosed poll type is selected then poll is votable and option states are PollUndisclosed`() { fun `given a sent poll when undisclosed poll type is selected then poll is votable and option states are PollUndisclosed`() {
val stringProvider = FakeStringProvider() // Given
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance) 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( val pollViewState = pollItemViewStateFactory.create(
pollContent = A_POLL_CONTENT, pollContent = A_POLL_CONTENT,
informationData = A_MESSAGE_INFORMATION_DATA, informationData = A_MESSAGE_INFORMATION_DATA,
) )
// Then
pollViewState shouldBeEqualTo PollViewState( pollViewState shouldBeEqualTo PollViewState(
question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "", 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, canVote = true,
optionViewStates = A_POLL_CONTENT.getBestPollCreationInfo()?.answers?.map { answer -> optionViewStates = optionViewStates,
PollOptionViewState.PollUndisclosed(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: "",
isSelected = false
) )
}, verify {
fakePollOptionViewStateFactory.createPollUndisclosedOptions(
A_POLL_CONTENT.getBestPollCreationInfo(),
A_MESSAGE_INFORMATION_DATA.pollResponseAggregatedSummary,
) )
} }
}
@Test @Test
fun `given a sent poll when my vote exists then poll is still votable and options states are PollVoted`() { fun `given a sent poll when my vote exists then poll is still votable and options states are PollVoted`() {
val stringProvider = FakeStringProvider() // Given
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance)
val votedPollData = A_POLL_RESPONSE_DATA.copy( val votedPollData = A_POLL_RESPONSE_DATA.copy(
totalVotes = 1, totalVotes = 1, myVote = A_POLL_OPTION_IDS[0], votes = mapOf(A_POLL_OPTION_IDS[0] to PollVoteSummaryData(total = 1, percentage = 1.0))
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( val disclosedPollContent = A_POLL_CONTENT.copy(
unstablePollCreationInfo = A_POLL_CONTENT.getBestPollCreationInfo()?.copy( unstablePollCreationInfo = A_POLL_CONTENT.getBestPollCreationInfo()?.copy(
@ -189,33 +181,46 @@ class PollItemViewStateFactoryTest {
), ),
) )
val votedInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = votedPollData) 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( val pollViewState = pollItemViewStateFactory.create(
pollContent = disclosedPollContent, pollContent = disclosedPollContent,
informationData = votedInformationData, informationData = votedInformationData,
) )
// Then
pollViewState shouldBeEqualTo PollViewState( pollViewState shouldBeEqualTo PollViewState(
question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "", 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, canVote = true,
optionViewStates = A_POLL_CONTENT.getBestPollCreationInfo()?.answers?.mapIndexed { index, answer -> optionViewStates = optionViewStates,
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
) )
}, verify {
fakePollOptionViewStateFactory.createPollVotedOptions(
disclosedPollContent.getBestPollCreationInfo(),
votedInformationData.pollResponseAggregatedSummary,
) )
} }
}
@Test @Test
fun `given a sent poll with decryption failure when my vote exists then a warning message is displayed`() { fun `given a sent poll with decryption failure when my vote exists then a warning message is displayed`() {
// Given // Given
val stringProvider = FakeStringProvider()
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance)
val votedPollData = A_POLL_RESPONSE_DATA.copy( val votedPollData = A_POLL_RESPONSE_DATA.copy(
totalVotes = 1, totalVotes = 1,
myVote = A_POLL_OPTION_IDS[0], myVote = A_POLL_OPTION_IDS[0],
@ -228,6 +233,21 @@ class PollItemViewStateFactoryTest {
), ),
) )
val votedInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = votedPollData) 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 // When
val pollViewState = pollItemViewStateFactory.create( val pollViewState = pollItemViewStateFactory.create(
@ -236,34 +256,46 @@ class PollItemViewStateFactoryTest {
) )
// Then // 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 @Test
fun `given a sent poll when poll type is disclosed then poll is votable and option view states are PollReady`() { fun `given a sent poll when poll type is disclosed then poll is votable and option view states are PollReady`() {
val stringProvider = FakeStringProvider() // Given
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance)
val disclosedPollContent = A_POLL_CONTENT.copy( val disclosedPollContent = A_POLL_CONTENT.copy(
unstablePollCreationInfo = A_POLL_CONTENT.getBestPollCreationInfo()?.copy( unstablePollCreationInfo = A_POLL_CONTENT.getBestPollCreationInfo()?.copy(
kind = PollType.DISCLOSED_UNSTABLE kind = PollType.DISCLOSED_UNSTABLE
) )
) )
val optionViewStates = listOf(
PollOptionViewState.PollReady(
optionId = "",
optionAnswer = "",
)
)
every {
fakePollOptionViewStateFactory.createPollReadyOptions(
disclosedPollContent.getBestPollCreationInfo(),
)
} returns optionViewStates
// When
val pollViewState = pollItemViewStateFactory.create( val pollViewState = pollItemViewStateFactory.create(
pollContent = disclosedPollContent, pollContent = disclosedPollContent,
informationData = A_MESSAGE_INFORMATION_DATA, informationData = A_MESSAGE_INFORMATION_DATA,
) )
// Then
pollViewState shouldBeEqualTo PollViewState( pollViewState shouldBeEqualTo PollViewState(
question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "", 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, canVote = true,
optionViewStates = A_POLL_CONTENT.getBestPollCreationInfo()?.answers?.map { answer -> optionViewStates = optionViewStates,
PollOptionViewState.PollReady(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: ""
) )
}, verify {
fakePollOptionViewStateFactory.createPollReadyOptions(
disclosedPollContent.getBestPollCreationInfo(),
) )
} }
} }
}

View File

@ -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
}
}

View File

@ -17,23 +17,26 @@
package im.vector.app.features.roomprofile.polls package im.vector.app.features.roomprofile.polls
import com.airbnb.mvrx.test.MavericksTestRule 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.DisposePollHistoryUseCase
import im.vector.app.features.roomprofile.polls.list.domain.GetLoadedPollsStatusUseCase
import im.vector.app.features.roomprofile.polls.list.domain.GetPollsUseCase 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.LoadMorePollsUseCase
import im.vector.app.features.roomprofile.polls.list.domain.SyncPollsUseCase 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.PollSummary
import im.vector.app.features.roomprofile.polls.list.ui.PollSummaryMapper
import im.vector.app.test.test import im.vector.app.test.test
import im.vector.app.test.testDispatcher import im.vector.app.test.testDispatcher
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coJustRun
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.justRun
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import org.junit.Rule import org.junit.Rule
import org.junit.Test 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" private const val A_ROOM_ID = "room-id"
@ -42,33 +45,37 @@ class RoomPollsViewModelTest {
@get:Rule @get:Rule
val mavericksTestRule = MavericksTestRule(testDispatcher = testDispatcher) val mavericksTestRule = MavericksTestRule(testDispatcher = testDispatcher)
private val initialState = RoomPollsViewState(A_ROOM_ID)
private val fakeGetPollsUseCase = mockk<GetPollsUseCase>() private val fakeGetPollsUseCase = mockk<GetPollsUseCase>()
private val fakeGetLoadedPollsStatusUseCase = mockk<GetLoadedPollsStatusUseCase>()
private val fakeLoadMorePollsUseCase = mockk<LoadMorePollsUseCase>() private val fakeLoadMorePollsUseCase = mockk<LoadMorePollsUseCase>()
private val fakeSyncPollsUseCase = mockk<SyncPollsUseCase>() private val fakeSyncPollsUseCase = mockk<SyncPollsUseCase>()
private val initialState = RoomPollsViewState(A_ROOM_ID) private val fakeDisposePollHistoryUseCase = mockk<DisposePollHistoryUseCase>()
private val fakePollSummaryMapper = mockk<PollSummaryMapper>()
private fun createViewModel(): RoomPollsViewModel { private fun createViewModel(): RoomPollsViewModel {
return RoomPollsViewModel( return RoomPollsViewModel(
initialState = initialState, initialState = initialState,
getPollsUseCase = fakeGetPollsUseCase, getPollsUseCase = fakeGetPollsUseCase,
getLoadedPollsStatusUseCase = fakeGetLoadedPollsStatusUseCase,
loadMorePollsUseCase = fakeLoadMorePollsUseCase, loadMorePollsUseCase = fakeLoadMorePollsUseCase,
syncPollsUseCase = fakeSyncPollsUseCase, syncPollsUseCase = fakeSyncPollsUseCase,
disposePollHistoryUseCase = fakeDisposePollHistoryUseCase,
pollSummaryMapper = fakePollSummaryMapper,
) )
} }
@Test @Test
fun `given viewModel when created then polls list is observed, sync is launched and viewState is updated`() { fun `given viewModel when created then polls list is observed, sync is launched and viewState is updated`() {
// Given // Given
val loadedPollsStatus = givenGetLoadedPollsStatusSuccess() val loadedPollsStatus = givenSyncPollsWithSuccess()
givenSyncPollsWithSuccess() val aPollEvent = givenAPollEvent()
val polls = listOf(givenAPollSummary()) val aPollSummary = givenAPollSummary()
val polls = listOf(aPollEvent)
every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns flowOf(polls) every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns flowOf(polls)
every { fakePollSummaryMapper.map(aPollEvent) } returns aPollSummary
val expectedViewState = initialState.copy( val expectedViewState = initialState.copy(
polls = polls, polls = listOf(aPollSummary),
canLoadMore = loadedPollsStatus.canLoadMore, canLoadMore = loadedPollsStatus.canLoadMore,
nbLoadedDays = loadedPollsStatus.nbLoadedDays, nbSyncedDays = loadedPollsStatus.daysSynced,
) )
// When // When
@ -81,6 +88,7 @@ class RoomPollsViewModelTest {
.finish() .finish()
verify { verify {
fakeGetPollsUseCase.execute(A_ROOM_ID) fakeGetPollsUseCase.execute(A_ROOM_ID)
fakePollSummaryMapper.map(aPollEvent)
} }
coVerify { fakeSyncPollsUseCase.execute(A_ROOM_ID) } coVerify { fakeSyncPollsUseCase.execute(A_ROOM_ID) }
} }
@ -88,10 +96,8 @@ class RoomPollsViewModelTest {
@Test @Test
fun `given viewModel and error during sync process when created then error is raised in view event`() { fun `given viewModel and error during sync process when created then error is raised in view event`() {
// Given // Given
givenGetLoadedPollsStatusSuccess()
givenSyncPollsWithError(Exception()) givenSyncPollsWithError(Exception())
val polls = listOf(givenAPollSummary()) every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns emptyFlow()
every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns flowOf(polls)
// When // When
val viewModel = createViewModel() val viewModel = createViewModel()
@ -104,19 +110,32 @@ class RoomPollsViewModelTest {
coVerify { fakeSyncPollsUseCase.execute(A_ROOM_ID) } 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 @Test
fun `given viewModel when handle load more action then viewState is updated`() { fun `given viewModel when handle load more action then viewState is updated`() {
// Given // Given
val loadedPollsStatus = givenGetLoadedPollsStatusSuccess() val loadedPollsStatus = givenSyncPollsWithSuccess()
givenSyncPollsWithSuccess() every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns emptyFlow()
val polls = listOf(givenAPollSummary())
every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns flowOf(polls)
val newLoadedPollsStatus = givenLoadMoreWithSuccess() val newLoadedPollsStatus = givenLoadMoreWithSuccess()
val viewModel = createViewModel() val viewModel = createViewModel()
val stateAfterInit = initialState.copy( val stateAfterInit = initialState.copy(
polls = polls, polls = emptyList(),
canLoadMore = loadedPollsStatus.canLoadMore, canLoadMore = loadedPollsStatus.canLoadMore,
nbLoadedDays = loadedPollsStatus.nbLoadedDays, nbSyncedDays = loadedPollsStatus.daysSynced,
) )
// When // When
@ -128,7 +147,7 @@ class RoomPollsViewModelTest {
.assertStatesChanges( .assertStatesChanges(
stateAfterInit, stateAfterInit,
{ copy(isLoadingMore = true) }, { copy(isLoadingMore = true) },
{ copy(canLoadMore = newLoadedPollsStatus.canLoadMore, nbLoadedDays = newLoadedPollsStatus.nbLoadedDays) }, { copy(canLoadMore = newLoadedPollsStatus.canLoadMore, nbSyncedDays = newLoadedPollsStatus.daysSynced) },
{ copy(isLoadingMore = false) }, { copy(isLoadingMore = false) },
) )
.finish() .finish()
@ -139,8 +158,14 @@ class RoomPollsViewModelTest {
return mockk() return mockk()
} }
private fun givenSyncPollsWithSuccess() { private fun givenAPollEvent(): TimelineEvent {
coJustRun { fakeSyncPollsUseCase.execute(A_ROOM_ID) } return mockk()
}
private fun givenSyncPollsWithSuccess(): LoadedPollsStatus {
val loadedPollsStatus = givenALoadedPollsStatus()
coEvery { fakeSyncPollsUseCase.execute(A_ROOM_ID) } returns loadedPollsStatus
return loadedPollsStatus
} }
private fun givenSyncPollsWithError(error: Exception) { private fun givenSyncPollsWithError(error: Exception) {
@ -148,20 +173,15 @@ class RoomPollsViewModelTest {
} }
private fun givenLoadMoreWithSuccess(): LoadedPollsStatus { 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 coEvery { fakeLoadMorePollsUseCase.execute(A_ROOM_ID) } returns loadedPollsStatus
return loadedPollsStatus return loadedPollsStatus
} }
private fun givenGetLoadedPollsStatusSuccess(): LoadedPollsStatus { private fun givenALoadedPollsStatus(canLoadMore: Boolean = true, nbSyncedDays: Int = 10) =
val loadedPollsStatus = givenALoadedPollsStatus()
every { fakeGetLoadedPollsStatusUseCase.execute(A_ROOM_ID) } returns loadedPollsStatus
return loadedPollsStatus
}
private fun givenALoadedPollsStatus(canLoadMore: Boolean = true, nbLoadedDays: Int = 10) =
LoadedPollsStatus( LoadedPollsStatus(
canLoadMore = canLoadMore, canLoadMore = canLoadMore,
nbLoadedDays = nbLoadedDays, daysSynced = nbSyncedDays,
hasCompletedASyncBackward = false,
) )
} }

View File

@ -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<TimelineEvent>()
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,
)
}

View File

@ -16,10 +16,11 @@
package im.vector.app.features.roomprofile.polls.list.data 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.coJustRun
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.justRun
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
@ -27,6 +28,8 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test 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" private const val A_ROOM_ID = "room-id"
@ -38,10 +41,22 @@ class RoomPollRepositoryTest {
roomPollDataSource = fakeRoomPollDataSource, 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 @Test
fun `given data source when getting polls then correct method of data source is called`() = runTest { fun `given data source when getting polls then correct method of data source is called`() = runTest {
// Given // Given
val expectedPolls = listOf<PollSummary>() val expectedPolls = listOf<TimelineEvent>()
every { fakeRoomPollDataSource.getPolls(A_ROOM_ID) } returns flowOf(expectedPolls) every { fakeRoomPollDataSource.getPolls(A_ROOM_ID) } returns flowOf(expectedPolls)
// When // When
@ -53,20 +68,21 @@ class RoomPollRepositoryTest {
} }
@Test @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 // Given
val expectedStatus = LoadedPollsStatus( val expectedStatus = LoadedPollsStatus(
canLoadMore = true, 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 // When
val result = roomPollRepository.getLoadedPollsStatus(A_ROOM_ID) val result = roomPollRepository.getLoadedPollsStatus(A_ROOM_ID)
// Then // Then
result shouldBeEqualTo expectedStatus result shouldBeEqualTo expectedStatus
verify { fakeRoomPollDataSource.getLoadedPollsStatus(A_ROOM_ID) } coVerify { fakeRoomPollDataSource.getLoadedPollsStatus(A_ROOM_ID) }
} }
@Test @Test

View File

@ -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<RoomPollRepository>()
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) }
}
}

View File

@ -16,13 +16,14 @@
package im.vector.app.features.roomprofile.polls.list.domain 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 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.mockk
import io.mockk.verify import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test import org.junit.Test
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
class GetLoadedPollsStatusUseCaseTest { class GetLoadedPollsStatusUseCaseTest {
@ -33,20 +34,21 @@ class GetLoadedPollsStatusUseCaseTest {
) )
@Test @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 // Given
val aRoomId = "roomId" val aRoomId = "roomId"
val expectedStatus = LoadedPollsStatus( val expectedStatus = LoadedPollsStatus(
canLoadMore = true, canLoadMore = true,
nbLoadedDays = 10, daysSynced = 10,
hasCompletedASyncBackward = true,
) )
every { fakeRoomPollRepository.getLoadedPollsStatus(aRoomId) } returns expectedStatus coEvery { fakeRoomPollRepository.getLoadedPollsStatus(aRoomId) } returns expectedStatus
// When // When
val status = getLoadedPollsStatusUseCase.execute(aRoomId) val status = getLoadedPollsStatusUseCase.execute(aRoomId)
// Then // Then
status shouldBeEqualTo expectedStatus status shouldBeEqualTo expectedStatus
verify { fakeRoomPollRepository.getLoadedPollsStatus(aRoomId) } coVerify { fakeRoomPollRepository.getLoadedPollsStatus(aRoomId) }
} }
} }

View File

@ -17,8 +17,6 @@
package im.vector.app.features.roomprofile.polls.list.domain 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.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.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
@ -27,6 +25,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test import org.junit.Test
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
class GetPollsUseCaseTest { class GetPollsUseCaseTest {
private val fakeRoomPollRepository = mockk<RoomPollRepository>() private val fakeRoomPollRepository = mockk<RoomPollRepository>()
@ -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 { fun `given repo when execute then correct method of repo is called and polls are sorted most recent first`() = runTest {
// Given // Given
val aRoomId = "roomId" val aRoomId = "roomId"
val poll1 = RoomPollFixture.anActivePollSummary(timestamp = 1) val poll1 = givenTimelineEvent(timestamp = 1)
val poll2 = RoomPollFixture.anActivePollSummary(timestamp = 2) val poll2 = givenTimelineEvent(timestamp = 2)
val poll3 = RoomPollFixture.anActivePollSummary(timestamp = 3) val poll3 = givenTimelineEvent(timestamp = 3)
val polls = listOf<PollSummary>( val polls = listOf(
poll1, poll1,
poll2, poll2,
poll3, poll3,
) )
every { fakeRoomPollRepository.getPolls(aRoomId) } returns flowOf(polls) every { fakeRoomPollRepository.getPolls(aRoomId) } returns flowOf(polls)
val expectedPolls = listOf<PollSummary>( val expectedPolls = listOf(
poll3, poll3,
poll2, poll2,
poll1, poll1,
@ -60,4 +59,10 @@ class GetPollsUseCaseTest {
result shouldBeEqualTo expectedPolls result shouldBeEqualTo expectedPolls
verify { fakeRoomPollRepository.getPolls(aRoomId) } verify { fakeRoomPollRepository.getPolls(aRoomId) }
} }
private fun givenTimelineEvent(timestamp: Long): TimelineEvent {
return mockk<TimelineEvent>().also {
every { it.root.originServerTs } returns timestamp
}
}
} }

View File

@ -17,11 +17,13 @@
package im.vector.app.features.roomprofile.polls.list.domain 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.data.RoomPollRepository
import io.mockk.coJustRun import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test import org.junit.Test
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
class LoadMorePollsUseCaseTest { class LoadMorePollsUseCaseTest {
@ -35,12 +37,18 @@ class LoadMorePollsUseCaseTest {
fun `given repo when execute then correct method of repo is called`() = runTest { fun `given repo when execute then correct method of repo is called`() = runTest {
// Given // Given
val aRoomId = "roomId" val aRoomId = "roomId"
coJustRun { fakeRoomPollRepository.loadMorePolls(aRoomId) } val loadedPollsStatus = LoadedPollsStatus(
canLoadMore = true,
daysSynced = 10,
hasCompletedASyncBackward = true,
)
coEvery { fakeRoomPollRepository.loadMorePolls(aRoomId) } returns loadedPollsStatus
// When // When
loadMorePollsUseCase.execute(aRoomId) val result = loadMorePollsUseCase.execute(aRoomId)
// Then // Then
result shouldBeEqualTo loadedPollsStatus
coVerify { fakeRoomPollRepository.loadMorePolls(aRoomId) } coVerify { fakeRoomPollRepository.loadMorePolls(aRoomId) }
} }
} }

View File

@ -17,30 +17,81 @@
package im.vector.app.features.roomprofile.polls.list.domain 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.data.RoomPollRepository
import io.mockk.coEvery
import io.mockk.coJustRun import io.mockk.coJustRun
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test import org.junit.Test
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
class SyncPollsUseCaseTest { class SyncPollsUseCaseTest {
private val fakeRoomPollRepository = mockk<RoomPollRepository>() private val fakeRoomPollRepository = mockk<RoomPollRepository>()
private val fakeGetLoadedPollsStatusUseCase = mockk<GetLoadedPollsStatusUseCase>()
private val fakeLoadMorePollsUseCase = mockk<LoadMorePollsUseCase>()
private val syncPollsUseCase = SyncPollsUseCase( private val syncPollsUseCase = SyncPollsUseCase(
roomPollRepository = fakeRoomPollRepository, roomPollRepository = fakeRoomPollRepository,
getLoadedPollsStatusUseCase = fakeGetLoadedPollsStatusUseCase,
loadMorePollsUseCase = fakeLoadMorePollsUseCase,
) )
@Test @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 // Given
val aRoomId = "roomId" val aRoomId = "roomId"
val aLoadedStatus = LoadedPollsStatus(
canLoadMore = true,
daysSynced = 10,
hasCompletedASyncBackward = true,
)
coJustRun { fakeRoomPollRepository.syncPolls(aRoomId) } coJustRun { fakeRoomPollRepository.syncPolls(aRoomId) }
coEvery { fakeGetLoadedPollsStatusUseCase.execute(aRoomId) } returns aLoadedStatus
// When // When
syncPollsUseCase.execute(aRoomId) val result = syncPollsUseCase.execute(aRoomId)
// Then // 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)
}
} }
} }

View File

@ -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<PollResponseDataFactory>()
private val fakePollOptionViewStateFactory = mockk<PollOptionViewStateFactory>()
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<PollOptionViewState.PollEnded>()
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<MessageTextContent>()
)
// 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<TimelineEvent>()
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<PollOptionViewState.PollEnded> = 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,
)
}
}

View File

@ -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<TimelineEvent>): LiveData<List<TimelineEvent>> {
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() }
}
}

View File

@ -25,6 +25,7 @@ class FakeRoom(
private val fakeTimelineService: FakeTimelineService = FakeTimelineService(), private val fakeTimelineService: FakeTimelineService = FakeTimelineService(),
private val fakeRelationService: FakeRelationService = FakeRelationService(), private val fakeRelationService: FakeRelationService = FakeRelationService(),
private val fakeStateService: FakeStateService = FakeStateService(), private val fakeStateService: FakeStateService = FakeStateService(),
private val fakePollHistoryService: FakePollHistoryService = FakePollHistoryService(),
) : Room by mockk() { ) : Room by mockk() {
override fun locationSharingService() = fakeLocationSharingService override fun locationSharingService() = fakeLocationSharingService
@ -36,4 +37,6 @@ class FakeRoom(
override fun relationService() = fakeRelationService override fun relationService() = fakeRelationService
override fun stateService() = fakeStateService override fun stateService() = fakeStateService
override fun pollHistoryService() = fakePollHistoryService
} }

View File

@ -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"
),
)
)
)
}

View File

@ -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<PollOptionViewState.PollEnded>
) = PollSummary.EndedPoll(
id = id,
creationTimestamp = timestamp,
title = title,
totalVotes = totalVotes,
winnerOptions = winnerOptions,
)
}