desktop: run calls in native call engine

This commit is contained in:
shum committed 2026-10-22 12:26:17 +00:00
1 parent c823ed5df2
commit 6e5327bd45
53 files changed
+1311 -927

No files matched your search

@@ -89,14 +89,12 @@ enum class CallState {
@Serializable
sealed class WCallCommand {
@Serializable @SerialName("capabilities") data class Capabilities(val media: CallMediaType): WCallCommand()
@Serializable @SerialName("permission") data class Permission(val title: String, val chrome: String, val safari: String): WCallCommand()
@Serializable @SerialName("start") data class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
@Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
@Serializable @SerialName("answer") data class Answer (val answer: String, val iceCandidates: String): WCallCommand()
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String, val session: String? = null): WCallCommand()
@Serializable @SerialName("media") data class Media(val source: CallMediaSource, val enable: Boolean): WCallCommand()
@Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand()
@Serializable @SerialName("description") data class Description(val state: String, val description: String): WCallCommand()
@Serializable @SerialName("layout") data class Layout(val layout: LayoutType): WCallCommand()
@Serializable @SerialName("end") object End: WCallCommand()
}
@@ -1548,8 +1548,6 @@
<string name="call_service_notification_video_call">مكالمة فيديو</string>
<string name="call_service_notification_audio_call">مكالمة صوتية</string>
<string name="call_service_notification_end_call">أنهيّ المكالمة</string>
<string name="unable_to_open_browser_desc">متصفح الويب الافتراضي مطلوب للمكالمات. يُرجى تضبيط المتصفح الافتراضي في النظام، ومشاركة المزيد من المعلومات مع المطوِّرين.</string>
<string name="unable_to_open_browser_title">حدث خطأ أثناء فتح المتصفح</string>
<string name="migrate_from_device_archive_and_upload">أرشف وأرفع</string>
<string name="v5_6_safer_groups_descr">يمكن للمُدراء حظر عضو للجميع.</string>
<string name="v5_6_app_data_migration">ترحيل بيانات التطبيق</string>
@@ -2010,8 +2008,6 @@
<string name="network_session_mode_server">الخادم</string>
<string name="network_session_mode_session_description">سيتم استخدام بيانات اعتماد SOCKS الجديدة في كل مرة تبدأ فيها تشغيل التطبيق.</string>
<string name="network_session_mode_server_description">سيتم استخدام بيانات اعتماد SOCKS الجديدة لكل خادم.</string>
<string name="call_desktop_permission_denied_chrome">انقر فوق زر المعلومات الموجود بالقرب من حقل العنوان للسماح باستخدام الميكروفون.</string>
<string name="call_desktop_permission_denied_safari">افتح إعدادات Safari / مواقع الويب / الميكروفون، ثم اختر السماح لـ localhost.</string>
<string name="call_desktop_permission_denied_title">لإجراء مكالمات، اسمح باستخدام الميكروفون. أنهِ المكالمة وحاول الاتصال مرة أخرى.</string>
<string name="v6_1_better_user_experience">تجربة مستخدم أفضل</string>
<string name="v6_1_customizable_message_descr">شكل الرسالة قابل للتخصيص.</string>
@@ -1306,8 +1306,6 @@
<string name="callstate_ended">ended</string>
<!-- CallView -->
<string name="unable_to_open_browser_title">Error opening browser</string>
<string name="unable_to_open_browser_desc">The default web browser is required for calls. Please configure the default browser in the system, and share more information with the developers.</string>
<string name="permissions_required">Grant permissions</string>
<string name="permissions_record_audio">Microphone</string>
<string name="permissions_camera">Camera</string>
@@ -1409,8 +1407,8 @@
<string name="icon_descr_video_call">video call</string>
<string name="icon_descr_audio_call">audio call</string>
<string name="call_desktop_permission_denied_title">To make calls, allow to use your microphone. End the call and try to call again.</string>
<string name="call_desktop_permission_denied_chrome">Click info button near address field to allow using microphone.</string>
<string name="call_desktop_permission_denied_safari">Open Safari Settings / Websites / Microphone, then choose Allow for localhost.</string>
<string name="call_desktop_engine_error">Calls are not available</string>
<string name="call_screen_share_screen">Screen %d</string>
<!-- Call settings -->
<string name="settings_audio_video_calls">Audio &amp; video calls</string>
@@ -1446,6 +1444,8 @@
<string name="icon_descr_audio_on">Audio on</string>
<string name="icon_descr_speaker_off">Speaker off</string>
<string name="icon_descr_speaker_on">Speaker on</string>
<string name="icon_descr_screen_share_on">Share screen</string>
<string name="icon_descr_screen_share_off">Stop sharing screen</string>
<string name="icon_descr_sound_muted">Sound muted</string>
<string name="icon_descr_flip_camera">Flip camera</string>
@@ -1559,8 +1559,6 @@
<string name="database_migration_in_progress">Извършва се миграция на базата данни.
\nМоже да отнеме няколко минути.</string>
<string name="call_service_notification_audio_call">Аудио разговор</string>
<string name="unable_to_open_browser_title">Грешка при отваряне на браузъра</string>
<string name="unable_to_open_browser_desc">За разговори е необходим уеб браузър по подразбиране. Моля, конфигурирайте браузъра по подразбиране в системните настройки и споделете повече информация с разработчиците.</string>
<string name="call_service_notification_video_call">Видео разговор</string>
<string name="call_service_notification_end_call">Край на разговора</string>
<string name="e2ee_info_pq"><![CDATA[Съобщенията, файловете и разговорите са защитени чрез <b>квантово устойчиво e2e криптиране</b> с перфектна секретност при препращане, правдоподобно опровержение и възстановяване при взлом.]]></string>
@@ -2017,8 +2015,6 @@
<string name="migrate_from_device_remove_archive_question">Премахни архив?</string>
<string name="migrate_from_device_uploaded_archive_will_be_removed">Каченият архив на базата данни ще бъде окончателно премахнат от сървърите.</string>
<string name="call_desktop_permission_denied_title">За да извършвате обаждания, разрешете да използва микрофона. Прекратете разговора и опитайте да се обадите отново.</string>
<string name="call_desktop_permission_denied_chrome">Натиснете бутона за информация близо до адресното поле, за да разрешите използването на микрофона.</string>
<string name="call_desktop_permission_denied_safari">Отворете Safari Настройки / Уеб страници / Микрофон, след което изберете Позволи за localhost.</string>
<string name="v6_0_new_chat_experience">Нов интерфейс 🎉</string>
<string name="v6_0_new_media_options">Нови медийни опции</string>
<string name="v6_1_better_user_experience">Подобрен интерфейс</string>
@@ -621,7 +621,6 @@
<string name="servers_info_reconnect_servers_error">Error en reconnectar els servidors</string>
<string name="enter_welcome_message_optional">Introduïu el missatge de benvinguda… (opcional)</string>
<string name="display_name">Introduïu el vostre nom:</string>
<string name="unable_to_open_browser_title">Error en obrir el navegador</string>
<string name="error_exporting_chat_database">Error en exportar la base de dades de xat</string>
<string name="error_deleting_database">S\'ha produït un error en suprimir la base de dades de xat</string>
<string name="error_importing_database">Error en importar la base de dades de xat</string>
@@ -637,7 +636,6 @@
<string name="chunks_uploaded">Blocs penjats</string>
<string name="delete_chat_for_all_members_cannot_undo_warning">El xat se suprimirà per a tots els membres; això no es pot desfer!</string>
<string name="colored_text">acolorit</string>
<string name="call_desktop_permission_denied_chrome">Feu clic al botó d\'informació al costat del camp d\'adreça per permetre l\'ús del micròfon.</string>
<string name="completed">Completades</string>
<string name="error_accepting_operator_conditions">Error en acceptar les condicions</string>
<string name="smp_proxy_error_connecting">Error en connectar-se al servidor de reenviament %1$s. Si us plau, prova-ho més tard.</string>
@@ -1140,7 +1138,6 @@
<string name="v5_7_network_descr">Connexió de xarxa més fiable.</string>
<string name="new_mobile_device">Nou dispositiu mòbil</string>
<string name="v6_2_improved_chat_navigation_descr">- El xat s\'obre amb al primer missatge no llegit.\n- Desplaçaments fins als missatges citats.</string>
<string name="call_desktop_permission_denied_safari">Obriu Configuració de Safari / Llocs web / Micròfon i, a continuació, trieu Permetre localhost.</string>
<string name="operator_server_alert_title">Servidor de l\'operador</string>
<string name="or_paste_archive_link">O enganxeu l\'enllaç de l\'arxiu</string>
<string name="migrate_from_device_or_share_this_file_link">O compartiu aquest enllaç de fitxer de manera segura</string>
@@ -1177,7 +1174,6 @@
<string name="callstate_waiting_for_answer">esperant resposta…</string>
<string name="callstate_waiting_for_confirmation">esperant confirmació…</string>
<string name="callstate_received_confirmation">confirmació rebuda…</string>
<string name="unable_to_open_browser_desc">Un navegador web predeterminat és necessari per a les trucades. Configura\'n un al sistema i comparteix més informació amb els desenvolupadors.</string>
<string name="onboarding_notifications_mode_title">Notificacions privades</string>
<string name="onboarding_notifications_mode_subtitle">Com afecta la bateria</string>
<string name="onboarding_notifications_mode_periodic">Periòdic</string>
@@ -1533,7 +1533,6 @@
\nDoporučuje se restartovat aplikaci.</string>
<string name="restart_chat_button">Restartovat chat</string>
<string name="remote_host_error_disconnected"><![CDATA[Mobilní <b>%s</b> byl odpojen]]></string>
<string name="unable_to_open_browser_title">Chyba otevření prohlížeče</string>
<string name="profile_update_event_removed_picture">odstraněn profilový obrázek</string>
<string name="profile_update_event_set_new_address">nastavil novou kontaktní adresu</string>
<string name="profile_update_event_set_new_picture">nastavil nový profilový obrázek</string>
@@ -1554,7 +1553,6 @@
<string name="remote_host_error_timeout"><![CDATA[Vypršel čas při připojování k mobilnímu <b>%s</b>]]></string>
<string name="call_service_notification_video_call">Videohovor</string>
<string name="profile_update_event_updated_profile">profil aktualizován</string>
<string name="unable_to_open_browser_desc">Výchozí webový prohlížeč je vyžadován pro volání. Nakonfigurujte výchozí prohlížeč v systému a sdílejte s vývojáři více informací.</string>
<string name="rcv_group_event_member_unblocked">odblokované %s</string>
<string name="v5_5_private_notes_descr">S šifrovanými soubory a médii.</string>
<string name="remote_ctrl_error_timeout">Vypršel čas při připojování k desktopu</string>
@@ -1913,8 +1911,6 @@
<string name="settings_message_shape_corner">Roh</string>
<string name="settings_section_title_message_shape">Tvar zpráv</string>
<string name="chat_database_exported_continue">Pokračovat</string>
<string name="call_desktop_permission_denied_chrome">Klikněte na info tlačítko blízko pole adresy, pro použití mikrofonu.</string>
<string name="call_desktop_permission_denied_safari">Otevřete nastavení Safari / Webové stránky / mikrofon, vyberte možnost Povolit pro localhost.</string>
<string name="appearance_font_size">Velikost písma</string>
<string name="v6_0_upgrade_app_descr">Stáhnout nové verze z GitHubu.</string>
<string name="v6_0_reachable_chat_toolbar">Dosažitelný panel nástrojů chatu</string>
@@ -1639,8 +1639,6 @@
<string name="call_service_notification_audio_call">Audioanruf</string>
<string name="call_service_notification_end_call">Anruf beenden</string>
<string name="call_service_notification_video_call">Videoanruf</string>
<string name="unable_to_open_browser_title">Fehler beim Öffnen des Browsers</string>
<string name="unable_to_open_browser_desc">Für Anrufe ist ein Default-Webbrowser erforderlich. Bitte konfigurieren Sie einen Default-Browser für das System und teilen Sie den Entwicklern mehr Informationen dazu mit.</string>
<string name="migrate_to_device_chat_migrated">Chat wurde migriert!</string>
<string name="migrate_from_device_creating_archive_link">Archiv-Link erzeugen</string>
<string name="migrate_from_device_choose_migrate_from_another_device"><![CDATA[Wählen Sie auf dem neuen Gerät <i>Von einem anderen Gerät migrieren</i> und scannen Sie den QR-Code.]]></string>
@@ -2105,9 +2103,7 @@
<string name="network_session_mode_session">App-Sitzung</string>
<string name="network_session_mode_server_description">Für jeden Server werden neue SOCKS-Anmeldeinformationen genutzt</string>
<string name="network_session_mode_server">Server</string>
<string name="call_desktop_permission_denied_chrome">Klicken Sie auf die Info-Schaltfläche neben dem Adressfeld, um die Verwendung des Mikrofons zu erlauben.</string>
<string name="call_desktop_permission_denied_title">Um Anrufe durchzuführen, erlauben Sie die Nutzung Ihres Mikrofons. Beenden Sie den Anruf und versuchen Sie es erneut.</string>
<string name="call_desktop_permission_denied_safari">Öffnen Sie die Safari-Einstellungen / Webseiten / Mikrofon und wählen Sie dann \"Für Localhost erlauben\".</string>
<string name="v6_1_better_calls">Verbesserte Anrufe</string>
<string name="v6_1_customizable_message_descr">Anpassbares Format des Nachrichtenfelds</string>
<string name="v6_1_delete_many_messages_descr">Bis zu 200 Nachrichten löschen oder moderieren</string>
@@ -514,7 +514,6 @@
<string name="clear_chat_question">Καθαρισμός συνομιλίας;</string>
<string name="clear_note_folder_question">Καθαρισμός ιδιωτικών σημειώσεων;</string>
<string name="clear_verification">Καθαρισμός επαλήθευσης</string>
<string name="call_desktop_permission_denied_chrome">Κάνε κλικ στο κουμπί πληροφοριών δίπλα στο πεδίο διεύθυνσης για να επιτρέψεις τη χρήση του μικροφώνου.</string>
<string name="icon_descr_close_button">Κουμπί κλεισίματος</string>
<string name="colored_text">χρωματισμένο</string>
<string name="color_mode">Λειτουργία χρώματος</string>
@@ -1289,7 +1288,6 @@
<string name="connection_you_accepted_will_be_cancelled">Η σύνδεση που αποδέχθηκες θα ακυρωθεί!</string>
<string name="contact_you_shared_link_with_wont_be_able_to_connect">Η επαφή με την οποία μοιράστηκες αυτόν το σύνδεσμο, ΔΕΝ θα μπορεί να συνδεθεί!</string>
<string name="database_initialization_error_desc">Η βάση δεδομένων δεν λειτουργεί σωστά. Πάτησε για να μάθεις περισσότερα.</string>
<string name="unable_to_open_browser_desc">Για τις κλήσεις απαιτείται ο προεπιλεγμένος περιηγητής. Ρύθμισε τον προεπιλεγμένο περιηγητή στο σύστημα σου και μοιράσου περισσότερες πληροφορίες με τους προγραμματιστές.</string>
<string name="this_device_name_shared_with_mobile">Το όνομα της συσκευής θα κοινοποιηθεί στην εφαρμογή του συνδεδεμένου κινητού.</string>
<string name="sync_connection_force_desc">Η κρυπτογράφηση λειτουργεί και η νέα κρυπτογράφηση δεν είναι απαραίτητη. Μπορεί να προκαλέσει σφάλματα σύνδεσης!</string>
<string name="alert_text_msg_bad_hash">Ο κωδικός ελέγχου του προηγούμενου μηνύματος είναι διαφορετικός.</string>
@@ -1486,7 +1484,6 @@
<string name="error_loading_chat_tags">Σφάλμα κατά τη φόρτωση των λιστών συνομιλιών</string>
<string name="error_loading_details">Σφάλμα κατά τη φόρτωση των λεπτομερειών</string>
<string name="error_marking_member_support_chat_read">Σφάλμα επισήμανσης ως αναγνωσμένου</string>
<string name="unable_to_open_browser_title">Σφάλμα κατά το άνοιγμα του προγράμματος περιήγησης</string>
<string name="error_preparing_contact">Σφάλμα κατά το άνοιγμα της συνομιλίας</string>
<string name="error_preparing_group">Σφάλμα κατά το άνοιγμα της ομάδας</string>
<string name="error_reading_passphrase">Σφάλμα κατά την ανάγνωση της φράσης πρόσβασης της βάσης δεδομένων</string>
@@ -1705,7 +1702,6 @@
<string name="connect_plan_open_new_chat">Άνοιξε νέα συνομιλία</string>
<string name="connect_plan_open_new_group">Άνοιξε νέα ομάδα</string>
<string name="open_port_in_firewall_title">Άνοιγμα θύρας στο τείχος προστασίας</string>
<string name="call_desktop_permission_denied_safari">Άνοιξε τις Ρυθμίσεις Safari / Ιστοσελίδες / Μικρόφωνο και στη συνέχεια επέλεξε Να επιτρέπεται για το localhost.</string>
<string name="permissions_open_settings">Άνοιγμα ρυθμίσεων</string>
<string name="open_simplex_chat_to_accept_call">Άνοιξε το SimpleX Chat για να αποδεχθείς την κλήση</string>
<string name="open_to_accept">Άνοιξε για να αποδεχθείς</string>
@@ -1559,8 +1559,6 @@
<string name="call_service_notification_audio_call">Llamada</string>
<string name="call_service_notification_end_call">Llamada finalizada</string>
<string name="call_service_notification_video_call">Videollamada</string>
<string name="unable_to_open_browser_title">Error al abrir navegador</string>
<string name="unable_to_open_browser_desc">Para llamadas se requiere el navegador web predeterminado. Por favor, configura el navegador predeterminado en el sistema y comparte más información con los desarrolladores.</string>
<string name="migrate_from_device_archiving_database">Archivando base de datos</string>
<string name="migrate_from_device_all_data_will_be_uploaded">Todos tus contactos, conversaciones y archivos serán cifrados, divididos y subidos de forma segura a los servidores XFTP configurados.</string>
<string name="v5_6_safer_groups_descr">Los administradores pueden bloquear a un miembro para los demás</string>
@@ -2025,8 +2023,6 @@
<string name="settings_message_shape_tail">Cola</string>
<string name="network_session_mode_session_description">Se usarán credenciales SOCKS nuevas cada vez que inicies la aplicación.</string>
<string name="network_session_mode_session">por sesión</string>
<string name="call_desktop_permission_denied_safari">Abre la configuración de Safari / Sitios Web / Micrófono y a continuación selecciona Permitir para localhost.</string>
<string name="call_desktop_permission_denied_chrome">Pulsa el botón info del campo dirección para permitir el uso del micrófono.</string>
<string name="call_desktop_permission_denied_title">Para hacer llamadas, permite el uso del micrófono. Cuelga e intenta llamar de nuevo.</string>
<string name="network_session_mode_server_description">Se usarán credenciales SOCKS nuevas para cada servidor.</string>
<string name="network_session_mode_server">por servidor</string>
@@ -752,7 +752,6 @@
<string name="audio_device_speaker">بلندگو</string>
<string name="audio_device_wired_headphones">هدفون‌ها</string>
<string name="callstate_ended">پایان یافت</string>
<string name="unable_to_open_browser_title">خطا در باز کردن مرورگر</string>
<string name="set_password_to_export">عبارت عبور برای اکسپورت را تعیین کنید</string>
<string name="protect_app_screen">محافظت از صفحه برنامه</string>
<string name="settings_restart_app">شروع مجدد</string>
@@ -833,7 +832,6 @@
<string name="chat_database_section">پایگاه داده چت</string>
<string name="onboarding_notifications_mode_periodic_desc"><![CDATA[<b>گزینه خوب برای باتری</b>. سرویس پس‌زمینه هر ۱۰ دقیقه پیام‌ها را بررسی می‌کند. ممکن است تماس‌ها یا پیام‌های ضروری را از دست دهید.]]></string>
<string name="alert_title_skipped_messages">پیام‌های نادیده گرفته شده</string>
<string name="unable_to_open_browser_desc">مرورگر وب پیش‌فرض برای تماس‌ها لازم است. لطفا مرورگر پیش‌فرض را در سیستم تنظیم کنید، و اطلاعات بیشتر را با توسعه‌دهندگان به اشتراک بگذارید.</string>
<string name="relay_server_if_necessary">سرور واسط فقط در زمان نیاز مورد استفاده قرار می‌گیرد. طرف دیگری قادر به مشاهده نشانی IP شما خواهد بود.</string>
<string name="show_call_on_lock_screen">نمایش</string>
<string name="no_call_on_lock_screen">غیرفعال</string>
@@ -1909,7 +1907,6 @@
<string name="delete_chat_for_all_members_cannot_undo_warning">پیام برای همه اعضا حذف خواهد شد - این عمل قابل بازگشت نیست!</string>
<string name="chunks_deleted">بخش های حذف شده</string>
<string name="chunks_downloaded">بخش های دانلود شده</string>
<string name="call_desktop_permission_denied_chrome">برای مجوز میکروفون، روی آیکون اطلاعات کنار نوار آدرس کلیک کنید</string>
<string name="completed">تکمیل شد</string>
<string name="confirm_delete_contact_question">تایید حذف مخاطب؟</string>
<string name="compose_view_connect">اتصال</string>
@@ -1924,7 +1921,6 @@
<string name="connect_plan_open_chat">باز کردن چت</string>
<string name="connect_plan_open_new_chat">باز کردن چت جدید</string>
<string name="connect_plan_open_new_group">باز کردن گروه جدید</string>
<string name="call_desktop_permission_denied_safari">به منوی تنظیمات سافاری / وب‌سایت‌ها / و سپس میکروفن رفته و گزینه \"اجازه دادن برای localhost\" را انتخاب کنید.</string>
<string name="privacy_chat_list_open_web_link">باز کردن لینک</string>
<string name="privacy_chat_list_open_links">باز کردن لینک‌ها از لیست چت‌ها</string>
<string name="open_to_accept">باز کردن برای پذیرش</string>
@@ -1334,7 +1334,6 @@
<string name="migrate_from_device_check_connection_and_try_again">Tarkista internetyhteys ja yritä uudelleen</string>
<string name="multicast_connect_automatically">Yhdistä automaattisesti</string>
<string name="migrate_from_device_delete_database_from_device">Poista tietokanta tältä laitteelta</string>
<string name="unable_to_open_browser_title">Virhe selainta avatessa</string>
<string name="share_text_file_status">Tiedoston tila: %s</string>
<string name="expand_verb">Laajenna</string>
<string name="wallpaper_preview_hello_bob">Hyvää huomenta!</string>
@@ -1559,8 +1559,6 @@
<string name="call_service_notification_video_call">Appel vidéo</string>
<string name="call_service_notification_end_call">Fin de l\'appel</string>
<string name="call_service_notification_audio_call">Appel audio</string>
<string name="unable_to_open_browser_desc">Le navigateur web par défaut est requis pour les appels. Veuillez configurer le navigateur par défaut dans le système et partager plus d\'informations avec les développeurs.</string>
<string name="unable_to_open_browser_title">Erreur lors de l\'ouverture du navigateur</string>
<string name="migrate_from_device_confirm_you_remember_passphrase">Confirmer que vous vous souvenez de la phrase secrète de la base de données pour la transférer.</string>
<string name="v5_6_app_data_migration">Transfert des données de l\'application</string>
<string name="v5_6_safer_groups_descr">Les admins peuvent bloquer un membre pour tous.</string>
@@ -2015,7 +2013,6 @@
<string name="forward_alert_forward_messages_without_files">Transférer les messages sans les fichiers ?</string>
<string name="network_session_mode_session_description">De nouveaux identifiants SOCKS seront utilisés chaque fois que vous démarrerez l\'application.</string>
<string name="forward_alert_title_nothing_to_forward">Rien à transférer !</string>
<string name="call_desktop_permission_denied_safari">Ouvrez Safari Paramètres / Sites web / Microphone, puis choisissez Autoriser pour localhost.</string>
<string name="compose_save_messages_n">Sauvegarde de %1$s messages</string>
<string name="migrate_from_device_uploaded_archive_will_be_removed">L\'archive de la base de données envoyée sera définitivement supprimée des serveurs.</string>
<string name="network_proxy_auth_mode_isolate_by_auth_user">Utilisez des identifiants de proxy différents pour chaque profil.</string>
@@ -2031,7 +2028,6 @@
<string name="settings_message_shape_corner">Coin</string>
<string name="settings_section_title_message_shape">Forme du message</string>
<string name="settings_message_shape_tail">Queue</string>
<string name="call_desktop_permission_denied_chrome">Cliquez sur le bouton info près du champ d\'adresse pour autoriser l\'utilisation du microphone.</string>
<string name="call_desktop_permission_denied_title">Pour passer des appels, autorisez l\'utilisation de votre microphone. Mettez fin à l\'appel et essayez d\'appeler à nouveau.</string>
<string name="migrate_from_device_remove_archive_question">Supprimer l\'archive ?</string>
<string name="v6_1_better_calls">Appels améliorés</string>
@@ -1128,7 +1128,6 @@
<string name="remote_host_error_disconnected"><![CDATA[Mobilni <b>%s</b> prekinuta veza]]></string>
<string name="connect_plan_this_is_your_own_simplex_address">Ovo je Vaša vlastita SimpleX adresa!</string>
<string name="clear_verification">Očistiti verifikaciju</string>
<string name="unable_to_open_browser_title">Greška u otvaranju pretraživača</string>
<string name="no_chats">Nema razgovora</string>
<string name="connect_with_contact_name_question">Povezati se sa %1$s?</string>
<string name="contact_wants_to_connect_with_you">želi da se poveze sa Vama!</string>
@@ -1394,7 +1393,6 @@
<string name="auth_unavailable">Autentifikacija nije dostupna</string>
<string name="auto_accept_contact">Automatsko prihvatanje</string>
<string name="save_preferences_question">Sačuvati preference?</string>
<string name="unable_to_open_browser_desc">Za pozive je potreban podrazumevani veb pretraživač. Molimo vas da konfigurišete podrazumevani pretraživač u sistemu i podelite više informacija sa programerima.</string>
<string name="snd_group_event_member_unblocked">odblokirali ste %s</string>
<string name="you_are_observer">Vi ste posmatrač.</string>
<string name="connect_plan_you_are_observer">Vi ste posmatrač</string>
@@ -1538,8 +1538,6 @@
<string name="call_service_notification_audio_call">Hanghívás</string>
<string name="call_service_notification_end_call">Hívás vége</string>
<string name="call_service_notification_video_call">Videóhívás</string>
<string name="unable_to_open_browser_title">Hiba történt a böngésző megnyitásakor</string>
<string name="unable_to_open_browser_desc">A hívásokhoz egy alapértelmezett webböngésző szükséges. Állítson be egy alapértelmezett webböngészőt az eszközön, és osszon meg további tudnivalókat a SimpleX Chat fejlesztőivel.</string>
<string name="migrate_to_device_confirm_network_settings">Hálózati beállítások megerősítése</string>
<string name="migrate_from_device_error_exporting_archive">Hiba történt a csevegési adatbázis exportálásakor</string>
<string name="migrate_to_device_apply_onion">Alkalmaz</string>
@@ -1999,8 +1997,6 @@
<string name="network_session_mode_session_description">Minden alkalommal, amikor elindítja az alkalmazást, új SOCKS-hitelesítési adatok lesznek használva.</string>
<string name="network_session_mode_session">Alkalmazás munkamenete</string>
<string name="network_session_mode_server_description">Az összes kiszolgálóhoz új, SOCKS-hitelesítési adatok lesznek használva.</string>
<string name="call_desktop_permission_denied_chrome">Kattintson a címmező melletti információgombra a mikrofon használatának engedélyezéséhez.</string>
<string name="call_desktop_permission_denied_safari">Nyissa meg a Safari / Beállítások / Weboldalak / Mikrofon menüt, majd válassza a helyi kiszolgálók engedélyezése beállítást.</string>
<string name="call_desktop_permission_denied_title">Hívások kezdeményezéséhez engedélyezze a mikrofon használatát. Fejezze be a hívást, és próbálja meg a hívást újra.</string>
<string name="v6_1_better_calls">Továbbfejlesztett hívásélmény</string>
<string name="v6_1_message_dates_descr">Továbbfejlesztett üzenetdátumok.</string>
@@ -972,9 +972,7 @@
<string name="message_reactions_prohibited_in_this_chat">Reaksi pesan dilarang dalam obrolan ini.</string>
<string name="migrate_from_device_to_another_device">Pindah ke perangkat lain</string>
<string name="call_desktop_permission_denied_title">Untuk melakukan panggilan, izinkan penggunaan mikrofon. Akhiri panggilan dan coba panggil lagi.</string>
<string name="call_desktop_permission_denied_chrome">Klik tombol info di dekat kolom alamat untuk mengizinkan penggunaan mikrofon.</string>
<string name="relay_server_protects_ip">Server relai melindungi alamat IP Anda, tetapi dapat mengamati durasi panggilan.</string>
<string name="call_desktop_permission_denied_safari">Buka Pengaturan Safari / Situs Web / Mikrofon, lalu pilih Izinkan untuk localhost.</string>
<string name="open_simplex_chat_to_accept_call">Buka SimpleX Chat untuk terima panggilan</string>
<string name="open_verb">Buka</string>
<string name="status_e2e_encrypted">terenkripsi e2e</string>
@@ -1072,8 +1070,6 @@
<string name="network_proxy_random_credentials">Gunakan kredensial acak</string>
<string name="callstate_waiting_for_confirmation">menunggu konfirmasi…</string>
<string name="permissions_required">Berikan izin</string>
<string name="unable_to_open_browser_title">Gagal membuka peramban</string>
<string name="unable_to_open_browser_desc">Peramban web bawaan diperlukan untuk panggilan. Harap konfigurasikan peramban bawaan dalam sistem, dan bagikan informasi lebih lanjut dengan pengembang.</string>
<string name="receipts_contacts_enable_for_all">Aktifkan untuk semua</string>
<string name="receipts_groups_disable_for_all">Nonaktifkan untuk semua grup</string>
<string name="icon_descr_add_members">Undang anggota</string>
@@ -1563,8 +1563,6 @@
<string name="call_service_notification_audio_call">Chiamata audio</string>
<string name="call_service_notification_end_call">Termina chiamata</string>
<string name="call_service_notification_video_call">Videochiamata</string>
<string name="unable_to_open_browser_title">Errore di apertura del browser</string>
<string name="unable_to_open_browser_desc">Il browser predefinito è necessario per le chiamate. Configura il browser predefinito nel sistema, poi condividi più informazioni con gli sviluppatori.</string>
<string name="e2ee_info_no_pq_short">Questa chat è protetta da crittografia end-to-end.</string>
<string name="e2ee_info_pq_short">Questa chat è protetta da crittografia end-to-end resistente alla quantistica.</string>
<string name="migrate_from_device_start_chat">Avvia chat</string>
@@ -2034,8 +2032,6 @@
<string name="network_session_mode_session_description">Le nuove credenziali SOCKS verranno usate ogni volta che avvii l\'app.</string>
<string name="network_session_mode_server_description">Le nuove credenziali SOCKS verranno usate per ogni server.</string>
<string name="network_session_mode_server">Server</string>
<string name="call_desktop_permission_denied_safari">Apri le impostazioni di Safari / Siti web / Microfono, quindi scegli Consenti per localhost.</string>
<string name="call_desktop_permission_denied_chrome">Clicca il pulsante info vicino al campo indirizzo per consentire l\'uso del microfono.</string>
<string name="call_desktop_permission_denied_title">Per effettuare chiamate, consenti di usare il microfono. Termina la chiamata e cerca di richiamare.</string>
<string name="v6_1_better_calls">Chiamate migliorate</string>
<string name="v6_1_message_dates_descr">Date dei messaggi migliorate.</string>
@@ -1469,7 +1469,6 @@
<string name="rcv_group_and_other_events">בנוסף ל- %d אירועים אחרים</string>
<string name="profile_update_event_set_new_address">איש הקשר הגדיר כתובת חדשה</string>
<string name="contact_tap_to_connect">לחץ לחיבור</string>
<string name="unable_to_open_browser_desc">דפדפן האינטרנט המוגדר כברירת מחדל נדרש לשיחות. אנא הגדר דפדפן ברירת מחדל במערכת, ושתף מידע נוסף עם המפתחים.</string>
<string name="e2ee_info_no_pq_short">השיחה הזו מוגנת באמצעות הצפנה קצה-אל-קצה.</string>
<string name="e2ee_info_pq_short">השיחה הזו מוגנת באמצעות הצפנה קצה-אל-קצה עמידה בפני מחשוב קוונטי.</string>
<string name="connect_plan_this_is_your_own_simplex_address">זהו כתובת ה-SimpleX שלך!</string>
@@ -1502,7 +1501,6 @@
<string name="error_alert_title">שגיאה</string>
<string name="error_creating_message">שגיאה ביצירת הודעה</string>
<string name="migrate_from_device_error_exporting_archive">שגיאה בייצוא מסד נתוני צ\'אט</string>
<string name="unable_to_open_browser_title">שגיאה בפתיחת דפדפן</string>
<string name="migrate_from_device_error_saving_settings">שגיאה בשמירת אפשרויות</string>
<string name="migrate_to_device_finalize_migration">סיים את ההעברה על מכשיר נוסף.</string>
<string name="invalid_qr_code">קוד QR לא חוקי</string>
@@ -1872,7 +1870,6 @@
<string name="onboarding_notifications_mode_periodic_desc_short">בדוק עבור הודעות חדשות כל 10 דקות</string>
<string name="operator_conditions_accepted_on">אישרת את תנאי השימוש ב:%s.</string>
<string name="delete_chat_for_all_members_cannot_undo_warning">הצ\'אט יימחק עבור כל החברים - לא ניתן לבטל את זה!</string>
<string name="call_desktop_permission_denied_chrome">לחץ על כפתור מידע ליד שדה כתובת כדי לאפשר שימוש במיקרופון.</string>
<string name="operator_conditions_accepted_for_some"><![CDATA[כבר הסכמת לתנאי שימוש למפעיל/ים האלו: <b>%s</b>.]]></string>
<string name="operator_conditions_will_be_accepted_for_some"><![CDATA[התנאי שימוש יאושרו למפעיל/ים: <b>%s</b>.]]></string>
<string name="v6_0_your_contacts_descr">העבר אנשי קשר לארכיון לשוחח מאוחר יותר</string>
@@ -1530,7 +1530,6 @@
<string name="e2ee_info_pq_short">このチャットは耐量子エンドツーエンド暗号化により保護されています。</string>
<string name="note_folder_local_display_name">プライベートノート</string>
<string name="call_service_notification_end_call">通話終了</string>
<string name="unable_to_open_browser_title">ブラウザ起動エラー</string>
<string name="migrate_from_another_device">別の端末から移行</string>
<string name="set_passphrase">パスフレーズを設定</string>
<string name="snd_group_event_member_blocked">%s をブロック済</string>
@@ -1636,7 +1635,6 @@
<string name="profile_update_event_set_new_picture">新しいプロフィール画像が設定されました</string>
<string name="profile_update_event_removed_picture">プロフィール画像が削除されました</string>
<string name="auth_open_migration_to_another_device">移行画面を開く</string>
<string name="unable_to_open_browser_desc">通話には既定のウェブブラウザが必要です。既定のブラウザを設定し、開発者へ情報を共有してください。</string>
<string name="profile_update_event_updated_profile">プロフィール更新済</string>
<string name="profile_update_event_member_name_changed">メンバーの %1$sが %2$sに名前変更済</string>
<string name="migrate_to_device_confirm_network_settings">ネットワーク設定を確認してください</string>
@@ -1862,7 +1860,6 @@
<string name="cant_send_message_to_member_alert_title">グループメンバーにメッセージを送信できません。</string>
<string name="v6_0_your_contacts_descr">後でチャットするために連絡先をアーカイブします。</string>
<string name="v6_0_connection_servers_status_descr">接続とサーバーのステータス</string>
<string name="call_desktop_permission_denied_chrome">アドレスフィールドの近くにある情報ボタンをクリックして、マイクの使用を許可してください。</string>
<string name="connections">接続</string>
<string name="v6_1_customizable_message_descr">カスタマイズ可能なメッセージの形。</string>
<string name="network_smp_proxy_fallback_prohibit_description">プライベートルーティングをサポートしていなくても、メッセージを直接送信しないでください。</string>
@@ -2318,7 +2315,6 @@
<string name="onboarding_configure_notifications">通知を設定</string>
<string name="onboarding_network_commitments">ネットワークの取り組み</string>
<string name="call_desktop_permission_denied_title">通話するにはマイクの使用を許可してください。通話を終了して、もう一度かけ直してください。</string>
<string name="call_desktop_permission_denied_safari">Safariの「設定」/「Webサイト」/「マイク」を開き、localhost に対して「許可」を選択してください。</string>
<string name="open_external_link_title">外部リンクを開きますか?</string>
<string name="icon_descr_sound_muted">サウンドをミュート中</string>
<string name="rcv_msg_error_dropped">破棄されました(%1$d回試行)</string>
@@ -1032,7 +1032,6 @@
<string name="attempts_label">시도</string>
<string name="network_session_mode_session">앱 세션</string>
<string name="app_check_for_updates">업데이트 확인</string>
<string name="call_desktop_permission_denied_chrome">주소 필드 근처에 있는 정보 버튼을 클릭하여 마이크를 사용할 수 있습니다.</string>
<string name="settings_message_shape_corner">모서리</string>
<string name="chat_database_exported_title">채팅 데이터베이스를 내보냈습니다</string>
<string name="cant_call_contact_alert_title">연락처에 전화를 걸 수 없음</string>
@@ -1225,7 +1224,6 @@
<string name="app_check_for_updates_download_started">앱 업데이트를 다운로드하는 중입니다. 앱을 닫지 마세요</string>
<string name="app_check_for_updates_button_download">%s (%s) 를 다운로드</string>
<string name="app_check_for_updates_notice_disable">비활성화</string>
<string name="unable_to_open_browser_title">브라우저를 여는 중 오류</string>
<string name="block_member_confirmation">차단</string>
<string name="theme_black">검은색</string>
<string name="receipts_contacts_disable_keep_overrides">사용 안 함 (변경 사항 유지)</string>
@@ -422,7 +422,6 @@
<string name="smp_servers_check_address">Adresa serverê kontrol bike û dîsa biceribîne.</string>
<string name="migrate_from_device_check_connection_and_try_again">Girêdana xwe yî înternetê kontrol bike û dîsa biceribîne</string>
<string name="clear_note_folder_question">Notên şexsî vala bike?</string>
<string name="call_desktop_permission_denied_chrome">Pêl pişkoka melûmatê ya nêzîkî cihê adresê bike ji bo destûrdana mîkrofonê.</string>
<string name="color_mode">Moda reng</string>
<string name="in_developing_title">Di wextekî nêzîk de tê!</string>
<string name="smp_server_test_compare_file">Dosya qiyas bike</string>
@@ -1122,7 +1122,6 @@
<string name="is_verified">%s yra patvirtintas</string>
<string name="tap_to_scan">Bakstelėkite, kad nuskanuoti</string>
<string name="shutdown_alert_question">Išjungti?</string>
<string name="unable_to_open_browser_desc">Skambučiams reikalinga numatytoji žiniatinklio naršyklė. Sukonfigūruokite numatytąją sistemos naršyklę ir pasidalykite daugiau informacijos su kūrėjais.</string>
<string name="receipts_section_groups">Mažos grupės (maks. 20)</string>
<string name="color_title">Pavadinimas</string>
<string name="v4_6_group_welcome_message_descr">Nustatykite žinutę rodomą naujiems nariams!</string>
@@ -1459,7 +1458,6 @@
<string name="you_will_be_connected_when_your_contacts_device_is_online">Prisijungsite kai jūsų konktakto įrenginys bus prisijungęs, palaukite arba patikrinkite vėliau!</string>
<string name="mobile_tap_open_in_mobile_app_then_tap_connect_in_app"><![CDATA[📱 mobilusis: bakstelėkite <b>Atidaryti mobilioje programėlėje</b>, tada bakstelėkite <b>Prisijungti</b> programėlėje.]]></string>
<string name="smp_servers_use_server_for_new_conn">Naudoti naujiems prisijungimams</string>
<string name="unable_to_open_browser_title">Įvyko klaida atveriant naršyklę</string>
<string name="secret_text">paslaptis</string>
<string name="shutdown_alert_desc">Pranešimai nustos veikti iki tol kol paleisite programėlę iš naujo</string>
<string name="you_can_use_markdown_to_format_messages__prompt">Galite naudoti markdown, kad formatuoti žinutes:</string>
@@ -423,8 +423,6 @@
<string name="icon_descr_video_call">Ikonas apraksts videozvanam</string>
<string name="icon_descr_audio_call">Ikona Audio zvans</string>
<string name="call_desktop_permission_denied_title">Zvana piekļuves atļauja darbvirsmai noraidīta</string>
<string name="call_desktop_permission_denied_chrome">Zvana piekļuves atļauja darbvirsmai noraidīta Chrome pārlūkā</string>
<string name="call_desktop_permission_denied_safari">Zvana piekļuves atļauja darbvirsmai noraidīta Safari pārlūkā</string>
<string name="settings_audio_video_calls">Audio un video zvanu iestatījumi</string>
<string name="your_calls">Tavi zvani</string>
<string name="always_use_relay">Vienmēr izmantot releju</string>
@@ -1483,8 +1481,6 @@
<string name="callstate_connecting">Zvana stāvoklis savienojas</string>
<string name="callstate_connected">Zvana stāvoklis savienots</string>
<string name="callstate_ended">Zvana stāvoklis beidzies</string>
<string name="unable_to_open_browser_title">Nevar atvērt pārlūkprogrammu</string>
<string name="unable_to_open_browser_desc">Nevar atvērt pārlūkprogrammu</string>
<string name="permissions_required">Nepieciešamas atļaujas</string>
<string name="permissions_record_audio">Atļaujas audio ierakstīšanai</string>
<string name="permissions_camera">Atļaujas kamerai</string>
@@ -1563,8 +1563,6 @@
<string name="call_service_notification_audio_call">Audio oproep</string>
<string name="call_service_notification_end_call">Ophangen</string>
<string name="call_service_notification_video_call">Video oproep</string>
<string name="unable_to_open_browser_title">Fout bij het openen van de browser</string>
<string name="unable_to_open_browser_desc">Voor oproepen is de standaard webbrowser vereist. Configureer de standaard browser in het systeem en deel meer informatie met de ontwikkelaars.</string>
<string name="migrate_to_device_confirm_network_settings">Bevestig netwerk instellingen</string>
<string name="migrate_from_device_archive_and_upload">Archiveren en uploaden</string>
<string name="migrate_from_device_archiving_database">Database archiveren</string>
@@ -2033,8 +2031,6 @@
<string name="network_session_mode_server_description">Voor elke server worden nieuwe SOCKS-inloggegevens gebruikt.</string>
<string name="network_session_mode_server">Server</string>
<string name="settings_message_shape_tail">Staart</string>
<string name="call_desktop_permission_denied_chrome">Klik op de infoknop naast het adresveld om het gebruik van de microfoon toe te staan.</string>
<string name="call_desktop_permission_denied_safari">Open Safari Instellingen / Websites / Microfoon en kies Toestaan voor localhost.</string>
<string name="call_desktop_permission_denied_title">Als u wilt bellen, geeft u toestemming om uw microfoon te gebruiken. Beëindig het gesprek en probeer opnieuw te bellen.</string>
<string name="v6_1_better_security">Betere beveiliging ✅</string>
<string name="v6_1_better_security_descr">SimpleX-protocollen beoordeeld door Trail of Bits.</string>
@@ -1563,8 +1563,6 @@
<string name="call_service_notification_audio_call">Połączenie audio</string>
<string name="call_service_notification_end_call">Zakończ połączenie</string>
<string name="call_service_notification_video_call">Połączenie wideo</string>
<string name="unable_to_open_browser_title">Błąd podczas otwierania przeglądarki</string>
<string name="unable_to_open_browser_desc">Do wykonywania połączeń wymagana jest domyślna przeglądarka internetowa. Skonfiguruj domyślną przeglądarkę w systemie i przekaż więcej informacji programistom.</string>
<string name="e2ee_info_pq_short">Ten czat jest chroniony przez szyfrowanie e2e odporne na ataki kwantowe.</string>
<string name="e2ee_info_no_pq"><![CDATA[Wiadomości, pliki i połączenia są chronione przez <b>szyfrowanie end-to-end</b> z perfect forward secrecy, zaprzeczalnością i odzyskiwaniem bezpieczeństwa po kompromitacji.]]></string>
<string name="auth_open_migration_to_another_device">Otwórz ekran migrowania</string>
@@ -2018,8 +2016,6 @@
<string name="network_session_mode_session">Sesja aplikacji</string>
<string name="network_session_mode_session_description">Nowe poświadczenia SOCKS będą używane przy każdym uruchomieniu aplikacji.</string>
<string name="network_session_mode_server_description">Dla każdego serwera zostaną użyte nowe poświadczenia SOCKS.</string>
<string name="call_desktop_permission_denied_chrome">Kliknij przycisk informacji przy polu adresu, aby zezwolić na korzystanie z mikrofonu.</string>
<string name="call_desktop_permission_denied_safari">Otwórz Safari Ustawienia / Strony internetowe / Mikrofon, a następnie wybierz opcję Zezwalaj dla localhost.</string>
<string name="network_proxy_auth_mode_isolate_by_auth_entity">Użyj różnych poświadczeń proxy dla każdego połączenia.</string>
<string name="network_proxy_auth_mode_isolate_by_auth_user">Użyj różnych poświadczeń proxy dla każdego profilu.</string>
<string name="network_proxy_random_credentials">Użyj losowych poświadczeń</string>
@@ -1454,7 +1454,6 @@
<string name="block_member_confirmation">Bloquear</string>
<string name="member_info_member_blocked">bloqueado</string>
<string name="code_you_scanned_is_not_simplex_link_qr_code">O código que você escaneou não é um QR Code SimpleX.</string>
<string name="unable_to_open_browser_desc">O navegador padrão é necessário para chamadas. Configure o navegador padrão no sistema e compartilhe mais informações com os desenvolvedores.</string>
<string name="these_settings_are_for_your_current_profile">Essas configurações são para o seu perfil atual</string>
<string name="video_decoding_exception_desc">O vídeo não pode ser decodificado. Por favor, tente com um vídeo diferente ou entre em contato com os desenvolvedores.</string>
<string name="connect_plan_this_is_your_own_one_time_link">Este é o seu próprio link de uso único!</string>
@@ -1482,7 +1481,6 @@
<string name="disconnect_desktop_question">Desconectar desktop?</string>
<string name="enable_camera_access">Habilitar o acesso à câmera</string>
<string name="encryption_renegotiation_error">Erro de renegociação de criptografia</string>
<string name="unable_to_open_browser_title">Erro ao abrir o navegador</string>
<string name="error_sending_message_contact_invitation">Erro ao enviar o convite</string>
<string name="loading_chats">Carregando conversas…</string>
<string name="remote_host_was_disconnected_toast"><![CDATA[Dispositivo Móvel <b>%s</b> foi desconectado]]></string>
@@ -2123,7 +2121,6 @@
<string name="disable_automatic_deletion">Desativar exclusão de mensagens</string>
<string name="encryption_renegotiation_in_progress">Renegociação de criptografia em andamento.</string>
<string name="delete_chat_list_menu_action">Apagar</string>
<string name="call_desktop_permission_denied_chrome">Clique no botão de informação perto do campo de endereço para permitir usar o microfone.</string>
<string name="onboarding_network_operators_app_will_use_for_routing">Por exemplo, se o seu contato receber mensagens por meio de um servidor SimpleX Chat, seu aplicativo as entregará por meio de um servidor Flux.</string>
<string name="connection_not_ready">A conexão não está pronta.</string>
<string name="errors_in_servers_configuration">Erros nas configurações de servidores.</string>
@@ -2203,7 +2200,6 @@
<string name="no_message_servers_configured_for_private_routing">Nenhum servidor para roteamento de mensagens privadas.</string>
<string name="no_media_servers_configured_for_private_routing">Nenhum servidor para receber arquivos.</string>
<string name="no_message_servers_configured_for_receiving">Nenhum servidor para receber mensagens.</string>
<string name="call_desktop_permission_denied_safari">Abra Configurações do Safari / Websites / Microfone, e escolha Permitir para localhost.</string>
<string name="operator_server_alert_title">Servidor do operador</string>
<string name="no_chats_in_list">Nenhuma conversa na lista %s.</string>
<string name="report_item_visibility_moderators">Somente o remetente e os moderadores podem vê-lo</string>
@@ -959,7 +959,6 @@
<string name="hide_profile">Ascunde profilul</string>
<string name="display_name_cannot_contain_whitespace">Numele afișat nu poate conține spații albe.</string>
<string name="invalid_name">Nume nevalid!</string>
<string name="unable_to_open_browser_title">Eroare la deschiderea browserului</string>
<string name="how_it_works">Cum funcționează</string>
<string name="app_check_for_updates_installed_successfully_title">Instalat cu succes</string>
<string name="app_check_for_updates_notice_disable">Dezactivează</string>
@@ -1385,7 +1384,6 @@
<string name="app_check_for_updates_button_download">Descărcați %s (%s)</string>
<string name="app_check_for_updates_button_install">Instalează actualizarea</string>
<string name="onboarding_network_operators_app_will_use_for_routing">De exemplu, dacă un contact al tău primește mesaje printr-un server SimpleX Chat, aplicația ta le va trimite printr-un server Flux.</string>
<string name="call_desktop_permission_denied_safari">Deschide Setările Safari / Site-uri web / Microfon, apoi selectează Permite pentru localhost.</string>
<string name="icon_descr_call_missed">Apel ratat</string>
<string name="alert_text_encryption_renegotiation_failed">Renegocierea criptării a eșuat.</string>
<string name="la_mode_off">Oprit</string>
@@ -1867,7 +1865,6 @@
<string name="info_view_video_button">video</string>
<string name="using_simplex_chat_servers">Utilizarea serverelor SimpleX Chat.</string>
<string name="network_enable_socks">Folosiți proxy SOCKS?</string>
<string name="unable_to_open_browser_desc">Browserul web implicit este necesar pentru apeluri. Vă rugăm să configurați browserul implicit în sistem și să partajați mai multe informații cu dezvoltatorii.</string>
<string name="private_routing_show_message_status">Afișează statusul mesajului</string>
<string name="network_smp_web_port_section_title">Port TCP pentru mesagerie</string>
<string name="app_check_for_updates_canceled">Descărcarea actualizării a fost anulată</string>
@@ -2218,7 +2215,6 @@
<string name="non_content_uri_alert_text">Ai partajat o cale de fișier nevalidă. Raportează problema dezvoltatorilor aplicației.</string>
<string name="mobile_tap_open_in_mobile_app_then_tap_connect_in_app"><![CDATA[📱 mobil: atinge <b>Deschide în aplicația mobilă</b>, apoi atinge <b>Conectare</b> în aplicație.]]></string>
<string name="add_short_link">Actualizează adresa</string>
<string name="call_desktop_permission_denied_chrome">Apasă pe butonul de informații de lângă bara de adrese pentru a permite accesul la microfon.</string>
<string name="settings_message_shape_corner">Colţ</string>
<string name="chat_database_exported_save">Puteți salva arhiva exportată.</string>
<string name="you_will_stop_receiving_messages_from_this_chat_chat_history_will_be_preserved">Nu veți mai primi mesaje de la acest chat. Istoricul conversației va fi păstrat.</string>
@@ -1714,8 +1714,6 @@
<string name="migrate_to_device_finalize_migration">Завершите миграцию на другом устройстве.</string>
<string name="database_migration_in_progress">Выполняется миграция базы данных.
\nЭто может занять несколько минут.</string>
<string name="unable_to_open_browser_title">Ошибка открытия браузера</string>
<string name="unable_to_open_browser_desc">Для звонков требуется веб-браузер по умолчанию. Пожалуйста, настройте браузер по умолчанию в системе и поделитесь дополнительной информацией с разработчиками.</string>
<string name="network_type_other">Другaя сеть</string>
<string name="network_type_network_wifi">WiFi</string>
<string name="network_type_ethernet">Проводная сеть</string>
@@ -2096,8 +2094,6 @@
<string name="network_session_mode_server">Сервер</string>
<string name="settings_section_title_message_shape">Форма сообщений</string>
<string name="settings_message_shape_tail">Хвост</string>
<string name="call_desktop_permission_denied_chrome">Нажмите кнопку информации рядом с адресной строкой, чтобы разрешить микрофон.</string>
<string name="call_desktop_permission_denied_safari">Откройте Настройки Safari / Веб-сайты / Микрофон, затем выберите Разрешить для localhost.</string>
<string name="v6_1_better_calls">Улучшенные звонки</string>
<string name="v6_1_message_dates_descr">Улучшенные даты сообщений.</string>
<string name="v6_1_better_security">Улучшенная безопасность ✅</string>
@@ -734,7 +734,6 @@
<string name="clear_chat_button">Vymazať obsah chatu</string>
<string name="clear_chat_question">Vymazať obsah chatu?</string>
<string name="clear_note_folder_question">Vymazať obsah súkromných poznámok?</string>
<string name="call_desktop_permission_denied_chrome">Kliknite na info tlačidlo blízko poľa adresy na povolenie použitia mikrofónu.</string>
<string name="icon_descr_close_button">Tlačidlo zavrieť</string>
<string name="v4_4_verify_connection_security_desc">Porovnajte bezpečnostné kódy s vašimi kontaktmi.</string>
<string name="operator_conditions_accepted_for_some"><![CDATA[Podmienky už boli prijaté pre tohto/týchto operátora(ov): <b>%s</b>.]]></string>
@@ -829,7 +828,6 @@
<string name="v5_3_new_desktop_app">Nová počítačová aplikácia!</string>
<string name="open_on_mobile_and_scan_qr_code"><![CDATA[Otvorte <i>Použiť z počítača</i> v mobilnej aplikácii a naskenujte QR kód.]]></string>
<string name="settings_section_title_use_from_desktop">Použiť z počítača</string>
<string name="call_desktop_permission_denied_safari">Otvorte nastavenia Safari / Webové stránky / mikrofón, potom vyberte povoliť pre localhost.</string>
<string name="paste_desktop_address">Vložiť adresu počítača</string>
<string name="remote_ctrl_connection_stopped_desc">Prosím skontrolujte, že mobilná a počítačová aplikácia sú pripojené ku rovnakej miestnej sieti, a že počítačový firewall povoľuje pripojenie.\nProsím zdieľajte akékoľvek iné problémy s vývojármi.</string>
<string name="scan_qr_code_from_desktop">Skenovať QR kód z počítača</string>
@@ -868,7 +866,6 @@
<string name="v6_4_1_keep_chats_clean_descr">Povolenie miznúcich správ v predvolených nastaveniach.</string>
<string name="network_options_reset_to_defaults">Obnoviť pôvodné nastavenia</string>
<string name="chat_theme_set_default_theme">Obnoviť pôvodnú tému</string>
<string name="unable_to_open_browser_desc">Predvolený webový prehliadač je potrebný na hovory. Prosím, nakonfigurujte predvolený prehliadač v systéme a zdieľajte s vývojármi viac informácií.</string>
<string name="disable_notifications_button">Vypnúť notifikácie</string>
<string name="disappearing_message">Miznúca správa</string>
<string name="timed_messages">Miznúce správy</string>
@@ -1282,7 +1279,6 @@
<string name="error_initializing_web_view_wrong_arch">Chyba inicializácie WebView. Ujistite sa, že máte nainštalovaní WebView ktorý podporuje architektúru arm64.\nChyba: %s</string>
<string name="error_loading_chat_tags">Chyba pri načítavaní zoznamov chatov</string>
<string name="error_marking_member_support_chat_read">Chyba pri označovaní ako prečítané</string>
<string name="unable_to_open_browser_title">Chyba pri otváraní prehliadača</string>
<string name="error_preparing_contact">Chyba pri otváraní chatu</string>
<string name="error_preparing_group">Chyba pri otváraní skupiny</string>
<string name="error_rejecting_contact_request">Chyba pri odmietaní žiadosti o kontakt</string>
@@ -1565,11 +1565,9 @@
<string name="message_too_large">Mesaj çok büyük</string>
<string name="database_migration_in_progress">Veritabanı geçişi devam ediyor.
\nBu birkaç dakika sürebilir.</string>
<string name="unable_to_open_browser_desc">Aramalar için varsayılan web tarayıcısı gereklidir. Lütfen sistemdeki varsayılan tarayıcıyı yapılandırın ve geliştiricilerle daha fazla bilgi paylaşın.</string>
<string name="call_service_notification_audio_call">Sesli arama</string>
<string name="call_service_notification_video_call">Görüntülü arama</string>
<string name="call_service_notification_end_call">Aramayı bitir</string>
<string name="unable_to_open_browser_title">Tarayıcıyı açarken hata</string>
<string name="e2ee_info_no_pq"><![CDATA[Mesajlar, dosyalar ve aramalar, mükemmel ileriye dönük gizlilik, reddetme ve izinsiz giriş kurtarması ile <b>uçtan uca şifreleme</b> ile korunmaktadır.]]></string>
<string name="e2ee_info_no_pq_short">Bu sohbet uçtan uca şifrelemeyle korunmaktadır.</string>
<string name="auth_open_migration_to_another_device">Taşıma ekranını aç</string>
@@ -2021,8 +2019,6 @@
<string name="app_check_for_updates_stable">Stabil</string>
<string name="app_check_for_updates_update_available">Güncelleme mevcut: %s</string>
<string name="remote_ctrl_connection_stopped_identity_desc">Bu bağlantı başka bir mobil cihazda kullanıldı, lütfen masaüstünde yeni bir bağlantı oluşturun.</string>
<string name="call_desktop_permission_denied_chrome">Mikrofon kullanımına izin vermek için adres alanının yanındaki bilgi düğmesine tıklayın.</string>
<string name="call_desktop_permission_denied_safari">Safari Ayarları / Web Siteleri / Mikrofon\'u açın, ardından localhost için İzin Ver\'i seçin.</string>
<string name="call_desktop_permission_denied_title">Arama yapmak için mikrofonunuzu kullanmanıza izin verin. Aramayı sonlandırın ve tekrar aramayı deneyin.</string>
<string name="settings_message_shape_tail">Konuşma balonu</string>
<string name="privacy_media_blur_radius_strong">Güçlü</string>
@@ -1562,8 +1562,6 @@
<string name="call_service_notification_end_call">Кінець дзвінка</string>
<string name="call_service_notification_video_call">Відеодзвінок</string>
<string name="call_service_notification_audio_call">Аудіодзвінок</string>
<string name="unable_to_open_browser_title">Помилка відкриття браузера</string>
<string name="unable_to_open_browser_desc">Для використання дзвінків потрібен браузер за замовчуванням. Будь ласка, налаштуйте браузер за замовчуванням в системі та надайте більше інформації розробникам.</string>
<string name="migrate_from_another_device">Перенести з іншого пристрою</string>
<string name="conn_event_enabled_pq">квантово-стійке шифрування e2e</string>
<string name="conn_event_disabled_pq">стандартне наскрізне шифрування</string>
@@ -2032,8 +2030,6 @@
<string name="network_session_mode_session_description">Нові облікові дані SOCKS будуть використовуватись щоразу, коли ви запускаєте додаток.</string>
<string name="network_session_mode_server_description">Нові облікові дані SOCKS будуть використовуватись для кожного сервера.</string>
<string name="network_session_mode_server">Сервер</string>
<string name="call_desktop_permission_denied_chrome">Натисніть кнопку інформації поруч із полем адреси, щоб дозволити використання мікрофона.</string>
<string name="call_desktop_permission_denied_safari">Відкрийте Налаштування Safari / Сайти / Мікрофон, а потім виберіть \"Дозволити для localhost\".</string>
<string name="call_desktop_permission_denied_title">Щоб здійснювати дзвінки, дозволіть використовувати ваш мікрофон. Завершіть дзвінок і спробуйте зателефонувати знову.</string>
<string name="v6_1_better_calls">Кращі дзвінки</string>
<string name="v6_1_better_security">Краща безпека ✅</string>
@@ -691,7 +691,6 @@
\nLỗi: %s</string>
<string name="error_removing_member">Lỗi xóa thành viên</string>
<string name="error_with_info">Lỗi: %s</string>
<string name="unable_to_open_browser_title">Lỗi mở trình duyệt</string>
<string name="error_joining_group">Lỗi tham gia nhóm</string>
<string name="error_loading_details">Lỗi tải thông tin chi tiết</string>
<string name="error_receiving_file">Lỗi nhận tệp</string>
@@ -866,7 +865,6 @@
<string name="host_verb">Lưu trữ</string>
<string name="network_session_mode_session">Phiên làm việc trên ứng dụng</string>
<string name="how_to_use_markdown">Cách sử dụng markdown</string>
<string name="call_desktop_permission_denied_chrome">Nhấn nút thông tin gần trường địa chỉ để cho phép sử dụng microphone.</string>
<string name="v6_1_better_calls">Trải nghiệm cuộc gọi tốt hơn</string>
<string name="v6_1_better_security">Bảo mật hơn ✅</string>
<string name="v6_1_better_user_experience">Trải nghiệm người dùng tuyệt vời hơn</string>
@@ -1209,7 +1207,6 @@
<string name="permissions_open_settings">Mở cài đặt</string>
<string name="migrate_from_device_or_share_this_file_link">Hoặc chia sẻ đường dẫn tệp này một cách an toàn.</string>
<string name="open_simplex_chat_to_accept_call">Mở SimpleX Chat để chấp nhận cuộc gọi</string>
<string name="call_desktop_permission_denied_safari">Mở Cài đặt Safari / Trang Web / Mic, rồi chọn Cho phép với localhost.</string>
<string name="la_mode_passcode">Mã truy cập</string>
<string name="open_chat">Mở cuộc trò chuyện</string>
<string name="app_check_for_updates_button_open">Mở vị trí tệp</string>
@@ -1884,7 +1881,6 @@
<string name="privacy_chat_list_open_links">Mở các đường dẫn từ danh sách trò chuyện</string>
<string name="connection_you_accepted_will_be_cancelled">Kết nối bạn đã chấp nhận sẽ bị hủy bỏ!</string>
<string name="contact_you_shared_link_with_wont_be_able_to_connect">Liên hệ mà bạn đã chia sẻ đường dẫn này sẽ không thể kết nối!</string>
<string name="unable_to_open_browser_desc">Trình duyệt web mặc định là cần thiết cho các cuộc gọi. Xin vui lòng thiết lập trình duyệt mặc định trong hệ thống, và chia sẻ thêm thông tin với các nhà phát triển.</string>
<string name="onboarding_network_operators_app_will_use_different_operators">Ứng dụng bảo vệ sự riêng tư của bạn bằng cách sử dụng các bên vận hành khác nhau trong mỗi cuộc trò chuyện.</string>
<string name="system_restricted_background_in_call_desc">Ứng dụng có thể bị đóng sau 1 phút chạy trong nền.</string>
<string name="periodic_notifications_desc">Ứng dụng tìm nhận tin nhắn mới một cách định kỳ - nó tiêu tốn một vài phần trăm pin mỗi ngày. Ứng dụng không sử dụng thông báo đẩy - dữ liệu ở thiết bị của bạn không được gửi đi tới máy chủ nào.</string>
@@ -1555,8 +1555,6 @@
<string name="call_service_notification_video_call">视频通话</string>
<string name="call_service_notification_audio_call">语音通话</string>
<string name="call_service_notification_end_call">结束通话</string>
<string name="unable_to_open_browser_title">打开浏览器出错</string>
<string name="unable_to_open_browser_desc">没有默认网络浏览器无法使用通话功能。请在系统中配置默认浏览器并和开发者分享更多信息。</string>
<string name="e2ee_info_pq_short">此聊天受抗量子的端到端加密保护。</string>
<string name="e2ee_info_no_pq_short">此聊天受端到端加密保护。</string>
<string name="auth_open_migration_to_another_device">打开迁移屏幕</string>
@@ -2022,9 +2020,7 @@
<string name="network_session_mode_session">应用会话</string>
<string name="network_session_mode_session_description">每次启动应用都会使用新的 SOCKS 凭据。</string>
<string name="network_session_mode_server">服务器</string>
<string name="call_desktop_permission_denied_safari">打开 Safari 设置/网站/麦克风,接着在 localhost 选择“允许”。</string>
<string name="call_desktop_permission_denied_title">要进行通话,请允许使用设备麦克风。结束通话并尝试再次呼叫。</string>
<string name="call_desktop_permission_denied_chrome">单击地址栏附近的“信息”按钮允许使用麦克风。</string>
<string name="network_session_mode_server_description">每个服务器都会使用新的 SOCKS 凭据。</string>
<string name="v6_1_message_dates_descr">更好的消息日期。</string>
<string name="v6_1_better_security">更佳的安全性✅</string>
@@ -1338,7 +1338,6 @@
<string name="app_check_for_updates_disabled">已停用</string>
<string name="app_check_for_updates_installed_successfully_title">安裝成功</string>
<string name="create_address_button">建立</string>
<string name="unable_to_open_browser_title">打開瀏覽器出錯</string>
<string name="receipts_contacts_enable_keep_overrides">啟用(保留覆蓋)</string>
<string name="receipts_groups_disable_keep_overrides">禁用(保留組覆蓋)</string>
<string name="receipts_groups_title_enable">為群組啟用回執?</string>
@@ -2319,7 +2318,6 @@
<string name="bio_too_large">簡介太長</string>
<string name="save_admission_question">要儲存加入審批設定嗎?</string>
<string name="save_and_notify_channel_subscribers">儲存並通知頻道訂閱者</string>
<string name="unable_to_open_browser_desc">通話需要預設網頁瀏覽器。請在系統中設定預設瀏覽器,並向開發者分享更多資訊。</string>
<string name="error_initializing_web_view_wrong_arch">初始化 WebView 時發生錯誤。請確認已安裝 WebView,且其支援的架構為 arm64。\n錯誤:%s</string>
<string name="onboarding_be_free">在你的網路中\n自由交流</string>
<string name="onboarding_private_and_secure">私密且安全的通訊。</string>
@@ -2355,8 +2353,6 @@
<string name="onboarding_configure_notifications">設定通知</string>
<string name="onboarding_network_commitments">網路承諾</string>
<string name="call_desktop_permission_denied_title">若要通話,請允許使用麥克風。結束通話後再嘗試撥打。</string>
<string name="call_desktop_permission_denied_chrome">點按地址欄旁的資訊按鈕,以允許使用麥克風。</string>
<string name="call_desktop_permission_denied_safari">開啟 Safari 設定 / 網站 / 麥克風,然後為 localhost 選擇「允許」。</string>
<string name="open_external_link_title">要開啟外部連結嗎?</string>
<string name="rcv_msg_error_dropped">已丟棄(%1$d 次嘗試)</string>
<string name="rcv_msg_error_parse">錯誤:%s</string>
@@ -1,81 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>SimpleX Chat WebRTC call</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link href="/desktop/style.css" rel="stylesheet" />
<script src="/lz-string.min.js"></script>
</head>
<body>
<video
id="remote-screen-video-stream"
class="inline"
playsinline
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
style="visibility: hidden"
onclick="javascript:toggleRemoteScreenVideoFitFill()"
></video>
<video
id="remote-video-stream"
class="inline"
playsinline
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
onclick="javascript:toggleRemoteVideoFitFill()"
></video>
<video
id="local-video-stream"
class="inline"
muted
playsinline
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
></video>
<video
id="local-screen-video-stream"
class="inline"
muted
playsinline
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
style="visibility: hidden"
></video>
<div id="progress"></div>
<div id="info-block">
<p id="state"></p>
<p id="description"></p>
<b id="media-sources" style="color: #fff"></b>
</div>
<div id="audio-call-icon">
<img src="/desktop/images/ic_phone_in_talk.svg" />
</div>
<div id="permission-denied">
<p id="permission-denied-title"></p>
<p id="permission-denied-desc"></p>
</div>
<p id="manage-call">
<button id="toggle-screen" onclick="javascript:toggleScreenManually()">
<img src="/desktop/images/ic_screen_share.svg" />
</button>
<button id="toggle-mic" onclick="javascript:toggleMicManually()">
<img src="/desktop/images/ic_mic.svg" />
</button>
<button id="end-call" style="background: red" onclick="javascript:endCallManually()">
<img src="/desktop/images/ic_call_end_filled.svg" />
</button>
<button id="toggle-speaker" onclick="javascript:toggleSpeakerManually()">
<img src="/desktop/images/ic_volume_up.svg" />
</button>
<button id="toggle-camera" onclick="javascript:toggleCameraManually()">
<img src="/desktop/images/ic_videocam_off.svg" />
</button>
</p>
</body>
<footer>
<script src="/call.js"></script>
<script src="/desktop/ui.js"></script>
</footer>
</html>
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="44" viewBox="0 96 960 960" width="44"><path fill="white" d="M480 418q125 0 238.75 50.25T914 613.5q8 9.5 8.25 21t-8.25 20L821 748q-8 8-22.5 8.75t-23-5.75l-113-84.5q-6-4.5-8.75-10.25T651 643.5v-139q-42-16-85.5-22.5t-85-6.5q-42 0-85.5 6.5t-85.5 22.5v139q0 6.5-2.75 12.5T298 666.5L184.5 751q-11.5 8.5-23.5 7.5T139.5 748L46 654.5q-8.5-8.5-8.25-20t8.25-21q81.5-95 195.25-145.25T480 418Z"/></svg>

Before

Width:  |  Height:  |  Size: 437 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="44" viewBox="0 96 960 960" width="44"><path fill="white" d="M480 630.5q-41.75 0-69.875-30.167Q382 570.167 382 527V278q0-40.417 28.566-68.708Q439.132 181 479.941 181t69.434 28.292Q578 237.583 578 278v249q0 43.167-28.125 73.333Q521.75 630.5 480 630.5Zm0-224.5Zm-.175 526q-12.325 0-20.325-8.375t-8-20.625V795.865Q354 786 285.25 719T206 557.5q-1.5-12.593 7.295-21.547Q222.091 527 235.5 527q9.917 0 18.148 7.542 8.232 7.541 9.852 18.458 10.5 80.5 72.044 134 61.543 53.5 144.347 53.5 82.805 0 144.457-53.5Q686 633.5 696.5 553q1.853-11.167 10.121-18.583Q714.89 527 725.543 527q12.91 0 21.434 8.953Q755.5 544.907 754 557.5 743.5 652 674.75 719T509 795.865V903q0 12.25-8.425 20.625-8.426 8.375-20.75 8.375ZM480 573q18.075 0 29.288-13.5Q520.5 546 520.5 527V278.335q0-16.835-11.629-28.335-11.628-11.5-28.818-11.5t-28.872 11.356Q439.5 261.212 439.5 278v248.868q0 19.132 11.212 32.632Q461.925 573 480 573Z"/></svg>

Before

Width:  |  Height:  |  Size: 949 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="44" viewBox="0 96 960 960" width="44"><path fill="white" d="M681.5 693 640 651.5q16.5-20.5 26-45.75T679 553q1.814-11.167 10.182-18.583Q697.551 527 707.847 527q13.153 0 21.653 8.953 8.5 8.954 7 21.547-4.5 37-18.5 71.75T681.5 693ZM554 566l-51-50V279.038q0-17.463-11.489-29.001-11.49-11.537-29.213-11.537t-29.01 11.431Q422 261.362 422 279v155l-57.5-57.5V279q0-40.833 28.515-69.417Q421.529 181 462.265 181q40.735 0 69.485 28.583Q560.5 238.167 560.5 279v248.23q0 7.103-1.5 19.186-1.5 12.084-5 19.584Zm-94.5-94.5Zm350.5 505L58.5 225q-8-7.444-8-18.222Q50.5 196 58.25 188q7.75-8 18.006-8 10.255 0 18.244 8L847 940.5q8 7.989 8 17.994 0 10.006-8 17.756-8 8.25-18.961 8.25-10.961 0-18.039-8ZM433.5 903V795.865Q336 786 267.5 719t-79-161.5q-2-12.5 7.045-21.5 9.046-9 22.455-9 9.5 0 17.75 7.5T246 553q10.053 80.713 71.588 134.107Q379.124 740.5 462.289 740.5q37.711 0 73.071-12.588Q570.721 715.325 599.5 693l41.5 41.5q-31 26-69.014 41.568Q533.972 791.635 491 796v107q0 12.25-8.463 20.625T462.325 932q-12.325 0-20.575-8.375T433.5 903Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="100" viewBox="0 -960 960 960" width="100"><path fill="white" d="M774.5-488.5q-5.5-119.5-89-203.25t-203-88.75V-838q71 2.5 133.5 30.5t109.75 75.25q47.25 47.25 75.5 110T832-488.5h-57.5Zm-168 0q-6-49.5-40.5-83.75t-83.5-39.25V-669q73 5 124.25 56T664-488.5h-57.5Zm184 363.5Q677-125 558-180.5T338-338Q236-439 180.5-557.75T125-790.692q0-18.808 12.714-31.558Q150.429-835 169.5-835H306q14 0 23.75 9.75t13.75 24.75l26.929 123.641Q372-663.5 369.5-652q-2.5 11.5-10.229 19.226L261-533q26 44 54.688 81.658Q344.375-413.683 379-380q36.5 38 77.25 69.323Q497-279.353 542-255l95.544-98q9.456-10.5 21.357-14.25T682.5-369l117.362 25.438Q815-340 825-327.801q10 12.198 10 27.301v131q0 19.071-12.714 31.786Q809.571-125 790.5-125ZM232-585.5l81-82-23.5-110H183q1.5 41.5 13 88.25t36 103.75Zm364 358q40 19 88.166 31t93.334 14v-107l-102-21.5-79.5 83.5Zm-364-358Zm364 358Z"/></svg>

Before

Width:  |  Height:  |  Size: 898 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="44" viewBox="0 -960 960 960" width="44"><path fill="white" d="M335.5-388H393v-89q0-23.875 16.35-40.438Q425.7-534 450.175-534H530v68l97-96.5-97-97v68h-80.077q-47.756 0-81.09 33.396Q335.5-524.708 335.5-477v89ZM74-126q-12.25 0-20.625-8.425Q45-142.851 45-154.925 45-167 53.375-175.25T74-183.5h812.5q11.675 0 20.088 8.463Q915-166.574 915-154.825q0 12.325-8.412 20.575Q898.175-126 886.5-126H74Zm68.5-117q-22.969 0-40.234-17.266Q85-277.531 85-300.5V-777q0-22.969 17.266-40.234Q119.531-834.5 142.5-834.5h675q22.969 0 40.234 17.266Q875-799.969 875-777v476.5q0 22.969-17.266 40.234Q840.469-243 817.5-243h-675Zm0-57.5h675V-777h-675v476.5Zm0 0V-777v476.5Z"/></svg>

Before

Width:  |  Height:  |  Size: 700 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="44" viewBox="0 -960 960 960" width="44"><path fill="white" d="m549-484.5-107-107h88v-68l97 97-78 78ZM799.5-233 741-291.5h70.5V-771h-550L204-828.5h607.5q22.969 0 40.234 17.266Q869-793.969 869-771v479.53q0 26.088-20.25 43.779Q828.5-230 799.5-233Zm36 200.5L737-131H45v-57.5h635L634.5-234h-485q-22.969 0-40.234-17.266Q92-268.531 92-291.5v-481.25q0-2.75.5-3.75l-56-55L78-873 877-74l-41.5 41.5ZM393-475.5v88h-57.5V-477q0-10 2.25-22.5t7.25-23.534L149.5-719v427.5H577l-184-184ZM502-532Zm-138 26.5Z"/></svg>

Before

Width:  |  Height:  |  Size: 546 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="44" viewBox="0 96 960 960" width="44"><path fill="white" d="M143.5 891.5q-23.031 0-40.266-17.234Q86 857.031 86 834V318q0-23.031 17.234-40.266Q120.469 260.5 143.5 260.5h516.211q22.289 0 39.789 17.234Q717 294.969 717 318v215.5L849 401q8-7.5 16.75-3.75t8.75 13.063V741q0 10-8.75 13.75t-16.85-4.35L717 618.5V834q0 23.031-17.5 40.266Q682 891.5 659.711 891.5H143.5Z"/></svg>

Before

Width:  |  Height:  |  Size: 416 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="44" viewBox="0 96 960 960" width="44"><path fill="white" d="M849.5 750.5 717 618.5v114L659.5 675V318H302l-57.5-57.5h415q22.969 0 40.234 17.266Q717 295.031 717 318v215l132.5-132.5q6.5-6.5 15.75-3.167 9.25 3.334 9.25 12.667v331q0 9.625-9.25 13.062Q856 757.5 849.5 750.5Zm-26.5 250-758-758q-8-7.547-8-19.069 0-11.522 9-20.431 8.5-8.5 20-8.5t20.5 8.5l758 758q7.5 7.93 7.5 19.465t-8.5 20.035q-9 9-20.5 9t-20-9Zm-340-502Zm-319.5-238L221 318h-77.5v516h516v-77.5L716 813v21q0 22.969-17.266 40.234Q681.469 891.5 658.5 891.5h-515q-22.969 0-40.234-17.266Q86 856.969 86 834V318q0-22.969 17.266-40.234Q120.531 260.5 143.5 260.5h20Zm236 316.5Z"/></svg>

Before

Width:  |  Height:  |  Size: 686 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="44" viewBox="0 96 960 960" width="44"><path fill="white" d="M232.5 693q-12.5 0-20.5-8t-8-20.5v-177q0-12.5 8-20.5t20.5-8h129L509 311.5q13.5-13.5 31-6.5t17.5 26v489.5q0 19.5-17.5 26.5t-31-6.5L361.5 693h-129ZM615 742V409.5q55 17 88 63.25T736 576q0 58-33 103.25T615 742ZM500 408.5l-112.5 108h-126v119h126L500 744V408.5ZM379 576Z"/></svg>

Before

Width:  |  Height:  |  Size: 381 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="44" viewBox="0 -960 960 960" width="44" fill="white"><path d="m809.5-61.5-133-133q-27 19-58.25 33.25T553.5-139.5V-199q21.5-6.5 42.5-14.5t39.5-22L476-396v229L280-363H122.5v-234H274L54.5-816.5 96-858l755 754-41.5 42.5ZM770-291l-41.5-41.5q20-33 29.75-70.67Q768-440.85 768-481q0-100.82-58.75-180.41T553.5-763v-59.5q120 28 196 123.25t76 218.25q0 50.5-14 98.75T770-291ZM642.5-418.5l-89-89v-132q46.5 21.5 73.75 64.75T654.5-480q0 16-3 31.5t-9 30ZM476-585 372-689l104-104v208Zm-57.5 278v-145.5l-87-87H180v119h124.5l114 113.5ZM375-496Z"/></svg>

Before

Width:  |  Height:  |  Size: 582 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="44" viewBox="0 96 960 960" width="44"><path fill="white" d="M600.5 904.5q-15.5 5.5-28.25-4T559.5 874q0-7.5 4.25-13.5t11.75-8q89-31.5 143.75-107T774 575q0-94.5-54.5-170.5T575.5 298q-7-2-11.5-8.5t-4.5-14.5q0-16 13.25-25.25t27.75-4.25Q704.5 283 768 373t63.5 202q0 112.5-63.5 202.5t-167.5 127ZM157 693q-12.5 0-20.5-8t-8-20.5v-177q0-12.5 8-20.5t20.5-8h129l147.5-147.5q13.5-13.5 31-6.25T482 331v489.5q0 19-17.5 26.25t-31-6.25L286 693H157Zm382.5 49V409.5q55 17 88 63.25t33 103.25q0 58-33 103.25t-88 62.75Zm-115-333.5L312 516.5H186v119h126L424.5 744V408.5Zm-93 167.5Z"/></svg>

Before

Width:  |  Height:  |  Size: 616 B

@@ -1,227 +0,0 @@
html,
body {
padding: 0;
margin: 0;
background-color: black;
font-family: Arial, Helvetica, sans-serif;
}
#remote-video-stream.inline {
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
}
#remote-video-stream.collapsed {
position: absolute;
width: 20%;
max-width: 20%;
height: 15.03vw;
object-fit: cover;
margin: 16px;
border-radius: 16px;
bottom: 80px;
right: 0;
}
#remote-video-stream.collapsed-pip {
position: absolute;
max-width: 30%;
max-height: 30%;
object-fit: cover;
margin: 8px;
border-radius: 8px;
bottom: 0;
right: 0;
}
#remote-screen-video-stream.inline {
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
}
#local-video-stream.inline {
background-color: black;
position: absolute;
width: 20%;
max-width: 20%;
height: 15.03vw;
object-fit: cover;
margin: 16px;
border-radius: 16px;
top: 0;
right: 0;
}
#local-screen-video-stream.inline {
position: absolute;
width: 20%;
max-width: 20%;
object-fit: cover;
margin: 16px;
border-radius: 16px;
top: 33%;
right: 0;
}
#remote-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
#remote-screen-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
#local-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
#local-screen-video-stream.fullscreen {
position: absolute;
height: 100%;
width: 100%;
object-fit: cover;
}
*::-webkit-media-controls {
display: none !important;
-webkit-appearance: none !important;
}
*::-webkit-media-controls-panel {
display: none !important;
-webkit-appearance: none !important;
}
*::-webkit-media-controls-play-button {
display: none !important;
-webkit-appearance: none !important;
}
*::-webkit-media-controls-start-playback-button {
display: none !important;
-webkit-appearance: none !important;
}
#manage-call {
position: absolute;
width: fit-content;
bottom: 15px;
left: 50%;
transform: translate(-50%, 0);
display: grid;
grid-auto-flow: column;
grid-column-gap: 38px;
}
#manage-call button {
border: none;
cursor: pointer;
appearance: none;
background-color: #ffffff33;
border-radius: 50%;
padding: 13px;
width: 61px;
height: 61px;
}
#manage-call img {
width: 35px;
height: 35px;
}
#manage-call button .video {
background: #00000033;
}
#progress {
position: absolute;
left: 50%;
top: 50%;
margin-left: -52px;
margin-top: -52px;
border-radius: 50%;
border-top: 5px solid white;
border-right: 5px solid white;
border-bottom: 5px solid white;
border-left: 5px solid black;
width: 100px;
height: 100px;
-webkit-animation: spin 2s linear infinite;
animation: spin 2s linear infinite;
}
#permission-denied {
position: absolute;
width: 100%;
text-align: center;
height: 100%;
align-content: center;
color: white;
}
#permission-denied-title {
font-weight: bold;
}
#permission-denied-desc {
font-style: italic;
}
@-webkit-keyframes spin {
0% {
-webkit-transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
#info-block {
position: absolute;
color: white;
opacity: 0.8;
width: 200px;
}
#info-block.audio {
text-align: center;
left: 50%;
top: 50%;
margin-left: -100px;
margin-top: 100px;
}
#info-block.video {
left: 16px;
top: 2px;
}
#audio-call-icon {
position: absolute;
display: none;
left: 50%;
top: 50%;
margin-left: -50px;
margin-top: -44px;
width: 100px;
height: 100px;
}
@@ -1,195 +0,0 @@
"use strict";
// Override defaults to enable worker on Chrome and Safari
useWorker = typeof window.Worker !== "undefined";
isDesktop = true;
// Create WebSocket connection. location.search carries the per-call ?token=... capability required by the server.
const socket = new WebSocket(`ws://${location.host}${location.search}`);
socket.addEventListener("open", (_event) => {
console.log("Opened socket");
sendMessageToNative = (msg) => {
console.log("Message to server");
socket.send(JSON.stringify(msg));
reactOnMessageToServer(msg);
};
});
socket.addEventListener("message", (event) => {
const parsed = JSON.parse(event.data);
reactOnMessageFromServer(parsed);
processCommand(parsed);
console.log("Message from server");
});
socket.addEventListener("close", (_event) => {
console.log("Closed socket");
sendMessageToNative = (_msg) => {
console.log("Tried to send message to native but the socket was closed already");
};
window.close();
});
function endCallManually() {
sendMessageToNative({ resp: { type: "end" } });
}
function toggleMicManually() {
const enable = activeCall ? !activeCall.localMediaSources.mic : !inactiveCallMediaSources.mic;
const apiCall = {
command: { type: "media", source: CallMediaSource.Mic, enable: enable },
};
processCommand(apiCall);
}
function toggleSpeakerManually() {
if ((activeCall === null || activeCall === void 0 ? void 0 : activeCall.remoteStream) && activeCall.peerMediaSources.mic) {
enableSpeakerIcon(togglePeerMedia(activeCall.remoteStream, CallMediaType.Audio), !activeCall.peerMediaSources.mic);
}
}
function toggleCameraManually() {
const enable = activeCall ? !activeCall.localMediaSources.camera : !inactiveCallMediaSources.camera;
const apiCall = {
command: { type: "media", source: CallMediaSource.Camera, enable: enable },
};
processCommand(apiCall);
}
async function toggleScreenManually() {
await toggleScreenShare();
}
// override function in call.ts to adapt UI to enabled media sources
localOrPeerMediaSourcesChanged = (call) => {
enableMicIcon(call.localMediaSources.mic);
enableCameraIcon(call.localMediaSources.camera);
enableScreenIcon(call.localMediaSources.screenVideo);
const className = localMedia(call) == CallMediaType.Video || peerMedia(call) == CallMediaType.Video ? CallMediaType.Video : CallMediaType.Audio;
document.getElementById("info-block").className = className;
if (call.connection.iceConnectionState == "connected") {
document.getElementById("audio-call-icon").style.display = className == CallMediaType.Audio ? "block" : "none";
}
// document.getElementById("media-sources")!.innerText = mediaSourcesStatus(call)
document.getElementById("manage-call").className = localMedia(call) == CallMediaType.Video ? CallMediaType.Video : "";
};
// override function in call.ts to adapt UI to enabled media sources
inactiveCallMediaSourcesChanged = (inactiveCallMediaSources) => {
const mic = inactiveCallMediaSources.mic;
const camera = inactiveCallMediaSources.camera;
const screenVideo = inactiveCallMediaSources.screenVideo;
enableMicIcon(mic);
enableCameraIcon(camera);
enableScreenIcon(screenVideo);
const className = camera ? CallMediaType.Video : CallMediaType.Audio;
document.getElementById("info-block").className = className;
// document.getElementById("media-sources")!.innerText = inactiveCallMediaSourcesStatus(inactiveCallMediaSources)
};
failedToGetPermissions = (title, description) => {
document.getElementById("info-block").style.visibility = "hidden";
document.getElementById("progress").style.visibility = "hidden";
document.getElementById("permission-denied-title").innerText = title;
document.getElementById("permission-denied-desc").innerText = description;
document.getElementById("toggle-mic").style.visibility = "hidden";
document.getElementById("toggle-camera").style.visibility = "hidden";
document.getElementById("toggle-screen").style.visibility = "hidden";
document.getElementById("toggle-speaker").style.visibility = "hidden";
};
function enableMicIcon(enabled) {
document.getElementById("toggle-mic").innerHTML = enabled
? '<img src="/desktop/images/ic_mic.svg" />'
: '<img src="/desktop/images/ic_mic_off.svg" />';
}
function enableCameraIcon(enabled) {
document.getElementById("toggle-camera").innerHTML = enabled
? '<img src="/desktop/images/ic_videocam_filled.svg" />'
: '<img src="/desktop/images/ic_videocam_off.svg" />';
}
function enableScreenIcon(enabled) {
document.getElementById("toggle-screen").innerHTML = enabled
? '<img src="/desktop/images/ic_stop_screen_share.svg" />'
: '<img src="/desktop/images/ic_screen_share.svg" />';
}
function enableSpeakerIcon(enabled, muted) {
document.getElementById("toggle-speaker").innerHTML = muted
? '<img src="/desktop/images/ic_volume_off.svg" />'
: enabled
? '<img src="/desktop/images/ic_volume_up.svg" />'
: '<img src="/desktop/images/ic_volume_down.svg" />';
document.getElementById("toggle-speaker").style.opacity = muted ? "0.7" : "1";
}
function mediaSourcesStatus(call) {
let status = "local";
if (call.localMediaSources.mic)
status += " mic";
if (call.localMediaSources.camera)
status += " cam";
if (call.localMediaSources.screenAudio)
status += " scrA";
if (call.localMediaSources.screenVideo)
status += " scrV";
status += " | peer";
if (call.peerMediaSources.mic)
status += " mic";
if (call.peerMediaSources.camera)
status += " cam";
if (call.peerMediaSources.screenAudio)
status += " scrA";
if (call.peerMediaSources.screenVideo)
status += " scrV";
return status;
}
function inactiveCallMediaSourcesStatus(inactiveCallMediaSources) {
let status = "local";
const mic = inactiveCallMediaSources.mic;
const camera = inactiveCallMediaSources.camera;
const screenAudio = inactiveCallMediaSources.screenAudio;
const screenVideo = inactiveCallMediaSources.screenVideo;
if (mic)
status += " mic";
if (camera)
status += " cam";
if (screenAudio)
status += " scrA";
if (screenVideo)
status += " scrV";
return status;
}
function reactOnMessageFromServer(msg) {
var _a, _b, _c;
// screen is not allowed to be enabled before connection estabilished
if (((_a = msg.command) === null || _a === void 0 ? void 0 : _a.type) == "capabilities" || ((_b = msg.command) === null || _b === void 0 ? void 0 : _b.type) == "offer") {
document.getElementById("toggle-screen").style.opacity = "0.7";
}
else if (activeCall) {
document.getElementById("toggle-screen").style.opacity = "1";
}
switch ((_c = msg.command) === null || _c === void 0 ? void 0 : _c.type) {
case "capabilities":
case "offer":
case "start":
document.getElementById("info-block").className = msg.command.media;
document.getElementById("toggle-mic").style.display = "inline-block";
document.getElementById("toggle-speaker").style.display = "inline-block";
document.getElementById("toggle-camera").style.display = "inline-block";
document.getElementById("toggle-screen").style.display = "inline-block";
enableSpeakerIcon(true, true);
break;
case "description":
updateCallInfoView(msg.command.state, msg.command.description);
if ((activeCall === null || activeCall === void 0 ? void 0 : activeCall.connection.connectionState) == "connected") {
document.getElementById("progress").style.display = "none";
document.getElementById("audio-call-icon").style.display =
document.getElementById("info-block").className == CallMediaType.Audio ? "block" : "none";
}
break;
}
}
function reactOnMessageToServer(msg) {
var _a;
if (!activeCall)
return;
switch ((_a = msg.resp) === null || _a === void 0 ? void 0 : _a.type) {
case "peerMedia":
const className = localMedia(activeCall) == CallMediaType.Video || peerMedia(activeCall) == CallMediaType.Video ? "video" : "audio";
document.getElementById("info-block").className = className;
document.getElementById("audio-call-icon").style.display = className == CallMediaType.Audio ? "block" : "none";
enableSpeakerIcon(activeCall.remoteStream.getAudioTracks().every((elem) => elem.enabled), !activeCall.peerMediaSources.mic);
break;
}
}
function updateCallInfoView(state, description) {
document.getElementById("state").innerText = state;
document.getElementById("description").innerText = description;
}
//# sourceMappingURL=ui.js.map
@@ -1,39 +1,62 @@
package chat.simplex.common.views.call
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.helpers.*
import chat.simplex.res.*
import kotlinx.coroutines.delay
import dev.icerock.moko.resources.StringResource
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import dev.onvoid.webrtc.media.FourCC
import dev.onvoid.webrtc.media.video.VideoBufferConverter
import dev.onvoid.webrtc.media.video.VideoDevice
import dev.onvoid.webrtc.media.video.VideoFrame
import dev.onvoid.webrtc.media.video.VideoTrack
import dev.onvoid.webrtc.media.video.VideoTrackSink
import kotlinx.coroutines.isActive
import kotlinx.coroutines.flow.*
import kotlinx.datetime.Clock
import kotlinx.serialization.encodeToString
import org.nanohttpd.protocols.http.IHTTPSession
import org.nanohttpd.protocols.http.response.Response
import org.nanohttpd.protocols.http.response.Response.newFixedLengthResponse
import org.nanohttpd.protocols.http.response.Status
import org.nanohttpd.protocols.websockets.*
import java.io.IOException
import java.net.BindException
import java.security.SecureRandom
import java.util.Base64
import java.util.concurrent.atomic.AtomicBoolean
import org.jetbrains.skia.ColorAlphaType
import org.jetbrains.skia.ColorType
import org.jetbrains.skia.Image
import org.jetbrains.skia.ImageInfo
import kotlin.math.min
private const val SERVER_HOST = "localhost"
private const val SERVER_PORT = 50395
private const val CALL_SERVER_TOKEN_BYTES = 32
val connections = ArrayList<WebSocket>()
private val callForeground = Color(0xFFFFFFD8)
private val controlButtonSize = 56.dp
private val localVideoWidth = 240.dp
// Spec: spec/services/calls.md#ActiveCallView
@Composable
actual fun ActiveCallView() {
val scope = rememberCoroutineScope()
WebRTCController(chatModel.callCommand) { apiMsg ->
Log.d(TAG, "received from WebRTCController: ${apiMsg.resp.javaClass.simpleName}")
val engine = NativeCallController(chatModel.callCommand) { apiMsg ->
Log.d(TAG, "received from NativeCallEngine: ${apiMsg.resp.javaClass.simpleName}")
val call = chatModel.activeCall.value
if (call != null) {
Log.d(TAG, "has active call $call")
@@ -111,24 +134,14 @@ actual fun ActiveCallView() {
CallMediaSource.ScreenVideo -> chatModel.activeCall.value = call.copy(localMediaSources = sources.copy(screenVideo = cmd.enable))
}
}
is WCallCommand.Camera -> {
chatModel.activeCall.value = call.copy(localCamera = cmd.camera)
if (!call.localMediaSources.mic) {
chatModel.callCommand.add(WCallCommand.Media(CallMediaSource.Mic, enable = false))
}
}
is WCallCommand.End ->
chatModel.showCallView.value = false
else -> {}
}
is WCallResponse.Error -> {
when (apiMsg.command) {
is WCallCommand.Capabilities -> chatModel.callCommand.add(WCallCommand.Permission(
title = generalGetString(MR.strings.call_desktop_permission_denied_title),
chrome = generalGetString(MR.strings.call_desktop_permission_denied_chrome),
safari = generalGetString(MR.strings.call_desktop_permission_denied_safari)
))
else -> {}
if (apiMsg.command is WCallCommand.Capabilities) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.call_desktop_permission_denied_title))
withBGApi { chatModel.callManager.endCall(call) }
}
Log.e(TAG, "ActiveCallView: command error ${r.message}")
}
@@ -136,7 +149,10 @@ actual fun ActiveCallView() {
}
}
SendStateUpdates()
val call = chatModel.activeCall.value
if (engine != null && call != null && !chatModel.activeCallViewIsCollapsed.value) {
ActiveCallLayout(engine, call)
}
DisposableEffect(Unit) {
chatModel.activeCallViewIsVisible.value = true
// After the first call, End command gets added to the list which prevents making another calls
@@ -150,170 +166,235 @@ actual fun ActiveCallView() {
}
@Composable
private fun SendStateUpdates() {
LaunchedEffect(Unit) {
snapshotFlow { chatModel.activeCall.value }
.distinctUntilChanged()
.filterNotNull()
.collect { call ->
val state = call.callState.text
val connInfo = call.connectionInfo
val connInfoText = if (connInfo == null) "" else " (${connInfo.text})"
val description = call.encryptionStatus + connInfoText
chatModel.callCommand.add(WCallCommand.Description(state, description))
}
}
}
@Composable
fun WebRTCController(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIMessage) -> Unit) {
val uriHandler = LocalUriHandler.current
val token = remember { newCallServerToken() }
val endCall = {
val call = chatModel.activeCall.value
if (call != null) withBGApi { chatModel.callManager.endCall(call) }
}
val server = remember {
startServer(onResponse, token = token).apply {
try {
uriHandler.openUri("http://${SERVER_HOST}:${listeningPort}/simplex/call/?token=$token")
} catch (e: Exception) {
Log.e(TAG, "Unable to open browser: ${e.stackTraceToString()}")
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.unable_to_open_browser_title),
text = generalGetString(MR.strings.unable_to_open_browser_desc)
)
endCall()
}
private fun NativeCallController(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIMessage) -> Unit): NativeCallEngine? {
val engine = remember {
try {
NativeCallEngine(onResponse)
} catch (e: Throwable) {
Log.e(TAG, "Unable to start call engine: ${e.stackTraceToString()}")
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.call_desktop_engine_error), e.message)
chatModel.activeCall.value?.let { call -> withBGApi { chatModel.callManager.endCall(call) } }
null
}
}
fun processCommand(cmd: WCallCommand) {
val apiCall = WVAPICall(command = cmd)
for (connection in connections.toList()) {
try {
connection.send(json.encodeToString(apiCall))
break
} catch (e: Exception) {
Log.e(TAG, "Failed to send message to browser: ${e.stackTraceToString()}")
}
if (engine != null) {
DisposableEffect(engine) {
onDispose { engine.close() }
}
}
DisposableEffect(Unit) {
onDispose {
processCommand(WCallCommand.End)
server.stop()
connections.clear()
}
}
LaunchedEffect(Unit) {
while (isActive) {
// waiting for the first command would miss a command equal to the previous one
snapshotFlow { callCommand.isNotEmpty() }.first { it }
while (connections.isEmpty()) {
delay(100)
}
while (callCommand.isNotEmpty()) {
val cmd = callCommand.removeFirstOrNull()
Log.d(TAG, "WebRTCController LaunchedEffect executing ${cmd?.javaClass?.simpleName}")
if (cmd != null) {
processCommand(cmd)
LaunchedEffect(engine) {
while (isActive) {
// waiting for the first command would miss a command equal to the previous one
snapshotFlow { callCommand.isNotEmpty() }.first { it }
while (callCommand.isNotEmpty()) {
val cmd = callCommand.removeFirstOrNull() ?: break
Log.d(TAG, "NativeCallController executing ${cmd.javaClass.simpleName}")
engine.process(cmd)
}
}
}
}
return engine
}
fun startServer(
onResponse: (WVAPIMessage) -> Unit,
port: Int = SERVER_PORT,
token: String = newCallServerToken(),
): NanoWSD {
val server = object: NanoWSD(SERVER_HOST, port) {
override fun openWebSocket(session: IHTTPSession): WebSocket = MyWebSocket(onResponse, session)
fun resourcesToResponse(path: String): Response {
val uri = Class.forName("chat.simplex.common.AppKt").getResource("/assets/www$path") ?: return resourceNotFound
val response = newFixedLengthResponse(
Status.OK, getMimeTypeForFile(uri.file),
uri.openStream().readBytes()
)
response.setKeepAlive(true)
response.setUseGzip(true)
return response
@Composable
private fun ActiveCallLayout(engine: NativeCallEngine, call: Call) {
val remoteCamera by engine.remoteCamera.collectAsState()
val remoteScreen by engine.remoteScreen.collectAsState()
val localCamera by engine.localCamera.collectAsState()
val peer = call.peerMediaSources
val mainVideo = if (peer.screenVideo) remoteScreen else if (peer.camera) remoteCamera else null
Box(
Modifier
.fillMaxSize()
.background(Color.Black)
// consumes clicks so they don't reach the chat UI below
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = {})
) {
if (mainVideo != null) {
CallVideoView(engine, mainVideo, Modifier.fillMaxSize())
} else {
Column(Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) {
ProfileImage(size = 192.dp, image = call.contact.profile.image)
CallInfoView(call, showName = true)
}
}
val resourceNotFound = newFixedLengthResponse(Status.NOT_FOUND, "text/plain", "This page couldn't be found")
val webSocketAccepted = AtomicBoolean(false)
override fun handle(session: IHTTPSession): Response {
return when {
session.headers["upgrade"] == "websocket" ->
if (
session.headers["origin"] == "http://${SERVER_HOST}:${listeningPort}"
&& hasValidCallServerToken(session.parameters, token)
&& webSocketAccepted.compareAndSet(false, true)
) {
super.handle(session)
} else {
unauthorizedResponse()
Column(Modifier.align(Alignment.TopEnd).padding(DEFAULT_PADDING), horizontalAlignment = Alignment.End) {
val camera = localCamera
if (camera != null && call.localMediaSources.camera) {
CallVideoView(engine, camera, Modifier.width(localVideoWidth).clip(RoundedCornerShape(8.dp)), sizeToFrame = true)
}
val remoteCameraBesideScreen = remoteCamera
if (peer.screenVideo && peer.camera && remoteCameraBesideScreen != null) {
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
CallVideoView(engine, remoteCameraBesideScreen, Modifier.width(localVideoWidth).clip(RoundedCornerShape(8.dp)), sizeToFrame = true)
}
}
Column {
CallAppBar(
title = {
if (mainVideo != null) {
Text(call.contact.chatViewName, color = callForeground, style = MaterialTheme.typography.h2, overflow = TextOverflow.Ellipsis, maxLines = 1)
}
session.uri.contains("/simplex/call/") ->
if (hasValidCallServerToken(session.parameters, token)) {
resourcesToResponse("/desktop/call.html")
} else {
unauthorizedResponse()
}
else -> resourcesToResponse(uriCreateOrNull(session.uri)?.path ?: return newFixedLengthResponse("Error parsing URL"))
},
onBack = { chatModel.activeCallViewIsCollapsed.value = true }
)
if (mainVideo != null) {
Box(Modifier.padding(horizontal = DEFAULT_PADDING)) { CallInfoView(call, showName = false) }
}
}
CallControls(engine, call, Modifier.align(Alignment.BottomCenter).padding(bottom = DEFAULT_PADDING * 2))
}
}
@Composable
private fun CallInfoView(call: Call, showName: Boolean) {
Column(horizontalAlignment = if (showName) Alignment.CenterHorizontally else Alignment.Start) {
if (showName) Text(call.contact.chatViewName, color = callForeground, style = MaterialTheme.typography.h2)
Text(call.callState.text, color = callForeground, style = MaterialTheme.typography.body2)
val connInfo = call.connectionInfo
val connInfoText = if (connInfo == null) "" else " (${connInfo.text})"
Text(call.encryptionStatus + connInfoText, color = callForeground, style = MaterialTheme.typography.body2)
}
}
@Composable
private fun CallControls(engine: NativeCallEngine, call: Call, modifier: Modifier) {
val remoteAudioEnabled = remember { mutableStateOf(true) }
// A video call starts with the camera marked on even when no camera could be opened
val cameraOn = call.localMediaSources.camera && engine.localCamera.collectAsState().value != null
Row(modifier, horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING), verticalAlignment = Alignment.CenterVertically) {
val sources = call.localMediaSources
ControlButton(
painterResource(if (sources.mic) MR.images.ic_mic else MR.images.ic_mic_off),
if (sources.mic) MR.strings.icon_descr_audio_off else MR.strings.icon_descr_audio_on
) { chatModel.callCommand.add(WCallCommand.Media(CallMediaSource.Mic, enable = !sources.mic)) }
ControlButton(
painterResource(if (remoteAudioEnabled.value) MR.images.ic_volume_up else MR.images.ic_volume_off),
if (remoteAudioEnabled.value) MR.strings.icon_descr_speaker_off else MR.strings.icon_descr_speaker_on
) {
remoteAudioEnabled.value = !remoteAudioEnabled.value
engine.setRemoteAudioEnabled(remoteAudioEnabled.value)
}
ControlButton(
painterResource(if (cameraOn) MR.images.ic_videocam_filled else MR.images.ic_videocam_off),
if (cameraOn) MR.strings.icon_descr_video_off else MR.strings.icon_descr_video_on
) { chatModel.callCommand.add(WCallCommand.Media(CallMediaSource.Camera, enable = !cameraOn)) }
ScreenShareButton(engine, sources.screenVideo)
CameraPickerButton(engine)
ControlButton(painterResource(MR.images.ic_call_end_filled), MR.strings.icon_descr_hang_up, background = Color.Red) {
withBGApi { chatModel.callManager.endCall(call) }
}
}
}
@Composable
private fun ScreenShareButton(engine: NativeCallEngine, sharing: Boolean) {
val showMenu = remember { mutableStateOf(false) }
val sources = remember { mutableStateOf(emptyList<ScreenShareSource>()) }
Box {
ControlButton(
painterResource(if (sharing) MR.images.ic_stop_filled else MR.images.ic_desktop),
if (sharing) MR.strings.icon_descr_screen_share_off else MR.strings.icon_descr_screen_share_on
) {
if (sharing) {
chatModel.callCommand.add(WCallCommand.Media(CallMediaSource.ScreenVideo, enable = false))
} else {
sources.value = NativeCallEngine.screenShareSources()
showMenu.value = true
}
}
DefaultDropdownMenu(showMenu) {
var screenNumber = 0
sources.value.forEach { source ->
val title = if (!source.isWindow) {
screenNumber++
source.title.ifEmpty { String.format(generalGetString(MR.strings.call_screen_share_screen), screenNumber) }
} else {
source.title
}
ItemAction(title, painterResource(if (source.isWindow) MR.images.ic_more_horiz else MR.images.ic_desktop), onClick = {
showMenu.value = false
engine.screenShareSource = source
chatModel.callCommand.add(WCallCommand.Media(CallMediaSource.ScreenVideo, enable = true))
})
}
}
}
try {
server.start(60_000_000)
} catch (e: BindException) {
if (port == 0) throw e
Log.w(TAG, "Call server port $port is busy, using a random port: ${e.message}")
server.stop()
return startServer(onResponse, port = 0, token = token)
}
return server
}
internal fun newCallServerToken(): String {
val bytes = ByteArray(CALL_SERVER_TOKEN_BYTES)
SecureRandom().nextBytes(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
internal fun hasValidCallServerToken(parameters: Map<String, List<String>>, token: String): Boolean =
token.isNotEmpty() && parameters["token"]?.any { it == token } == true
private fun unauthorizedResponse(): Response =
newFixedLengthResponse(Status.UNAUTHORIZED, "text/plain", "Unauthorized")
class MyWebSocket(val onResponse: (WVAPIMessage) -> Unit, handshakeRequest: IHTTPSession) : WebSocket(handshakeRequest) {
override fun onOpen() {
connections.add(this)
}
override fun onClose(closeCode: CloseCode?, reason: String?, initiatedByRemote: Boolean) {
onResponse(WVAPIMessage(null, WCallResponse.End))
}
override fun onMessage(message: WebSocketFrame) {
Log.d(TAG, "MyWebSocket.onMessage")
try {
// for debugging
// onResponse(message.textPayload)
onResponse(json.decodeFromString(message.textPayload))
} catch (e: Exception) {
Log.e(TAG, "failed parsing browser message")
@Composable
private fun CameraPickerButton(engine: NativeCallEngine) {
val showMenu = remember { mutableStateOf(false) }
val cameras = remember { mutableStateOf(emptyList<VideoDevice>()) }
Box {
ControlButton(painterResource(MR.images.ic_settings), MR.strings.permissions_camera) {
cameras.value = NativeCallEngine.videoCaptureDevices()
showMenu.value = true
}
DefaultDropdownMenu(showMenu) {
if (cameras.value.isEmpty()) {
Text(stringResource(MR.strings.camera_not_available), Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), color = MaterialTheme.colors.secondary)
}
cameras.value.forEach { camera ->
ItemAction(camera.name, onClick = {
showMenu.value = false
engine.selectCamera(camera)
})
}
}
}
}
override fun onPong(pong: WebSocketFrame?) = Unit
override fun onException(exception: IOException) {
Log.e(TAG, "WebSocket exception: ${exception.stackTraceToString()}")
@Composable
private fun ControlButton(icon: Painter, description: StringResource, background: Color = Color.White.copy(alpha = 0.2f), action: () -> Unit) {
Box(
Modifier
.size(controlButtonSize)
.background(background, CircleShape)
.clip(CircleShape)
.clickable(onClick = action, role = Role.Button),
contentAlignment = Alignment.Center
) {
Icon(icon, stringResource(description), Modifier.padding(controlButtonSize * 0.2f).fillMaxSize(), tint = callForeground)
}
}
private class RenderedFrame(val image: ImageBitmap, val rotation: Int)
@Composable
private fun CallVideoView(engine: NativeCallEngine, track: VideoTrack, modifier: Modifier, sizeToFrame: Boolean = false) {
val frame = remember(track) { mutableStateOf<RenderedFrame?>(null) }
DisposableEffect(track) {
val sink = VideoTrackSink { videoFrame -> frame.value = RenderedFrame(videoFrame.toImageBitmap(), videoFrame.rotation) }
engine.addVideoSink(track, sink)
onDispose { engine.removeVideoSink(track, sink) }
}
val f = frame.value ?: return
val rotated = f.rotation % 180 != 0
val width = if (rotated) f.image.height else f.image.width
val height = if (rotated) f.image.width else f.image.height
Canvas(if (sizeToFrame) modifier.aspectRatio(width.toFloat() / height) else modifier) {
val scale = min(size.width / width, size.height / height)
val dstSize = IntSize((f.image.width * scale).toInt(), (f.image.height * scale).toInt())
translate(size.width / 2, size.height / 2) {
rotate(f.rotation.toFloat(), pivot = Offset.Zero) {
drawImage(f.image, dstOffset = IntOffset(-dstSize.width / 2, -dstSize.height / 2), dstSize = dstSize)
}
}
}
}
private fun VideoFrame.toImageBitmap(): ImageBitmap {
val i420 = buffer.toI420()
try {
val width = i420.width
val height = i420.height
val pixels = ByteArray(width * height * 4)
// libyuv names formats by 32-bit word, so ARGB is laid out in memory as B, G, R, A
VideoBufferConverter.convertFromI420(i420, pixels, FourCC.ARGB)
val info = ImageInfo(width, height, ColorType.BGRA_8888, ColorAlphaType.OPAQUE)
return Image.makeRaster(info, pixels, width * 4).toComposeImageBitmap()
} finally {
i420.release()
}
}
@@ -0,0 +1,729 @@
package chat.simplex.common.views.call
import chat.simplex.common.platform.Log
import chat.simplex.common.platform.TAG
import dev.onvoid.webrtc.*
import dev.onvoid.webrtc.media.MediaDevices
import dev.onvoid.webrtc.media.MediaStreamTrack
import dev.onvoid.webrtc.media.MediaType
import dev.onvoid.webrtc.media.audio.*
import dev.onvoid.webrtc.media.video.*
import dev.onvoid.webrtc.media.video.desktop.DesktopSource
import dev.onvoid.webrtc.media.video.desktop.ScreenCapturer
import dev.onvoid.webrtc.media.video.desktop.WindowCapturer
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import java.util.concurrent.Executors
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
data class ScreenShareSource(val title: String, val id: Long, val isWindow: Boolean)
// Native counterpart of call.ts processCommand: consumes WCallCommand and reports WCallResponse with the same
// semantics, so it interoperates with call.ts (Android, older desktop) and iOS peers.
// All state is confined to one thread; webrtc callbacks are posted onto it.
class NativeCallEngine(private val onResponse: (WVAPIMessage) -> Unit) {
private val executor = Executors.newSingleThreadExecutor { Thread(it, "call-engine").apply { isDaemon = true } }
private val scope = CoroutineScope(executor.asCoroutineDispatcher() + SupervisorJob())
// Without any audio backend the call can still receive video, so audio falls back to the dummy layer
private val audioModule = try {
AudioDeviceModule()
} catch (e: Error) {
Log.e(TAG, "NativeCallEngine audio devices unavailable: ${e.message}")
AudioDeviceModule(AudioLayer.kDummyAudio)
}
private val factory = PeerConnectionFactory(audioModule)
private val micSource = factory.createAudioSource(AudioOptions().apply {
echoCancellation = true
autoGainControl = true
noiseSuppression = true
})
private val micTrack = factory.createAudioTrack("mic", micSource)
private var cameraSource: VideoDeviceSource? = null
private var cameraTrack: VideoTrack? = null
private var screenSource: VideoDesktopSource? = null
private var screenTrack: VideoTrack? = null
private val placeholderVideoSource = CustomVideoSource()
private val placeholderTracks = ArrayList<MediaStreamTrack>()
private var pc: RTCPeerConnection? = null
private val slots = ArrayList<TransceiverSlot>()
private var senderCipher: CallFrameCipher? = null
private var receiverCipher: CallFrameCipher? = null
private var micEnabled = true
private var peerHasOldVersion = false
private val peerMedia = HashMap<CallMediaSource, Boolean>()
private val pendingCandidates = ArrayList<IceCandidateJson>()
private val gatheredCandidates = ArrayList<IceCandidateJson>()
private var iceGatheringComplete = CompletableDeferred<Unit>()
private var connectionTimeout: Job? = null
private var reconnectingTimeout: Job? = null
private var iceRestartTimeout: Job? = null
private var wasConnected = false
private var statsPolling: Job? = null
private val videoSinks = ArrayList<Pair<VideoTrack, VideoTrackSink>>()
private var closed = false
private val _localCamera = MutableStateFlow<VideoTrack?>(null)
val localCamera: StateFlow<VideoTrack?> = _localCamera
private val _remoteCamera = MutableStateFlow<VideoTrack?>(null)
val remoteCamera: StateFlow<VideoTrack?> = _remoteCamera
private val _remoteScreen = MutableStateFlow<VideoTrack?>(null)
val remoteScreen: StateFlow<VideoTrack?> = _remoteScreen
var screenShareSource: ScreenShareSource? = null
private var remoteAudioEnabled = true
fun process(command: WCallCommand) {
scope.launch {
val resp = try {
handle(command)
} catch (e: Exception) {
Log.e(TAG, "NativeCallEngine ${command.javaClass.simpleName}: ${e.stackTraceToString()}")
WCallResponse.Error("${commandName(command)}: ${e.message}")
}
onResponse(WVAPIMessage(resp = resp, command = command))
}
}
fun addVideoSink(track: VideoTrack, sink: VideoTrackSink) {
scope.launch {
if (closed) return@launch
track.addSink(sink)
videoSinks.add(track to sink)
}
}
fun removeVideoSink(track: VideoTrack, sink: VideoTrackSink) {
scope.launch {
if (videoSinks.remove(track to sink)) track.removeSink(sink)
}
}
fun setRemoteAudioEnabled(enabled: Boolean) {
scope.launch {
remoteAudioEnabled = enabled
slots.forEach { if (it.receiver.track is AudioTrack) it.receiver.track.isEnabled = enabled }
}
}
fun selectCamera(device: VideoDevice) {
scope.launch {
selectedCamera = device
if (cameraSource != null) {
slot(CallMediaSource.Camera)?.sender?.replaceTrack(null)
stopCamera()
startCamera()
slot(CallMediaSource.Camera)?.sender?.replaceTrack(cameraTrack)
}
}
}
fun close(): Job =
scope.launch {
closed = true
endCall()
micTrack.dispose()
micSource.dispose()
placeholderVideoSource.dispose()
factory.dispose()
audioModule.dispose()
scope.cancel()
executor.shutdown()
}
private suspend fun handle(command: WCallCommand): WCallResponse = when (command) {
is WCallCommand.Capabilities ->
if (MediaDevices.getAudioCaptureDevices().isEmpty()) {
WCallResponse.Error("capabilities: no permissions were granted for mic and/or camera")
} else {
if (command.media == CallMediaType.Video) startCamera()
WCallResponse.Capabilities(CallCapabilities(encryption = true))
}
is WCallCommand.Start -> {
if (pc != null) endCall()
if (command.media == CallMediaType.Video && cameraSource == null) startCamera()
val conn = createConnection(command.iceServers, command.relay, command.aesKey)
addTransceivers(conn)
setupCodecsAndEncryption()
val offer = conn.awaitCreateOffer()
conn.awaitSetLocalDescription(offer)
setupAudioSending()
WCallResponse.Offer(serializeSdp(offer), initialIceCandidates(), CallCapabilities(encryption = true))
}
is WCallCommand.Offer ->
if (pc != null) {
WCallResponse.Error("accept: call already started")
} else {
val conn = createConnection(command.iceServers, command.relay, command.aesKey)
conn.awaitSetRemoteDescription(parseSdp(command.offer))
conn.transceivers.forEach { slots.add(TransceiverSlot(it)) }
slot(CallMediaSource.Mic)?.sender?.replaceTrack(micTrack)
slot(CallMediaSource.Camera)?.sender?.replaceTrack(cameraTrack)
slots.forEach { it.transceiver.direction = RTCRtpTransceiverDirection.SEND_RECV }
adaptToOldVersion(slots.size <= 2)
setupCodecsAndEncryption()
startPeerMediaPolling(conn)
val answer = conn.awaitCreateAnswer()
conn.awaitSetLocalDescription(answer)
setupAudioSending()
addIceCandidates(conn, parseIceCandidates(command.iceCandidates) + pendingCandidates)
pendingCandidates.clear()
WCallResponse.Answer(serializeSdp(answer), initialIceCandidates())
}
is WCallCommand.Answer -> {
val conn = pc
when {
conn == null -> WCallResponse.Error("answer: call not started")
conn.localDescription == null -> WCallResponse.Error("answer: local description is not set")
conn.remoteDescription != null -> WCallResponse.Error("answer: remote description already set")
else -> {
conn.awaitSetRemoteDescription(parseSdp(command.answer))
adaptToOldVersion(slot(CallMediaSource.ScreenAudio)?.transceiver?.currentDirection == RTCRtpTransceiverDirection.SEND_ONLY)
startPeerMediaPolling(conn)
addIceCandidates(conn, parseIceCandidates(command.iceCandidates) + pendingCandidates)
pendingCandidates.clear()
WCallResponse.Ok
}
}
}
is WCallCommand.Ice -> {
val conn = pc
val candidates = parseIceCandidates(command.iceCandidates)
if (conn != null && command.session != null) {
receiveRestartSession(conn, command.session, candidates)
WCallResponse.Ok
} else if (conn?.remoteDescription != null) {
addIceCandidates(conn, candidates)
WCallResponse.Ok
} else {
pendingCandidates.addAll(candidates)
WCallResponse.Error("ice: call not started yet, will add candidates later")
}
}
is WCallCommand.Media -> enableMedia(command.source, command.enable)
is WCallCommand.End -> {
endCall()
WCallResponse.Ok
}
is WCallCommand.Camera -> WCallResponse.Error("camera: switching front/back camera is not supported")
is WCallCommand.Layout -> WCallResponse.Ok
}
private fun enableMedia(source: CallMediaSource, enable: Boolean): WCallResponse = when (source) {
CallMediaSource.Mic -> {
micEnabled = enable
slot(CallMediaSource.Mic)?.let { setSending(it, enable) }
WCallResponse.Ok
}
CallMediaSource.Camera ->
if (enable && peerHasOldVersion && peerMedia[CallMediaSource.Camera] != true) {
WCallResponse.Error("media: cannot enable camera since the peer has an old version")
} else if (enable) {
if (cameraSource == null) startCamera()
if (cameraTrack == null) {
WCallResponse.Error("media: cannot enable media source")
} else {
slot(CallMediaSource.Camera)?.sender?.replaceTrack(cameraTrack)
WCallResponse.Ok
}
} else {
slot(CallMediaSource.Camera)?.sender?.replaceTrack(null)
stopCamera()
WCallResponse.Ok
}
CallMediaSource.ScreenVideo ->
if (enable) {
if (screenSource == null) startScreenShare()
val screenSlot = slot(CallMediaSource.ScreenVideo)
if (screenTrack == null || screenSlot == null) {
stopScreenShare()
WCallResponse.Error("media: cannot enable media source")
} else {
screenSlot.sender.replaceTrack(screenTrack)
WCallResponse.Ok
}
} else {
slot(CallMediaSource.ScreenVideo)?.sender?.replaceTrack(null)
stopScreenShare()
WCallResponse.Ok
}
CallMediaSource.ScreenAudio -> WCallResponse.Error("media: sending screen audio is not supported")
}
private fun createConnection(iceServers: List<RTCIceServer>?, relay: Boolean?, aesKey: String?): RTCPeerConnection {
val config = RTCConfiguration().apply {
this.iceServers = (iceServers ?: defaultIceServers).map { s ->
dev.onvoid.webrtc.RTCIceServer().apply {
urls = s.urls
username = s.username
password = s.credential
}
}
iceTransportPolicy = if (relay == true) RTCIceTransportPolicy.RELAY else RTCIceTransportPolicy.ALL
}
startIceGathering()
wasConnected = false
peerMedia.clear()
if (aesKey != null) {
senderCipher = CallFrameCipher(aesKey)
receiverCipher = CallFrameCipher(aesKey)
}
val conn = factory.createPeerConnection(config, observer)
pc = conn
connectionTimeout = scope.launch {
delay(ANSWER_TIMEOUT_MS)
onConnectionStateChange(conn)
}
return conn
}
// Transceiver order defines the m-line order that both peers map to media sources: mic, camera, screen audio, screen video.
private fun addTransceivers(conn: RTCPeerConnection) {
val micCamera = RTCRtpTransceiverInit().apply { direction = RTCRtpTransceiverDirection.SEND_RECV; streamIds = listOf("micCamera") }
val screen = RTCRtpTransceiverInit().apply { direction = RTCRtpTransceiverDirection.SEND_RECV; streamIds = listOf("screenAudioVideo") }
// webrtc-java can only add a transceiver with a track, so empty slots get a placeholder track that is detached at once.
listOf(
Triple(micTrack, micCamera, micTrack),
Triple(cameraTrack ?: placeholderVideoTrack(), micCamera, cameraTrack),
Triple(placeholderAudioTrack(), screen, null),
Triple(placeholderVideoTrack(), screen, null),
).forEach { (track, init, sending) ->
val slot = TransceiverSlot(conn.addTransceiver(track, init))
slots.add(slot)
slot.sender.replaceTrack(sending)
}
}
// Device-captured audio reaches every audio sender regardless of its track, so audio is stopped by deactivating the
// encoding. The stream then stops sending RTP, which peers detect as muted, as when call.ts detaches its track.
private fun setupAudioSending() {
slot(CallMediaSource.Mic)?.let { setSending(it, micEnabled) }
slot(CallMediaSource.ScreenAudio)?.let { setSending(it, false) }
}
private fun setSending(slot: TransceiverSlot, sending: Boolean) {
val parameters = slot.sender.parameters
parameters.encodings.forEach { it.active = sending }
slot.sender.parameters = parameters
}
private fun setupCodecsAndEncryption() {
// call.ts keeps codec headers in the clear assuming VP8, so VP8 is preferred for camera and screen
val vp8First = factory.getRtpReceiverCapabilities(MediaType.VIDEO).codecs.sortedBy { if (it.name == "VP8") 0 else 1 }
slots.forEach { s ->
val track = s.receiver.track
if (track is VideoTrack) s.transceiver.setCodecPreferences(vp8First)
if (track is AudioTrack) track.isEnabled = remoteAudioEnabled
senderCipher?.let { c -> s.sender.setTransform(c::encrypt) }
receiverCipher?.let { c -> s.receiver.setTransform(c::decrypt) }
}
_remoteCamera.value = slot(CallMediaSource.Camera)?.receiver?.track as? VideoTrack
_remoteScreen.value = slot(CallMediaSource.ScreenVideo)?.receiver?.track as? VideoTrack
}
private fun adaptToOldVersion(oldVersion: Boolean) {
peerHasOldVersion = oldVersion
if (oldVersion) {
onMediaMuteUnmute(CallMediaSource.Mic, mute = false)
if (_remoteCamera.value != null) onMediaMuteUnmute(CallMediaSource.Camera, mute = false)
}
}
private fun slot(source: CallMediaSource): TransceiverSlot? = slots.getOrNull(SLOT_SOURCES.indexOf(source))
private var selectedCamera: VideoDevice? = null
private fun startCamera() {
val device = selectedCamera ?: MediaDevices.getVideoCaptureDevices().firstOrNull() ?: return
val source = VideoDeviceSource()
try {
source.setVideoCaptureDevice(device)
preferredCapability(device)?.let(source::setVideoCaptureCapability)
source.start()
} catch (e: Exception) {
Log.e(TAG, "NativeCallEngine camera: ${e.stackTraceToString()}")
source.dispose()
return
}
cameraSource = source
cameraTrack = factory.createVideoTrack("camera", source).also { _localCamera.value = it }
}
private fun stopCamera() {
_localCamera.value?.let(::detachSinks)
_localCamera.value = null
cameraTrack?.dispose()
cameraTrack = null
cameraSource?.stop()
cameraSource?.dispose()
cameraSource = null
}
private fun startScreenShare() {
val selected = screenShareSource ?: screenShareSources().firstOrNull() ?: return
val source = VideoDesktopSource()
try {
source.setSourceId(selected.id, selected.isWindow)
source.setFrameRate(SCREEN_FRAME_RATE)
source.setMaxFrameSize(SCREEN_MAX_WIDTH, SCREEN_MAX_HEIGHT)
source.start()
} catch (e: Exception) {
Log.e(TAG, "NativeCallEngine screen: ${e.stackTraceToString()}")
source.dispose()
return
}
screenSource = source
screenTrack = factory.createVideoTrack("screen", source)
}
private fun stopScreenShare() {
screenTrack?.dispose()
screenTrack = null
screenSource?.stop()
screenSource?.dispose()
screenSource = null
}
private fun placeholderVideoTrack(): VideoTrack =
factory.createVideoTrack("placeholder-video", placeholderVideoSource).also { placeholderTracks.add(it) }
private fun placeholderAudioTrack(): AudioTrack =
factory.createAudioTrack("placeholder-audio", micSource).also { placeholderTracks.add(it) }
private fun startIceGathering() {
iceGatheringComplete = CompletableDeferred()
gatheredCandidates.clear()
}
private suspend fun initialIceCandidates(): String {
withTimeoutOrNull(ICE_INITIAL_DELAY_MS) { iceGatheringComplete.await() }
val candidates = takeGatheredCandidates()
scope.launch { sendMoreIceCandidates() }
return candidates
}
private suspend fun sendMoreIceCandidates() {
withTimeoutOrNull(ICE_EXTRAS_TIMEOUT_MS) {
while (!iceGatheringComplete.isCompleted) {
delay(ICE_EXTRAS_INTERVAL_MS)
sendGatheredCandidates()
}
}
sendGatheredCandidates()
}
private fun sendGatheredCandidates() {
if (gatheredCandidates.isEmpty() || pc == null) return
onResponse(WVAPIMessage(resp = WCallResponse.Ice(takeGatheredCandidates())))
}
private fun takeGatheredCandidates(): String {
val candidates = LZString.compressToBase64(json.encodeToString(ListSerializer(IceCandidateJson.serializer()), gatheredCandidates))
gatheredCandidates.clear()
return candidates
}
private fun addIceCandidates(conn: RTCPeerConnection, candidates: List<IceCandidateJson>) {
for (c in candidates) {
try {
conn.addIceCandidate(dev.onvoid.webrtc.RTCIceCandidate(c.sdpMid ?: "", c.sdpMLineIndex ?: 0, c.candidate))
} catch (e: Exception) {
Log.e(TAG, "NativeCallEngine addIceCandidate: ${e.message}")
}
}
}
private fun onConnectionStateChange(conn: RTCPeerConnection, reconnectTimedOut: Boolean = false) {
if (conn !== pc) return
var state = conn.connectionState.jsName()
if (wasConnected) {
if (reconnectTimedOut) {
reconnectingTimeout = null
if (state == "connected") return
state = "disconnected"
} else if (state == "disconnected" || state == "failed") {
if (reconnectingTimeout != null) return
reconnectingTimeout = scope.launch {
delay(RECONNECT_TIMEOUT_MS)
onConnectionStateChange(conn, reconnectTimedOut = true)
}
if (isIceRestarter(conn)) scope.launch { restartIce(conn) }
state = "reconnecting"
} else if (state == "connecting" || state == "new") {
return
}
}
onResponse(WVAPIMessage(resp = WCallResponse.Connection(ConnectionState(
connectionState = state,
iceConnectionState = conn.iceConnectionState.jsName(),
iceGatheringState = conn.iceGatheringState.jsName(),
signalingState = conn.signalingState.jsName()
))))
when (state) {
"disconnected", "failed" -> {
connectionTimeout?.cancel()
clearReconnecting()
endCall()
onResponse(WVAPIMessage(resp = WCallResponse.Ended))
}
"connected" -> {
connectionTimeout?.cancel()
clearReconnecting()
wasConnected = true
conn.getStats { report -> scope.launch { reportConnected(conn, report) } }
}
else -> {}
}
}
private fun clearReconnecting() {
reconnectingTimeout?.cancel()
reconnectingTimeout = null
iceRestartTimeout?.cancel()
iceRestartTimeout = null
}
// Only the party that sent the initial offer restarts ICE, so that restart offers never collide
private fun isIceRestarter(conn: RTCPeerConnection): Boolean = conn.localDescription?.sdpType == RTCSdpType.OFFER
private suspend fun restartIce(conn: RTCPeerConnection) {
if (conn !== pc || reconnectingTimeout == null) return
iceRestartTimeout = scope.launch {
delay(ICE_RESTART_INTERVAL_MS)
restartIce(conn)
}
// the previous restart offer is not answered yet
if (conn.signalingState != RTCSignalingState.STABLE) return
try {
val offer = conn.awaitCreateOffer(RTCOfferOptions().apply { iceRestart = true })
startIceGathering()
conn.awaitSetLocalDescription(offer)
onResponse(WVAPIMessage(resp = WCallResponse.Ice(initialIceCandidates(), serializeSdp(offer))))
} catch (e: Exception) {
Log.e(TAG, "NativeCallEngine ICE restart: ${e.message}")
}
}
private suspend fun receiveRestartSession(conn: RTCPeerConnection, session: String, iceCandidates: List<IceCandidateJson>) {
val desc = parseSdp(session)
if (desc.sdpType == RTCSdpType.OFFER && !isIceRestarter(conn)) {
conn.awaitSetRemoteDescription(desc)
startIceGathering()
val answer = conn.awaitCreateAnswer()
conn.awaitSetLocalDescription(answer)
addIceCandidates(conn, iceCandidates)
onResponse(WVAPIMessage(resp = WCallResponse.Ice(initialIceCandidates(), serializeSdp(answer))))
} else if (desc.sdpType == RTCSdpType.ANSWER && conn.signalingState == RTCSignalingState.HAVE_LOCAL_OFFER) {
conn.awaitSetRemoteDescription(desc)
addIceCandidates(conn, iceCandidates)
} else {
error("unexpected ICE restart ${desc.sdpType.jsName()} in ${conn.signalingState.jsName()} state")
}
}
private suspend fun reportConnected(conn: RTCPeerConnection, report: RTCStatsReport) {
val stats = report.stats
val pair = stats.values.firstOrNull { it.type == RTCStatsType.CANDIDATE_PAIR && it.attributes["state"] == "succeeded" } ?: return
fun candidate(id: Any?) = stats[id as? String]?.attributes?.let { a ->
RTCIceCandidate(
candidateType = RTCIceCandidateType.entries.firstOrNull { it.value == a["candidateType"] },
protocol = a["protocol"] as? String
)
}
val info = ConnectionInfo(candidate(pair.attributes["localCandidateId"]), candidate(pair.attributes["remoteCandidateId"]))
delay(CONNECTED_REPORT_DELAY_MS)
if (conn === pc) onResponse(WVAPIMessage(resp = WCallResponse.Connected(info)))
}
// The peer stops sending a source by detaching its track, so a source is treated as muted after 3 seconds without received bytes.
private fun startPeerMediaPolling(conn: RTCPeerConnection) {
val receivers = slots.zip(SLOT_SOURCES) { s, source -> source to s.receiver }
val lastBytes = HashMap<CallMediaSource, Long>()
val mutedSeconds = HashMap<CallMediaSource, Int>()
statsPolling?.cancel()
statsPolling = scope.launch {
while (isActive && conn === pc) {
delay(PEER_MEDIA_POLL_MS)
for ((source, receiver) in receivers) {
val bytes = receivedBytes(conn, receiver) ?: continue
if (bytes <= (lastBytes[source] ?: 0)) {
val muted = (mutedSeconds[source] ?: MUTED_INITIALLY_SECONDS) + 1
mutedSeconds[source] = muted
if (muted == MUTED_AFTER_SECONDS) onMediaMuteUnmute(source, mute = true)
} else {
if ((mutedSeconds[source] ?: MUTED_INITIALLY_SECONDS) >= MUTED_AFTER_SECONDS) onMediaMuteUnmute(source, mute = false)
lastBytes[source] = bytes
mutedSeconds[source] = 0
}
}
}
}
}
private suspend fun receivedBytes(conn: RTCPeerConnection, receiver: RTCRtpReceiver): Long? =
suspendCancellableCoroutine { cont ->
conn.getStats(receiver) { report ->
val bytes = report.stats.values.firstOrNull { it.type == RTCStatsType.INBOUND_RTP }?.attributes?.get("bytesReceived") as? Number
cont.resume(bytes?.toLong())
}
}
private fun onMediaMuteUnmute(source: CallMediaSource, mute: Boolean) {
if ((peerMedia[source] ?: false) != mute) return
peerMedia[source] = !mute
onResponse(WVAPIMessage(resp = WCallResponse.PeerMedia(source, enabled = !mute)))
}
private fun detachSinks(track: VideoTrack) {
val it = videoSinks.iterator()
while (it.hasNext()) {
val (t, sink) = it.next()
if (t === track) {
t.removeSink(sink)
it.remove()
}
}
}
private fun endCall() {
connectionTimeout?.cancel()
clearReconnecting()
statsPolling?.cancel()
videoSinks.forEach { (track, sink) -> track.removeSink(sink) }
videoSinks.clear()
_remoteCamera.value = null
_remoteScreen.value = null
val conn = pc
pc = null
// Senders release their tracks synchronously here, while closing the connection tears them down asynchronously
slots.forEach { it.sender.replaceTrack(null) }
conn?.close()
slots.forEach(TransceiverSlot::dispose)
slots.clear()
stopCamera()
stopScreenShare()
placeholderTracks.forEach { it.dispose() }
placeholderTracks.clear()
pendingCandidates.clear()
senderCipher = null
receiverCipher = null
}
private val observer = object : PeerConnectionObserver {
override fun onIceCandidate(candidate: dev.onvoid.webrtc.RTCIceCandidate) {
scope.launch { gatheredCandidates.add(IceCandidateJson(candidate.sdp, candidate.sdpMid, candidate.sdpMLineIndex)) }
}
override fun onIceGatheringChange(state: RTCIceGatheringState) {
if (state == RTCIceGatheringState.COMPLETE) scope.launch { iceGatheringComplete.complete(Unit) }
}
// As in call.ts, "failed" is only acted on by the answer timeout until connected: it is reported early when the peer has not answered yet.
override fun onConnectionChange(state: RTCPeerConnectionState) {
scope.launch { if (state != RTCPeerConnectionState.FAILED || wasConnected) pc?.let(::onConnectionStateChange) }
}
}
private fun commandName(command: WCallCommand): String = command.javaClass.simpleName.replaceFirstChar { it.lowercase() }
private fun preferredCapability(device: VideoDevice): VideoCaptureCapability? =
MediaDevices.getVideoCaptureCapabilities(device)
.filter { it.width <= CAMERA_MAX_WIDTH }
.maxWithOrNull(compareBy<VideoCaptureCapability> { it.frameRate >= CAMERA_FRAME_RATE }.thenBy { it.width })
companion object {
// Index of each source's transceiver, equal to its mid
private val SLOT_SOURCES = listOf(CallMediaSource.Mic, CallMediaSource.Camera, CallMediaSource.ScreenAudio, CallMediaSource.ScreenVideo)
private const val ANSWER_TIMEOUT_MS = 30_000L
private const val RECONNECT_TIMEOUT_MS = 30_000L
private const val ICE_RESTART_INTERVAL_MS = 10_000L
private const val ICE_INITIAL_DELAY_MS = 750L
private const val ICE_EXTRAS_INTERVAL_MS = 1_500L
private const val ICE_EXTRAS_TIMEOUT_MS = 12_000L
private const val CONNECTED_REPORT_DELAY_MS = 500L
private const val PEER_MEDIA_POLL_MS = 1_000L
private const val MUTED_INITIALLY_SECONDS = 4
private const val MUTED_AFTER_SECONDS = 3
private const val CAMERA_MAX_WIDTH = 1280
private const val CAMERA_FRAME_RATE = 24
private const val SCREEN_FRAME_RATE = 24
private const val SCREEN_MAX_WIDTH = 1920
private const val SCREEN_MAX_HEIGHT = 1080
private val defaultIceServers = listOf(
RTCIceServer(urls = listOf("stuns:stun.simplex.im:443")),
RTCIceServer(urls = listOf("turns:turn.simplex.im:443?transport=tcp"), username = "private2", credential = "Hxuq2QxUjnhj96Zq2r4HjqHRj"),
)
private val json = Json { ignoreUnknownKeys = true; explicitNulls = false }
private fun Enum<*>.jsName(): String = name.lowercase().replace('_', '-')
fun serializeSdp(d: RTCSessionDescription): String =
LZString.compressToBase64(json.encodeToString(SessionDescriptionJson.serializer(), SessionDescriptionJson(d.sdpType.jsName(), d.sdp)))
fun parseSdp(s: String): RTCSessionDescription {
val d = json.decodeFromString(SessionDescriptionJson.serializer(), LZString.decompressFromBase64(s) ?: error("invalid session description"))
return RTCSessionDescription(RTCSdpType.valueOf(d.type.uppercase()), d.sdp)
}
fun parseIceCandidates(s: String): List<IceCandidateJson> =
json.decodeFromString(ListSerializer(IceCandidateJson.serializer()), LZString.decompressFromBase64(s) ?: error("invalid ice candidates"))
fun videoCaptureDevices(): List<VideoDevice> = MediaDevices.getVideoCaptureDevices()
fun screenShareSources(): List<ScreenShareSource> {
val screens = ScreenCapturer().run { try { desktopSources } finally { dispose() } }
val windows = WindowCapturer().run { try { desktopSources } finally { dispose() } }
return screens.map { it.toShareSource(isWindow = false) } + windows.map { it.toShareSource(isWindow = true) }
}
private fun DesktopSource.toShareSource(isWindow: Boolean) = ScreenShareSource(title, id, isWindow)
}
}
// getTransceivers, getSender and getReceiver each return a new wrapper holding a native reference, so they are taken once and disposed
private class TransceiverSlot(val transceiver: RTCRtpTransceiver) {
val sender: RTCRtpSender = transceiver.sender
val receiver: RTCRtpReceiver = transceiver.receiver
fun dispose() {
sender.dispose()
receiver.dispose()
transceiver.dispose()
}
}
@Serializable
data class SessionDescriptionJson(val type: String, val sdp: String)
@Serializable
data class IceCandidateJson(val candidate: String, val sdpMid: String? = null, val sdpMLineIndex: Int? = null)
private suspend fun RTCPeerConnection.awaitCreateOffer(options: RTCOfferOptions = RTCOfferOptions()): RTCSessionDescription =
suspendCancellableCoroutine { cont -> createOffer(options, sessionObserver(cont)) }
private suspend fun RTCPeerConnection.awaitCreateAnswer(): RTCSessionDescription =
suspendCancellableCoroutine { cont -> createAnswer(RTCAnswerOptions(), sessionObserver(cont)) }
private suspend fun RTCPeerConnection.awaitSetLocalDescription(d: RTCSessionDescription) =
suspendCancellableCoroutine { cont -> setLocalDescription(d, setObserver(cont)) }
private suspend fun RTCPeerConnection.awaitSetRemoteDescription(d: RTCSessionDescription) =
suspendCancellableCoroutine { cont -> setRemoteDescription(d, setObserver(cont)) }
private fun sessionObserver(cont: CancellableContinuation<RTCSessionDescription>) = object : CreateSessionDescriptionObserver {
override fun onSuccess(description: RTCSessionDescription) = cont.resume(description)
override fun onFailure(error: String) = cont.resumeWithException(IllegalStateException(error))
}
private fun setObserver(cont: CancellableContinuation<Unit>) = object : SetSessionDescriptionObserver {
override fun onSuccess() = cont.resume(Unit)
override fun onFailure(error: String) = cont.resumeWithException(IllegalStateException(error))
}
@@ -48,6 +48,7 @@ private fun ActiveCallInteractiveAreaOneHand(call: Call, showMenu: MutableState<
Modifier
.minimumInteractiveComponentSize()
.combinedClickable(onClick = {
chatModel.activeCallViewIsCollapsed.value = false
val chat = chatModel.getChat(call.contact.id)
if (chat != null) {
withBGApi {
@@ -113,6 +114,7 @@ private fun ActiveCallInteractiveAreaNonOneHand(call: Call, showMenu: MutableSta
.padding(end = 15.dp, bottom = 92.dp)
.size(67.dp)
.combinedClickable(onClick = {
chatModel.activeCallViewIsCollapsed.value = false
val chat = chatModel.getChat(call.contact.id)
if (chat != null) {
withBGApi {
@@ -1,88 +0,0 @@
package chat.simplex.app
import chat.simplex.common.views.call.startServer
import java.net.Socket
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
// Integration test for the desktop call server's token gate (the handle() enforcement),
// which the unit-level CallServerTokenTest does not exercise.
class CallServerAuthTest {
private val token = "integration-test-token"
// port = 0 binds a random free port, avoiding a clash with a real call server on SERVER_PORT
private val server = startServer(onResponse = {}, port = 0, token = token)
private val port get() = server.listeningPort
@AfterTest
fun tearDown() = server.stop()
@Test
fun testWebSocketUpgradeRejectedWithoutToken() {
assertEquals(401, requestStatus(webSocketUpgrade(path = "/")))
}
@Test
fun testWebSocketUpgradeRejectedWithWrongToken() {
assertEquals(401, requestStatus(webSocketUpgrade(path = "/?token=wrong")))
}
@Test
fun testWebSocketUpgradeAcceptedWithToken() {
assertEquals(101, requestStatus(webSocketUpgrade(path = "/?token=$token")))
}
@Test
fun testWebSocketUpgradeRejectedFromOtherOrigin() {
assertEquals(401, requestStatus(webSocketUpgrade(path = "/?token=$token", origin = "http://example.com")))
}
@Test
fun testSecondWebSocketUpgradeRejected() {
assertEquals(101, requestStatus(webSocketUpgrade(path = "/?token=$token")))
assertEquals(401, requestStatus(webSocketUpgrade(path = "/?token=$token")))
}
@Test
fun testWebSocketUpgradeAcceptedAfterWrongToken() {
assertEquals(401, requestStatus(webSocketUpgrade(path = "/?token=wrong")))
assertEquals(101, requestStatus(webSocketUpgrade(path = "/?token=$token")))
}
@Test
fun testCallPageRejectedWithoutToken() {
assertEquals(401, requestStatus(get(path = "/simplex/call/")))
}
@Test
fun testCallPagePassesAuthGateWithToken() {
// Resource serving may differ in the test classpath, so assert only that the auth gate was passed (not 401)
assertNotEquals(401, requestStatus(get(path = "/simplex/call/?token=$token")))
}
private fun get(path: String): List<String> = listOf("GET $path HTTP/1.1", "Host: localhost:$port")
private fun webSocketUpgrade(path: String, origin: String = "http://localhost:$port"): List<String> =
listOf(
"GET $path HTTP/1.1",
"Host: localhost:$port",
"Origin: $origin",
"Upgrade: websocket",
"Connection: Upgrade",
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
"Sec-WebSocket-Version: 13",
)
// Sends a raw HTTP request and returns the response status code from the status line.
private fun requestStatus(requestLines: List<String>): Int =
Socket("localhost", port).use { socket ->
socket.soTimeout = 5000
socket.getOutputStream().apply {
write((requestLines.joinToString("\r\n") + "\r\n\r\n").toByteArray())
flush()
}
val statusLine = socket.getInputStream().bufferedReader().readLine() ?: error("no response from call server")
statusLine.split(" ")[1].toInt()
}
}
@@ -1,29 +0,0 @@
package chat.simplex.app
import chat.simplex.common.views.call.hasValidCallServerToken
import chat.simplex.common.views.call.newCallServerToken
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class CallServerTokenTest {
@Test
fun testCallServerTokenRequiresExactTokenParameter() {
val token = "secret"
assertTrue(hasValidCallServerToken(mapOf("token" to listOf(token)), token))
assertFalse(hasValidCallServerToken(mapOf("token" to listOf("wrong")), token))
assertFalse(hasValidCallServerToken(mapOf("x-token" to listOf(token)), token))
assertFalse(hasValidCallServerToken(mapOf("token" to listOf(token)), ""))
}
@Test
fun testCallServerTokenIsUrlSafe() {
val token = newCallServerToken()
assertTrue(token.length >= 40)
assertFalse(token.contains("+"))
assertFalse(token.contains("/"))
assertFalse(token.contains("="))
}
}
@@ -0,0 +1,316 @@
package chat.simplex.app
import chat.simplex.common.views.call.*
import dev.onvoid.webrtc.RTCSdpType
import dev.onvoid.webrtc.RTCSessionDescription
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetSocketAddress
import java.net.SocketAddress
import java.net.SocketException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import kotlin.concurrent.thread
import kotlin.test.*
class NativeCallEngineTest {
@Serializable
private data class Vector(val input: String, val compressed: String)
// The last two lzstring.json vectors are a session description and ICE candidates serialized as call.ts does
private val vectors = Json.decodeFromString(ListSerializer(Vector.serializer()), javaClass.getResource("/call/lzstring.json")!!.readText())
private val aesKey = "6r8EhZnO_fUPaa5xK92wY9qfCr2pk36gq5pgtJd2Ong="
// On one host the peers connect directly, either side may see the other as peer-reflexive
private val directConnections = setOf("peer-to-peer", "host / prflx", "prflx / host")
companion object {
private const val DISCONNECT_DETECTION_MS = 15_000L
private const val SIGNALING_MS = 10_000L
private const val RECONNECT_MS = 20_000L
private const val RECONNECT_TIMEOUT_MS = 30_000L
private const val UDP_BUFFER_SIZE = 65_536
}
@Test
fun testParsesSessionDescriptionFromCallJs() {
val d = NativeCallEngine.parseSdp(vectors[vectors.size - 2].compressed)
assertEquals(RTCSdpType.OFFER, d.sdpType)
assertTrue(d.sdp.startsWith("v=0\r\n"), d.sdp)
assertTrue(d.sdp.contains("a=group:BUNDLE 0 1 2 3"), d.sdp)
}
@Test
fun testSerializesSessionDescriptionForCallJs() {
val serialized = NativeCallEngine.serializeSdp(RTCSessionDescription(RTCSdpType.ANSWER, "v=0\r\n"))
assertEquals("""{"type":"answer","sdp":"v=0\r\n"}""", LZString.decompressFromBase64(serialized))
}
@Test
fun testParsesIceCandidatesFromCallJs() {
val candidates = NativeCallEngine.parseIceCandidates(vectors.last().compressed)
assertEquals(listOf(IceCandidateJson("candidate:1 1 udp 2122260223 192.168.1.2 51000 typ host generation 0", "0", 0)), candidates)
}
@Test
fun testTwoEnginesNegotiateEncryptedCall() = runBlocking<Unit> {
val callerInbox = Inbox()
val calleeInbox = Inbox()
val caller = NativeCallEngine(callerInbox::post)
val callee = NativeCallEngine(calleeInbox::post)
try {
withTimeout(30_000) {
// The callee of the invitation creates the offer, as CallManager.justAcceptIncomingCall does
callee.process(WCallCommand.Start(CallMediaType.Audio, aesKey, iceServers = emptyList(), relay = false))
val offer = calleeInbox.awaitResponse<WCallResponse.Offer>()
assertTrue(offer.capabilities.encryption)
caller.process(WCallCommand.Offer(offer.offer, offer.iceCandidates, CallMediaType.Audio, aesKey, iceServers = emptyList(), relay = false))
val answer = callerInbox.awaitResponse<WCallResponse.Answer>()
callee.process(WCallCommand.Answer(answer.answer, answer.iceCandidates))
calleeInbox.awaitResponse<WCallResponse.Ok>()
val connected = listOf(
async { calleeInbox.relayUntilConnected(caller) },
async { callerInbox.relayUntilConnected(callee) }
).awaitAll()
connected.forEach { info -> assertTrue(info.text in directConnections, info.text) }
caller.process(WCallCommand.End)
callerInbox.awaitResponse<WCallResponse.Ok>()
}
} finally {
listOf(caller.close(), callee.close()).joinAll()
}
}
@Test
fun testRejectsOutOfOrderCommands() = runBlocking<Unit> {
val inbox = Inbox()
val engine = NativeCallEngine(inbox::post)
try {
withTimeout(30_000) {
engine.process(WCallCommand.Answer("x", "y"))
assertEquals(WCallResponse.Error("answer: call not started"), inbox.awaitResponse<WCallResponse.Error>())
engine.process(WCallCommand.Ice(LZString.compressToBase64("[]")))
assertEquals(WCallResponse.Error("ice: call not started yet, will add candidates later"), inbox.awaitResponse<WCallResponse.Error>())
engine.process(WCallCommand.Start(CallMediaType.Audio, aesKey, iceServers = emptyList(), relay = false))
val offer = inbox.awaitResponse<WCallResponse.Offer>()
engine.process(WCallCommand.Offer(offer.offer, offer.iceCandidates, CallMediaType.Audio, aesKey, iceServers = emptyList(), relay = false))
assertEquals(WCallResponse.Error("accept: call already started"), inbox.awaitResponse<WCallResponse.Error>())
}
} finally {
engine.close().join()
}
}
@Test
fun testOffererRestartsIceAndCallRecovers() = runBlocking<Unit> {
withRelayedCall { caller, callee, network ->
network.down = true
network.changePath()
assertEquals("reconnecting", caller.nextState(DISCONNECT_DETECTION_MS))
assertEquals("reconnecting", callee.nextState(DISCONNECT_DETECTION_MS))
// The callee of the invitation sent the initial offer, so it restarts ICE and the caller answers
assertEquals(RTCSdpType.OFFER, callee.nextSession(SIGNALING_MS))
assertEquals(RTCSdpType.ANSWER, caller.nextSession(SIGNALING_MS))
network.down = false
assertEquals("connected", caller.nextState(RECONNECT_MS))
assertEquals("connected", callee.nextState(RECONNECT_MS))
assertTrue(network.newestRelayForwarded(), "media does not use the new path")
}
}
@Test
fun testEndsCallWhenReconnectionTimesOut() = runBlocking<Unit> {
withRelayedCall { caller, callee, network ->
network.down = true
for (side in listOf(caller, callee)) {
assertEquals("reconnecting", side.nextState(DISCONNECT_DETECTION_MS), side.name)
}
for (side in listOf(caller, callee)) {
assertEquals("disconnected", side.nextState(RECONNECT_TIMEOUT_MS + SIGNALING_MS), side.name)
assertEquals("ended", side.nextState(SIGNALING_MS), side.name)
}
}
}
@Test
fun testRejectsUnexpectedIceRestartSessions() = runBlocking<Unit> {
val callerInbox = Inbox()
val calleeInbox = Inbox()
val caller = NativeCallEngine(callerInbox::post)
val callee = NativeCallEngine(calleeInbox::post)
try {
withTimeout(30_000) {
callee.process(WCallCommand.Start(CallMediaType.Audio, aesKey, iceServers = emptyList(), relay = false))
val offer = calleeInbox.awaitResponse<WCallResponse.Offer>()
caller.process(WCallCommand.Offer(offer.offer, offer.iceCandidates, CallMediaType.Audio, aesKey, iceServers = emptyList(), relay = false))
val answer = callerInbox.awaitResponse<WCallResponse.Answer>()
callee.process(WCallCommand.Answer(answer.answer, answer.iceCandidates))
calleeInbox.awaitResponse<WCallResponse.Ok>()
val noCandidates = LZString.compressToBase64("[]")
// the offerer restarts ICE, so it does not accept restart offers, and nobody accepts an answer without a restart offer
callee.process(WCallCommand.Ice(noCandidates, offer.offer))
assertEquals(WCallResponse.Error("ice: unexpected ICE restart offer in stable state"), calleeInbox.awaitResponse<WCallResponse.Error>())
caller.process(WCallCommand.Ice(noCandidates, answer.answer))
assertEquals(WCallResponse.Error("ice: unexpected ICE restart answer in stable state"), callerInbox.awaitResponse<WCallResponse.Error>())
}
} finally {
listOf(caller.close(), callee.close()).joinAll()
}
}
private class Side(val name: String) {
val inbox = Inbox()
val engine = NativeCallEngine(inbox::post)
val states = Channel<String>(Channel.UNLIMITED)
val sessions = Channel<RTCSdpType>(Channel.UNLIMITED)
suspend fun nextState(timeoutMs: Long): String = withTimeout(timeoutMs) { states.receive() }
suspend fun nextSession(timeoutMs: Long): RTCSdpType = withTimeout(timeoutMs) { sessions.receive() }
}
// A call where media can only flow through the test's UDP relays, as in packages/simplex-chat-webrtc/test/harness.js
private suspend fun withRelayedCall(run: suspend (caller: Side, callee: Side, network: Network) -> Unit) = coroutineScope {
val caller = Side("caller")
val callee = Side("callee")
val network = Network(listOf(caller, callee))
try {
withTimeout(30_000) {
callee.engine.process(WCallCommand.Start(CallMediaType.Audio, aesKey, iceServers = emptyList(), relay = false))
val offer = callee.inbox.awaitResponse<WCallResponse.Offer>()
caller.engine.process(WCallCommand.Offer(offer.offer, network.route(callee, offer.iceCandidates), CallMediaType.Audio, aesKey, iceServers = emptyList(), relay = false))
val answer = caller.inbox.awaitResponse<WCallResponse.Answer>()
callee.engine.process(WCallCommand.Answer(answer.answer, network.route(caller, answer.iceCandidates)))
callee.inbox.awaitResponse<WCallResponse.Ok>()
}
val relays = listOf(launch { caller.inbox.relay(caller, callee, network) }, launch { callee.inbox.relay(callee, caller, network) })
for (side in listOf(caller, callee)) {
withTimeout(30_000) { while (side.states.receive() != "connected") continue }
}
run(caller, callee, network)
relays.forEach { it.cancel() }
} finally {
listOf(caller.engine.close(), callee.engine.close()).joinAll()
network.close()
}
}
// Each relay connects one ICE generation of both sides, and each side is told that the other side is at the relay socket
private class Relay(private val network: Network, sides: List<Side>, ip: String) {
val ufrags = HashMap<Side, String>()
val real = ConcurrentHashMap<Side, SocketAddress>()
val sockets = sides.associateWith { DatagramSocket(InetSocketAddress(ip, 0)) }
val forwarded = AtomicInteger()
@Volatile var dead = false
init {
for (side in sides) thread(isDaemon = true) { forward(receiver = side, sender = sides.first { it !== side }) }
}
// packets on the receiver's socket come from the sender, as this socket is advertised to the sender as the receiver's address
private fun forward(receiver: Side, sender: Side) {
val socket = sockets.getValue(receiver)
val buffer = ByteArray(UDP_BUFFER_SIZE)
while (true) {
val packet = DatagramPacket(buffer, buffer.size)
try {
socket.receive(packet)
} catch (e: SocketException) {
return
}
if (dead || network.down) continue
real[sender] = packet.socketAddress
val to = real[receiver] ?: continue
sockets.getValue(sender).send(DatagramPacket(packet.data, packet.length, to))
forwarded.incrementAndGet()
}
}
}
private class Network(private val sides: List<Side>) : AutoCloseable {
@Volatile var down = false
private val relays = ArrayList<Relay>()
// Routes the first IPv4 UDP host candidate of each ICE generation through a relay and drops the other candidates
@Synchronized fun route(side: Side, iceCandidates: String): String {
val routed = NativeCallEngine.parseIceCandidates(iceCandidates).mapNotNull { c ->
val parts = c.candidate.split(" ")
val ufrag = parts.getOrNull(parts.indexOf("ufrag") + 1)
val relayable = parts[1] == "1" && parts[2].equals("udp", ignoreCase = true) && parts[7] == "host" && ':' !in parts[4]
if (!relayable || ufrag == null || relays.any { it.ufrags[side] == ufrag }) return@mapNotNull null
val relay = relays.firstOrNull { side !in it.ufrags } ?: Relay(this, sides, parts[4]).also(relays::add)
relay.ufrags[side] = ufrag
relay.real[side] = InetSocketAddress(parts[4], parts[5].toInt())
c.copy(candidate = parts.toMutableList().also { it[5] = relay.sockets.getValue(side).localPort.toString() }.joinToString(" "))
}
return LZString.compressToBase64(Json.encodeToString(ListSerializer(IceCandidateJson.serializer()), routed))
}
// Addresses of all current ICE generations become unreachable, as when a device switches networks
@Synchronized fun changePath() {
relays.forEach { it.dead = true }
}
@Synchronized fun newestRelayForwarded(): Boolean = relays.last().let { !it.dead && it.forwarded.get() > 0 }
@Synchronized override fun close() {
relays.forEach { r -> r.sockets.values.forEach { it.close() } }
}
}
// Command responses carry the command; ICE, connection state and peer media notifications don't and are kept for the relay
private class Inbox {
private val channel = Channel<WVAPIMessage>(Channel.UNLIMITED)
private val notifications = ArrayDeque<WVAPIMessage>()
fun post(msg: WVAPIMessage) {
channel.trySend(msg)
}
suspend inline fun <reified T : WCallResponse> awaitResponse(): T {
while (true) {
val msg = channel.receive()
if (msg.command == null) notifications.addLast(msg) else return assertIs<T>(msg.resp, "response to ${msg.command}")
}
}
// Forwards ICE candidates and restart sessions to the peer through the network and records connection states
suspend fun relay(side: Side, peer: Side, network: Network) {
while (true) {
val msg = notifications.removeFirstOrNull() ?: channel.receive()
when (val resp = msg.resp) {
is WCallResponse.Ice -> {
resp.session?.let { side.sessions.send(NativeCallEngine.parseSdp(it).sdpType) }
peer.engine.process(WCallCommand.Ice(network.route(side, resp.iceCandidates), resp.session))
}
is WCallResponse.Connection -> side.states.send(resp.state.connectionState)
is WCallResponse.Ended -> side.states.send("ended")
else -> {}
}
}
}
// Forwards ICE candidates to the peer and returns the connection info once this side reports a connected call
suspend fun relayUntilConnected(peer: NativeCallEngine): ConnectionInfo {
var connectionReported = false
while (true) {
val msg = notifications.removeFirstOrNull() ?: channel.receive()
when (val resp = msg.resp) {
is WCallResponse.Ice -> peer.process(WCallCommand.Ice(resp.iceCandidates))
is WCallResponse.Connection -> if (resp.state.connectionState == "connected") connectionReported = true
is WCallResponse.Connected -> {
assertTrue(connectionReported, "connection state is reported before connection info")
return resp.connectionInfo
}
is WCallResponse.Ended -> fail("call ended before connecting")
else -> {}
}
}
}
}
}