diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt index 86e826e946..08bbdf901e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt @@ -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? = 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? = 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() } diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/ar/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ar/strings.xml index 2ebb8bdf11..912512bc0c 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/ar/strings.xml @@ -1548,8 +1548,6 @@ مكالمة فيديو مكالمة صوتية أنهيّ المكالمة - متصفح الويب الافتراضي مطلوب للمكالمات. يُرجى تضبيط المتصفح الافتراضي في النظام، ومشاركة المزيد من المعلومات مع المطوِّرين. - حدث خطأ أثناء فتح المتصفح أرشف وأرفع يمكن للمُدراء حظر عضو للجميع. ترحيل بيانات التطبيق @@ -2010,8 +2008,6 @@ الخادم سيتم استخدام بيانات اعتماد SOCKS الجديدة في كل مرة تبدأ فيها تشغيل التطبيق. سيتم استخدام بيانات اعتماد SOCKS الجديدة لكل خادم. - انقر فوق زر المعلومات الموجود بالقرب من حقل العنوان للسماح باستخدام الميكروفون. - افتح إعدادات Safari / مواقع الويب / الميكروفون، ثم اختر السماح لـ localhost. لإجراء مكالمات، اسمح باستخدام الميكروفون. أنهِ المكالمة وحاول الاتصال مرة أخرى. تجربة مستخدم أفضل شكل الرسالة قابل للتخصيص. diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml index cd80492607..aacf0e699d 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -1306,8 +1306,6 @@ ended - Error opening browser - The default web browser is required for calls. Please configure the default browser in the system, and share more information with the developers. Grant permissions Microphone Camera @@ -1409,8 +1407,8 @@ video call audio call To make calls, allow to use your microphone. End the call and try to call again. - Click info button near address field to allow using microphone. - Open Safari Settings / Websites / Microphone, then choose Allow for localhost. + Calls are not available + Screen %d Audio & video calls @@ -1446,6 +1444,8 @@ Audio on Speaker off Speaker on + Share screen + Stop sharing screen Sound muted Flip camera diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/bg/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/bg/strings.xml index 055dbc2196..0bd52872a2 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/bg/strings.xml @@ -1559,8 +1559,6 @@ Извършва се миграция на базата данни. \nМоже да отнеме няколко минути. Аудио разговор - Грешка при отваряне на браузъра - За разговори е необходим уеб браузър по подразбиране. Моля, конфигурирайте браузъра по подразбиране в системните настройки и споделете повече информация с разработчиците. Видео разговор Край на разговора квантово устойчиво e2e криптиране с перфектна секретност при препращане, правдоподобно опровержение и възстановяване при взлом.]]> @@ -2017,8 +2015,6 @@ Премахни архив? Каченият архив на базата данни ще бъде окончателно премахнат от сървърите. За да извършвате обаждания, разрешете да използва микрофона. Прекратете разговора и опитайте да се обадите отново. - Натиснете бутона за информация близо до адресното поле, за да разрешите използването на микрофона. - Отворете Safari Настройки / Уеб страници / Микрофон, след което изберете Позволи за localhost. Нов интерфейс 🎉 Нови медийни опции Подобрен интерфейс diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/ca/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ca/strings.xml index 4e9fa56bc0..fbbf34243a 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/ca/strings.xml @@ -621,7 +621,6 @@ Error en reconnectar els servidors Introduïu el missatge de benvinguda… (opcional) Introduïu el vostre nom: - Error en obrir el navegador Error en exportar la base de dades de xat S\'ha produït un error en suprimir la base de dades de xat Error en importar la base de dades de xat @@ -637,7 +636,6 @@ Blocs penjats El xat se suprimirà per a tots els membres; això no es pot desfer! acolorit - Feu clic al botó d\'informació al costat del camp d\'adreça per permetre l\'ús del micròfon. Completades Error en acceptar les condicions Error en connectar-se al servidor de reenviament %1$s. Si us plau, prova-ho més tard. @@ -1140,7 +1138,6 @@ Connexió de xarxa més fiable. Nou dispositiu mòbil - El xat s\'obre amb al primer missatge no llegit.\n- Desplaçaments fins als missatges citats. - Obriu Configuració de Safari / Llocs web / Micròfon i, a continuació, trieu Permetre localhost. Servidor de l\'operador O enganxeu l\'enllaç de l\'arxiu O compartiu aquest enllaç de fitxer de manera segura @@ -1177,7 +1174,6 @@ esperant resposta… esperant confirmació… confirmació rebuda… - Un navegador web predeterminat és necessari per a les trucades. Configura\'n un al sistema i comparteix més informació amb els desenvolupadors. Notificacions privades Com afecta la bateria Periòdic diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/cs/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/cs/strings.xml index ba30f1446a..c9dcf9e7a5 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/cs/strings.xml @@ -1533,7 +1533,6 @@ \nDoporučuje se restartovat aplikaci. Restartovat chat %s byl odpojen]]> - Chyba otevření prohlížeče odstraněn profilový obrázek nastavil novou kontaktní adresu nastavil nový profilový obrázek @@ -1554,7 +1553,6 @@ %s]]> Videohovor profil aktualizován - 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í. odblokované %s S šifrovanými soubory a médii. Vypršel čas při připojování k desktopu @@ -1913,8 +1911,6 @@ Roh Tvar zpráv Pokračovat - Klikněte na info tlačítko blízko pole adresy, pro použití mikrofonu. - Otevřete nastavení Safari / Webové stránky / mikrofon, vyberte možnost Povolit pro localhost. Velikost písma Stáhnout nové verze z GitHubu. Dosažitelný panel nástrojů chatu diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/de/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/de/strings.xml index 780b9e80f0..dd5d1239c8 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/de/strings.xml @@ -1639,8 +1639,6 @@ Audioanruf Anruf beenden Videoanruf - Fehler beim Öffnen des Browsers - 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. Chat wurde migriert! Archiv-Link erzeugen Von einem anderen Gerät migrieren und scannen Sie den QR-Code.]]> @@ -2105,9 +2103,7 @@ App-Sitzung Für jeden Server werden neue SOCKS-Anmeldeinformationen genutzt Server - Klicken Sie auf die Info-Schaltfläche neben dem Adressfeld, um die Verwendung des Mikrofons zu erlauben. Um Anrufe durchzuführen, erlauben Sie die Nutzung Ihres Mikrofons. Beenden Sie den Anruf und versuchen Sie es erneut. - Öffnen Sie die Safari-Einstellungen / Webseiten / Mikrofon und wählen Sie dann \"Für Localhost erlauben\". Verbesserte Anrufe Anpassbares Format des Nachrichtenfelds Bis zu 200 Nachrichten löschen oder moderieren diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/el/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/el/strings.xml index caff251b2c..0e78880c57 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/el/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/el/strings.xml @@ -514,7 +514,6 @@ Καθαρισμός συνομιλίας; Καθαρισμός ιδιωτικών σημειώσεων; Καθαρισμός επαλήθευσης - Κάνε κλικ στο κουμπί πληροφοριών δίπλα στο πεδίο διεύθυνσης για να επιτρέψεις τη χρήση του μικροφώνου. Κουμπί κλεισίματος χρωματισμένο Λειτουργία χρώματος @@ -1289,7 +1288,6 @@ Η σύνδεση που αποδέχθηκες θα ακυρωθεί! Η επαφή με την οποία μοιράστηκες αυτόν το σύνδεσμο, ΔΕΝ θα μπορεί να συνδεθεί! Η βάση δεδομένων δεν λειτουργεί σωστά. Πάτησε για να μάθεις περισσότερα. - Για τις κλήσεις απαιτείται ο προεπιλεγμένος περιηγητής. Ρύθμισε τον προεπιλεγμένο περιηγητή στο σύστημα σου και μοιράσου περισσότερες πληροφορίες με τους προγραμματιστές. Το όνομα της συσκευής θα κοινοποιηθεί στην εφαρμογή του συνδεδεμένου κινητού. Η κρυπτογράφηση λειτουργεί και η νέα κρυπτογράφηση δεν είναι απαραίτητη. Μπορεί να προκαλέσει σφάλματα σύνδεσης! Ο κωδικός ελέγχου του προηγούμενου μηνύματος είναι διαφορετικός. @@ -1486,7 +1484,6 @@ Σφάλμα κατά τη φόρτωση των λιστών συνομιλιών Σφάλμα κατά τη φόρτωση των λεπτομερειών Σφάλμα επισήμανσης ως αναγνωσμένου - Σφάλμα κατά το άνοιγμα του προγράμματος περιήγησης Σφάλμα κατά το άνοιγμα της συνομιλίας Σφάλμα κατά το άνοιγμα της ομάδας Σφάλμα κατά την ανάγνωση της φράσης πρόσβασης της βάσης δεδομένων @@ -1705,7 +1702,6 @@ Άνοιξε νέα συνομιλία Άνοιξε νέα ομάδα Άνοιγμα θύρας στο τείχος προστασίας - Άνοιξε τις Ρυθμίσεις Safari / Ιστοσελίδες / Μικρόφωνο και στη συνέχεια επέλεξε Να επιτρέπεται για το localhost. Άνοιγμα ρυθμίσεων Άνοιξε το SimpleX Chat για να αποδεχθείς την κλήση Άνοιξε για να αποδεχθείς diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/es/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/es/strings.xml index 9288b230ef..35d3590419 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/es/strings.xml @@ -1559,8 +1559,6 @@ Llamada Llamada finalizada Videollamada - Error al abrir navegador - 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. Archivando base de datos Todos tus contactos, conversaciones y archivos serán cifrados, divididos y subidos de forma segura a los servidores XFTP configurados. Los administradores pueden bloquear a un miembro para los demás @@ -2025,8 +2023,6 @@ Cola Se usarán credenciales SOCKS nuevas cada vez que inicies la aplicación. por sesión - Abre la configuración de Safari / Sitios Web / Micrófono y a continuación selecciona Permitir para localhost. - Pulsa el botón info del campo dirección para permitir el uso del micrófono. Para hacer llamadas, permite el uso del micrófono. Cuelga e intenta llamar de nuevo. Se usarán credenciales SOCKS nuevas para cada servidor. por servidor diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/fa/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/fa/strings.xml index eb187475dd..5f943a41db 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/fa/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/fa/strings.xml @@ -752,7 +752,6 @@ بلندگو هدفون‌ها پایان یافت - خطا در باز کردن مرورگر عبارت عبور برای اکسپورت را تعیین کنید محافظت از صفحه برنامه شروع مجدد @@ -833,7 +832,6 @@ پایگاه داده چت گزینه خوب برای باتری. سرویس پس‌زمینه هر ۱۰ دقیقه پیام‌ها را بررسی می‌کند. ممکن است تماس‌ها یا پیام‌های ضروری را از دست دهید.]]> پیام‌های نادیده گرفته شده - مرورگر وب پیش‌فرض برای تماس‌ها لازم است. لطفا مرورگر پیش‌فرض را در سیستم تنظیم کنید، و اطلاعات بیشتر را با توسعه‌دهندگان به اشتراک بگذارید. سرور واسط فقط در زمان نیاز مورد استفاده قرار می‌گیرد. طرف دیگری قادر به مشاهده نشانی IP شما خواهد بود. نمایش غیرفعال @@ -1909,7 +1907,6 @@ پیام برای همه اعضا حذف خواهد شد - این عمل قابل بازگشت نیست! بخش های حذف شده بخش های دانلود شده - برای مجوز میکروفون، روی آیکون اطلاعات کنار نوار آدرس کلیک کنید تکمیل شد تایید حذف مخاطب؟ اتصال @@ -1924,7 +1921,6 @@ باز کردن چت باز کردن چت جدید باز کردن گروه جدید - به منوی تنظیمات سافاری / وب‌سایت‌ها / و سپس میکروفن رفته و گزینه \"اجازه دادن برای localhost\" را انتخاب کنید. باز کردن لینک باز کردن لینک‌ها از لیست چت‌ها باز کردن برای پذیرش diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/fi/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/fi/strings.xml index 51a566a284..b8faadd6eb 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/fi/strings.xml @@ -1334,7 +1334,6 @@ Tarkista internetyhteys ja yritä uudelleen Yhdistä automaattisesti Poista tietokanta tältä laitteelta - Virhe selainta avatessa Tiedoston tila: %s Laajenna Hyvää huomenta! diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/fr/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/fr/strings.xml index e3c9c28d17..39ce11bb93 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/fr/strings.xml @@ -1559,8 +1559,6 @@ Appel vidéo Fin de l\'appel Appel audio - 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. - Erreur lors de l\'ouverture du navigateur Confirmer que vous vous souvenez de la phrase secrète de la base de données pour la transférer. Transfert des données de l\'application Les admins peuvent bloquer un membre pour tous. @@ -2015,7 +2013,6 @@ Transférer les messages sans les fichiers ? De nouveaux identifiants SOCKS seront utilisés chaque fois que vous démarrerez l\'application. Rien à transférer ! - Ouvrez Safari Paramètres / Sites web / Microphone, puis choisissez Autoriser pour localhost. Sauvegarde de %1$s messages L\'archive de la base de données envoyée sera définitivement supprimée des serveurs. Utilisez des identifiants de proxy différents pour chaque profil. @@ -2031,7 +2028,6 @@ Coin Forme du message Queue - Cliquez sur le bouton info près du champ d\'adresse pour autoriser l\'utilisation du microphone. Pour passer des appels, autorisez l\'utilisation de votre microphone. Mettez fin à l\'appel et essayez d\'appeler à nouveau. Supprimer l\'archive ? Appels améliorés diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/hr/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/hr/strings.xml index 2f010d8aa9..c75be4c124 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/hr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/hr/strings.xml @@ -1128,7 +1128,6 @@ %s prekinuta veza]]> Ovo je Vaša vlastita SimpleX adresa! Očistiti verifikaciju - Greška u otvaranju pretraživača Nema razgovora Povezati se sa %1$s? želi da se poveze sa Vama! @@ -1394,7 +1393,6 @@ Autentifikacija nije dostupna Automatsko prihvatanje Sačuvati preference? - 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. odblokirali ste %s Vi ste posmatrač. Vi ste posmatrač diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/hu/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/hu/strings.xml index 1f499672d0..11ec429db0 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/hu/strings.xml @@ -1538,8 +1538,6 @@ Hanghívás Hívás vége Videóhívás - Hiba történt a böngésző megnyitásakor - 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. Hálózati beállítások megerősítése Hiba történt a csevegési adatbázis exportálásakor Alkalmaz @@ -1999,8 +1997,6 @@ Minden alkalommal, amikor elindítja az alkalmazást, új SOCKS-hitelesítési adatok lesznek használva. Alkalmazás munkamenete Az összes kiszolgálóhoz új, SOCKS-hitelesítési adatok lesznek használva. - Kattintson a címmező melletti információgombra a mikrofon használatának engedélyezéséhez. - 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. 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. Továbbfejlesztett hívásélmény Továbbfejlesztett üzenetdátumok. diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/in/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/in/strings.xml index 29ebaac8d3..ff6859ef54 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/in/strings.xml @@ -972,9 +972,7 @@ Reaksi pesan dilarang dalam obrolan ini. Pindah ke perangkat lain Untuk melakukan panggilan, izinkan penggunaan mikrofon. Akhiri panggilan dan coba panggil lagi. - Klik tombol info di dekat kolom alamat untuk mengizinkan penggunaan mikrofon. Server relai melindungi alamat IP Anda, tetapi dapat mengamati durasi panggilan. - Buka Pengaturan Safari / Situs Web / Mikrofon, lalu pilih Izinkan untuk localhost. Buka SimpleX Chat untuk terima panggilan Buka terenkripsi e2e @@ -1072,8 +1070,6 @@ Gunakan kredensial acak menunggu konfirmasi… Berikan izin - Gagal membuka peramban - Peramban web bawaan diperlukan untuk panggilan. Harap konfigurasikan peramban bawaan dalam sistem, dan bagikan informasi lebih lanjut dengan pengembang. Aktifkan untuk semua Nonaktifkan untuk semua grup Undang anggota diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/it/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/it/strings.xml index 9eb810486d..6067f49c89 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/it/strings.xml @@ -1563,8 +1563,6 @@ Chiamata audio Termina chiamata Videochiamata - Errore di apertura del browser - Il browser predefinito è necessario per le chiamate. Configura il browser predefinito nel sistema, poi condividi più informazioni con gli sviluppatori. Questa chat è protetta da crittografia end-to-end. Questa chat è protetta da crittografia end-to-end resistente alla quantistica. Avvia chat @@ -2034,8 +2032,6 @@ Le nuove credenziali SOCKS verranno usate ogni volta che avvii l\'app. Le nuove credenziali SOCKS verranno usate per ogni server. Server - Apri le impostazioni di Safari / Siti web / Microfono, quindi scegli Consenti per localhost. - Clicca il pulsante info vicino al campo indirizzo per consentire l\'uso del microfono. Per effettuare chiamate, consenti di usare il microfono. Termina la chiamata e cerca di richiamare. Chiamate migliorate Date dei messaggi migliorate. diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/iw/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/iw/strings.xml index 8088ae4f23..4796720fcf 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/iw/strings.xml @@ -1469,7 +1469,6 @@ בנוסף ל- %d אירועים אחרים איש הקשר הגדיר כתובת חדשה לחץ לחיבור - דפדפן האינטרנט המוגדר כברירת מחדל נדרש לשיחות. אנא הגדר דפדפן ברירת מחדל במערכת, ושתף מידע נוסף עם המפתחים. השיחה הזו מוגנת באמצעות הצפנה קצה-אל-קצה. השיחה הזו מוגנת באמצעות הצפנה קצה-אל-קצה עמידה בפני מחשוב קוונטי. זהו כתובת ה-SimpleX שלך! @@ -1502,7 +1501,6 @@ שגיאה שגיאה ביצירת הודעה שגיאה בייצוא מסד נתוני צ\'אט - שגיאה בפתיחת דפדפן שגיאה בשמירת אפשרויות סיים את ההעברה על מכשיר נוסף. קוד QR לא חוקי @@ -1872,7 +1870,6 @@ בדוק עבור הודעות חדשות כל 10 דקות אישרת את תנאי השימוש ב:%s. הצ\'אט יימחק עבור כל החברים - לא ניתן לבטל את זה! - לחץ על כפתור מידע ליד שדה כתובת כדי לאפשר שימוש במיקרופון. %s.]]> %s.]]> העבר אנשי קשר לארכיון לשוחח מאוחר יותר diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/ja/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ja/strings.xml index c5cd469d81..7ac061c51f 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/ja/strings.xml @@ -1530,7 +1530,6 @@ このチャットは耐量子エンドツーエンド暗号化により保護されています。 プライベートノート 通話終了 - ブラウザ起動エラー 別の端末から移行 パスフレーズを設定 %s をブロック済 @@ -1636,7 +1635,6 @@ 新しいプロフィール画像が設定されました プロフィール画像が削除されました 移行画面を開く - 通話には既定のウェブブラウザが必要です。既定のブラウザを設定し、開発者へ情報を共有してください。 プロフィール更新済 メンバーの %1$sが %2$sに名前変更済 ネットワーク設定を確認してください @@ -1862,7 +1860,6 @@ グループメンバーにメッセージを送信できません。 後でチャットするために連絡先をアーカイブします。 接続とサーバーのステータス - アドレスフィールドの近くにある情報ボタンをクリックして、マイクの使用を許可してください。 接続 カスタマイズ可能なメッセージの形。 プライベートルーティングをサポートしていなくても、メッセージを直接送信しないでください。 @@ -2318,7 +2315,6 @@ 通知を設定 ネットワークの取り組み 通話するにはマイクの使用を許可してください。通話を終了して、もう一度かけ直してください。 - Safariの「設定」/「Webサイト」/「マイク」を開き、localhost に対して「許可」を選択してください。 外部リンクを開きますか? サウンドをミュート中 破棄されました(%1$d回試行) diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/ko/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ko/strings.xml index 01812e23e2..533695dd37 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/ko/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/ko/strings.xml @@ -1032,7 +1032,6 @@ 시도 앱 세션 업데이트 확인 - 주소 필드 근처에 있는 정보 버튼을 클릭하여 마이크를 사용할 수 있습니다. 모서리 채팅 데이터베이스를 내보냈습니다 연락처에 전화를 걸 수 없음 @@ -1225,7 +1224,6 @@ 앱 업데이트를 다운로드하는 중입니다. 앱을 닫지 마세요 %s (%s) 를 다운로드 비활성화 - 브라우저를 여는 중 오류 차단 검은색 사용 안 함 (변경 사항 유지) diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/ku/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ku/strings.xml index b7ff37fa77..4f14a3e1cf 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/ku/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/ku/strings.xml @@ -422,7 +422,6 @@ Adresa serverê kontrol bike û dîsa biceribîne. Girêdana xwe yî înternetê kontrol bike û dîsa biceribîne Notên şexsî vala bike? - Pêl pişkoka melûmatê ya nêzîkî cihê adresê bike ji bo destûrdana mîkrofonê. Moda reng Di wextekî nêzîk de tê! Dosya qiyas bike diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/lt/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/lt/strings.xml index 1eef42dd4e..5701ede413 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/lt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/lt/strings.xml @@ -1122,7 +1122,6 @@ %s yra patvirtintas Bakstelėkite, kad nuskanuoti Išjungti? - Skambučiams reikalinga numatytoji žiniatinklio naršyklė. Sukonfigūruokite numatytąją sistemos naršyklę ir pasidalykite daugiau informacijos su kūrėjais. Mažos grupės (maks. 20) Pavadinimas Nustatykite žinutę rodomą naujiems nariams! @@ -1459,7 +1458,6 @@ Prisijungsite kai jūsų konktakto įrenginys bus prisijungęs, palaukite arba patikrinkite vėliau! Atidaryti mobilioje programėlėje, tada bakstelėkite Prisijungti programėlėje.]]> Naudoti naujiems prisijungimams - Įvyko klaida atveriant naršyklę paslaptis Pranešimai nustos veikti iki tol kol paleisite programėlę iš naujo Galite naudoti markdown, kad formatuoti žinutes: diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/lv/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/lv/strings.xml index b58411035a..9d4647281d 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/lv/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/lv/strings.xml @@ -423,8 +423,6 @@ Ikonas apraksts videozvanam Ikona Audio zvans Zvana piekļuves atļauja darbvirsmai noraidīta - Zvana piekļuves atļauja darbvirsmai noraidīta Chrome pārlūkā - Zvana piekļuves atļauja darbvirsmai noraidīta Safari pārlūkā Audio un video zvanu iestatījumi Tavi zvani Vienmēr izmantot releju @@ -1483,8 +1481,6 @@ Zvana stāvoklis savienojas Zvana stāvoklis savienots Zvana stāvoklis beidzies - Nevar atvērt pārlūkprogrammu - Nevar atvērt pārlūkprogrammu Nepieciešamas atļaujas Atļaujas audio ierakstīšanai Atļaujas kamerai diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/nl/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/nl/strings.xml index 708f938ad9..954aa32045 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/nl/strings.xml @@ -1563,8 +1563,6 @@ Audio oproep Ophangen Video oproep - Fout bij het openen van de browser - Voor oproepen is de standaard webbrowser vereist. Configureer de standaard browser in het systeem en deel meer informatie met de ontwikkelaars. Bevestig netwerk instellingen Archiveren en uploaden Database archiveren @@ -2033,8 +2031,6 @@ Voor elke server worden nieuwe SOCKS-inloggegevens gebruikt. Server Staart - Klik op de infoknop naast het adresveld om het gebruik van de microfoon toe te staan. - Open Safari Instellingen / Websites / Microfoon en kies Toestaan voor localhost. Als u wilt bellen, geeft u toestemming om uw microfoon te gebruiken. Beëindig het gesprek en probeer opnieuw te bellen. Betere beveiliging ✅ SimpleX-protocollen beoordeeld door Trail of Bits. diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/pl/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/pl/strings.xml index 942df14f13..bbf10655a3 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/pl/strings.xml @@ -1563,8 +1563,6 @@ Połączenie audio Zakończ połączenie Połączenie wideo - Błąd podczas otwierania przeglądarki - Do wykonywania połączeń wymagana jest domyślna przeglądarka internetowa. Skonfiguruj domyślną przeglądarkę w systemie i przekaż więcej informacji programistom. Ten czat jest chroniony przez szyfrowanie e2e odporne na ataki kwantowe. szyfrowanie end-to-end z perfect forward secrecy, zaprzeczalnością i odzyskiwaniem bezpieczeństwa po kompromitacji.]]> Otwórz ekran migrowania @@ -2018,8 +2016,6 @@ Sesja aplikacji Nowe poświadczenia SOCKS będą używane przy każdym uruchomieniu aplikacji. Dla każdego serwera zostaną użyte nowe poświadczenia SOCKS. - Kliknij przycisk informacji przy polu adresu, aby zezwolić na korzystanie z mikrofonu. - Otwórz Safari Ustawienia / Strony internetowe / Mikrofon, a następnie wybierz opcję Zezwalaj dla localhost. Użyj różnych poświadczeń proxy dla każdego połączenia. Użyj różnych poświadczeń proxy dla każdego profilu. Użyj losowych poświadczeń diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/pt-rBR/strings.xml index 33548f056c..e6421fbf5b 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/pt-rBR/strings.xml @@ -1454,7 +1454,6 @@ Bloquear bloqueado O código que você escaneou não é um QR Code SimpleX. - O navegador padrão é necessário para chamadas. Configure o navegador padrão no sistema e compartilhe mais informações com os desenvolvedores. Essas configurações são para o seu perfil atual O vídeo não pode ser decodificado. Por favor, tente com um vídeo diferente ou entre em contato com os desenvolvedores. Este é o seu próprio link de uso único! @@ -1482,7 +1481,6 @@ Desconectar desktop? Habilitar o acesso à câmera Erro de renegociação de criptografia - Erro ao abrir o navegador Erro ao enviar o convite Carregando conversas… %s foi desconectado]]> @@ -2123,7 +2121,6 @@ Desativar exclusão de mensagens Renegociação de criptografia em andamento. Apagar - Clique no botão de informação perto do campo de endereço para permitir usar o microfone. 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. A conexão não está pronta. Erros nas configurações de servidores. @@ -2203,7 +2200,6 @@ Nenhum servidor para roteamento de mensagens privadas. Nenhum servidor para receber arquivos. Nenhum servidor para receber mensagens. - Abra Configurações do Safari / Websites / Microfone, e escolha Permitir para localhost. Servidor do operador Nenhuma conversa na lista %s. Somente o remetente e os moderadores podem vê-lo diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/ro/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ro/strings.xml index f332586376..2c439ee164 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/ro/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/ro/strings.xml @@ -959,7 +959,6 @@ Ascunde profilul Numele afișat nu poate conține spații albe. Nume nevalid! - Eroare la deschiderea browserului Cum funcționează Instalat cu succes Dezactivează @@ -1385,7 +1384,6 @@ Descărcați %s (%s) Instalează actualizarea 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. - Deschide Setările Safari / Site-uri web / Microfon, apoi selectează Permite pentru localhost. Apel ratat Renegocierea criptării a eșuat. Oprit @@ -1867,7 +1865,6 @@ video Utilizarea serverelor SimpleX Chat. Folosiți proxy SOCKS? - 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. Afișează statusul mesajului Port TCP pentru mesagerie Descărcarea actualizării a fost anulată @@ -2218,7 +2215,6 @@ Ai partajat o cale de fișier nevalidă. Raportează problema dezvoltatorilor aplicației. Deschide în aplicația mobilă, apoi atinge Conectare în aplicație.]]> Actualizează adresa - Apasă pe butonul de informații de lângă bara de adrese pentru a permite accesul la microfon. Colţ Puteți salva arhiva exportată. Nu veți mai primi mesaje de la acest chat. Istoricul conversației va fi păstrat. diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/ru/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ru/strings.xml index 144e81471d..4465bcde56 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/ru/strings.xml @@ -1714,8 +1714,6 @@ Завершите миграцию на другом устройстве. Выполняется миграция базы данных. \nЭто может занять несколько минут. - Ошибка открытия браузера - Для звонков требуется веб-браузер по умолчанию. Пожалуйста, настройте браузер по умолчанию в системе и поделитесь дополнительной информацией с разработчиками. Другaя сеть WiFi Проводная сеть @@ -2096,8 +2094,6 @@ Сервер Форма сообщений Хвост - Нажмите кнопку информации рядом с адресной строкой, чтобы разрешить микрофон. - Откройте Настройки Safari / Веб-сайты / Микрофон, затем выберите Разрешить для localhost. Улучшенные звонки Улучшенные даты сообщений. Улучшенная безопасность ✅ diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/sk/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/sk/strings.xml index 10a65f3f5d..6e07652ad9 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/sk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/sk/strings.xml @@ -734,7 +734,6 @@ Vymazať obsah chatu Vymazať obsah chatu? Vymazať obsah súkromných poznámok? - Kliknite na info tlačidlo blízko poľa adresy na povolenie použitia mikrofónu. Tlačidlo zavrieť Porovnajte bezpečnostné kódy s vašimi kontaktmi. %s.]]> @@ -829,7 +828,6 @@ Nová počítačová aplikácia! Použiť z počítača v mobilnej aplikácii a naskenujte QR kód.]]> Použiť z počítača - Otvorte nastavenia Safari / Webové stránky / mikrofón, potom vyberte povoliť pre localhost. Vložiť adresu počítača 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. Skenovať QR kód z počítača @@ -868,7 +866,6 @@ Povolenie miznúcich správ v predvolených nastaveniach. Obnoviť pôvodné nastavenia Obnoviť pôvodnú tému - 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í. Vypnúť notifikácie Miznúca správa Miznúce správy @@ -1282,7 +1279,6 @@ Chyba inicializácie WebView. Ujistite sa, že máte nainštalovaní WebView ktorý podporuje architektúru arm64.\nChyba: %s Chyba pri načítavaní zoznamov chatov Chyba pri označovaní ako prečítané - Chyba pri otváraní prehliadača Chyba pri otváraní chatu Chyba pri otváraní skupiny Chyba pri odmietaní žiadosti o kontakt diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/tr/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/tr/strings.xml index 989782053d..3e83b86c80 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/tr/strings.xml @@ -1565,11 +1565,9 @@ Mesaj çok büyük Veritabanı geçişi devam ediyor. \nBu birkaç dakika sürebilir. - 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. Sesli arama Görüntülü arama Aramayı bitir - Tarayıcıyı açarken hata uçtan uca şifreleme ile korunmaktadır.]]> Bu sohbet uçtan uca şifrelemeyle korunmaktadır. Taşıma ekranını aç @@ -2021,8 +2019,6 @@ Stabil Güncelleme mevcut: %s Bu bağlantı başka bir mobil cihazda kullanıldı, lütfen masaüstünde yeni bir bağlantı oluşturun. - Mikrofon kullanımına izin vermek için adres alanının yanındaki bilgi düğmesine tıklayın. - Safari Ayarları / Web Siteleri / Mikrofon\'u açın, ardından localhost için İzin Ver\'i seçin. Arama yapmak için mikrofonunuzu kullanmanıza izin verin. Aramayı sonlandırın ve tekrar aramayı deneyin. Konuşma balonu Güçlü diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/uk/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/uk/strings.xml index 67ed554185..a7f0aa857b 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/uk/strings.xml @@ -1562,8 +1562,6 @@ Кінець дзвінка Відеодзвінок Аудіодзвінок - Помилка відкриття браузера - Для використання дзвінків потрібен браузер за замовчуванням. Будь ласка, налаштуйте браузер за замовчуванням в системі та надайте більше інформації розробникам. Перенести з іншого пристрою квантово-стійке шифрування e2e стандартне наскрізне шифрування @@ -2032,8 +2030,6 @@ Нові облікові дані SOCKS будуть використовуватись щоразу, коли ви запускаєте додаток. Нові облікові дані SOCKS будуть використовуватись для кожного сервера. Сервер - Натисніть кнопку інформації поруч із полем адреси, щоб дозволити використання мікрофона. - Відкрийте Налаштування Safari / Сайти / Мікрофон, а потім виберіть \"Дозволити для localhost\". Щоб здійснювати дзвінки, дозволіть використовувати ваш мікрофон. Завершіть дзвінок і спробуйте зателефонувати знову. Кращі дзвінки Краща безпека ✅ diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/vi/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/vi/strings.xml index 11e9080687..e1a7495f81 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/vi/strings.xml @@ -691,7 +691,6 @@ \nLỗi: %s Lỗi xóa thành viên Lỗi: %s - Lỗi mở trình duyệt Lỗi tham gia nhóm Lỗi tải thông tin chi tiết Lỗi nhận tệp @@ -866,7 +865,6 @@ Lưu trữ Phiên làm việc trên ứng dụng Cách sử dụng markdown - Nhấn nút thông tin gần trường địa chỉ để cho phép sử dụng microphone. Trải nghiệm cuộc gọi tốt hơn Bảo mật hơn ✅ Trải nghiệm người dùng tuyệt vời hơn @@ -1209,7 +1207,6 @@ Mở cài đặt Hoặc chia sẻ đường dẫn tệp này một cách an toàn. Mở SimpleX Chat để chấp nhận cuộc gọi - Mở Cài đặt Safari / Trang Web / Mic, rồi chọn Cho phép với localhost. Mã truy cập Mở cuộc trò chuyện Mở vị trí tệp @@ -1884,7 +1881,6 @@ Mở các đường dẫn từ danh sách trò chuyện Kết nối bạn đã chấp nhận sẽ bị hủy bỏ! Liên hệ mà bạn đã chia sẻ đường dẫn này sẽ không thể kết nối! - 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. Ứ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. Ứng dụng có thể bị đóng sau 1 phút chạy trong nền. Ứ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. diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/zh-rCN/strings.xml index f23990b393..10b25592cc 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/zh-rCN/strings.xml @@ -1555,8 +1555,6 @@ 视频通话 语音通话 结束通话 - 打开浏览器出错 - 没有默认网络浏览器无法使用通话功能。请在系统中配置默认浏览器并和开发者分享更多信息。 此聊天受抗量子的端到端加密保护。 此聊天受端到端加密保护。 打开迁移屏幕 @@ -2022,9 +2020,7 @@ 应用会话 每次启动应用都会使用新的 SOCKS 凭据。 服务器 - 打开 Safari 设置/网站/麦克风,接着在 localhost 选择“允许”。 要进行通话,请允许使用设备麦克风。结束通话并尝试再次呼叫。 - 单击地址栏附近的“信息”按钮允许使用麦克风。 每个服务器都会使用新的 SOCKS 凭据。 更好的消息日期。 更佳的安全性✅ diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/zh-rTW/strings.xml index c146c778a8..6d5fa59045 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/zh-rTW/strings.xml @@ -1338,7 +1338,6 @@ 已停用 安裝成功 建立 - 打開瀏覽器出錯 啟用(保留覆蓋) 禁用(保留組覆蓋) 為群組啟用回執? @@ -2319,7 +2318,6 @@ 簡介太長 要儲存加入審批設定嗎? 儲存並通知頻道訂閱者 - 通話需要預設網頁瀏覽器。請在系統中設定預設瀏覽器,並向開發者分享更多資訊。 初始化 WebView 時發生錯誤。請確認已安裝 WebView,且其支援的架構為 arm64。\n錯誤:%s 在你的網路中\n自由交流 私密且安全的通訊。 @@ -2355,8 +2353,6 @@ 設定通知 網路承諾 若要通話,請允許使用麥克風。結束通話後再嘗試撥打。 - 點按地址欄旁的資訊按鈕,以允許使用麥克風。 - 開啟 Safari 設定 / 網站 / 麥克風,然後為 localhost 選擇「允許」。 要開啟外部連結嗎? 已丟棄(%1$d 次嘗試) 錯誤:%s diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/call.html b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/call.html deleted file mode 100644 index 8ea76ed488..0000000000 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/call.html +++ /dev/null @@ -1,81 +0,0 @@ - - - - SimpleX Chat WebRTC call - - - - - - - - - - - - - -
-
-

-

- -
-
- -
- -
-

-

-
- -

- - - - - -

- -
- - -
- diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_call_end_filled.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_call_end_filled.svg deleted file mode 100644 index 5ce74d3cda..0000000000 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_call_end_filled.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic.svg deleted file mode 100644 index afebf258d3..0000000000 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic_off.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic_off.svg deleted file mode 100644 index 941dc182a0..0000000000 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic_off.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_phone_in_talk.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_phone_in_talk.svg deleted file mode 100644 index 43cfd7cb9f..0000000000 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_phone_in_talk.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_screen_share.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_screen_share.svg deleted file mode 100644 index 979af2b073..0000000000 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_screen_share.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_stop_screen_share.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_stop_screen_share.svg deleted file mode 100644 index 92e1ff338c..0000000000 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_stop_screen_share.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_filled.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_filled.svg deleted file mode 100644 index def80d4719..0000000000 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_filled.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_off.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_off.svg deleted file mode 100644 index 07557e277e..0000000000 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_off.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_down.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_down.svg deleted file mode 100644 index 19999e82af..0000000000 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_down.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_off.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_off.svg deleted file mode 100644 index 2767d8d48d..0000000000 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_off.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_up.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_up.svg deleted file mode 100644 index 2857a913f9..0000000000 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_up.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/style.css b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/style.css deleted file mode 100644 index 5110c7c7d6..0000000000 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/style.css +++ /dev/null @@ -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; -} diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js deleted file mode 100644 index be1c820b4e..0000000000 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js +++ /dev/null @@ -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 - ? '' - : ''; -} -function enableCameraIcon(enabled) { - document.getElementById("toggle-camera").innerHTML = enabled - ? '' - : ''; -} -function enableScreenIcon(enabled) { - document.getElementById("toggle-screen").innerHTML = enabled - ? '' - : ''; -} -function enableSpeakerIcon(enabled, muted) { - document.getElementById("toggle-speaker").innerHTML = muted - ? '' - : enabled - ? '' - : ''; - 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 \ No newline at end of file diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt index 616f46dab6..52dae5563d 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt @@ -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() +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, 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, 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()) } + 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>, 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()) } + 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(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() } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/NativeCallEngine.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/NativeCallEngine.kt new file mode 100644 index 0000000000..04839843e4 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/NativeCallEngine.kt @@ -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() + + private var pc: RTCPeerConnection? = null + private val slots = ArrayList() + private var senderCipher: CallFrameCipher? = null + private var receiverCipher: CallFrameCipher? = null + private var micEnabled = true + private var peerHasOldVersion = false + private val peerMedia = HashMap() + private val pendingCandidates = ArrayList() + private val gatheredCandidates = ArrayList() + private var iceGatheringComplete = CompletableDeferred() + 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>() + private var closed = false + + private val _localCamera = MutableStateFlow(null) + val localCamera: StateFlow = _localCamera + private val _remoteCamera = MutableStateFlow(null) + val remoteCamera: StateFlow = _remoteCamera + private val _remoteScreen = MutableStateFlow(null) + val remoteScreen: StateFlow = _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?, 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) { + 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) { + 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() + val mutedSeconds = HashMap() + 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 { 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 = + json.decodeFromString(ListSerializer(IceCandidateJson.serializer()), LZString.decompressFromBase64(s) ?: error("invalid ice candidates")) + + fun videoCaptureDevices(): List = MediaDevices.getVideoCaptureDevices() + + fun screenShareSources(): List { + 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) = object : CreateSessionDescriptionObserver { + override fun onSuccess(description: RTCSessionDescription) = cont.resume(description) + override fun onFailure(error: String) = cont.resumeWithException(IllegalStateException(error)) +} + +private fun setObserver(cont: CancellableContinuation) = object : SetSessionDescriptionObserver { + override fun onSuccess() = cont.resume(Unit) + override fun onFailure(error: String) = cont.resumeWithException(IllegalStateException(error)) +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt index 250d7ed3d9..fa8a7fe012 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt @@ -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 { diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerAuthTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerAuthTest.kt deleted file mode 100644 index 92146833b8..0000000000 --- a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerAuthTest.kt +++ /dev/null @@ -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 = listOf("GET $path HTTP/1.1", "Host: localhost:$port") - - private fun webSocketUpgrade(path: String, origin: String = "http://localhost:$port"): List = - 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): 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() - } -} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerTokenTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerTokenTest.kt deleted file mode 100644 index dc729b1ec2..0000000000 --- a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/CallServerTokenTest.kt +++ /dev/null @@ -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("=")) - } -} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/NativeCallEngineTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/NativeCallEngineTest.kt new file mode 100644 index 0000000000..c14f43ad1a --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/NativeCallEngineTest.kt @@ -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 { + 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() + assertTrue(offer.capabilities.encryption) + caller.process(WCallCommand.Offer(offer.offer, offer.iceCandidates, CallMediaType.Audio, aesKey, iceServers = emptyList(), relay = false)) + val answer = callerInbox.awaitResponse() + callee.process(WCallCommand.Answer(answer.answer, answer.iceCandidates)) + calleeInbox.awaitResponse() + 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() + } + } finally { + listOf(caller.close(), callee.close()).joinAll() + } + } + + @Test + fun testRejectsOutOfOrderCommands() = runBlocking { + 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()) + engine.process(WCallCommand.Ice(LZString.compressToBase64("[]"))) + assertEquals(WCallResponse.Error("ice: call not started yet, will add candidates later"), inbox.awaitResponse()) + engine.process(WCallCommand.Start(CallMediaType.Audio, aesKey, iceServers = emptyList(), relay = false)) + val offer = inbox.awaitResponse() + engine.process(WCallCommand.Offer(offer.offer, offer.iceCandidates, CallMediaType.Audio, aesKey, iceServers = emptyList(), relay = false)) + assertEquals(WCallResponse.Error("accept: call already started"), inbox.awaitResponse()) + } + } finally { + engine.close().join() + } + } + + @Test + fun testOffererRestartsIceAndCallRecovers() = runBlocking { + 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 { + 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 { + 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() + caller.process(WCallCommand.Offer(offer.offer, offer.iceCandidates, CallMediaType.Audio, aesKey, iceServers = emptyList(), relay = false)) + val answer = callerInbox.awaitResponse() + callee.process(WCallCommand.Answer(answer.answer, answer.iceCandidates)) + calleeInbox.awaitResponse() + 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()) + caller.process(WCallCommand.Ice(noCandidates, answer.answer)) + assertEquals(WCallResponse.Error("ice: unexpected ICE restart answer in stable state"), callerInbox.awaitResponse()) + } + } finally { + listOf(caller.close(), callee.close()).joinAll() + } + } + + private class Side(val name: String) { + val inbox = Inbox() + val engine = NativeCallEngine(inbox::post) + val states = Channel(Channel.UNLIMITED) + val sessions = Channel(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() + caller.engine.process(WCallCommand.Offer(offer.offer, network.route(callee, offer.iceCandidates), CallMediaType.Audio, aesKey, iceServers = emptyList(), relay = false)) + val answer = caller.inbox.awaitResponse() + callee.engine.process(WCallCommand.Answer(answer.answer, network.route(caller, answer.iceCandidates))) + callee.inbox.awaitResponse() + } + 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, ip: String) { + val ufrags = HashMap() + val real = ConcurrentHashMap() + 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) : AutoCloseable { + @Volatile var down = false + private val relays = ArrayList() + + // 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(Channel.UNLIMITED) + private val notifications = ArrayDeque() + + fun post(msg: WVAPIMessage) { + channel.trySend(msg) + } + + suspend inline fun awaitResponse(): T { + while (true) { + val msg = channel.receive() + if (msg.command == null) notifications.addLast(msg) else return assertIs(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 -> {} + } + } + } + } +}