diff --git a/CHANGELOG.md b/CHANGELOG.md index a105ca8b64518f3655232fbcbda1442ae2482769..0e9eb28bdb610c5be4c278f26231eca5a45b28ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +### Version 2.13.0 + +* Easier access to 'Show QR code' +* Support PEP Native Bookmarks +* Add support for SDP Offer / Answer Model (Used by SIP gateways) +* Raise target API to Android 14 + ### Version 2.12.12 * Support Private DNS (DNS over TLS) diff --git a/build.gradle b/build.gradle index 72bb7dedbb5e96356c59fe7a4dd77a57cd41d34e..2dbeb77d2efd4730dfec3d84c4c2fe6f4ee9e84f 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.2.0-rc01' + classpath 'com.android.tools.build:gradle:8.2.0-rc03' } } diff --git a/fastlane/metadata/android/en-US/changelogs/4208104.txt b/fastlane/metadata/android/en-US/changelogs/4208104.txt new file mode 100644 index 0000000000000000000000000000000000000000..a945cbcb3c6e709d27a27f42fa0d61c650bf7224 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/4208104.txt @@ -0,0 +1,4 @@ +* Easier access to 'Show QR code' +* Support PEP Native Bookmarks +* Add support for SDP Offer / Answer Model (Used by SIP gateways) +* Raise target API to Android 14 diff --git a/fastlane/metadata/android/es-ES/changelogs/4208104.txt b/fastlane/metadata/android/es-ES/changelogs/4208104.txt new file mode 100644 index 0000000000000000000000000000000000000000..dde4e4f058d1c6a09f4fd3bb44635756eff6ade2 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/4208104.txt @@ -0,0 +1,4 @@ +* Acceso más fácil a "Mostrar el código QR +* Soporte para marcadores nativos PEP +* Añadir soporte para SDP Oferta / Respuesta Modelo (Utilizado por pasarelas SIP) +* Aumento de la API de destino a Android 14 diff --git a/fastlane/metadata/android/gl-ES/changelogs/4208104.txt b/fastlane/metadata/android/gl-ES/changelogs/4208104.txt new file mode 100644 index 0000000000000000000000000000000000000000..f4532b6797151479e6086a6f92774ffd41b78906 --- /dev/null +++ b/fastlane/metadata/android/gl-ES/changelogs/4208104.txt @@ -0,0 +1,4 @@ +* Acceso mais rápido á 'Mostrar código QR' +* Soporte para a PEP Marcadores Nativos +* Engadido soporte para SDP Offer / Answer Model (usado por pasarelas SIP) +* Establecida a API de Android 14 como obxectivo diff --git a/fastlane/metadata/android/it-IT/changelogs/4208104.txt b/fastlane/metadata/android/it-IT/changelogs/4208104.txt new file mode 100644 index 0000000000000000000000000000000000000000..00be75ede439405a86d9b989cd973df1e6eca5c8 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/4208104.txt @@ -0,0 +1,4 @@ +* Accesso più facile a 'Mostra codice QR' +* Supporto per PEP Native Bookmarks +* Aggiunto supporto per il modello Offerta / Risposta SDP (usato dai gateway SIP) +* Aumentata l'API di destinazione ad Android 14 diff --git a/fastlane/metadata/android/uk/changelogs/4208104.txt b/fastlane/metadata/android/uk/changelogs/4208104.txt new file mode 100644 index 0000000000000000000000000000000000000000..9235074b11e12a68e74ea5b6165a2f40f52ea25b --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/4208104.txt @@ -0,0 +1,4 @@ +* Простіший доступ до «Показати QR-код» +* Підтримка закладок PEP Native Bookmarks +* Додано підтримку моделі SDP пропозиція/відповідь (Використовується шлюзами SIP) +* Підвищено цільовий API до Android 14 diff --git a/fastlane/metadata/android/zh-CN/changelogs/398.txt b/fastlane/metadata/android/zh-CN/changelogs/398.txt index 5a3df8a3ff6c4e670c57ca5bf4a723c6c2d475fb..3985836d3ae063f958c7e6316d695983c714f5aa 100644 --- a/fastlane/metadata/android/zh-CN/changelogs/398.txt +++ b/fastlane/metadata/android/zh-CN/changelogs/398.txt @@ -1,4 +1,4 @@ * 搜索个人对话 * 消息传递失败时通知用户 -* 重新启动时记住 Quicksy 用户的显示名称(昵称) +* 重启时记住 Quicksy 用户的显示名称(昵称) * 如有必要,添加按钮以从通知中启动 Orbot(Tor) diff --git a/fastlane/metadata/android/zh-CN/changelogs/4207704.txt b/fastlane/metadata/android/zh-CN/changelogs/4207704.txt index d07b29e61ee671b6673661da9e8c0f441038bd46..4c33d6b999f833dc5f368cc91281e56864a934e6 100644 --- a/fastlane/metadata/android/zh-CN/changelogs/4207704.txt +++ b/fastlane/metadata/android/zh-CN/changelogs/4207704.txt @@ -1,3 +1,3 @@ -* 支持私人 DNS (DNS over TLS) +* 支持私人 DNS(DNS over TLS) * 支持主题启动器图标 * 修复在 Android 11+ 上分享文件时罕见的权限问题 diff --git a/fastlane/metadata/android/zh-CN/changelogs/4208104.txt b/fastlane/metadata/android/zh-CN/changelogs/4208104.txt new file mode 100644 index 0000000000000000000000000000000000000000..7e5f80a1388f3abd11603e8c3299c70a948db70d --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/4208104.txt @@ -0,0 +1,4 @@ +* 更容易访问“显示二维码” +* 支持 PEP Native Bookmarks +* 添加对 SDP 请求/响应模型的支持(由 SIP 网关使用) +* 将目标 API 提升到 Android 14 diff --git a/src/conversations/fastlane/metadata/android/eo/short_description.txt b/src/conversations/fastlane/metadata/android/eo/short_description.txt new file mode 100644 index 0000000000000000000000000000000000000000..514cca5bdd23b8b7e514bd6b2e05121f6ad3dfec --- /dev/null +++ b/src/conversations/fastlane/metadata/android/eo/short_description.txt @@ -0,0 +1 @@ +Ĉifrita, facile uzebla XMPP tujmesaĝilo por via poŝtelefono diff --git a/src/conversations/fastlane/metadata/android/fi-FI/full_description.txt b/src/conversations/fastlane/metadata/android/fi-FI/full_description.txt new file mode 100644 index 0000000000000000000000000000000000000000..8071c92d06fc52f9232ffc84c9e9a69cd909eae8 --- /dev/null +++ b/src/conversations/fastlane/metadata/android/fi-FI/full_description.txt @@ -0,0 +1,39 @@ +Helppokäyttöinen, luotettava ja vähän akkua käyttävä. Sisäänrakennettu tuki kuville, ryhmille ja päästä päähän -salaukselle. + +Periaatteet: + +* Ole niin kaunis ja helppokäyttöinen kuin on mahdolista turvallisuudesta ja ykstyisyydestä tinkimättä +* Käytä valmiita ja vakiintuneita protokollia +* Älä riipu Google-tunnuksesta äläkä Google Cloud Messaging -palvelusta (GCM) +* Vaadi niin vähän käyttöoikeuksia kuin mahdollista + +Ominaisuudet: + +* Päästä päähän -salaus joko OMEMO:lla tai OpenPGP:llä +* Lähetä ja vastaanota kuvia +* Salatut ääni- ja videopuhelut (DTLS-SRTP) +* Intuitiivinen käyttöliittymä joka noudattaa Androidin muotoilukieltä +* Profiilikuvat yhteystiedoille +* Synkronoi työpöytäversion kanssa +* Konferenssit (kirjanmerkkituella) +* Osoitekirjaintegrointi +* Useampi tili yhdessä näkymässä +* Todella pieni akun kulutus + +Conversations:lla on helppo luoda tili conversations.im-palvelimella. Silti Conversations toimii myös minkä tahansa muun XMPP-palvelimen kanssa. Monia XMPP-palvelimia ylläpidetään ilmaiseksi vapaaehtoisvoimin. + +XMPP-ominaisuudet: + +Conversations toimii kaikkien XMPP-palvelinten kanssa. XMPP on kuitenkin laajennettava protokolla. Nämä laajennukset on standardoitu niin kutsuttuina XEP:inä. Conversations tukee muutamaa näistä tehdäkseen käyttäjäkokemuksesta paremman. On mahdollista että nykyinen XMPP-palvelimesi ei tue kaikkia näitä laajennoksia. Siispä saadaksesi kaiken ilon irti Conversationsista kannattaa harkita joko sellaiseen palvelimeen, joka tukee näitä, vaihtamista tai oman XMPP-palvelimen ylläpitämistä itsellesi ja kavereillesi. + +XEP:t ovat tällä hetkellä: + +* XEP-0065: SOCKS5 Bytestreams (tai mod_proxy65). Käytetään tiedostojen siirtoon jos molemmat osapuolet ovat palomuurin tai NAT:n takana. +* XEP-0163: Personal Eventing Protocol profiilikuville +* XEP-0191: Blocking command lets you blacklist spammers or block contacts without removing them from your roster. +* XEP-0198: Stream Management mahdollistaa XMPP:n selviämisen pienestä verkon pätkimisestä ja TCP-yhteyden muutoksista. +* XEP-0280: Kopiot lähettämistäsi viesteistä muille laitteillesi. Mahdolistaa laitteiden vaihdon kesken keskustelun täysin saumoitta. +* XEP-0237: Roster Versioning säästää dataa heikoila yhteyksillä +* XEP-0313: Message Archive Management synchronize message history with the server. Catch up with messages that were sent while Conversations was offline. +* XEP-0352: Client State Indication lets the server know whether or not Conversations is in the background. Allows the server to save bandwidth by withholding unimportant packages. +* XEP-0363: HTTP File Upload allows you to share files in conferences and with offline contacts. Requires an additional component on your server. diff --git a/src/conversations/fastlane/metadata/android/fi-FI/short_description.txt b/src/conversations/fastlane/metadata/android/fi-FI/short_description.txt new file mode 100644 index 0000000000000000000000000000000000000000..2713b6efd11a4066561968fc0fc38e802281e007 --- /dev/null +++ b/src/conversations/fastlane/metadata/android/fi-FI/short_description.txt @@ -0,0 +1 @@ +Salattu ja helppokäyttöinen XMPP-pikaviestin mobiililaitteellesi diff --git a/src/conversations/res/values-eo/strings.xml b/src/conversations/res/values-eo/strings.xml new file mode 100644 index 0000000000000000000000000000000000000000..d5fc7d80eb682cb660b14fbfcc0e041d660652d4 --- /dev/null +++ b/src/conversations/res/values-eo/strings.xml @@ -0,0 +1,20 @@ + + + Uzi conversations.im + Aliĝu al %1$s kaj babilu kun mi: %2$s + Vi estis invitita al %1$s. Ni gvidos vin tra la procezo de kreado de konto. +\nElektante %1$s kiel provizanton vi povos komuniki kun uzantoj de aliaj provizantoj donante al ili vian plenan XMPP-adreson. + Ĉu vi jam havas XMPP-konton\? Ĉi tio povus esti la kazo se vi jam uzas alian XMPP-klienton aŭ antaŭe uzis Conversations. Se ne, vi povas krei novan XMPP-konton nun. +\nKonsileto: Iuj retpoŝtaj provizantoj ankaŭ provizas XMPP-kontojn. + Se via kontakto estas proksime, ili ankaŭ povas skani la suban kodon por akcepti vian inviton. + Elekti vian XMPP-provizanton + Kunhavigi inviton kun… + XMPP estas provizanta sendependa tujmesaĝa reto. Vi povas uzi ĉi tiun klienton per kia ajn XMPP-servilo, kiun vi elektas. +\nTamen por via komforto ni faciligis krei konton ĉe conversations.im; provizanto speciale taŭga por la uzo kun Conversations. + Vi estis invitita al %1$s. Uzantnomo jam estas elektita por vi. Ni gvidos vin tra la procezo de kreado de konto. +\nVi povos komuniki kun uzantoj de aliaj provizantoj donante al ili vian plenan XMPP-adreson. + Nedece formatita provizokodo + Premu la kunhavigi butonon por sendi al via kontakto inviton al %1$s. + Via servila invito + Krei novan konton + \ No newline at end of file diff --git a/src/main/java/eu/siacs/conversations/Config.java b/src/main/java/eu/siacs/conversations/Config.java index 4a0581f4cbea83603fd5d876e6a2302a60b9362d..c4ce7e6f0b53675cffe11f0dacc5e294721bf267 100644 --- a/src/main/java/eu/siacs/conversations/Config.java +++ b/src/main/java/eu/siacs/conversations/Config.java @@ -41,10 +41,11 @@ public final class Config { public static final String LOGTAG = BuildConfig.APP_NAME.toLowerCase(Locale.US); + public static final boolean QUICK_LOG = false; + public static final Jid BUG_REPORTS = Jid.of("+14169938000@cheogram.com"); public static final Uri HELP = Uri.parse("https://cheogram.com"); - public static final String DOMAIN_LOCK = null; //only allow account creation for this domain public static final String MAGIC_CREATE_DOMAIN = "chatterboxtown.us"; public static final Jid QUICKSY_DOMAIN = Jid.of("cheogram.com"); @@ -116,7 +117,7 @@ public final class Config { public static final boolean OMEMO_PADDING = false; public static final boolean PUT_AUTH_TAG_INTO_KEY = true; public static final boolean AUTOMATICALLY_COMPLETE_SESSIONS = true; - public static final boolean DISABLE_PROXY_LOOKUP = false; //useful to debug ibb + public static final boolean DISABLE_PROXY_LOOKUP = false; //disables STUN/TURN and Proxy65 look up (useful to debug IBB fallback) public static final boolean USE_DIRECT_JINGLE_CANDIDATES = true; public static final boolean DISABLE_HTTP_UPLOAD = false; public static final boolean EXTENDED_SM_LOGGING = false; // log stanza counts diff --git a/src/main/java/eu/siacs/conversations/crypto/axolotl/AxolotlService.java b/src/main/java/eu/siacs/conversations/crypto/axolotl/AxolotlService.java index 9cf3d9091a6cf4d659fe91797e94817f6b73b120..3721f4cfeed67e57914529130bae30f63025823b 100644 --- a/src/main/java/eu/siacs/conversations/crypto/axolotl/AxolotlService.java +++ b/src/main/java/eu/siacs/conversations/crypto/axolotl/AxolotlService.java @@ -62,11 +62,13 @@ import eu.siacs.conversations.xml.Namespace; import eu.siacs.conversations.xmpp.Jid; import eu.siacs.conversations.xmpp.OnAdvancedStreamFeaturesLoaded; import eu.siacs.conversations.xmpp.OnIqPacketReceived; +import eu.siacs.conversations.xmpp.jingle.DescriptionTransport; import eu.siacs.conversations.xmpp.jingle.OmemoVerification; import eu.siacs.conversations.xmpp.jingle.OmemoVerifiedRtpContentMap; import eu.siacs.conversations.xmpp.jingle.RtpContentMap; import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo; import eu.siacs.conversations.xmpp.jingle.stanzas.OmemoVerifiedIceUdpTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription; import eu.siacs.conversations.xmpp.pep.PublishOptions; import eu.siacs.conversations.xmpp.stanzas.IqPacket; import eu.siacs.conversations.xmpp.stanzas.MessagePacket; @@ -1262,12 +1264,12 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded { if (Config.REQUIRE_RTP_VERIFICATION) { requireVerification(session); } - final ImmutableMap.Builder descriptionTransportBuilder = new ImmutableMap.Builder<>(); + final ImmutableMap.Builder> descriptionTransportBuilder = new ImmutableMap.Builder<>(); final OmemoVerification omemoVerification = new OmemoVerification(); omemoVerification.setDeviceId(session.getRemoteAddress().getDeviceId()); omemoVerification.setSessionFingerprint(session.getFingerprint()); - for (final Map.Entry content : rtpContentMap.contents.entrySet()) { - final RtpContentMap.DescriptionTransport descriptionTransport = content.getValue(); + for (final Map.Entry> content : rtpContentMap.contents.entrySet()) { + final DescriptionTransport descriptionTransport = content.getValue(); final OmemoVerifiedIceUdpTransportInfo encryptedTransportInfo; try { encryptedTransportInfo = encrypt(descriptionTransport.transport, session); @@ -1276,7 +1278,7 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded { } descriptionTransportBuilder.put( content.getKey(), - new RtpContentMap.DescriptionTransport(descriptionTransport.senders, descriptionTransport.description, encryptedTransportInfo) + new DescriptionTransport<>(descriptionTransport.senders, descriptionTransport.description, encryptedTransportInfo) ); } return Futures.immediateFuture( @@ -1296,11 +1298,11 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded { } public ListenableFuture> decrypt(OmemoVerifiedRtpContentMap omemoVerifiedRtpContentMap, final Jid from) { - final ImmutableMap.Builder descriptionTransportBuilder = new ImmutableMap.Builder<>(); + final ImmutableMap.Builder> descriptionTransportBuilder = new ImmutableMap.Builder<>(); final OmemoVerification omemoVerification = new OmemoVerification(); final ImmutableList.Builder> pepVerificationFutures = new ImmutableList.Builder<>(); - for (final Map.Entry content : omemoVerifiedRtpContentMap.contents.entrySet()) { - final RtpContentMap.DescriptionTransport descriptionTransport = content.getValue(); + for (final Map.Entry> content : omemoVerifiedRtpContentMap.contents.entrySet()) { + final DescriptionTransport descriptionTransport = content.getValue(); final OmemoVerifiedPayload decryptedTransport; try { decryptedTransport = decrypt((OmemoVerifiedIceUdpTransportInfo) descriptionTransport.transport, from, pepVerificationFutures); @@ -1310,7 +1312,7 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded { omemoVerification.setOrEnsureEqual(decryptedTransport); descriptionTransportBuilder.put( content.getKey(), - new RtpContentMap.DescriptionTransport(descriptionTransport.senders, descriptionTransport.description, decryptedTransport.payload) + new DescriptionTransport<>(descriptionTransport.senders, descriptionTransport.description, decryptedTransport.payload) ); } processPostponed(); @@ -1376,18 +1378,15 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded { )); } - public void prepareKeyTransportMessage(final Conversation conversation, final OnMessageCreatedCallback onMessageCreatedCallback) { - executor.execute(new Runnable() { - @Override - public void run() { - final XmppAxolotlMessage axolotlMessage = new XmppAxolotlMessage(account.getJid().asBareJid(), getOwnDeviceId()); - if (buildHeader(axolotlMessage, conversation)) { - onMessageCreatedCallback.run(axolotlMessage); - } else { - onMessageCreatedCallback.run(null); - } + public ListenableFuture prepareKeyTransportMessage(final Conversation conversation) { + return Futures.submit(()->{ + final XmppAxolotlMessage axolotlMessage = new XmppAxolotlMessage(account.getJid().asBareJid(), getOwnDeviceId()); + if (buildHeader(axolotlMessage, conversation)) { + return axolotlMessage; + } else { + throw new IllegalStateException("No session to decrypt to"); } - }); + },executor); } public XmppAxolotlMessage fetchAxolotlMessageFromCache(Message message) { diff --git a/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java b/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java index f4e2b3b931c0d6b19cc2b2a32b8ed13db221ce35..f513947599b4daddd23998dcbe2b5abfcc8ef641 100644 --- a/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java +++ b/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java @@ -27,11 +27,7 @@ public abstract class AbstractGenerator { private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US); private final String[] FEATURES = { Namespace.JINGLE, - - //Jingle File Transfer - FileTransferDescription.Version.FT_3.getNamespace(), - FileTransferDescription.Version.FT_4.getNamespace(), - FileTransferDescription.Version.FT_5.getNamespace(), + Namespace.JINGLE_APPS_FILE_TRANSFER, Namespace.JINGLE_TRANSPORTS_S5B, Namespace.JINGLE_TRANSPORTS_IBB, Namespace.JINGLE_ENCRYPTED_TRANSPORT, @@ -126,6 +122,7 @@ public abstract class AbstractGenerator { if (!mXmppConnectionService.useTorToConnect() && !account.isOnion()) { features.addAll(Arrays.asList(PRIVACY_SENSITIVE)); features.addAll(Arrays.asList(VOIP_NAMESPACES)); + features.add(Namespace.JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL); } if (mXmppConnectionService.broadcastLastActivity()) { features.add(Namespace.IDLE); diff --git a/src/main/java/eu/siacs/conversations/persistance/UnifiedPushDatabase.java b/src/main/java/eu/siacs/conversations/persistance/UnifiedPushDatabase.java index f36506bd1db43a27fff4ad2264f226cd92e35d66..fd7b27db4a2f1a3bebe6f03a0a84454b08a19cc7 100644 --- a/src/main/java/eu/siacs/conversations/persistance/UnifiedPushDatabase.java +++ b/src/main/java/eu/siacs/conversations/persistance/UnifiedPushDatabase.java @@ -16,6 +16,7 @@ import com.google.common.collect.ImmutableList; import org.jetbrains.annotations.NotNull; +import java.util.ArrayList; import java.util.List; import eu.siacs.conversations.Config; @@ -129,6 +130,23 @@ public class UnifiedPushDatabase extends SQLiteOpenHelper { return null; } + public List deletePushTargets() { + final SQLiteDatabase sqLiteDatabase = getReadableDatabase(); + final ImmutableList.Builder builder = new ImmutableList.Builder<>(); + try (final Cursor cursor = sqLiteDatabase.query("push",new String[]{"application","instance"},null,null,null,null,null)) { + if (cursor != null && cursor.moveToFirst()) { + builder.add(new PushTarget( + cursor.getString(cursor.getColumnIndexOrThrow("application")), + cursor.getString(cursor.getColumnIndexOrThrow("instance")))); + } + } catch (final Exception e) { + Log.d(Config.LOGTAG,"unable to retrieve push targets",e); + return builder.build(); + } + sqLiteDatabase.delete("push",null,null); + return builder.build(); + } + public boolean hasEndpoints(final UnifiedPushBroker.Transport transport) { final SQLiteDatabase sqLiteDatabase = getReadableDatabase(); try (final Cursor cursor = diff --git a/src/main/java/eu/siacs/conversations/services/UnifiedPushBroker.java b/src/main/java/eu/siacs/conversations/services/UnifiedPushBroker.java index f27bf7fc52a0920910019b49925a359118de544b..2562e13a62a22d80edfc7e4bbe7d7a17a40ce5e3 100644 --- a/src/main/java/eu/siacs/conversations/services/UnifiedPushBroker.java +++ b/src/main/java/eu/siacs/conversations/services/UnifiedPushBroker.java @@ -1,5 +1,6 @@ package eu.siacs.conversations.services; +import android.app.PendingIntent; import android.content.ComponentName; import android.content.Intent; import android.content.SharedPreferences; @@ -9,10 +10,18 @@ import android.os.Messenger; import android.os.RemoteException; import android.preference.PreferenceManager; import android.util.Log; + +import androidx.annotation.NonNull; + import com.google.common.base.Optional; import com.google.common.base.Strings; import com.google.common.collect.Iterables; import com.google.common.io.BaseEncoding; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; + import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.entities.Account; @@ -87,14 +96,32 @@ public class UnifiedPushBroker { if (transport.account.isEnabled()) { renewUnifiedEndpoint(transportOptional.get(), pushTargetMessenger); } else { + if (pushTargetMessenger.messenger != null) { + sendRegistrationDelayed(pushTargetMessenger.messenger,"account is disabled"); + } Log.d(Config.LOGTAG, "skipping UnifiedPush endpoint renewal. Account is disabled"); } } else { + if (pushTargetMessenger.messenger != null) { + sendRegistrationDelayed(pushTargetMessenger.messenger,"no transport selected"); + } Log.d(Config.LOGTAG, "skipping UnifiedPush endpoint renewal. No transport selected"); } return transportOptional; } + private void sendRegistrationDelayed(final Messenger messenger, final String error) { + final Intent intent = new Intent(UnifiedPushDistributor.ACTION_REGISTRATION_DELAYED); + intent.putExtra(UnifiedPushDistributor.EXTRA_MESSAGE, error); + final var message = new Message(); + message.obj = intent; + try { + messenger.send(message); + } catch (final RemoteException e) { + Log.d(Config.LOGTAG,"unable to tell messenger of delayed registration",e); + } + } + private void renewUnifiedEndpoint(final Transport transport, final PushTargetMessenger pushTargetMessenger) { final Account account = transport.account; final UnifiedPushDatabase unifiedPushDatabase = UnifiedPushDatabase.getInstance(service); @@ -112,6 +139,7 @@ public class UnifiedPushBroker { Log.d( Config.LOGTAG, account.getJid().asBareJid() + ": try to renew UnifiedPush " + renewal); + UnifiedPushDistributor.quickLog(service,String.format("%s: try to renew UnifiedPush %s", account.getJid(), renewal.toString())); final String hashedApplication = UnifiedPushDistributor.hash(account.getUuid(), renewal.application); final String hashedInstance = @@ -186,7 +214,16 @@ public class UnifiedPushBroker { + renewal.instance + " was updated to " + endpoint); - final UnifiedPushDatabase.ApplicationEndpoint applicationEndpoint = new UnifiedPushDatabase.ApplicationEndpoint(renewal.application, endpoint); + UnifiedPushDistributor.quickLog( + service, + "endpoint for " + + renewal.application + + "/" + + renewal.instance + + " was updated to " + + endpoint); + final UnifiedPushDatabase.ApplicationEndpoint applicationEndpoint = + new UnifiedPushDatabase.ApplicationEndpoint(renewal.application, endpoint); sendEndpoint(messenger, renewal.instance, applicationEndpoint); } } @@ -210,6 +247,9 @@ public class UnifiedPushBroker { public boolean reconfigurePushDistributor() { final boolean enabled = getTransport().isPresent(); setUnifiedPushDistributorEnabled(enabled); + if (!enabled) { + unregisterCurrentPushTargets(); + } return enabled; } @@ -232,6 +272,43 @@ public class UnifiedPushBroker { } } + private void unregisterCurrentPushTargets() { + final var future = deletePushTargets(); + Futures.addCallback( + future, + new FutureCallback<>() { + @Override + public void onSuccess( + final List pushTargets) { + broadcastUnregistered(pushTargets); + } + + @Override + public void onFailure(@NonNull Throwable throwable) { + Log.d( + Config.LOGTAG, + "could not delete endpoints after UnifiedPushDistributor was disabled"); + } + }, + MoreExecutors.directExecutor()); + } + + private ListenableFuture> deletePushTargets() { + return Futures.submit(() -> UnifiedPushDatabase.getInstance(service).deletePushTargets(),SCHEDULER); + } + + private void broadcastUnregistered(final List pushTargets) { + for(final UnifiedPushDatabase.PushTarget pushTarget : pushTargets) { + Log.d(Config.LOGTAG,"sending unregistered to "+pushTarget); + broadcastUnregistered(pushTarget); + } + } + + private void broadcastUnregistered(final UnifiedPushDatabase.PushTarget pushTarget) { + final var intent = unregisteredIntent(pushTarget); + service.sendBroadcast(intent); + } + public boolean processPushMessage( final Account account, final Jid transport, final Element push) { final String instance = push.getAttribute("instance"); @@ -326,6 +403,12 @@ public class UnifiedPushBroker { updateIntent.putExtra("token", target.instance); updateIntent.putExtra("bytesMessage", payload); updateIntent.putExtra("message", new String(payload, StandardCharsets.UTF_8)); + final var distributorVerificationIntent = new Intent(); + distributorVerificationIntent.setPackage(service.getPackageName()); + final var pendingIntent = + PendingIntent.getBroadcast( + service, 0, distributorVerificationIntent, PendingIntent.FLAG_IMMUTABLE); + updateIntent.putExtra("distributor", pendingIntent); service.sendBroadcast(updateIntent); } @@ -336,11 +419,30 @@ public class UnifiedPushBroker { service.sendBroadcast(updateIntent); } - private static Intent endpointIntent(final String instance, final UnifiedPushDatabase.ApplicationEndpoint endpoint) { + private Intent endpointIntent(final String instance, final UnifiedPushDatabase.ApplicationEndpoint endpoint) { final Intent intent = new Intent(UnifiedPushDistributor.ACTION_NEW_ENDPOINT); intent.setPackage(endpoint.application); intent.putExtra("token", instance); intent.putExtra("endpoint", endpoint.endpoint); + final var distributorVerificationIntent = new Intent(); + distributorVerificationIntent.setPackage(service.getPackageName()); + final var pendingIntent = + PendingIntent.getBroadcast( + service, 0, distributorVerificationIntent, PendingIntent.FLAG_IMMUTABLE); + intent.putExtra("distributor", pendingIntent); + return intent; + } + + private Intent unregisteredIntent(final UnifiedPushDatabase.PushTarget pushTarget) { + final Intent intent = new Intent(UnifiedPushDistributor.ACTION_UNREGISTERED); + intent.setPackage(pushTarget.application); + intent.putExtra("token", pushTarget.instance); + final var distributorVerificationIntent = new Intent(); + distributorVerificationIntent.setPackage(service.getPackageName()); + final var pendingIntent = + PendingIntent.getBroadcast( + service, 0, distributorVerificationIntent, PendingIntent.FLAG_IMMUTABLE); + intent.putExtra("distributor", pendingIntent); return intent; } @@ -368,7 +470,7 @@ public class UnifiedPushBroker { public static class PushTargetMessenger { private final UnifiedPushDatabase.PushTarget pushTarget; - private final Messenger messenger; + public final Messenger messenger; public PushTargetMessenger(UnifiedPushDatabase.PushTarget pushTarget, Messenger messenger) { this.pushTarget = pushTarget; diff --git a/src/main/java/eu/siacs/conversations/services/UnifiedPushDistributor.java b/src/main/java/eu/siacs/conversations/services/UnifiedPushDistributor.java index c5402dec6b2fa3eab65f69c2beae6bb3cb493f1b..b47a61a531fdc5b4afcadd2f540acf8b803000c3 100644 --- a/src/main/java/eu/siacs/conversations/services/UnifiedPushDistributor.java +++ b/src/main/java/eu/siacs/conversations/services/UnifiedPushDistributor.java @@ -6,8 +6,10 @@ import android.content.Context; import android.content.Intent; import android.content.pm.ResolveInfo; import android.net.Uri; +import android.os.Message; import android.os.Messenger; import android.os.Parcelable; +import android.os.RemoteException; import android.util.Log; import com.google.common.base.Charsets; @@ -27,16 +29,30 @@ import eu.siacs.conversations.utils.Compatibility; public class UnifiedPushDistributor extends BroadcastReceiver { + // distributor actions (these are actios used for connector->distributor broadcasts) + // we, the distributor, have a broadcast receiver listening for those actions + public static final String ACTION_REGISTER = "org.unifiedpush.android.distributor.REGISTER"; public static final String ACTION_UNREGISTER = "org.unifiedpush.android.distributor.UNREGISTER"; + + + // connector actions (these are actions used for distributor->connector broadcasts) + public static final String ACTION_UNREGISTERED = "org.unifiedpush.android.connector.UNREGISTERED"; public static final String ACTION_BYTE_MESSAGE = "org.unifiedpush.android.distributor.feature.BYTES_MESSAGE"; public static final String ACTION_REGISTRATION_FAILED = "org.unifiedpush.android.connector.REGISTRATION_FAILED"; + + // this action is only used in 'messenger' communication to tell the app that a registration is + // probably fine but can not be processed right now; for example due to spotty internet + public static final String ACTION_REGISTRATION_DELAYED = + "org.unifiedpush.android.connector.REGISTRATION_DELAYED"; public static final String ACTION_MESSAGE = "org.unifiedpush.android.connector.MESSAGE"; public static final String ACTION_NEW_ENDPOINT = "org.unifiedpush.android.connector.NEW_ENDPOINT"; + public static final String EXTRA_MESSAGE = "message"; + public static final String PREFERENCE_ACCOUNT = "up_push_account"; public static final String PREFERENCE_PUSH_SERVER = "up_push_server"; @@ -50,9 +66,8 @@ public class UnifiedPushDistributor extends BroadcastReceiver { } final String action = intent.getAction(); final String application; - final Parcelable appByPendingIntent = intent.getParcelableExtra("app"); - if (appByPendingIntent instanceof PendingIntent) { - final PendingIntent pendingIntent = (PendingIntent) appByPendingIntent; + final Parcelable appVerification = intent.getParcelableExtra("app"); + if (appVerification instanceof PendingIntent pendingIntent) { application = pendingIntent.getIntentSender().getCreatorPackage(); Log.d(Config.LOGTAG,"received application name via pending intent "+ application); } else { @@ -62,18 +77,12 @@ public class UnifiedPushDistributor extends BroadcastReceiver { final String instance = intent.getStringExtra("token"); final List features = intent.getStringArrayListExtra("features"); switch (Strings.nullToEmpty(action)) { - case ACTION_REGISTER: - register(context, application, instance, features, messenger); - break; - case ACTION_UNREGISTER: - unregister(context, instance); - break; - case Intent.ACTION_PACKAGE_FULLY_REMOVED: - unregisterApplication(context, intent.getData()); - break; - default: - Log.d(Config.LOGTAG, "UnifiedPushDistributor received unknown action " + action); - break; + case ACTION_REGISTER -> register(context, application, instance, features, messenger); + case ACTION_UNREGISTER -> unregister(context, instance); + case Intent.ACTION_PACKAGE_FULLY_REMOVED -> + unregisterApplication(context, intent.getData()); + default -> + Log.d(Config.LOGTAG, "UnifiedPushDistributor received unknown action " + action); } } @@ -102,6 +111,7 @@ public class UnifiedPushDistributor extends BroadcastReceiver { Log.d( Config.LOGTAG, "successfully created UnifiedPush entry. waking up XmppConnectionService"); + quickLog(context, String.format("successfully registered %s (token = %s) for UnifiedPushed", application, instance)); final Intent serviceIntent = new Intent(context, XmppConnectionService.class); serviceIntent.setAction(XmppConnectionService.ACTION_RENEW_UNIFIED_PUSH_ENDPOINTS); serviceIntent.putExtra("instance", instance); @@ -113,11 +123,25 @@ public class UnifiedPushDistributor extends BroadcastReceiver { } else { Log.d(Config.LOGTAG, "not successful. sending error message back to application"); final Intent registrationFailed = new Intent(ACTION_REGISTRATION_FAILED); + registrationFailed.putExtra(EXTRA_MESSAGE, "instance already exits"); registrationFailed.setPackage(application); registrationFailed.putExtra("token", instance); - context.sendBroadcast(registrationFailed); + if (messenger instanceof Messenger m) { + final var message = new Message(); + message.obj = registrationFailed; + try { + m.send(message); + } catch (final RemoteException e) { + context.sendBroadcast(registrationFailed); + } + } else { + context.sendBroadcast(registrationFailed); + } } } else { + if (messenger instanceof Messenger m) { + sendRegistrationFailed(m,"Your application is not registered to receive messages"); + } Log.d( Config.LOGTAG, "ignoring invalid UnifiedPush registration. Unknown application " @@ -125,6 +149,18 @@ public class UnifiedPushDistributor extends BroadcastReceiver { } } + private void sendRegistrationFailed(final Messenger messenger, final String error) { + final Intent intent = new Intent(ACTION_REGISTRATION_FAILED); + intent.putExtra(EXTRA_MESSAGE, error); + final var message = new Message(); + message.obj = intent; + try { + messenger.send(message); + } catch (final RemoteException e) { + Log.d(Config.LOGTAG,"unable to tell messenger of failed registration",e); + } + } + private List getBroadcastReceivers(final Context context, final String application) { final Intent messageIntent = new Intent(ACTION_MESSAGE); messageIntent.setPackage(application); @@ -141,7 +177,9 @@ public class UnifiedPushDistributor extends BroadcastReceiver { } final UnifiedPushDatabase unifiedPushDatabase = UnifiedPushDatabase.getInstance(context); if (unifiedPushDatabase.deleteInstance(instance)) { + quickLog(context, String.format("successfully unregistered token %s from UnifiedPushed (application requested unregister)", instance)); Log.d(Config.LOGTAG, "successfully removed " + instance + " from UnifiedPush"); + // TODO send UNREGISTERED broadcast back to app?! } } @@ -154,6 +192,7 @@ public class UnifiedPushDistributor extends BroadcastReceiver { Log.d(Config.LOGTAG, "app " + application + " has been removed from the system"); final UnifiedPushDatabase database = UnifiedPushDatabase.getInstance(context); if (database.deleteApplication(application)) { + quickLog(context, String.format("successfully removed %s from UnifiedPushed (ACTION_PACKAGE_FULLY_REMOVED)", application)); Log.d(Config.LOGTAG, "successfully removed " + application + " from UnifiedPush"); } } @@ -166,4 +205,11 @@ public class UnifiedPushDistributor extends BroadcastReceiver { .hashString(Joiner.on('\0').join(components), Charsets.UTF_8) .asBytes()); } + + public static void quickLog(final Context context, final String message) { + final Intent intent = new Intent(context, XmppConnectionService.class); + intent.setAction(XmppConnectionService.ACTION_QUICK_LOG); + intent.putExtra("message", message); + context.startService(intent); + } } diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index f4b1b0ffb1d9b395765541b71e1b0551cfd71a6e..6d90180640f89a87cb6488f556b72e4d84c604b8 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -153,6 +153,7 @@ import eu.siacs.conversations.ui.UiCallback; import eu.siacs.conversations.ui.interfaces.OnAvatarPublication; import eu.siacs.conversations.ui.interfaces.OnMediaLoaded; import eu.siacs.conversations.ui.interfaces.OnSearchResultsAvailable; +import eu.siacs.conversations.utils.AccountUtils; import eu.siacs.conversations.utils.Compatibility; import eu.siacs.conversations.utils.ConversationsFileObserver; import eu.siacs.conversations.utils.CryptoHelper; @@ -227,6 +228,7 @@ public class XmppConnectionService extends Service { public static final String ACTION_PROVISION_ACCOUNT = "provision_account"; private static final String ACTION_POST_CONNECTIVITY_CHANGE = "eu.siacs.conversations.POST_CONNECTIVITY_CHANGE"; public static final String ACTION_RENEW_UNIFIED_PUSH_ENDPOINTS = "eu.siacs.conversations.UNIFIED_PUSH_RENEW"; + public static final String ACTION_QUICK_LOG = "eu.siacs.conversations.QUICK_LOG"; private static final String SETTING_LAST_ACTIVITY_TS = "last_activity_timestamp"; @@ -1016,6 +1018,12 @@ public class XmppConnectionService extends Service { case ACTION_FCM_MESSAGE_RECEIVED: Log.d(Config.LOGTAG, "push message arrived in service. account"); break; + case ACTION_QUICK_LOG: + final String message = intent == null ? null : intent.getStringExtra("message"); + if (message != null && Config.QUICK_LOG) { + quickLog(message); + } + break; case Intent.ACTION_SEND: final Uri uri = intent == null ? null : intent.getData(); if (uri != null) { @@ -1036,6 +1044,23 @@ public class XmppConnectionService extends Service { return START_STICKY; } + private void quickLog(final String message) { + if (Strings.isNullOrEmpty(message)) { + return; + } + final Account account = AccountUtils.getFirstEnabled(this); + if (account == null) { + return; + } + final Conversation conversation = + findOrCreateConversation(account, Config.BUG_REPORTS, false, true); + final Message report = new Message(conversation, message, Message.ENCRYPTION_NONE); + report.setStatus(Message.STATUS_RECEIVED); + conversation.add(report); + databaseBackend.createMessage(report); + updateConversationUi(); + } + private void manageAccountConnectionStatesInternal() { manageAccountConnectionStates(ACTION_INTERNAL_PING, null); } diff --git a/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java b/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java index 1d87efdac43c4ed7b2e4416d539c34d0993a340f..3cd197deda24a233f1424582ac0d68ee0de4111f 100644 --- a/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java @@ -1,8 +1,9 @@ package eu.siacs.conversations.ui; -import static java.util.Arrays.asList; import static eu.siacs.conversations.utils.PermissionUtils.getFirstDenied; +import static java.util.Arrays.asList; + import android.Manifest; import android.annotation.SuppressLint; import android.app.Activity; @@ -65,7 +66,6 @@ import eu.siacs.conversations.ui.util.MainThreadExecutor; import eu.siacs.conversations.ui.util.Rationals; import eu.siacs.conversations.utils.PermissionUtils; import eu.siacs.conversations.utils.TimeFrameUtils; -import eu.siacs.conversations.xml.Namespace; import eu.siacs.conversations.xmpp.Jid; import eu.siacs.conversations.xmpp.jingle.AbstractJingleConnection; import eu.siacs.conversations.xmpp.jingle.ContentAddition; @@ -75,6 +75,16 @@ import eu.siacs.conversations.xmpp.jingle.Media; import eu.siacs.conversations.xmpp.jingle.RtpCapability; import eu.siacs.conversations.xmpp.jingle.RtpEndUserState; +import org.webrtc.RendererCommon; +import org.webrtc.SurfaceViewRenderer; +import org.webrtc.VideoTrack; + +import java.lang.ref.WeakReference; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; + public class RtpSessionActivity extends XmppActivity implements XmppConnectionService.OnJingleRtpConnectionUpdate, eu.siacs.conversations.ui.widget.SurfaceViewRenderer.OnAspectRatioChanged { @@ -109,9 +119,7 @@ public class RtpSessionActivity extends XmppActivity RtpEndUserState.RECONNECTING, RtpEndUserState.INCOMING_CONTENT_ADD); private static final List STATES_CONSIDERED_CONNECTED = - Arrays.asList( - RtpEndUserState.CONNECTED, - RtpEndUserState.RECONNECTING); + Arrays.asList(RtpEndUserState.CONNECTED, RtpEndUserState.RECONNECTING); private static final List STATES_SHOWING_PIP_PLACEHOLDER = Arrays.asList( RtpEndUserState.ACCEPTING_CALL, @@ -262,21 +270,22 @@ public class RtpSessionActivity extends XmppActivity } public boolean onOptionsItemSelected(final MenuItem item) { - switch (item.getItemId()) { - case R.id.action_help: - launchHelpInBrowser(); - return true; - case R.id.action_goto_chat: - switchToConversation(); - return true; - case R.id.action_dialpad: - toggleDialpadVisibility(); - return true; - case R.id.action_switch_to_video: - requestPermissionAndSwitchToVideo(); - return true; + final var itemItem = item.getItemId(); + if (itemItem == R.id.action_help) { + launchHelpInBrowser(); + return true; + } else if (itemItem == R.id.action_goto_chat) { + switchToConversation(); + return true; + } else if (itemItem == R.id.action_dialpad) { + toggleDialpadVisibility(); + return true; + } else if (itemItem == R.id.action_switch_to_video) { + requestPermissionAndSwitchToVideo(); + return true; + } else { + return super.onOptionsItemSelected(item); } - return super.onOptionsItemSelected(item); } private void launchHelpInBrowser() { @@ -353,8 +362,9 @@ public class RtpSessionActivity extends XmppActivity } private void acceptContentAdd(final ContentAddition contentAddition) { - if (contentAddition == null || contentAddition.direction != ContentAddition.Direction.INCOMING) { - Log.d(Config.LOGTAG,"ignore press on content-accept button"); + if (contentAddition == null + || contentAddition.direction != ContentAddition.Direction.INCOMING) { + Log.d(Config.LOGTAG, "ignore press on content-accept button"); return; } requestPermissionAndAcceptContentAdd(contentAddition); @@ -363,7 +373,11 @@ public class RtpSessionActivity extends XmppActivity private void requestPermissionAndAcceptContentAdd(final ContentAddition contentAddition) { final List permissions = permissions(contentAddition.media()); if (PermissionUtils.hasPermission(this, permissions, REQUEST_ACCEPT_CONTENT)) { - requireRtpConnection().acceptContentAdd(contentAddition.summary); + try { + requireRtpConnection().acceptContentAdd(contentAddition.summary); + } catch (final IllegalStateException e) { + Toast.makeText(this, e.getMessage(), Toast.LENGTH_SHORT).show(); + } } } @@ -715,8 +729,10 @@ public class RtpSessionActivity extends XmppActivity private boolean isConnected() { final JingleRtpConnection connection = this.rtpConnectionReference != null ? this.rtpConnectionReference.get() : null; - final RtpEndUserState endUserState = connection == null ? null : connection.getEndUserState(); - return STATES_CONSIDERED_CONNECTED.contains(endUserState) || endUserState == RtpEndUserState.INCOMING_CONTENT_ADD; + final RtpEndUserState endUserState = + connection == null ? null : connection.getEndUserState(); + return STATES_CONSIDERED_CONNECTED.contains(endUserState) + || endUserState == RtpEndUserState.INCOMING_CONTENT_ADD; } private boolean switchToPictureInPicture() { @@ -875,68 +891,43 @@ public class RtpSessionActivity extends XmppActivity updateStateDisplay(state, Collections.emptySet(), null); } - private void updateStateDisplay(final RtpEndUserState state, final Set media, final ContentAddition contentAddition) { + private void updateStateDisplay( + final RtpEndUserState state, + final Set media, + final ContentAddition contentAddition) { switch (state) { - case INCOMING_CALL: + case INCOMING_CALL -> { Preconditions.checkArgument(media.size() > 0, "Media must not be empty"); if (media.contains(Media.VIDEO)) { setTitle(R.string.rtp_state_incoming_video_call); } else { setTitle(R.string.rtp_state_incoming_call); } - break; - case INCOMING_CONTENT_ADD: + } + case INCOMING_CONTENT_ADD -> { if (contentAddition != null && contentAddition.media().contains(Media.VIDEO)) { setTitle(R.string.rtp_state_content_add_video); } else { setTitle(R.string.rtp_state_content_add); } - break; - case CONNECTING: - setTitle(R.string.rtp_state_connecting); - break; - case CONNECTED: - setTitle(R.string.rtp_state_connected); - break; - case RECONNECTING: - setTitle(R.string.rtp_state_reconnecting); - break; - case ACCEPTING_CALL: - setTitle(R.string.rtp_state_accepting_call); - break; - case ENDING_CALL: - setTitle(R.string.rtp_state_ending_call); - break; - case FINDING_DEVICE: - setTitle(R.string.rtp_state_finding_device); - break; - case RINGING: - setTitle(R.string.rtp_state_ringing); - break; - case DECLINED_OR_BUSY: - setTitle(R.string.rtp_state_declined_or_busy); - break; - case CONNECTIVITY_ERROR: - setTitle(R.string.rtp_state_connectivity_error); - break; - case CONNECTIVITY_LOST_ERROR: - setTitle(R.string.rtp_state_connectivity_lost_error); - break; - case RETRACTED: - setTitle(R.string.rtp_state_retracted); - break; - case APPLICATION_ERROR: - setTitle(R.string.rtp_state_application_failure); - break; - case SECURITY_ERROR: - setTitle(R.string.rtp_state_security_error); - break; - case ENDED: - throw new IllegalStateException( - "Activity should have called finishAndReleaseWakeLock();"); - default: - throw new IllegalStateException( - String.format("State %s has not been handled in UI", state)); + } + case CONNECTING -> setTitle(R.string.rtp_state_connecting); + case CONNECTED -> setTitle(R.string.rtp_state_connected); + case RECONNECTING -> setTitle(R.string.rtp_state_reconnecting); + case ACCEPTING_CALL -> setTitle(R.string.rtp_state_accepting_call); + case ENDING_CALL -> setTitle(R.string.rtp_state_ending_call); + case FINDING_DEVICE -> setTitle(R.string.rtp_state_finding_device); + case RINGING -> setTitle(R.string.rtp_state_ringing); + case DECLINED_OR_BUSY -> setTitle(R.string.rtp_state_declined_or_busy); + case CONNECTIVITY_ERROR -> setTitle(R.string.rtp_state_connectivity_error); + case CONNECTIVITY_LOST_ERROR -> setTitle(R.string.rtp_state_connectivity_lost_error); + case RETRACTED -> setTitle(R.string.rtp_state_retracted); + case APPLICATION_ERROR -> setTitle(R.string.rtp_state_application_failure); + case SECURITY_ERROR -> setTitle(R.string.rtp_state_security_error); + case ENDED -> throw new IllegalStateException( + "Activity should have called finishAndReleaseWakeLock();"); + default -> throw new IllegalStateException( + String.format("State %s has not been handled in UI", state)); } } @@ -992,7 +983,10 @@ public class RtpSessionActivity extends XmppActivity } @SuppressLint("RestrictedApi") - private void updateButtonConfiguration(final RtpEndUserState state, final Set media, final ContentAddition contentAddition) { + private void updateButtonConfiguration( + final RtpEndUserState state, + final Set media, + final ContentAddition contentAddition) { if (state == RtpEndUserState.ENDING_CALL || isPictureInPicture()) { this.binding.rejectCall.setVisibility(View.INVISIBLE); this.binding.endCall.setVisibility(View.INVISIBLE); @@ -1008,7 +1002,8 @@ public class RtpSessionActivity extends XmppActivity this.binding.acceptCall.setImageResource(R.drawable.ic_call_white_48dp); this.binding.acceptCall.setVisibility(View.VISIBLE); } else if (state == RtpEndUserState.INCOMING_CONTENT_ADD) { - this.binding.rejectCall.setContentDescription(getString(R.string.reject_switch_to_video)); + this.binding.rejectCall.setContentDescription( + getString(R.string.reject_switch_to_video)); this.binding.rejectCall.setOnClickListener(this::rejectContentAdd); this.binding.rejectCall.setImageResource(R.drawable.ic_clear_white_48dp); this.binding.rejectCall.setVisibility(View.VISIBLE); @@ -1100,7 +1095,7 @@ public class RtpSessionActivity extends XmppActivity private void updateInCallButtonConfigurationSpeaker( final AppRTCAudioManager.AudioDevice selectedAudioDevice, final int numberOfChoices) { switch (selectedAudioDevice) { - case EARPIECE: + case EARPIECE -> { this.binding.inCallActionRight.setImageResource( R.drawable.ic_volume_off_black_24dp); if (numberOfChoices >= 2) { @@ -1109,13 +1104,13 @@ public class RtpSessionActivity extends XmppActivity this.binding.inCallActionRight.setOnClickListener(null); this.binding.inCallActionRight.setClickable(false); } - break; - case WIRED_HEADSET: + } + case WIRED_HEADSET -> { this.binding.inCallActionRight.setImageResource(R.drawable.ic_headset_black_24dp); this.binding.inCallActionRight.setOnClickListener(null); this.binding.inCallActionRight.setClickable(false); - break; - case SPEAKER_PHONE: + } + case SPEAKER_PHONE -> { this.binding.inCallActionRight.setImageResource(R.drawable.ic_volume_up_black_24dp); if (numberOfChoices >= 2) { this.binding.inCallActionRight.setOnClickListener(this::switchToEarpiece); @@ -1123,13 +1118,13 @@ public class RtpSessionActivity extends XmppActivity this.binding.inCallActionRight.setOnClickListener(null); this.binding.inCallActionRight.setClickable(false); } - break; - case BLUETOOTH: + } + case BLUETOOTH -> { this.binding.inCallActionRight.setImageResource( R.drawable.ic_bluetooth_audio_black_24dp); this.binding.inCallActionRight.setOnClickListener(null); this.binding.inCallActionRight.setClickable(false); - break; + } } this.binding.inCallActionRight.setVisibility(View.VISIBLE); } @@ -1158,10 +1153,10 @@ public class RtpSessionActivity extends XmppActivity private void switchCamera(final View view) { Futures.addCallback( requireRtpConnection().switchCamera(), - new FutureCallback() { + new FutureCallback<>() { @Override public void onSuccess(@Nullable Boolean isFrontCamera) { - binding.localVideo.setMirror(isFrontCamera); + binding.localVideo.setMirror(Boolean.TRUE.equals(isFrontCamera)); } @Override @@ -1526,8 +1521,7 @@ public class RtpSessionActivity extends XmppActivity final Account account, Jid with, final RtpEndUserState state, final Set media) { final Intent intent = new Intent(Intent.ACTION_VIEW); intent.putExtra(EXTRA_ACCOUNT, account.getJid().toEscapedString()); - if (RtpCapability.jmiSupport(account.getRoster() - .getContact(with))) { + if (RtpCapability.jmiSupport(account.getRoster().getContact(with))) { intent.putExtra(EXTRA_WITH, with.asBareJid().toEscapedString()); } else { intent.putExtra(EXTRA_WITH, with.toEscapedString()); diff --git a/src/main/java/eu/siacs/conversations/utils/Checksum.java b/src/main/java/eu/siacs/conversations/utils/Checksum.java deleted file mode 100644 index 407cb1944c8aaf283a1cc40dbf75d970680c8a4f..0000000000000000000000000000000000000000 --- a/src/main/java/eu/siacs/conversations/utils/Checksum.java +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (c) 2018, Daniel Gultsch All rights reserved. - * - * Redistribution and use in source and binary forms, with or without modification, - * are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, this - * list of conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation and/or - * other materials provided with the distribution. - * - * 3. Neither the name of the copyright holder nor the names of its contributors - * may be used to endorse or promote products derived from this software without - * specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR - * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package eu.siacs.conversations.utils; - -import android.util.Base64; - -import java.io.IOException; -import java.io.InputStream; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; - -public class Checksum { - - public static String md5(InputStream inputStream) throws IOException { - byte[] buffer = new byte[4096]; - MessageDigest messageDigest; - try { - messageDigest = MessageDigest.getInstance("MD5"); - } catch (NoSuchAlgorithmException e) { - throw new AssertionError(e); - } - - int count; - do { - count = inputStream.read(buffer); - if (count > 0) { - messageDigest.update(buffer, 0, count); - } - } while (count != -1); - inputStream.close(); - return Base64.encodeToString(messageDigest.digest(), Base64.NO_WRAP); - } -} diff --git a/src/main/java/eu/siacs/conversations/xml/LocalizedContent.java b/src/main/java/eu/siacs/conversations/xml/LocalizedContent.java index eb440f51375f31888ea1f30b64d7e594d6e319b8..8afd52c4c8daa6f26c51e3cde502c18cbb214b72 100644 --- a/src/main/java/eu/siacs/conversations/xml/LocalizedContent.java +++ b/src/main/java/eu/siacs/conversations/xml/LocalizedContent.java @@ -29,7 +29,7 @@ public class LocalizedContent { final String childLanguage = child.getAttribute("xml:lang"); final String lang = childLanguage == null ? parentLanguage : childLanguage; final String content = child.getContent(); - if (namespace == null || "jabber:client".equals(namespace)) { + if (content != null && (namespace == null || Namespace.JABBER_CLIENT.equals(namespace))) { if (contents.put(lang, content == null ? "" : content) != null) { //anything that has multiple contents for the same language is invalid return null; diff --git a/src/main/java/eu/siacs/conversations/xml/Namespace.java b/src/main/java/eu/siacs/conversations/xml/Namespace.java index 27bc38dce3e3be6fe2e7b33d3f360f3993e455d6..87cf2167a3f09aadcb4c76816c50766ea77a3add 100644 --- a/src/main/java/eu/siacs/conversations/xml/Namespace.java +++ b/src/main/java/eu/siacs/conversations/xml/Namespace.java @@ -2,6 +2,7 @@ package eu.siacs.conversations.xml; public final class Namespace { public static final String STREAMS = "http://etherx.jabber.org/streams"; + public static final String JABBER_CLIENT = "jabber:client"; public static final String DISCO_ITEMS = "http://jabber.org/protocol/disco#items"; public static final String DISCO_INFO = "http://jabber.org/protocol/disco#info"; public static final String EXTERNAL_SERVICE_DISCOVERY = "urn:xmpp:extdisco:2"; @@ -46,7 +47,11 @@ public final class Namespace { public static final String JINGLE_TRANSPORTS_S5B = "urn:xmpp:jingle:transports:s5b:1"; public static final String JINGLE_TRANSPORTS_IBB = "urn:xmpp:jingle:transports:ibb:1"; public static final String JINGLE_TRANSPORT_ICE_UDP = "urn:xmpp:jingle:transports:ice-udp:1"; + public static final String JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL = "urn:xmpp:jingle:transports:webrtc-datachannel:1"; + public static final String JINGLE_TRANSPORT = "urn:xmpp:jingle:transports:dtls-sctp:1"; public static final String JINGLE_APPS_RTP = "urn:xmpp:jingle:apps:rtp:1"; + + public static final String JINGLE_APPS_FILE_TRANSFER = "urn:xmpp:jingle:apps:file-transfer:5"; public static final String JINGLE_APPS_DTLS = "urn:xmpp:jingle:apps:dtls:0"; public static final String JINGLE_APPS_GROUPING = "urn:xmpp:jingle:apps:grouping:0"; public static final String JINGLE_FEATURE_AUDIO = "urn:xmpp:jingle:apps:rtp:audio"; @@ -71,4 +76,5 @@ public final class Namespace { public static final String REPORTING = "urn:xmpp:reporting:1"; public static final String REPORTING_REASON_SPAM = "urn:xmpp:reporting:spam"; public static final String SDP_OFFER_ANSWER = "urn:ietf:rfc:3264"; + public static final String HASHES = "urn:xmpp:hashes:2"; } diff --git a/src/main/java/eu/siacs/conversations/xml/Tag.java b/src/main/java/eu/siacs/conversations/xml/Tag.java index db2b111727227823eb46a0e662598844fdffc688..d8d98348e592c5b136847dbc7be4cb82be320220 100644 --- a/src/main/java/eu/siacs/conversations/xml/Tag.java +++ b/src/main/java/eu/siacs/conversations/xml/Tag.java @@ -43,6 +43,10 @@ public class Tag { return name; } + public String identifier() { + return String.format("%s#%s", name, this.attributes.get("xmlns")); + } + public String getAttribute(final String attrName) { return this.attributes.get(attrName); } diff --git a/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java b/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java index 93b4ba32775feebb80b67e8e1382c41fe02a63bf..02726486e6dd4f82bd962611483a1ee843b2823b 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java +++ b/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java @@ -17,8 +17,11 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.util.Consumer; +import com.google.common.base.MoreObjects; import com.google.common.base.Optional; +import com.google.common.base.Preconditions; import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; import org.xmlpull.v1.XmlPullParserException; @@ -169,7 +172,7 @@ public class XmppConnection implements Runnable { private boolean isBound = false; private Element streamFeatures; private Element boundStreamFeatures; - private String streamId = null; + private StreamId streamId = null; private int stanzasReceived = 0; private int stanzasSent = 0; private int stanzasSentBeforeAuthentication; @@ -192,7 +195,7 @@ public class XmppConnection implements Runnable { private OnStatusChanged statusListener = null; private OnBindListener bindListener = null; private OnMessageAcknowledged acknowledgedListener = null; - private SaslMechanism saslMechanism; + private LoginInfo loginInfo; private HashedToken.Mechanism hashTokenRequest; private HttpUrl redirectionUrl = null; private String verifiedHostname = null; @@ -382,6 +385,12 @@ public class XmppConnection implements Runnable { + storedBackupResult); } } + final StreamId streamId = this.streamId; + final Resolver.Result resumeLocation = streamId == null ? null : streamId.location; + if (resumeLocation != null) { + Log.d(Config.LOGTAG,account.getJid().asBareJid()+": injected resume location on position 0"); + results.add(0, resumeLocation); + } final Resolver.Result seeOtherHost = this.seeOtherHostResolverResult; if (seeOtherHost != null) { Log.d(Config.LOGTAG,account.getJid().asBareJid()+": injected see-other-host on position 0"); @@ -599,7 +608,6 @@ public class XmppConnection implements Runnable { if (processSuccess(success)) { break; } - } else if (nextTag.isStart("failure", Namespace.TLS)) { throw new StateChangingException(Account.State.TLS_ERROR); } else if (nextTag.isStart("failure")) { @@ -609,7 +617,7 @@ public class XmppConnection implements Runnable { // two step sasl2 - we don’t support this yet throw new StateChangingException(Account.State.INCOMPATIBLE_CLIENT); } else if (nextTag.isStart("challenge")) { - if (isSecure() && this.saslMechanism != null) { + if (isSecure() && this.loginInfo != null) { final Element challenge = tagReader.readElement(nextTag); processChallenge(challenge); } else { @@ -622,10 +630,10 @@ public class XmppConnection implements Runnable { } else if (nextTag.isStart("enabled", Namespace.STREAM_MANAGEMENT)) { final Element enabled = tagReader.readElement(nextTag); processEnabled(enabled); - } else if (nextTag.isStart("resumed")) { + } else if (nextTag.isStart("resumed", Namespace.STREAM_MANAGEMENT)) { final Element resumed = tagReader.readElement(nextTag); processResumed(resumed); - } else if (nextTag.isStart("r")) { + } else if (nextTag.isStart("r", Namespace.STREAM_MANAGEMENT)) { tagReader.readElement(nextTag); if (Config.EXTENDED_SM_LOGGING) { Log.d( @@ -636,7 +644,7 @@ public class XmppConnection implements Runnable { } final AckPacket ack = new AckPacket(this.stanzasReceived); tagWriter.writeStanzaAsync(ack); - } else if (nextTag.isStart("a")) { + } else if (nextTag.isStart("a", Namespace.STREAM_MANAGEMENT)) { boolean accountUiNeedsRefresh = false; synchronized (NotificationService.CATCHUP_LOCK) { if (mWaitingForSmCatchup.compareAndSet(true, false)) { @@ -679,15 +687,22 @@ public class XmppConnection implements Runnable { if (acknowledgedMessages) { mXmppConnectionService.updateConversationUi(); } - } else if (nextTag.isStart("failed")) { + } else if (nextTag.isStart("failed", Namespace.STREAM_MANAGEMENT)) { final Element failed = tagReader.readElement(nextTag); processFailed(failed, true); - } else if (nextTag.isStart("iq")) { + } else if (nextTag.isStart("iq", Namespace.JABBER_CLIENT)) { processIq(nextTag); - } else if (nextTag.isStart("message")) { + } else if (nextTag.isStart("message", Namespace.JABBER_CLIENT)) { processMessage(nextTag); - } else if (nextTag.isStart("presence")) { + } else if (nextTag.isStart("presence", Namespace.JABBER_CLIENT)) { processPresence(nextTag); + } else { + Log.e( + Config.LOGTAG, + account.getJid().asBareJid() + + ": Encountered unknown stream element" + + nextTag.identifier()); + throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER); } nextTag = tagReader.readTag(); } @@ -712,7 +727,7 @@ public class XmppConnection implements Runnable { throw new AssertionError("Missing implementation for " + version); } try { - response.setContent(saslMechanism.getResponse(challenge.getContent(), sslSocketOrNull(socket))); + response.setContent(this.loginInfo.saslMechanism.getResponse(challenge.getContent(), sslSocketOrNull(socket))); } catch (final SaslMechanism.AuthenticationException e) { // TODO: Send auth abort tag. Log.e(Config.LOGTAG, e.toString()); @@ -729,8 +744,9 @@ public class XmppConnection implements Runnable { } catch (final IllegalArgumentException e) { throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER); } - final SaslMechanism currentSaslMechanism = this.saslMechanism; - if (currentSaslMechanism == null) { + final LoginInfo currentLoginInfo = this.loginInfo; + final SaslMechanism currentSaslMechanism = LoginInfo.mechanism(currentLoginInfo); + if (currentLoginInfo == null || currentSaslMechanism == null) { throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER); } final String challenge; @@ -829,13 +845,25 @@ public class XmppConnection implements Runnable { //if we did not enable stream management in bind do it now waitForDisco = enableStreamManagement(); } + final boolean negotiatedCarbons; if (carbonsEnabled != null) { + negotiatedCarbons = true; Log.d( Config.LOGTAG, - account.getJid().asBareJid() + ": successfully enabled carbons"); + account.getJid().asBareJid() + + ": successfully enabled carbons (via Bind 2.0)"); features.carbonsEnabled = true; + } else if (loginInfo.inlineBindFeatures.contains(Namespace.CARBONS)) { + negotiatedCarbons = true; + Log.d( + Config.LOGTAG, + account.getJid().asBareJid() + + ": successfully enabled carbons (via Bind 2.0/implicit)"); + features.carbonsEnabled = true; + } else { + negotiatedCarbons = false; } - sendPostBindInitialization(waitForDisco, carbonsEnabled != null); + sendPostBindInitialization(waitForDisco, negotiatedCarbons); } final HashedToken.Mechanism tokenMechanism; if (SaslMechanism.hashedToken(currentSaslMechanism)) { @@ -939,7 +967,7 @@ public class XmppConnection implements Runnable { } Log.d(Config.LOGTAG, failure.toString()); Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": login failure " + version); - if (SaslMechanism.hashedToken(this.saslMechanism)) { + if (SaslMechanism.hashedToken(LoginInfo.mechanism(this.loginInfo))) { Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": resetting token"); account.resetFastToken(); mXmppConnectionService.databaseBackend.updateAccount(account); @@ -965,7 +993,7 @@ public class XmppConnection implements Runnable { } } } - if (SaslMechanism.hashedToken(this.saslMechanism)) { + if (SaslMechanism.hashedToken(LoginInfo.mechanism(this.loginInfo))) { Log.d( Config.LOGTAG, account.getJid().asBareJid() @@ -985,18 +1013,27 @@ public class XmppConnection implements Runnable { } private void processEnabled(final Element enabled) { - final String streamId; + final String id; if (enabled.getAttributeAsBoolean("resume")) { - streamId = enabled.getAttribute("id"); - Log.d( - Config.LOGTAG, - account.getJid().asBareJid().toString() - + ": stream management enabled (resumable)"); + id = enabled.getAttribute("id"); + } else { + id = null; + } + final String locationAttribute = enabled.getAttribute("location"); + final Resolver.Result currentResolverResult = this.currentResolverResult; + final Resolver.Result location; + if (Strings.isNullOrEmpty(locationAttribute) || currentResolverResult == null) { + location = null; + } else { + location = currentResolverResult.seeOtherHost(locationAttribute); + } + final StreamId streamId = id == null ? null : new StreamId(id, location); + if (streamId == null) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": stream management enabled"); } else { Log.d( Config.LOGTAG, - account.getJid().asBareJid().toString() + ": stream management enabled"); - streamId = null; + account.getJid().asBareJid() + ": stream management enabled. resume at: " + streamId.location); } this.streamId = streamId; this.stanzasReceived = 0; @@ -1363,7 +1400,7 @@ public class XmppConnection implements Runnable { account.getJid().asBareJid() + ": quick start in progress. ignoring features: " + XmlHelper.printElementNames(this.streamFeatures)); - if (SaslMechanism.hashedToken(this.saslMechanism)) { + if (SaslMechanism.hashedToken(LoginInfo.mechanism(this.loginInfo))) { return; } if (isFastTokenAvailable( @@ -1421,7 +1458,7 @@ public class XmppConnection implements Runnable { + ": resuming after stanza #" + stanzasReceived); } - final ResumePacket resume = new ResumePacket(this.streamId, stanzasReceived); + final ResumePacket resume = new ResumePacket(this.streamId.id, stanzasReceived); this.mSmCatchupMessageCounter.set(0); this.mWaitingForSmCatchup.set(true); this.tagWriter.writeStanzaAsync(resume); @@ -1448,7 +1485,8 @@ public class XmppConnection implements Runnable { private void authenticate() throws IOException { final boolean isSecure = isSecure(); - if (isSecure && this.streamFeatures.hasChild("authentication", Namespace.SASL_2)) {authenticate(SaslMechanism.Version.SASL_2); + if (isSecure && this.streamFeatures.hasChild("authentication", Namespace.SASL_2)) { + authenticate(SaslMechanism.Version.SASL_2); } else if (isSecure && this.streamFeatures.hasChild("mechanisms", Namespace.SASL)) { authenticate(SaslMechanism.Version.SASL); } else { @@ -1473,10 +1511,10 @@ public class XmppConnection implements Runnable { final Collection channelBindings = ChannelBinding.of(cbElement); final SaslMechanism.Factory factory = new SaslMechanism.Factory(account); final SaslMechanism saslMechanism = factory.of(mechanisms, channelBindings, version, SSLSockets.version(this.socket)); - this.saslMechanism = validate(saslMechanism, mechanisms); + this.validate(saslMechanism, mechanisms); final boolean quickStartAvailable; - final String firstMessage = this.saslMechanism.getClientFirstMessage(sslSocketOrNull(this.socket)); - final boolean usingFast = SaslMechanism.hashedToken(this.saslMechanism); + final String firstMessage = saslMechanism.getClientFirstMessage(sslSocketOrNull(this.socket)); + final boolean usingFast = SaslMechanism.hashedToken(saslMechanism); final Element authenticate; if (version == SaslMechanism.Version.SASL) { authenticate = new Element("auth", Namespace.SASL); @@ -1484,6 +1522,7 @@ public class XmppConnection implements Runnable { authenticate.setContent(firstMessage); } quickStartAvailable = false; + this.loginInfo = new LoginInfo(saslMechanism,version,Collections.emptyList()); } else if (version == SaslMechanism.Version.SASL_2) { final Element inline = authElement.findChild("inline", Namespace.SASL_2); final boolean sm = inline != null && inline.hasChild("sm", Namespace.STREAM_MANAGEMENT); @@ -1512,6 +1551,7 @@ public class XmppConnection implements Runnable { return; } } + this.loginInfo = new LoginInfo(saslMechanism,version,bindFeatures); this.hashTokenRequest = hashTokenRequest; authenticate = generateAuthenticationRequest(firstMessage, usingFast, hashTokenRequest, bindFeatures, sm); } else { @@ -1528,8 +1568,8 @@ public class XmppConnection implements Runnable { + ": Authenticating with " + version + "/" - + this.saslMechanism.getMechanism()); - authenticate.setAttribute("mechanism", this.saslMechanism.getMechanism()); + + LoginInfo.mechanism(this.loginInfo).getMechanism()); + authenticate.setAttribute("mechanism", LoginInfo.mechanism(this.loginInfo).getMechanism()); synchronized (this.mStanzaQueue) { this.stanzasSentBeforeAuthentication = this.stanzasSent; tagWriter.writeElement(authenticate); @@ -1541,8 +1581,7 @@ public class XmppConnection implements Runnable { return inline != null && inline.hasChild("fast", Namespace.FAST); } - @NonNull - private SaslMechanism validate(final @Nullable SaslMechanism saslMechanism, Collection mechanisms) throws StateChangingException { + private void validate(final @Nullable SaslMechanism saslMechanism, Collection mechanisms) throws StateChangingException { if (saslMechanism == null) { Log.d( Config.LOGTAG, @@ -1552,7 +1591,7 @@ public class XmppConnection implements Runnable { throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER); } if (SaslMechanism.hashedToken(saslMechanism)) { - return saslMechanism; + return; } final int pinnedMechanism = account.getPinnedMechanismPriority(); if (pinnedMechanism > saslMechanism.getPriority()) { @@ -1567,7 +1606,6 @@ public class XmppConnection implements Runnable { + "). Possible downgrade attack?"); throw new StateChangingException(Account.State.DOWNGRADE_ATTACK); } - return saslMechanism; } private Element generateAuthenticationRequest(final String firstMessage, final boolean usingFast) { @@ -1594,13 +1632,14 @@ public class XmppConnection implements Runnable { .addChild("device") .setContent(String.format("%s %s", Build.MANUFACTURER, Build.MODEL)); } - // do not include bind if 'inlinestreamManagment' is missing and we have a streamId + // do not include bind if 'inlineStreamManagement' is missing and we have a streamId + // (because we would rather just do a normal SM/resume) final boolean mayAttemptBind = streamId == null || inlineStreamManagement; if (bind != null && mayAttemptBind) { authenticate.addChild(generateBindRequest(bind)); } if (inlineStreamManagement && streamId != null) { - final ResumePacket resume = new ResumePacket(this.streamId, stanzasReceived); + final ResumePacket resume = new ResumePacket(this.streamId.id, stanzasReceived); this.mSmCatchupMessageCounter.set(0); this.mWaitingForSmCatchup.set(true); authenticate.addChild(resume); @@ -1772,7 +1811,7 @@ public class XmppConnection implements Runnable { synchronized (this.commands) { this.commands.clear(); } - this.saslMechanism = null; + this.loginInfo = null; } private void sendBindRequest() { @@ -2268,7 +2307,7 @@ public class XmppConnection implements Runnable { && quickStartMechanism != null && account.isOptionSet(Account.OPTION_QUICKSTART_AVAILABLE)) { mXmppConnectionService.restoredFromDatabaseLatch.await(); - this.saslMechanism = quickStartMechanism; + this.loginInfo = new LoginInfo(quickStartMechanism, SaslMechanism.Version.SASL_2, Bind2.QUICKSTART_FEATURES); final boolean usingFast = quickStartMechanism instanceof HashedToken; final Element authenticate = generateAuthenticationRequest(quickStartMechanism.getClientFirstMessage(sslSocketOrNull(this.socket)), usingFast); @@ -2298,7 +2337,7 @@ public class XmppConnection implements Runnable { } stream.setAttribute("version", "1.0"); stream.setAttribute("xml:lang", LocalizedContent.STREAM_LANGUAGE); - stream.setAttribute("xmlns", "jabber:client"); + stream.setAttribute("xmlns", Namespace.JABBER_CLIENT); stream.setAttribute("xmlns:stream", Namespace.STREAMS); tagWriter.writeTag(stream, flush); } @@ -2681,6 +2720,49 @@ public class XmppConnection implements Runnable { } } + private static class LoginInfo { + public final SaslMechanism saslMechanism; + public final SaslMechanism.Version saslVersion; + public final List inlineBindFeatures; + + private LoginInfo( + final SaslMechanism saslMechanism, + final SaslMechanism.Version saslVersion, + final Collection inlineBindFeatures) { + Preconditions.checkNotNull(saslMechanism, "SASL Mechanism must not be null"); + Preconditions.checkNotNull(saslVersion, "SASL version must not be null"); + this.saslMechanism = saslMechanism; + this.saslVersion = saslVersion; + this.inlineBindFeatures = + inlineBindFeatures == null + ? Collections.emptyList() + : ImmutableList.copyOf(inlineBindFeatures); + } + + public static SaslMechanism mechanism(final LoginInfo loginInfo) { + return loginInfo == null ? null : loginInfo.saslMechanism; + } + } + + private static class StreamId { + public final String id; + public final Resolver.Result location; + + private StreamId(String id, Resolver.Result location) { + this.id = id; + this.location = location; + } + + @NonNull + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("id", id) + .add("location", location) + .toString(); + } + } + private static class StateChangingError extends Error { private final Account.State state; diff --git a/src/main/java/eu/siacs/conversations/xmpp/bind/Bind2.java b/src/main/java/eu/siacs/conversations/xmpp/bind/Bind2.java index 21c957a0f46e6a5c4ae526412d6dc1eb2d1b3e22..c3f847ecab4bdc9df1b3886aaba7f833c9fa34bd 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/bind/Bind2.java +++ b/src/main/java/eu/siacs/conversations/xmpp/bind/Bind2.java @@ -1,5 +1,6 @@ package eu.siacs.conversations.xmpp.bind; +import com.google.common.base.Predicates; import com.google.common.collect.Collections2; import java.util.Arrays; @@ -27,7 +28,12 @@ public class Bind2 { if (inlineBind2Inline == null) { return Collections.emptyList(); } - return Collections2.transform( - inlineBind2Inline.getChildren(), c -> c == null ? null : c.getAttribute("var")); + return Collections2.filter( + Collections2.transform( + Collections2.filter( + inlineBind2Inline.getChildren(), + c -> "feature".equals(c.getName())), + c -> c.getAttribute("var")), + Predicates.notNull()); } } diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractContentMap.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractContentMap.java new file mode 100644 index 0000000000000000000000000000000000000000..847678a05753255f1bd600ef928403d0e289038e --- /dev/null +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractContentMap.java @@ -0,0 +1,82 @@ +package eu.siacs.conversations.xmpp.jingle; + +import com.google.common.collect.Collections2; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; + +import eu.siacs.conversations.xmpp.jingle.stanzas.Content; +import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription; +import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.Group; +import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +public abstract class AbstractContentMap< + D extends GenericDescription, T extends GenericTransportInfo> { + + public final Group group; + + public final Map> contents; + + protected AbstractContentMap( + final Group group, final Map> contents) { + this.group = group; + this.contents = contents; + } + + public static class UnsupportedApplicationException extends IllegalArgumentException { + UnsupportedApplicationException(String message) { + super(message); + } + } + + public static class UnsupportedTransportException extends IllegalArgumentException { + UnsupportedTransportException(String message) { + super(message); + } + } + + public Set getSenders() { + return ImmutableSet.copyOf(Collections2.transform(contents.values(), dt -> dt.senders)); + } + + public List getNames() { + return ImmutableList.copyOf(contents.keySet()); + } + + JinglePacket toJinglePacket(final JinglePacket.Action action, final String sessionId) { + final JinglePacket jinglePacket = new JinglePacket(action, sessionId); + for (final Map.Entry> entry : this.contents.entrySet()) { + final DescriptionTransport descriptionTransport = entry.getValue(); + final Content content = + new Content( + Content.Creator.INITIATOR, + descriptionTransport.senders, + entry.getKey()); + if (descriptionTransport.description != null) { + content.addChild(descriptionTransport.description); + } + content.addChild(descriptionTransport.transport); + jinglePacket.addJingleContent(content); + } + if (this.group != null) { + jinglePacket.addGroup(this.group); + } + return jinglePacket; + } + + void requireContentDescriptions() { + if (this.contents.size() == 0) { + throw new IllegalStateException("No contents available"); + } + for (final Map.Entry> entry : this.contents.entrySet()) { + if (entry.getValue().description == null) { + throw new IllegalStateException( + String.format("%s is lacking content description", entry.getKey())); + } + } + } +} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractJingleConnection.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractJingleConnection.java index d719c729e95a62a0ac5cb5475c528d437196404b..efc32f5ff2eacf7e2ae921a9f5d5ac093d951d8b 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractJingleConnection.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractJingleConnection.java @@ -1,47 +1,352 @@ package eu.siacs.conversations.xmpp.jingle; +import android.util.Log; + import androidx.annotation.NonNull; import com.google.common.base.MoreObjects; import com.google.common.base.Objects; import com.google.common.base.Preconditions; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import eu.siacs.conversations.Config; import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Contact; import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.entities.Presence; +import eu.siacs.conversations.entities.ServiceDiscoveryResult; import eu.siacs.conversations.services.XmppConnectionService; import eu.siacs.conversations.xmpp.Jid; import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket; +import eu.siacs.conversations.xmpp.jingle.stanzas.Reason; +import eu.siacs.conversations.xmpp.stanzas.IqPacket; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; public abstract class AbstractJingleConnection { public static final String JINGLE_MESSAGE_PROPOSE_ID_PREFIX = "jm-propose-"; public static final String JINGLE_MESSAGE_PROCEED_ID_PREFIX = "jm-proceed-"; + protected static final List TERMINATED = + Arrays.asList( + State.ACCEPTED, + State.REJECTED, + State.REJECTED_RACED, + State.RETRACTED, + State.RETRACTED_RACED, + State.TERMINATED_SUCCESS, + State.TERMINATED_DECLINED_OR_BUSY, + State.TERMINATED_CONNECTIVITY_ERROR, + State.TERMINATED_CANCEL_OR_TIMEOUT, + State.TERMINATED_APPLICATION_FAILURE, + State.TERMINATED_SECURITY_ERROR); + + private static final Map> VALID_TRANSITIONS; + + static { + final ImmutableMap.Builder> transitionBuilder = + new ImmutableMap.Builder<>(); + transitionBuilder.put( + State.NULL, + ImmutableList.of( + State.PROPOSED, + State.SESSION_INITIALIZED, + State.TERMINATED_APPLICATION_FAILURE, + State.TERMINATED_SECURITY_ERROR)); + transitionBuilder.put( + State.PROPOSED, + ImmutableList.of( + State.ACCEPTED, + State.PROCEED, + State.REJECTED, + State.RETRACTED, + State.TERMINATED_APPLICATION_FAILURE, + State.TERMINATED_SECURITY_ERROR, + State.TERMINATED_CONNECTIVITY_ERROR // only used when the xmpp connection + // rebinds + )); + transitionBuilder.put( + State.PROCEED, + ImmutableList.of( + State.REJECTED_RACED, + State.RETRACTED_RACED, + State.SESSION_INITIALIZED_PRE_APPROVED, + State.TERMINATED_SUCCESS, + State.TERMINATED_APPLICATION_FAILURE, + State.TERMINATED_SECURITY_ERROR, + State.TERMINATED_CONNECTIVITY_ERROR // at this state used for error + // bounces of the proceed message + )); + transitionBuilder.put( + State.SESSION_INITIALIZED, + ImmutableList.of( + State.SESSION_ACCEPTED, + State.TERMINATED_SUCCESS, + State.TERMINATED_DECLINED_OR_BUSY, + State.TERMINATED_CONNECTIVITY_ERROR, // at this state used for IQ errors + // and IQ timeouts + State.TERMINATED_CANCEL_OR_TIMEOUT, + State.TERMINATED_APPLICATION_FAILURE, + State.TERMINATED_SECURITY_ERROR)); + transitionBuilder.put( + State.SESSION_INITIALIZED_PRE_APPROVED, + ImmutableList.of( + State.SESSION_ACCEPTED, + State.TERMINATED_SUCCESS, + State.TERMINATED_DECLINED_OR_BUSY, + State.TERMINATED_CONNECTIVITY_ERROR, // at this state used for IQ errors + // and IQ timeouts + State.TERMINATED_CANCEL_OR_TIMEOUT, + State.TERMINATED_APPLICATION_FAILURE, + State.TERMINATED_SECURITY_ERROR)); + transitionBuilder.put( + State.SESSION_ACCEPTED, + ImmutableList.of( + State.TERMINATED_SUCCESS, + State.TERMINATED_DECLINED_OR_BUSY, + State.TERMINATED_CONNECTIVITY_ERROR, + State.TERMINATED_CANCEL_OR_TIMEOUT, + State.TERMINATED_APPLICATION_FAILURE, + State.TERMINATED_SECURITY_ERROR)); + VALID_TRANSITIONS = transitionBuilder.build(); + } + final JingleConnectionManager jingleConnectionManager; protected final XmppConnectionService xmppConnectionService; protected final Id id; private final Jid initiator; - AbstractJingleConnection(final JingleConnectionManager jingleConnectionManager, final Id id, final Jid initiator) { + protected State state = State.NULL; + + AbstractJingleConnection( + final JingleConnectionManager jingleConnectionManager, + final Id id, + final Jid initiator) { this.jingleConnectionManager = jingleConnectionManager; this.xmppConnectionService = jingleConnectionManager.getXmppConnectionService(); this.id = id; this.initiator = initiator; } + public Id getId() { + return id; + } + boolean isInitiator() { return initiator.equals(id.account.getJid()); } + boolean isResponder() { + return !initiator.equals(id.account.getJid()); + } + + public State getState() { + return this.state; + } + + protected synchronized boolean isInState(State... state) { + return Arrays.asList(state).contains(this.state); + } + + protected boolean transition(final State target) { + return transition(target, null); + } + + protected synchronized boolean transition(final State target, final Runnable runnable) { + final Collection validTransitions = VALID_TRANSITIONS.get(this.state); + if (validTransitions != null && validTransitions.contains(target)) { + this.state = target; + if (runnable != null) { + runnable.run(); + } + Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": transitioned into " + target); + return true; + } else { + return false; + } + } + + protected void transitionOrThrow(final State target) { + if (!transition(target)) { + throw new IllegalStateException( + String.format("Unable to transition from %s to %s", this.state, target)); + } + } + + boolean isTerminated() { + return TERMINATED.contains(this.state); + } + abstract void deliverPacket(JinglePacket jinglePacket); - public Id getId() { - return id; + protected void receiveOutOfOrderAction( + final JinglePacket jinglePacket, final JinglePacket.Action action) { + Log.d( + Config.LOGTAG, + String.format( + "%s: received %s even though we are in state %s", + id.account.getJid().asBareJid(), action, getState())); + if (isTerminated()) { + Log.d( + Config.LOGTAG, + String.format( + "%s: got a reason to terminate with out-of-order. but already in state %s", + id.account.getJid().asBareJid(), getState())); + respondWithOutOfOrder(jinglePacket); + } else { + terminateWithOutOfOrder(jinglePacket); + } + } + + protected void terminateWithOutOfOrder(final JinglePacket jinglePacket) { + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + ": terminating session with out-of-order"); + terminateTransport(); + transitionOrThrow(State.TERMINATED_APPLICATION_FAILURE); + respondWithOutOfOrder(jinglePacket); + this.finish(); } + protected void finish() { + if (isTerminated()) { + this.jingleConnectionManager.finishConnectionOrThrow(this); + } else { + throw new AssertionError( + String.format("Unable to call finish from %s", this.state)); + } + } + + protected abstract void terminateTransport(); + abstract void notifyRebound(); + protected void sendSessionTerminate( + final Reason reason, final String text, final Consumer trigger) { + final State previous = this.state; + final State target = reasonToState(reason); + transitionOrThrow(target); + if (previous != State.NULL && trigger != null) { + trigger.accept(target); + } + final JinglePacket jinglePacket = + new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId); + jinglePacket.setReason(reason, text); + send(jinglePacket); + finish(); + } + + protected void send(final JinglePacket jinglePacket) { + jinglePacket.setTo(id.with); + xmppConnectionService.sendIqPacket(id.account, jinglePacket, this::handleIqResponse); + } + + protected void respondOk(final JinglePacket jinglePacket) { + xmppConnectionService.sendIqPacket( + id.account, jinglePacket.generateResponse(IqPacket.TYPE.RESULT), null); + } + + protected void respondWithTieBreak(final JinglePacket jinglePacket) { + respondWithJingleError(jinglePacket, "tie-break", "conflict", "cancel"); + } + + protected void respondWithOutOfOrder(final JinglePacket jinglePacket) { + respondWithJingleError(jinglePacket, "out-of-order", "unexpected-request", "wait"); + } + + protected void respondWithItemNotFound(final JinglePacket jinglePacket) { + respondWithJingleError(jinglePacket, null, "item-not-found", "cancel"); + } + + private void respondWithJingleError( + final IqPacket original, + String jingleCondition, + String condition, + String conditionType) { + jingleConnectionManager.respondWithJingleError( + id.account, original, jingleCondition, condition, conditionType); + } + + private synchronized void handleIqResponse(final Account account, final IqPacket response) { + if (response.getType() == IqPacket.TYPE.ERROR) { + handleIqErrorResponse(response); + return; + } + if (response.getType() == IqPacket.TYPE.TIMEOUT) { + handleIqTimeoutResponse(response); + } + } + + protected void handleIqErrorResponse(final IqPacket response) { + Preconditions.checkArgument(response.getType() == IqPacket.TYPE.ERROR); + final String errorCondition = response.getErrorCondition(); + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + + ": received IQ-error from " + + response.getFrom() + + " in RTP session. " + + errorCondition); + if (isTerminated()) { + Log.i( + Config.LOGTAG, + id.account.getJid().asBareJid() + + ": ignoring error because session was already terminated"); + return; + } + this.terminateTransport(); + final State target; + if (Arrays.asList( + "service-unavailable", + "recipient-unavailable", + "remote-server-not-found", + "remote-server-timeout") + .contains(errorCondition)) { + target = State.TERMINATED_CONNECTIVITY_ERROR; + } else { + target = State.TERMINATED_APPLICATION_FAILURE; + } + transitionOrThrow(target); + this.finish(); + } + + protected void handleIqTimeoutResponse(final IqPacket response) { + Preconditions.checkArgument(response.getType() == IqPacket.TYPE.TIMEOUT); + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + + ": received IQ timeout in RTP session with " + + id.with + + ". terminating with connectivity error"); + if (isTerminated()) { + Log.i( + Config.LOGTAG, + id.account.getJid().asBareJid() + + ": ignoring error because session was already terminated"); + return; + } + this.terminateTransport(); + transitionOrThrow(State.TERMINATED_CONNECTIVITY_ERROR); + this.finish(); + } + + protected boolean remoteHasFeature(final String feature) { + final Contact contact = id.getContact(); + final Presence presence = + contact.getPresences().get(Strings.nullToEmpty(id.with.getResource())); + final ServiceDiscoveryResult serviceDiscoveryResult = + presence == null ? null : presence.getServiceDiscoveryResult(); + final List features = + serviceDiscoveryResult == null ? null : serviceDiscoveryResult.getFeatures(); + return features != null && features.contains(feature); + } public static class Id implements OngoingRtpSession { public final Account account; @@ -73,8 +378,7 @@ public abstract class AbstractJingleConnection { return new Id( message.getConversation().getAccount(), message.getCounterpart(), - JingleConnectionManager.nextRandomId() - ); + JingleConnectionManager.nextRandomId()); } public Contact getContact() { @@ -86,9 +390,9 @@ public abstract class AbstractJingleConnection { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Id id = (Id) o; - return Objects.equal(account.getUuid(), id.account.getUuid()) && - Objects.equal(with, id.with) && - Objects.equal(sessionId, id.sessionId); + return Objects.equal(account.getUuid(), id.account.getUuid()) + && Objects.equal(with, id.with) + && Objects.equal(sessionId, id.sessionId); } @Override @@ -122,23 +426,36 @@ public abstract class AbstractJingleConnection { } } + protected static State reasonToState(Reason reason) { + return switch (reason) { + case SUCCESS -> State.TERMINATED_SUCCESS; + case DECLINE, BUSY -> State.TERMINATED_DECLINED_OR_BUSY; + case CANCEL, TIMEOUT -> State.TERMINATED_CANCEL_OR_TIMEOUT; + case SECURITY_ERROR -> State.TERMINATED_SECURITY_ERROR; + case FAILED_APPLICATION, UNSUPPORTED_TRANSPORTS, UNSUPPORTED_APPLICATIONS -> State + .TERMINATED_APPLICATION_FAILURE; + default -> State.TERMINATED_CONNECTIVITY_ERROR; + }; + } public enum State { - NULL, //default value; nothing has been sent or received yet + NULL, // default value; nothing has been sent or received yet PROPOSED, ACCEPTED, PROCEED, REJECTED, - REJECTED_RACED, //used when we want to reject but haven’t received session init yet + REJECTED_RACED, // used when we want to reject but haven’t received session init yet RETRACTED, - RETRACTED_RACED, //used when receiving a retract after we already asked to proceed - SESSION_INITIALIZED, //equal to 'PENDING' + RETRACTED_RACED, // used when receiving a retract after we already asked to proceed + SESSION_INITIALIZED, // equal to 'PENDING' SESSION_INITIALIZED_PRE_APPROVED, - SESSION_ACCEPTED, //equal to 'ACTIVE' - TERMINATED_SUCCESS, //equal to 'ENDED' (after successful call) ui will just close - TERMINATED_DECLINED_OR_BUSY, //equal to 'ENDED' (after other party declined the call) - TERMINATED_CONNECTIVITY_ERROR, //equal to 'ENDED' (but after network failures; ui will display retry button) - TERMINATED_CANCEL_OR_TIMEOUT, //more or less the same as retracted; caller pressed end call before session was accepted + SESSION_ACCEPTED, // equal to 'ACTIVE' + TERMINATED_SUCCESS, // equal to 'ENDED' (after successful call) ui will just close + TERMINATED_DECLINED_OR_BUSY, // equal to 'ENDED' (after other party declined the call) + TERMINATED_CONNECTIVITY_ERROR, // equal to 'ENDED' (but after network failures; ui will + // display retry button) + TERMINATED_CANCEL_OR_TIMEOUT, // more or less the same as retracted; caller pressed end call + // before session was accepted TERMINATED_APPLICATION_FAILURE, TERMINATED_SECURITY_ERROR } diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/ContentAddition.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/ContentAddition.java index 97bf802fd7fea0b1a97485fb640f7af3aa6fa7e8..ab2dffc6ddb210d2a51440629cbddd06ed719000 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/ContentAddition.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/ContentAddition.java @@ -1,5 +1,7 @@ package eu.siacs.conversations.xmpp.jingle; +import androidx.annotation.NonNull; + import com.google.common.base.MoreObjects; import com.google.common.base.Objects; import com.google.common.collect.Collections2; @@ -8,6 +10,8 @@ import com.google.common.collect.ImmutableSet; import java.util.Set; import eu.siacs.conversations.xmpp.jingle.stanzas.Content; +import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription; public final class ContentAddition { @@ -32,12 +36,13 @@ public final class ContentAddition { Collections2.transform( rtpContentMap.contents.entrySet(), e -> { - final RtpContentMap.DescriptionTransport dt = e.getValue(); + final DescriptionTransport dt = e.getValue(); return new Summary(e.getKey(), dt.description.getMedia(), dt.senders); })); } @Override + @NonNull public String toString() { return MoreObjects.toStringHelper(this) .add("direction", direction) @@ -77,6 +82,7 @@ public final class ContentAddition { } @Override + @NonNull public String toString() { return MoreObjects.toStringHelper(this) .add("name", name) diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/DescriptionTransport.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/DescriptionTransport.java new file mode 100644 index 0000000000000000000000000000000000000000..70d6c512c786211dbd65e8e09e65167ed82da395 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/DescriptionTransport.java @@ -0,0 +1,19 @@ +package eu.siacs.conversations.xmpp.jingle; + +import eu.siacs.conversations.xmpp.jingle.stanzas.Content; +import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription; +import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo; + +public class DescriptionTransport { + + public final Content.Senders senders; + public final D description; + public final T transport; + + public DescriptionTransport( + final Content.Senders senders, final D description, final T transport) { + this.senders = senders; + this.description = description; + this.transport = transport; + } +} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/DirectConnectionUtils.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/DirectConnectionUtils.java index 83a2b95e4cf98398a5bd1ad29f75cfa51f4f565c..a2a5c40327950259e5a182fa0eabbdd1258531c0 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/DirectConnectionUtils.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/DirectConnectionUtils.java @@ -1,5 +1,7 @@ package eu.siacs.conversations.xmpp.jingle; +import com.google.common.collect.ImmutableList; + import java.net.Inet6Address; import java.net.InetAddress; import java.net.NetworkInterface; @@ -15,13 +17,13 @@ import eu.siacs.conversations.xmpp.Jid; public class DirectConnectionUtils { - private static List getLocalAddresses() { - final List addresses = new ArrayList<>(); + public static List getLocalAddresses() { + final ImmutableList.Builder inetAddresses = new ImmutableList.Builder<>(); final Enumeration interfaces; try { interfaces = NetworkInterface.getNetworkInterfaces(); - } catch (SocketException e) { - return addresses; + } catch (final SocketException e) { + return inetAddresses.build(); } while (interfaces.hasMoreElements()) { NetworkInterface networkInterface = interfaces.nextElement(); @@ -34,31 +36,15 @@ public class DirectConnectionUtils { if (inetAddress instanceof Inet6Address) { //let's get rid of scope try { - addresses.add(Inet6Address.getByAddress(inetAddress.getAddress())); + inetAddresses.add(Inet6Address.getByAddress(inetAddress.getAddress())); } catch (UnknownHostException e) { //ignored } } else { - addresses.add(inetAddress); + inetAddresses.add(inetAddress); } } } - return addresses; + return inetAddresses.build(); } - - public static List getLocalCandidates(Jid jid) { - SecureRandom random = new SecureRandom(); - ArrayList candidates = new ArrayList<>(); - for (InetAddress inetAddress : getLocalAddresses()) { - final JingleCandidate candidate = new JingleCandidate(UUID.randomUUID().toString(), true); - candidate.setHost(inetAddress.getHostAddress()); - candidate.setPort(random.nextInt(60000) + 1024); - candidate.setType(JingleCandidate.TYPE_DIRECT); - candidate.setJid(jid); - candidate.setPriority(8257536 + candidates.size()); - candidates.add(candidate); - } - return candidates; - } - } diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/FileTransferContentMap.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/FileTransferContentMap.java new file mode 100644 index 0000000000000000000000000000000000000000..c678c91cb8b1de6fba4994a69ec3408620202954 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/FileTransferContentMap.java @@ -0,0 +1,219 @@ +package eu.siacs.conversations.xmpp.jingle; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Iterables; +import com.google.common.collect.Maps; + +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xml.Namespace; +import eu.siacs.conversations.xmpp.jingle.stanzas.Content; +import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription; +import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription; +import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.Group; +import eu.siacs.conversations.xmpp.jingle.stanzas.IbbTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket; +import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription; +import eu.siacs.conversations.xmpp.jingle.stanzas.SocksByteStreamsTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.WebRTCDataChannelTransportInfo; +import eu.siacs.conversations.xmpp.jingle.transports.Transport; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public class FileTransferContentMap + extends AbstractContentMap { + + private static final List> SUPPORTED_TRANSPORTS = + Arrays.asList( + SocksByteStreamsTransportInfo.class, + IbbTransportInfo.class, + WebRTCDataChannelTransportInfo.class); + + protected FileTransferContentMap( + final Group group, final Map> + contents) { + super(group, contents); + } + + public static FileTransferContentMap of(final JinglePacket jinglePacket) { + final Map> + contents = of(jinglePacket.getJingleContents()); + return new FileTransferContentMap(jinglePacket.getGroup(), contents); + } + + public static DescriptionTransport of( + final Content content) { + final GenericDescription description = content.getDescription(); + final GenericTransportInfo transportInfo = content.getTransport(); + final Content.Senders senders = content.getSenders(); + final FileTransferDescription fileTransferDescription; + if (description == null) { + fileTransferDescription = null; + } else if (description instanceof FileTransferDescription ftDescription) { + fileTransferDescription = ftDescription; + } else { + throw new UnsupportedApplicationException( + "Content does not contain file transfer description"); + } + if (!SUPPORTED_TRANSPORTS.contains(transportInfo.getClass())) { + throw new UnsupportedTransportException("Content does not have supported transport"); + } + return new DescriptionTransport<>(senders, fileTransferDescription, transportInfo); + } + + private static Map> + of(final Map contents) { + return ImmutableMap.copyOf( + Maps.transformValues(contents, content -> content == null ? null : of(content))); + } + + public static FileTransferContentMap of( + final FileTransferDescription.File file, final Transport.InitialTransportInfo initialTransportInfo) { + // TODO copy groups + final var transportInfo = initialTransportInfo.transportInfo; + return new FileTransferContentMap(initialTransportInfo.group, + Map.of( + initialTransportInfo.contentName, + new DescriptionTransport<>( + Content.Senders.INITIATOR, + FileTransferDescription.of(file), + transportInfo))); + } + + public FileTransferDescription.File requireOnlyFile() { + if (this.contents.size() != 1) { + throw new IllegalStateException("Only one file at a time is supported"); + } + final var dt = Iterables.getOnlyElement(this.contents.values()); + return dt.description.getFile(); + } + + public FileTransferDescription requireOnlyFileTransferDescription() { + if (this.contents.size() != 1) { + throw new IllegalStateException("Only one file at a time is supported"); + } + final var dt = Iterables.getOnlyElement(this.contents.values()); + return dt.description; + } + + public GenericTransportInfo requireOnlyTransportInfo() { + if (this.contents.size() != 1) { + throw new IllegalStateException( + "We expect exactly one content with one transport info"); + } + final var dt = Iterables.getOnlyElement(this.contents.values()); + return dt.transport; + } + + public FileTransferContentMap withTransport(final Transport.TransportInfo transportWrapper) { + final var transportInfo = transportWrapper.transportInfo; + return new FileTransferContentMap(transportWrapper.group, + ImmutableMap.copyOf( + Maps.transformValues( + contents, + content -> { + if (content == null) { + return null; + } + return new DescriptionTransport<>( + content.senders, content.description, transportInfo); + }))); + } + + public FileTransferContentMap candidateUsed(final String streamId, final String cid) { + return new FileTransferContentMap(null, + ImmutableMap.copyOf( + Maps.transformValues( + contents, + content -> { + if (content == null) { + return null; + } + final var transportInfo = + new SocksByteStreamsTransportInfo( + streamId, Collections.emptyList()); + final Element candidateUsed = + transportInfo.addChild( + "candidate-used", + Namespace.JINGLE_TRANSPORTS_S5B); + candidateUsed.setAttribute("cid", cid); + return new DescriptionTransport<>( + content.senders, null, transportInfo); + }))); + } + + public FileTransferContentMap candidateError(final String streamId) { + return new FileTransferContentMap(null, + ImmutableMap.copyOf( + Maps.transformValues( + contents, + content -> { + if (content == null) { + return null; + } + final var transportInfo = + new SocksByteStreamsTransportInfo( + streamId, Collections.emptyList()); + transportInfo.addChild( + "candidate-error", Namespace.JINGLE_TRANSPORTS_S5B); + return new DescriptionTransport<>( + content.senders, null, transportInfo); + }))); + } + + public FileTransferContentMap proxyActivated(final String streamId, final String cid) { + return new FileTransferContentMap(null, + ImmutableMap.copyOf( + Maps.transformValues( + contents, + content -> { + if (content == null) { + return null; + } + final var transportInfo = + new SocksByteStreamsTransportInfo( + streamId, Collections.emptyList()); + final Element candidateUsed = + transportInfo.addChild( + "activated", Namespace.JINGLE_TRANSPORTS_S5B); + candidateUsed.setAttribute("cid", cid); + return new DescriptionTransport<>( + content.senders, null, transportInfo); + }))); + } + + FileTransferContentMap transportInfo() { + return new FileTransferContentMap(this.group, + Maps.transformValues( + contents, + dt -> new DescriptionTransport<>(dt.senders, null, dt.transport))); + } + + FileTransferContentMap transportInfo( + final String contentName, final IceUdpTransportInfo.Candidate candidate) { + final DescriptionTransport descriptionTransport = + contents.get(contentName); + if (descriptionTransport == null) { + throw new IllegalArgumentException( + "Unable to find transport info for content name " + contentName); + } + final WebRTCDataChannelTransportInfo transportInfo; + if (descriptionTransport.transport instanceof WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) { + transportInfo = webRTCDataChannelTransportInfo; + } else { + throw new IllegalStateException("TransportInfo is not WebRTCDataChannel"); + } + final WebRTCDataChannelTransportInfo newTransportInfo = transportInfo.cloneWrapper(); + newTransportInfo.addCandidate(candidate); + return new FileTransferContentMap( + null, + ImmutableMap.of( + contentName, + new DescriptionTransport<>( + descriptionTransport.senders, null, newTransportInfo))); + } +} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/IceServers.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/IceServers.java new file mode 100644 index 0000000000000000000000000000000000000000..7b2f884578f55ed00e28db715b23db1ac534ba7a --- /dev/null +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/IceServers.java @@ -0,0 +1,98 @@ +package eu.siacs.conversations.xmpp.jingle; + +import android.util.Log; + +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.primitives.Ints; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.utils.IP; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xml.Namespace; +import eu.siacs.conversations.xmpp.stanzas.IqPacket; + +import org.webrtc.PeerConnection; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public final class IceServers { + + public static List parse(final IqPacket response) { + ImmutableList.Builder listBuilder = new ImmutableList.Builder<>(); + if (response.getType() == IqPacket.TYPE.RESULT) { + final Element services = + response.findChild("services", Namespace.EXTERNAL_SERVICE_DISCOVERY); + final List children = + services == null ? Collections.emptyList() : services.getChildren(); + for (final Element child : children) { + if ("service".equals(child.getName())) { + final String type = child.getAttribute("type"); + final String host = child.getAttribute("host"); + final String sport = child.getAttribute("port"); + final Integer port = sport == null ? null : Ints.tryParse(sport); + final String transport = child.getAttribute("transport"); + final String username = child.getAttribute("username"); + final String password = child.getAttribute("password"); + if (Strings.isNullOrEmpty(host) || port == null) { + continue; + } + if (port < 0 || port > 65535) { + continue; + } + + if (Arrays.asList("stun", "stuns", "turn", "turns").contains(type) + && Arrays.asList("udp", "tcp").contains(transport)) { + if (Arrays.asList("stuns", "turns").contains(type) + && "udp".equals(transport)) { + Log.w( + Config.LOGTAG, + "skipping invalid combination of udp/tls in external services"); + continue; + } + + // STUN URLs do not support a query section since M110 + final String uri; + if (Arrays.asList("stun", "stuns").contains(type)) { + uri = String.format("%s:%s:%s", type, IP.wrapIPv6(host), port); + } else { + uri = + String.format( + "%s:%s:%s?transport=%s", + type, IP.wrapIPv6(host), port, transport); + } + + final PeerConnection.IceServer.Builder iceServerBuilder = + PeerConnection.IceServer.builder(uri); + iceServerBuilder.setTlsCertPolicy( + PeerConnection.TlsCertPolicy.TLS_CERT_POLICY_INSECURE_NO_CHECK); + if (username != null && password != null) { + iceServerBuilder.setUsername(username); + iceServerBuilder.setPassword(password); + } else if (Arrays.asList("turn", "turns").contains(type)) { + // The WebRTC spec requires throwing an + // InvalidAccessError when username (from libwebrtc + // source coder) + // https://chromium.googlesource.com/external/webrtc/+/master/pc/ice_server_parsing.cc + Log.w( + Config.LOGTAG, + "skipping " + + type + + "/" + + transport + + " without username and password"); + continue; + } + final PeerConnection.IceServer iceServer = + iceServerBuilder.createIceServer(); + Log.w(Config.LOGTAG, "discovered ICE Server: " + iceServer); + listBuilder.add(iceServer); + } + } + } + } + return listBuilder.build(); + } +} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleCandidate.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleCandidate.java deleted file mode 100644 index 78ffb28bee9a72a79a1bcc9fa0b224aab1ddfb76..0000000000000000000000000000000000000000 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleCandidate.java +++ /dev/null @@ -1,152 +0,0 @@ -package eu.siacs.conversations.xmpp.jingle; - -import java.util.ArrayList; -import java.util.List; - -import eu.siacs.conversations.xml.Element; -import eu.siacs.conversations.xmpp.InvalidJid; -import eu.siacs.conversations.xmpp.Jid; - -public class JingleCandidate { - - public static int TYPE_UNKNOWN; - public static int TYPE_DIRECT = 0; - public static int TYPE_PROXY = 1; - - private final boolean ours; - private boolean usedByCounterpart = false; - private final String cid; - private String host; - private int port; - private int type; - private Jid jid; - private int priority; - - public JingleCandidate(String cid, boolean ours) { - this.ours = ours; - this.cid = cid; - } - - public String getCid() { - return cid; - } - - public void setHost(String host) { - this.host = host; - } - - public String getHost() { - return this.host; - } - - public void setJid(final Jid jid) { - this.jid = jid; - } - - public Jid getJid() { - return this.jid; - } - - public void setPort(int port) { - this.port = port; - } - - public int getPort() { - return this.port; - } - - public void setType(int type) { - this.type = type; - } - - public void setType(String type) { - if (type == null) { - this.type = TYPE_UNKNOWN; - return; - } - switch (type) { - case "proxy": - this.type = TYPE_PROXY; - break; - case "direct": - this.type = TYPE_DIRECT; - break; - default: - this.type = TYPE_UNKNOWN; - break; - } - } - - public void setPriority(int i) { - this.priority = i; - } - - public int getPriority() { - return this.priority; - } - - public boolean equals(JingleCandidate other) { - return this.getCid().equals(other.getCid()); - } - - public boolean equalValues(JingleCandidate other) { - return other != null && other.getHost().equals(this.getHost()) && (other.getPort() == this.getPort()); - } - - public boolean isOurs() { - return ours; - } - - public int getType() { - return this.type; - } - - public static List parse(final List elements) { - final List candidates = new ArrayList<>(); - for (final Element element : elements) { - if ("candidate".equals(element.getName())) { - candidates.add(JingleCandidate.parse(element)); - } - } - return candidates; - } - - public static JingleCandidate parse(Element element) { - final JingleCandidate candidate = new JingleCandidate(element.getAttribute("cid"), false); - candidate.setHost(element.getAttribute("host")); - candidate.setJid(InvalidJid.getNullForInvalid(element.getAttributeAsJid("jid"))); - candidate.setType(element.getAttribute("type")); - candidate.setPriority(Integer.parseInt(element.getAttribute("priority"))); - candidate.setPort(Integer.parseInt(element.getAttribute("port"))); - return candidate; - } - - public Element toElement() { - Element element = new Element("candidate"); - element.setAttribute("cid", this.getCid()); - element.setAttribute("host", this.getHost()); - element.setAttribute("port", Integer.toString(this.getPort())); - if (jid != null) { - element.setAttribute("jid", jid); - } - element.setAttribute("priority", Integer.toString(this.getPriority())); - if (this.getType() == TYPE_DIRECT) { - element.setAttribute("type", "direct"); - } else if (this.getType() == TYPE_PROXY) { - element.setAttribute("type", "proxy"); - } - return element; - } - - public void flagAsUsedByCounterpart() { - this.usedByCounterpart = true; - } - - public boolean isUsedByCounterpart() { - return this.usedByCounterpart; - } - - public String toString() { - return String.format("%s:%s (priority=%s,ours=%s)", getHost(), getPort(), getPriority(), isOurs()); - } -} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java index 48f011f098877b225fdc6fe08528d6b825cb415f..026f06ad559b9b08508e5e012b37b0417b017d3a 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java @@ -12,20 +12,6 @@ import com.google.common.collect.Collections2; import com.google.common.collect.ComparisonChain; import com.google.common.collect.ImmutableSet; -import java.lang.ref.WeakReference; -import java.security.SecureRandom; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; - import eu.siacs.conversations.Config; import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Contact; @@ -39,18 +25,33 @@ import eu.siacs.conversations.services.XmppConnectionService; import eu.siacs.conversations.xml.Element; import eu.siacs.conversations.xml.Namespace; import eu.siacs.conversations.xmpp.Jid; -import eu.siacs.conversations.xmpp.OnIqPacketReceived; import eu.siacs.conversations.xmpp.XmppConnection; import eu.siacs.conversations.xmpp.jingle.stanzas.Content; import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription; import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription; +import eu.siacs.conversations.xmpp.jingle.stanzas.IbbTransportInfo; import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket; import eu.siacs.conversations.xmpp.jingle.stanzas.Propose; import eu.siacs.conversations.xmpp.jingle.stanzas.Reason; import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription; +import eu.siacs.conversations.xmpp.jingle.transports.InbandBytestreamsTransport; +import eu.siacs.conversations.xmpp.jingle.transports.Transport; import eu.siacs.conversations.xmpp.stanzas.IqPacket; import eu.siacs.conversations.xmpp.stanzas.MessagePacket; +import java.lang.ref.WeakReference; +import java.security.SecureRandom; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + public class JingleConnectionManager extends AbstractConnectionManager { static final ScheduledExecutorService SCHEDULED_EXECUTOR_SERVICE = Executors.newSingleThreadScheduledExecutor(); @@ -63,8 +64,6 @@ public class JingleConnectionManager extends AbstractConnectionManager { private final Cache terminatedSessions = CacheBuilder.newBuilder().expireAfterWrite(24, TimeUnit.HOURS).build(); - private final HashMap primaryCandidates = new HashMap<>(); - public JingleConnectionManager(XmppConnectionService service) { super(service); this.toneManager = new ToneManager(service); @@ -92,7 +91,7 @@ public class JingleConnectionManager extends AbstractConnectionManager { final String descriptionNamespace = content == null ? null : content.getDescriptionNamespace(); final AbstractJingleConnection connection; - if (FileTransferDescription.NAMESPACES.contains(descriptionNamespace)) { + if (Namespace.JINGLE_APPS_FILE_TRANSFER.equals(descriptionNamespace)) { connection = new JingleFileTransferConnection(this, id, from); } else if (Namespace.JINGLE_APPS_RTP.equals(descriptionNamespace) && isUsingClearNet(account)) { @@ -170,8 +169,7 @@ public class JingleConnectionManager extends AbstractConnectionManager { public boolean hasJingleRtpConnection(final Account account) { for (AbstractJingleConnection connection : this.connections.values()) { - if (connection instanceof JingleRtpConnection) { - final JingleRtpConnection rtpConnection = (JingleRtpConnection) connection; + if (connection instanceof JingleRtpConnection rtpConnection) { if (rtpConnection.isTerminated()) { continue; } @@ -185,8 +183,7 @@ public class JingleConnectionManager extends AbstractConnectionManager { public void notifyPhoneCallStarted() { for (AbstractJingleConnection connection : connections.values()) { - if (connection instanceof JingleRtpConnection) { - final JingleRtpConnection rtpConnection = (JingleRtpConnection) connection; + if (connection instanceof JingleRtpConnection rtpConnection) { if (rtpConnection.isTerminated()) { continue; } @@ -195,7 +192,6 @@ public class JingleConnectionManager extends AbstractConnectionManager { } } - private Optional findMatchingSessionProposal( final Account account, final Jid with, final Set media) { synchronized (this.rtpSessionProposals) { @@ -220,8 +216,7 @@ public class JingleConnectionManager extends AbstractConnectionManager { private String hasMatchingRtpSession(final Account account, final Jid with, final Set media) { for (AbstractJingleConnection connection : this.connections.values()) { - if (connection instanceof JingleRtpConnection) { - final JingleRtpConnection rtpConnection = (JingleRtpConnection) connection; + if (connection instanceof JingleRtpConnection rtpConnection) { if (rtpConnection.isTerminated()) { continue; } @@ -281,8 +276,7 @@ public class JingleConnectionManager extends AbstractConnectionManager { } if ("accept".equals(message.getName()) || "reject".equals(message.getName())) { for (AbstractJingleConnection connection : connections.values()) { - if (connection instanceof JingleRtpConnection) { - final JingleRtpConnection rtpConnection = (JingleRtpConnection) connection; + if (connection instanceof JingleRtpConnection rtpConnection) { final AbstractJingleConnection.Id id = connection.getId(); if (id.account == account && id.sessionId.equals(sessionId)) { rtpConnection.deliveryMessage(from, message, serverMsgId, timestamp); @@ -599,13 +593,10 @@ public class JingleConnectionManager extends AbstractConnectionManager { if (old != null) { old.cancel(); } - final Account account = message.getConversation().getAccount(); - final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(message); final JingleFileTransferConnection connection = - new JingleFileTransferConnection(this, id, account.getJid()); - mXmppConnectionService.markMessage(message, Message.STATUS_WAITING); - this.connections.put(id, connection); - connection.init(message); + new JingleFileTransferConnection(this, message); + this.connections.put(connection.getId(), connection); + connection.sendSessionInitialize(); } public Optional getOngoingRtpConnection(final Contact contact) { @@ -644,15 +635,16 @@ public class JingleConnectionManager extends AbstractConnectionManager { final AbstractJingleConnection.Id id = connection.getId(); if (this.connections.remove(id) == null) { throw new IllegalStateException( - String.format("Unable to finish connection with id=%s", id.toString())); + String.format("Unable to finish connection with id=%s", id)); } + // update chat UI to remove 'ongoing call' icon + mXmppConnectionService.updateConversationUi(); } public boolean fireJingleRtpConnectionStateUpdates() { boolean firedUpdates = false; for (final AbstractJingleConnection connection : this.connections.values()) { - if (connection instanceof JingleRtpConnection) { - final JingleRtpConnection jingleRtpConnection = (JingleRtpConnection) connection; + if (connection instanceof JingleRtpConnection jingleRtpConnection) { if (jingleRtpConnection.isTerminated()) { continue; } @@ -663,73 +655,6 @@ public class JingleConnectionManager extends AbstractConnectionManager { return firedUpdates; } - void getPrimaryCandidate( - final Account account, - final boolean initiator, - final OnPrimaryCandidateFound listener) { - if (Config.DISABLE_PROXY_LOOKUP) { - listener.onPrimaryCandidateFound(false, null); - return; - } - if (!this.primaryCandidates.containsKey(account.getJid().asBareJid())) { - final Jid proxy = - account.getXmppConnection().findDiscoItemByFeature(Namespace.BYTE_STREAMS); - if (proxy != null) { - IqPacket iq = new IqPacket(IqPacket.TYPE.GET); - iq.setTo(proxy); - iq.query(Namespace.BYTE_STREAMS); - account.getXmppConnection() - .sendIqPacket( - iq, - new OnIqPacketReceived() { - - @Override - public void onIqPacketReceived( - Account account, IqPacket packet) { - final Element streamhost = - packet.query() - .findChild( - "streamhost", - Namespace.BYTE_STREAMS); - final String host = - streamhost == null - ? null - : streamhost.getAttribute("host"); - final String port = - streamhost == null - ? null - : streamhost.getAttribute("port"); - if (host != null && port != null) { - try { - JingleCandidate candidate = - new JingleCandidate(nextRandomId(), true); - candidate.setHost(host); - candidate.setPort(Integer.parseInt(port)); - candidate.setType(JingleCandidate.TYPE_PROXY); - candidate.setJid(proxy); - candidate.setPriority( - 655360 + (initiator ? 30 : 0)); - primaryCandidates.put( - account.getJid().asBareJid(), candidate); - listener.onPrimaryCandidateFound(true, candidate); - } catch (final NumberFormatException e) { - listener.onPrimaryCandidateFound(false, null); - } - } else { - listener.onPrimaryCandidateFound(false, null); - } - } - }); - } else { - listener.onPrimaryCandidateFound(false, null); - } - - } else { - listener.onPrimaryCandidateFound( - true, this.primaryCandidates.get(account.getJid().asBareJid())); - } - } - public void retractSessionProposal(final Account account, final Jid with) { synchronized (this.rtpSessionProposals) { RtpSessionProposal matchingProposal = null; @@ -831,40 +756,53 @@ public class JingleConnectionManager extends AbstractConnectionManager { return false; } - public void deliverIbbPacket(Account account, IqPacket packet) { + public void deliverIbbPacket(final Account account, final IqPacket packet) { final String sid; final Element payload; + final InbandBytestreamsTransport.PacketType packetType; if (packet.hasChild("open", Namespace.IBB)) { + packetType = InbandBytestreamsTransport.PacketType.OPEN; payload = packet.findChild("open", Namespace.IBB); sid = payload.getAttribute("sid"); } else if (packet.hasChild("data", Namespace.IBB)) { + packetType = InbandBytestreamsTransport.PacketType.DATA; payload = packet.findChild("data", Namespace.IBB); sid = payload.getAttribute("sid"); } else if (packet.hasChild("close", Namespace.IBB)) { + packetType = InbandBytestreamsTransport.PacketType.CLOSE; payload = packet.findChild("close", Namespace.IBB); sid = payload.getAttribute("sid"); } else { + packetType = null; payload = null; sid = null; } - if (sid != null) { - for (final AbstractJingleConnection connection : this.connections.values()) { - if (connection instanceof JingleFileTransferConnection) { - final JingleFileTransferConnection fileTransfer = - (JingleFileTransferConnection) connection; - final JingleTransport transport = fileTransfer.getTransport(); - if (transport instanceof JingleInBandTransport) { - final JingleInBandTransport inBandTransport = - (JingleInBandTransport) transport; - if (inBandTransport.matches(account, sid)) { - inBandTransport.deliverPayload(packet, payload); + if (sid == null) { + Log.d(Config.LOGTAG, account.getJid().asBareJid()+": unable to deliver ibb packet. missing sid"); + account.getXmppConnection() + .sendIqPacket(packet.generateResponse(IqPacket.TYPE.ERROR), null); + return; + } + for (final AbstractJingleConnection connection : this.connections.values()) { + if (connection instanceof JingleFileTransferConnection fileTransfer) { + final Transport transport = fileTransfer.getTransport(); + if (transport instanceof InbandBytestreamsTransport inBandTransport) { + if (sid.equals(inBandTransport.getStreamId())) { + if (inBandTransport.deliverPacket(packetType, packet.getFrom(), payload)) { + account.getXmppConnection() + .sendIqPacket( + packet.generateResponse(IqPacket.TYPE.RESULT), null); + } else { + account.getXmppConnection() + .sendIqPacket( + packet.generateResponse(IqPacket.TYPE.ERROR), null); } return; } } } } - Log.d(Config.LOGTAG, "unable to deliver ibb packet: " + packet.toString()); + Log.d(Config.LOGTAG, account.getJid().asBareJid()+": unable to deliver ibb packet with sid="+sid); account.getXmppConnection() .sendIqPacket(packet.generateResponse(IqPacket.TYPE.ERROR), null); } @@ -970,7 +908,8 @@ public class JingleConnectionManager extends AbstractConnectionManager { } } - public void failProceed(Account account, final Jid with, final String sessionId, final String message) { + public void failProceed( + Account account, final Jid with, final String sessionId, final String message) { final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(account, with, sessionId); final AbstractJingleConnection existingJingleConnection = connections.get(id); @@ -1044,15 +983,11 @@ public class JingleConnectionManager extends AbstractConnectionManager { FAILED; public RtpEndUserState toEndUserState() { - switch (this) { - case SEARCHING: - case SEARCHING_ACKNOWLEDGED: - return RtpEndUserState.FINDING_DEVICE; - case DISCOVERED: - return RtpEndUserState.RINGING; - default: - return RtpEndUserState.CONNECTIVITY_ERROR; - } + return switch (this) { + case SEARCHING, SEARCHING_ACKNOWLEDGED -> RtpEndUserState.FINDING_DEVICE; + case DISCOVERED -> RtpEndUserState.RINGING; + default -> RtpEndUserState.CONNECTIVITY_ERROR; + }; } } @@ -1062,10 +997,6 @@ public class JingleConnectionManager extends AbstractConnectionManager { public final Set media; private final Account account; - private RtpSessionProposal(Account account, Jid with, String sessionId) { - this(account, with, sessionId, Collections.emptySet()); - } - private RtpSessionProposal(Account account, Jid with, String sessionId, Set media) { this.account = account; this.with = with; diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java index 1b21966916e0e38aef825f768c32e7a2c6be08a7..983ce433f610f977d5df317560b621cd0829623e 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java @@ -1,1279 +1,1454 @@ package eu.siacs.conversations.xmpp.jingle; -import android.util.Base64; import android.util.Log; +import androidx.annotation.NonNull; + import com.google.common.base.Preconditions; import com.google.common.base.Strings; -import com.google.common.collect.Collections2; -import com.google.common.collect.FluentIterable; +import com.google.common.base.Throwables; +import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.Iterator; -import java.util.List; -import java.util.Map.Entry; -import java.util.concurrent.ConcurrentHashMap; +import com.google.common.hash.Hashing; +import com.google.common.primitives.Ints; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.SettableFuture; import eu.siacs.conversations.Config; -import eu.siacs.conversations.crypto.axolotl.AxolotlService; import eu.siacs.conversations.crypto.axolotl.XmppAxolotlMessage; -import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Conversation; -import eu.siacs.conversations.entities.DownloadableFile; import eu.siacs.conversations.entities.Message; -import eu.siacs.conversations.entities.Presence; -import eu.siacs.conversations.entities.ServiceDiscoveryResult; import eu.siacs.conversations.entities.Transferable; import eu.siacs.conversations.entities.TransferablePlaceholder; -import eu.siacs.conversations.parser.IqParser; -import eu.siacs.conversations.persistance.FileBackend; import eu.siacs.conversations.services.AbstractConnectionManager; -import eu.siacs.conversations.services.XmppConnectionService; -import eu.siacs.conversations.utils.CryptoHelper; -import eu.siacs.conversations.utils.MimeUtils; -import eu.siacs.conversations.xml.Element; import eu.siacs.conversations.xml.Namespace; import eu.siacs.conversations.xmpp.Jid; -import eu.siacs.conversations.xmpp.OnIqPacketReceived; -import eu.siacs.conversations.xmpp.jingle.stanzas.Content; +import eu.siacs.conversations.xmpp.XmppConnection; import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription; import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo; import eu.siacs.conversations.xmpp.jingle.stanzas.IbbTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo; import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket; import eu.siacs.conversations.xmpp.jingle.stanzas.Reason; -import eu.siacs.conversations.xmpp.jingle.stanzas.S5BTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.SocksByteStreamsTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.WebRTCDataChannelTransportInfo; +import eu.siacs.conversations.xmpp.jingle.transports.InbandBytestreamsTransport; +import eu.siacs.conversations.xmpp.jingle.transports.SocksByteStreamsTransport; +import eu.siacs.conversations.xmpp.jingle.transports.Transport; +import eu.siacs.conversations.xmpp.jingle.transports.WebRTCDataChannelTransport; import eu.siacs.conversations.xmpp.stanzas.IqPacket; -public class JingleFileTransferConnection extends AbstractJingleConnection implements Transferable { - - private static final int JINGLE_STATUS_TRANSMITTING = 5; - private static final String JET_OMEMO_CIPHER = "urn:xmpp:ciphers:aes-128-gcm-nopadding"; - private static final int JINGLE_STATUS_INITIATED = 0; - private static final int JINGLE_STATUS_ACCEPTED = 1; - private static final int JINGLE_STATUS_FINISHED = 4; - private static final int JINGLE_STATUS_FAILED = 99; - private static final int JINGLE_STATUS_OFFERED = -1; - - private static final int MAX_IBB_BLOCK_SIZE = 8192; - - private int ibbBlockSize = MAX_IBB_BLOCK_SIZE; - - private int mJingleStatus = JINGLE_STATUS_OFFERED; //migrate to enum - private int mStatus = Transferable.STATUS_UNKNOWN; - private Message message; - private Jid responder; - private final List candidates = new ArrayList<>(); - private final ConcurrentHashMap connections = new ConcurrentHashMap<>(); - - private String transportId; - private FileTransferDescription description; - private DownloadableFile file = null; +import org.bouncycastle.crypto.engines.AESEngine; +import org.bouncycastle.crypto.io.CipherInputStream; +import org.bouncycastle.crypto.io.CipherOutputStream; +import org.bouncycastle.crypto.modes.AEADBlockCipher; +import org.bouncycastle.crypto.modes.GCMBlockCipher; +import org.bouncycastle.crypto.params.AEADParameters; +import org.bouncycastle.crypto.params.KeyParameter; +import org.webrtc.IceCandidate; + +import java.io.Closeable; +import java.io.EOFException; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Queue; +import java.util.concurrent.CountDownLatch; - private boolean proxyActivationFailed = false; +public class JingleFileTransferConnection extends AbstractJingleConnection + implements Transport.Callback, Transferable { - private String contentName; - private Content.Creator contentCreator; - private Content.Senders contentSenders; - private Class initialTransport; - private boolean remoteSupportsOmemoJet; + private final Message message; - private int mProgress = 0; + private FileTransferContentMap initiatorFileTransferContentMap; + private FileTransferContentMap responderFileTransferContentMap; - private boolean receivedCandidate = false; - private boolean sentCandidate = false; + private Transport transport; + private TransportSecurity transportSecurity; + private AbstractFileTransceiver fileTransceiver; + private final Queue pendingIncomingIceCandidates = new LinkedList<>(); private boolean acceptedAutomatically = false; - private boolean cancelled = false; - - private XmppAxolotlMessage mXmppAxolotlMessage; - private JingleTransport transport = null; + public JingleFileTransferConnection( + final JingleConnectionManager jingleConnectionManager, final Message message) { + super( + jingleConnectionManager, + AbstractJingleConnection.Id.of(message), + message.getConversation().getAccount().getJid()); + Preconditions.checkArgument( + message.isFileOrImage(), + "only file or images messages can be transported via jingle"); + this.message = message; + this.message.setTransferable(this); + xmppConnectionService.markMessage(message, Message.STATUS_WAITING); + } - private OutputStream mFileOutputStream; - private InputStream mFileInputStream; + public JingleFileTransferConnection( + final JingleConnectionManager jingleConnectionManager, + final Id id, + final Jid initiator) { + super(jingleConnectionManager, id, initiator); + final Conversation conversation = + this.xmppConnectionService.findOrCreateConversation( + id.account, id.with.asBareJid(), false, false); + this.message = new Message(conversation, "", Message.ENCRYPTION_NONE); + this.message.setStatus(Message.STATUS_RECEIVED); + this.message.setErrorMessage(null); + this.message.setTransferable(this); + } - private final OnIqPacketReceived responseListener = (account, packet) -> { - if (packet.getType() != IqPacket.TYPE.RESULT) { - if (mJingleStatus != JINGLE_STATUS_FAILED && mJingleStatus != JINGLE_STATUS_FINISHED) { - fail(IqParser.extractErrorMessage(packet)); - } else { - Log.d(Config.LOGTAG, "ignoring late delivery of jingle packet to jingle session with status=" + mJingleStatus + ": " + packet.toString()); + @Override + void deliverPacket(final JinglePacket jinglePacket) { + switch (jinglePacket.getAction()) { + case SESSION_ACCEPT -> receiveSessionAccept(jinglePacket); + case SESSION_INITIATE -> receiveSessionInitiate(jinglePacket); + case SESSION_INFO -> receiveSessionInfo(jinglePacket); + case SESSION_TERMINATE -> receiveSessionTerminate(jinglePacket); + case TRANSPORT_ACCEPT -> receiveTransportAccept(jinglePacket); + case TRANSPORT_INFO -> receiveTransportInfo(jinglePacket); + case TRANSPORT_REPLACE -> receiveTransportReplace(jinglePacket); + default -> { + respondOk(jinglePacket); + Log.d( + Config.LOGTAG, + String.format( + "%s: received unhandled jingle action %s", + id.account.getJid().asBareJid(), jinglePacket.getAction())); } } - }; - private byte[] expectedHash = new byte[0]; - private final OnFileTransmissionStatusChanged onFileTransmissionStatusChanged = new OnFileTransmissionStatusChanged() { + } - @Override - public void onFileTransmitted(DownloadableFile file) { - DownloadableFile finalFile; - - if (responding()) { - if (expectedHash.length > 0) { - if (Arrays.equals(expectedHash, file.getSha1Sum())) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received file matched the expected hash"); - } else { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": hashes did not match"); + public void sendSessionInitialize() { + final ListenableFuture> keyTransportMessage; + if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) { + keyTransportMessage = + Futures.transform( + id.account + .getAxolotlService() + .prepareKeyTransportMessage(requireConversation()), + Optional::of, + MoreExecutors.directExecutor()); + } else { + keyTransportMessage = Futures.immediateFuture(Optional.empty()); + } + Futures.addCallback( + keyTransportMessage, + new FutureCallback<>() { + @Override + public void onSuccess(final Optional xmppAxolotlMessage) { + sendSessionInitialize(xmppAxolotlMessage.orElse(null)); } - } else { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": other party did not include file hash in file transfer"); - } - sendSuccess(); - - final String extension = MimeUtils.extractRelevantExtension(file.getName()); - try { - xmppConnectionService.getFileBackend().setupRelativeFilePath(message, new FileInputStream(file), extension); - finalFile = xmppConnectionService.getFileBackend().getFile(message); - boolean didRename = file.renameTo(finalFile); - if (!didRename) throw new IOException("rename failed"); - } catch (final IOException e) { - finalFile = file; - message.setRelativeFilePath(finalFile.getAbsolutePath()); - } catch (final XmppConnectionService.BlockedMediaException e) { - finalFile = file; - file.delete(); - message.setRelativeFilePath(null); - message.setDeleted(true); - } - - xmppConnectionService.getFileBackend().updateFileParams(message, null, false); - xmppConnectionService.databaseBackend.createMessage(message); - xmppConnectionService.markMessage(message, Message.STATUS_RECEIVED); - if (acceptedAutomatically) { - message.markUnread(); - if (message.getEncryption() == Message.ENCRYPTION_PGP) { - id.account.getPgpDecryptionService().decrypt(message, true); - } else { - xmppConnectionService.getFileBackend().updateMediaScanner(file, () -> JingleFileTransferConnection.this.xmppConnectionService.getNotificationService().push(message)); + @Override + public void onFailure(@NonNull Throwable throwable) { + Log.d(Config.LOGTAG, "can not send message"); } - Log.d(Config.LOGTAG, "successfully transmitted file:" + file.getAbsolutePath() + " (" + CryptoHelper.bytesToHex(file.getSha1Sum()) + ")"); - return; - } else if (message.getEncryption() == Message.ENCRYPTION_PGP) { - id.account.getPgpDecryptionService().decrypt(message, true); - } - } else { - finalFile = file; - - if (description.getVersion() == FileTransferDescription.Version.FT_5) { //older Conversations will break when receiving a session-info - sendHash(); - } - if (message.getEncryption() == Message.ENCRYPTION_PGP) { - id.account.getPgpDecryptionService().decrypt(message, false); - } - if (message.getEncryption() == Message.ENCRYPTION_PGP || message.getEncryption() == Message.ENCRYPTION_DECRYPTED) { - finalFile.delete(); - } - disconnectSocks5Connections(); - } - Log.d(Config.LOGTAG, "successfully transmitted file:" + finalFile.getAbsolutePath() + " (" + CryptoHelper.bytesToHex(file.getSha1Sum()) + ")"); - if (message.getEncryption() != Message.ENCRYPTION_PGP) { - xmppConnectionService.getFileBackend().updateMediaScanner(finalFile); - } - } + }, + MoreExecutors.directExecutor()); + } - @Override - public void onFileTransferAborted() { - JingleFileTransferConnection.this.sendSessionTerminate(Reason.CONNECTIVITY_ERROR); - JingleFileTransferConnection.this.fail(); - } - }; - private final OnTransportConnected onIbbTransportConnected = new OnTransportConnected() { - @Override - public void failed() { - Log.d(Config.LOGTAG, "ibb open failed"); - } + private void sendSessionInitialize(final XmppAxolotlMessage xmppAxolotlMessage) { + this.transport = setupTransport(); + this.transport.setTransportCallback(this); + final File file = xmppConnectionService.getFileBackend().getFile(message); + final var fileDescription = + new FileTransferDescription.File( + file.length(), + file.getName(), + message.getMimeType(), + Collections.emptyList()); + final var transportInfoFuture = this.transport.asInitialTransportInfo(); + Futures.addCallback( + transportInfoFuture, + new FutureCallback<>() { + @Override + public void onSuccess( + final Transport.InitialTransportInfo initialTransportInfo) { + final FileTransferContentMap contentMap = + FileTransferContentMap.of(fileDescription, initialTransportInfo); + sendSessionInitialize(xmppAxolotlMessage, contentMap); + } - @Override - public void established() { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": ibb transport connected. sending file"); - mJingleStatus = JINGLE_STATUS_TRANSMITTING; - JingleFileTransferConnection.this.transport.send(file, onFileTransmissionStatusChanged); - } - }; - private final OnProxyActivated onProxyActivated = new OnProxyActivated() { + @Override + public void onFailure(@NonNull Throwable throwable) {} + }, + MoreExecutors.directExecutor()); + } - @Override - public void success() { - if (isInitiator()) { - Log.d(Config.LOGTAG, "we were initiating. sending file"); - transport.send(file, onFileTransmissionStatusChanged); - } else { - transport.receive(file, onFileTransmissionStatusChanged); - Log.d(Config.LOGTAG, "we were responding. receiving file"); - } + private Conversation requireConversation() { + final var conversational = message.getConversation(); + if (conversational instanceof Conversation c) { + return c; + } else { + throw new IllegalStateException("Message had no proper conversation attached"); } + } - @Override - public void failed() { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": proxy activation failed"); - proxyActivationFailed = true; - if (isInitiator()) { - sendFallbackToIbb(); + private void sendSessionInitialize( + final XmppAxolotlMessage xmppAxolotlMessage, final FileTransferContentMap contentMap) { + if (transition( + State.SESSION_INITIALIZED, + () -> this.initiatorFileTransferContentMap = contentMap)) { + final var jinglePacket = + contentMap.toJinglePacket(JinglePacket.Action.SESSION_INITIATE, id.sessionId); + if (xmppAxolotlMessage != null) { + this.transportSecurity = + new TransportSecurity( + xmppAxolotlMessage.getInnerKey(), xmppAxolotlMessage.getIV()); + jinglePacket.setSecurity( + Iterables.getOnlyElement(contentMap.contents.keySet()), xmppAxolotlMessage); } + Log.d(Config.LOGTAG, "--> " + jinglePacket.toString()); + jinglePacket.setTo(id.with); + xmppConnectionService.sendIqPacket( + id.account, + jinglePacket, + (a, response) -> { + if (response.getType() == IqPacket.TYPE.RESULT) { + xmppConnectionService.markMessage(message, Message.STATUS_OFFERED); + return; + } + if (response.getType() == IqPacket.TYPE.ERROR) { + handleIqErrorResponse(response); + return; + } + if (response.getType() == IqPacket.TYPE.TIMEOUT) { + handleIqTimeoutResponse(response); + } + }); + this.transport.readyToSentAdditionalCandidates(); } - }; - - JingleFileTransferConnection(JingleConnectionManager jingleConnectionManager, Id id, Jid initiator) { - super(jingleConnectionManager, id, initiator); } - private static long parseLong(final Element element, final long l) { - final String input = element == null ? null : element.getContent(); - if (input == null) { - return l; + private void receiveSessionAccept(final JinglePacket jinglePacket) { + Log.d(Config.LOGTAG, "receive session accept " + jinglePacket); + if (isResponder()) { + receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.SESSION_ACCEPT); + return; } + final FileTransferContentMap contentMap; try { - return Long.parseLong(input); - } catch (Exception e) { - return l; + contentMap = FileTransferContentMap.of(jinglePacket); + contentMap.requireOnlyFileTransferDescription(); + } catch (final RuntimeException e) { + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + ": improperly formatted contents", + Throwables.getRootCause(e)); + respondOk(jinglePacket); + sendSessionTerminate(Reason.of(e), e.getMessage()); + return; } + receiveSessionAccept(jinglePacket, contentMap); } - //TODO get rid and use isInitiator() instead - private boolean responding() { - return responder != null && responder.equals(id.account.getJid()); + private void receiveSessionAccept( + final JinglePacket jinglePacket, final FileTransferContentMap contentMap) { + if (transition(State.SESSION_ACCEPTED, () -> setRemoteContentMap(contentMap))) { + respondOk(jinglePacket); + final var transport = this.transport; + if (configureTransportWithPeerInfo(transport, contentMap)) { + transport.connect(); + } else { + Log.e( + Config.LOGTAG, + "Transport in session accept did not match our session-initialize"); + terminateTransport(); + sendSessionTerminate( + Reason.FAILED_APPLICATION, + "Transport in session accept did not match our session-initialize"); + } + } else { + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + ": receive out of order session-accept"); + receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.SESSION_ACCEPT); + } } - - InputStream getFileInputStream() { - return this.mFileInputStream; + private static boolean configureTransportWithPeerInfo( + final Transport transport, final FileTransferContentMap contentMap) { + final GenericTransportInfo transportInfo = contentMap.requireOnlyTransportInfo(); + if (transport instanceof WebRTCDataChannelTransport webRTCDataChannelTransport + && transportInfo instanceof WebRTCDataChannelTransportInfo) { + webRTCDataChannelTransport.setResponderDescription(SessionDescription.of(contentMap)); + return true; + } else if (transport instanceof SocksByteStreamsTransport socksBytestreamsTransport + && transportInfo + instanceof SocksByteStreamsTransportInfo socksBytestreamsTransportInfo) { + socksBytestreamsTransport.setTheirCandidates( + socksBytestreamsTransportInfo.getCandidates()); + return true; + } else if (transport instanceof InbandBytestreamsTransport inbandBytestreamsTransport + && transportInfo instanceof IbbTransportInfo ibbTransportInfo) { + final var peerBlockSize = ibbTransportInfo.getBlockSize(); + if (peerBlockSize != null) { + inbandBytestreamsTransport.setPeerBlockSize(peerBlockSize); + } + return true; + } else { + return false; + } } - OutputStream getFileOutputStream() throws IOException { - if (this.file == null) { - Log.d(Config.LOGTAG, "file object was not assigned"); - return null; + private void receiveSessionInitiate(final JinglePacket jinglePacket) { + if (isInitiator()) { + receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.SESSION_INITIATE); + return; } - final File parent = this.file.getParentFile(); - if (parent != null && parent.mkdirs()) { - Log.d(Config.LOGTAG, "created parent directories for file " + file.getAbsolutePath()); + Log.d(Config.LOGTAG, "receive session initiate " + jinglePacket); + final FileTransferContentMap contentMap; + final FileTransferDescription.File file; + try { + contentMap = FileTransferContentMap.of(jinglePacket); + contentMap.requireContentDescriptions(); + file = contentMap.requireOnlyFile(); + // TODO check is offer + } catch (final RuntimeException e) { + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + ": improperly formatted contents", + Throwables.getRootCause(e)); + respondOk(jinglePacket); + sendSessionTerminate(Reason.of(e), e.getMessage()); + return; } - if (this.file.createNewFile()) { - Log.d(Config.LOGTAG, "created output file " + file.getAbsolutePath()); + final XmppAxolotlMessage.XmppAxolotlKeyTransportMessage keyTransportMessage; + final var security = + jinglePacket.getSecurity(Iterables.getOnlyElement(contentMap.contents.keySet())); + if (security != null) { + Log.d(Config.LOGTAG, "found security element!"); + keyTransportMessage = + id.account + .getAxolotlService() + .processReceivingKeyTransportMessage(security, false); + } else { + keyTransportMessage = null; } - this.mFileOutputStream = AbstractConnectionManager.createOutputStream(this.file, false, true); - return this.mFileOutputStream; + receiveSessionInitiate(jinglePacket, contentMap, file, keyTransportMessage); } - @Override - void deliverPacket(final JinglePacket packet) { - final JinglePacket.Action action = packet.getAction(); - //TODO switch case - if (action == JinglePacket.Action.SESSION_INITIATE) { - init(packet); - } else if (action == JinglePacket.Action.SESSION_TERMINATE) { - final Reason reason = packet.getReason().reason; - switch (reason) { - case CANCEL: - this.cancelled = true; - this.fail(); - break; - case SUCCESS: - this.receiveSuccess(); - break; - default: - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received session-terminate with reason " + reason); - this.fail(); - break; - - } - } else if (action == JinglePacket.Action.SESSION_ACCEPT) { - receiveAccept(packet); - } else if (action == JinglePacket.Action.SESSION_INFO) { - final Element checksum = packet.getJingleChild("checksum"); - final Element file = checksum == null ? null : checksum.findChild("file"); - final Element hash = file == null ? null : file.findChild("hash", "urn:xmpp:hashes:2"); - if (hash != null && "sha-1".equalsIgnoreCase(hash.getAttribute("algo"))) { - try { - this.expectedHash = Base64.decode(hash.getContent(), Base64.DEFAULT); - } catch (Exception e) { - this.expectedHash = new byte[0]; - } + private void receiveSessionInitiate( + final JinglePacket jinglePacket, + final FileTransferContentMap contentMap, + final FileTransferDescription.File file, + final XmppAxolotlMessage.XmppAxolotlKeyTransportMessage keyTransportMessage) { + + if (transition(State.SESSION_INITIALIZED, () -> setRemoteContentMap(contentMap))) { + respondOk(jinglePacket); + Log.d(Config.LOGTAG, jinglePacket.toString()); + Log.d( + Config.LOGTAG, + "got file offer " + file + " jet=" + Objects.nonNull(keyTransportMessage)); + setFileOffer(file); + if (keyTransportMessage != null) { + this.transportSecurity = + new TransportSecurity( + keyTransportMessage.getKey(), keyTransportMessage.getIv()); + this.message.setFingerprint(keyTransportMessage.getFingerprint()); + this.message.setEncryption(Message.ENCRYPTION_AXOLOTL); + } else { + this.transportSecurity = null; + this.message.setFingerprint(null); } - respondToIq(packet, true); - } else if (action == JinglePacket.Action.TRANSPORT_INFO) { - receiveTransportInfo(packet); - } else if (action == JinglePacket.Action.TRANSPORT_REPLACE) { - final Content content = packet.getJingleContent(); - final GenericTransportInfo transportInfo = content == null ? null : content.getTransport(); - if (transportInfo instanceof IbbTransportInfo) { - receiveFallbackToIbb(packet, (IbbTransportInfo) transportInfo); + final var conversation = (Conversation) message.getConversation(); + conversation.add(message); + + // make auto accept decision + if (id.account.getRoster().getContact(id.with).showInContactList() + && jingleConnectionManager.hasStoragePermission() + && file.size <= this.jingleConnectionManager.getAutoAcceptFileSize() + && xmppConnectionService.isDataSaverDisabled()) { + Log.d(Config.LOGTAG, "auto accepting file from " + id.with); + this.acceptedAutomatically = true; + this.sendSessionAccept(); } else { - Log.d(Config.LOGTAG, "trying to fallback to something unknown" + packet.toString()); - respondToIq(packet, false); + Log.d( + Config.LOGTAG, + "not auto accepting new file offer with size: " + + file.size + + " allowed size:" + + this.jingleConnectionManager.getAutoAcceptFileSize()); + message.markUnread(); + this.xmppConnectionService.updateConversationUi(); + this.xmppConnectionService.getNotificationService().push(message); } - } else if (action == JinglePacket.Action.TRANSPORT_ACCEPT) { - receiveTransportAccept(packet); } else { - Log.d(Config.LOGTAG, "packet arrived in connection. action was " + packet.getAction()); - respondToIq(packet, false); + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + ": receive out of order session-initiate"); + receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.SESSION_INITIATE); } } - @Override - void notifyRebound() { - if (getJingleStatus() == JINGLE_STATUS_TRANSMITTING) { - abort(Reason.CONNECTIVITY_ERROR); + private void setFileOffer(final FileTransferDescription.File file) { + final AbstractConnectionManager.Extension extension = + AbstractConnectionManager.Extension.of(file.name); + if (VALID_CRYPTO_EXTENSIONS.contains(extension.main)) { + this.message.setEncryption(Message.ENCRYPTION_PGP); + } else { + this.message.setEncryption(Message.ENCRYPTION_NONE); } + final String ext = extension.getExtension(); + final String filename = + Strings.isNullOrEmpty(ext) + ? message.getUuid() + : String.format("%s.%s", message.getUuid(), ext); + xmppConnectionService.getFileBackend().setupRelativeFilePath(message, filename); } - private void respondToIq(final IqPacket packet, final boolean result) { - final IqPacket response; - if (result) { - response = packet.generateResponse(IqPacket.TYPE.RESULT); - } else { - response = packet.generateResponse(IqPacket.TYPE.ERROR); - final Element error = response.addChild("error").setAttribute("type", "cancel"); - error.addChild("not-acceptable", "urn:ietf:params:xml:ns:xmpp-stanzas"); + public void sendSessionAccept() { + final FileTransferContentMap contentMap = this.initiatorFileTransferContentMap; + final Transport transport; + try { + transport = setupTransport(contentMap.requireOnlyTransportInfo()); + } catch (final RuntimeException e) { + sendSessionTerminate(Reason.of(e), e.getMessage()); + return; } - xmppConnectionService.sendIqPacket(id.account, response, null); - } + transitionOrThrow(State.SESSION_ACCEPTED); + this.transport = transport; + this.transport.setTransportCallback(this); + if (this.transport instanceof WebRTCDataChannelTransport webRTCDataChannelTransport) { + final var sessionDescription = SessionDescription.of(contentMap); + webRTCDataChannelTransport.setInitiatorDescription(sessionDescription); + } + final var transportInfoFuture = transport.asTransportInfo(); + Futures.addCallback( + transportInfoFuture, + new FutureCallback<>() { + @Override + public void onSuccess(final Transport.TransportInfo transportInfo) { + final FileTransferContentMap responderContentMap = + contentMap.withTransport(transportInfo); + sendSessionAccept(responderContentMap); + } - private void respondToIqWithOutOfOrder(final IqPacket packet) { - final IqPacket response = packet.generateResponse(IqPacket.TYPE.ERROR); - final Element error = response.addChild("error").setAttribute("type", "wait"); - error.addChild("unexpected-request", "urn:ietf:params:xml:ns:xmpp-stanzas"); - error.addChild("out-of-order", "urn:xmpp:jingle:errors:1"); - xmppConnectionService.sendIqPacket(id.account, response, null); + @Override + public void onFailure(@NonNull Throwable throwable) { + failureToAcceptSession(throwable); + } + }, + MoreExecutors.directExecutor()); } - public void init(final Message message) { - Preconditions.checkArgument(message.isFileOrImage()); - if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) { - Conversation conversation = (Conversation) message.getConversation(); - conversation.getAccount().getAxolotlService().prepareKeyTransportMessage(conversation, xmppAxolotlMessage -> { - if (xmppAxolotlMessage != null) { - init(message, xmppAxolotlMessage); - } else { - fail(); - } - }); - } else { - init(message, null); + private void sendSessionAccept(final FileTransferContentMap contentMap) { + setLocalContentMap(contentMap); + final var jinglePacket = + contentMap.toJinglePacket(JinglePacket.Action.SESSION_ACCEPT, id.sessionId); + Log.d(Config.LOGTAG, "--> " + jinglePacket.toString()); + send(jinglePacket); + // this needs to come after session-accept or else our candidate-error might arrive first + this.transport.connect(); + this.transport.readyToSentAdditionalCandidates(); + if (this.transport instanceof WebRTCDataChannelTransport webRTCDataChannelTransport) { + drainPendingIncomingIceCandidates(webRTCDataChannelTransport); } } - private void init(final Message message, final XmppAxolotlMessage xmppAxolotlMessage) { - this.mXmppAxolotlMessage = xmppAxolotlMessage; - this.contentCreator = Content.Creator.INITIATOR; - this.contentSenders = Content.Senders.INITIATOR; - this.contentName = JingleConnectionManager.nextRandomId(); - this.message = message; - final List remoteFeatures = getRemoteFeatures(); - final FileTransferDescription.Version remoteVersion = getAvailableFileTransferVersion(remoteFeatures); - this.initialTransport = remoteFeatures.contains(Namespace.JINGLE_TRANSPORTS_S5B) ? S5BTransportInfo.class : IbbTransportInfo.class; - this.remoteSupportsOmemoJet = remoteFeatures.contains(Namespace.JINGLE_ENCRYPTED_TRANSPORT_OMEMO); - this.message.setTransferable(this); - this.mStatus = Transferable.STATUS_UPLOADING; - this.responder = this.id.with; - this.transportId = JingleConnectionManager.nextRandomId(); - this.setupDescription(remoteVersion); - if (this.initialTransport == IbbTransportInfo.class) { - this.sendInitRequest(); - } else { - gatherAndConnectDirectCandidates(); - this.jingleConnectionManager.getPrimaryCandidate(id.account, isInitiator(), (success, candidate) -> { - if (success) { - final JingleSocks5Transport socksConnection = new JingleSocks5Transport(this, candidate); - connections.put(candidate.getCid(), socksConnection); - socksConnection.connect(new OnTransportConnected() { - - @Override - public void failed() { - Log.d(Config.LOGTAG, String.format("connection to our own proxy65 candidate failed (%s:%d)", candidate.getHost(), candidate.getPort())); - sendInitRequest(); - } - - @Override - public void established() { - Log.d(Config.LOGTAG, "successfully connected to our own proxy65 candidate"); - mergeCandidate(candidate); - sendInitRequest(); - } - }); - mergeCandidate(candidate); - } else { - Log.d(Config.LOGTAG, "no proxy65 candidate of our own was found"); - sendInitRequest(); - } - }); + private void drainPendingIncomingIceCandidates( + final WebRTCDataChannelTransport webRTCDataChannelTransport) { + while (this.pendingIncomingIceCandidates.peek() != null) { + final var candidate = this.pendingIncomingIceCandidates.poll(); + if (candidate == null) { + continue; + } + webRTCDataChannelTransport.addIceCandidates(ImmutableList.of(candidate)); } - } - private void gatherAndConnectDirectCandidates() { - final List directCandidates; - if (Config.USE_DIRECT_JINGLE_CANDIDATES) { - if (id.account.isOnion() || xmppConnectionService.useTorToConnect()) { - directCandidates = Collections.emptyList(); - } else { - directCandidates = DirectConnectionUtils.getLocalCandidates(id.account.getJid()); + private Transport setupTransport(final GenericTransportInfo transportInfo) { + final XmppConnection xmppConnection = id.account.getXmppConnection(); + final boolean useTor = id.account.isOnion() || xmppConnectionService.useTorToConnect(); + if (transportInfo instanceof IbbTransportInfo ibbTransportInfo) { + final String streamId = ibbTransportInfo.getTransportId(); + final Long blockSize = ibbTransportInfo.getBlockSize(); + if (streamId == null || blockSize == null) { + throw new IllegalStateException("ibb transport is missing sid and/or block-size"); } + return new InbandBytestreamsTransport( + xmppConnection, + id.with, + isInitiator(), + streamId, + Ints.saturatedCast(blockSize)); + } else if (transportInfo + instanceof SocksByteStreamsTransportInfo socksBytestreamsTransportInfo) { + final String streamId = socksBytestreamsTransportInfo.getTransportId(); + final String destination = socksBytestreamsTransportInfo.getDestinationAddress(); + final List candidates = + socksBytestreamsTransportInfo.getCandidates(); + Log.d(Config.LOGTAG, "received socks candidates " + candidates); + return new SocksByteStreamsTransport( + xmppConnection, id, isInitiator(), useTor, streamId, candidates); + } else if (!useTor && transportInfo instanceof WebRTCDataChannelTransportInfo) { + return new WebRTCDataChannelTransport( + xmppConnectionService.getApplicationContext(), + xmppConnection, + id.account, + isInitiator()); } else { - directCandidates = Collections.emptyList(); - } - for (JingleCandidate directCandidate : directCandidates) { - final JingleSocks5Transport socksConnection = new JingleSocks5Transport(this, directCandidate); - connections.put(directCandidate.getCid(), socksConnection); - candidates.add(directCandidate); + throw new IllegalArgumentException("Do not know how to create transport"); } } - private FileTransferDescription.Version getAvailableFileTransferVersion(List remoteFeatures) { - if (remoteFeatures.contains(FileTransferDescription.Version.FT_5.getNamespace())) { - return FileTransferDescription.Version.FT_5; - } else if (remoteFeatures.contains(FileTransferDescription.Version.FT_4.getNamespace())) { - return FileTransferDescription.Version.FT_4; - } else { - return FileTransferDescription.Version.FT_3; + private Transport setupTransport() { + final XmppConnection xmppConnection = id.account.getXmppConnection(); + final boolean useTor = id.account.isOnion() || xmppConnectionService.useTorToConnect(); + if (!useTor && remoteHasFeature(Namespace.JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL)) { + return new WebRTCDataChannelTransport( + xmppConnectionService.getApplicationContext(), + xmppConnection, + id.account, + isInitiator()); } + if (remoteHasFeature(Namespace.JINGLE_TRANSPORTS_S5B)) { + return new SocksByteStreamsTransport(xmppConnection, id, isInitiator(), useTor); + } + return setupLastResortTransport(); } - private List getRemoteFeatures() { - final String resource = Strings.nullToEmpty(this.id.with.getResource()); - final Presence presence = this.id.account.getRoster().getContact(id.with).getPresences().get(resource); - final ServiceDiscoveryResult result = presence != null ? presence.getServiceDiscoveryResult() : null; - return result == null ? Collections.emptyList() : result.getFeatures(); + private Transport setupLastResortTransport() { + final XmppConnection xmppConnection = id.account.getXmppConnection(); + return new InbandBytestreamsTransport(xmppConnection, id.with, isInitiator()); } - private void init(JinglePacket packet) { //should move to deliverPacket - //TODO if not 'OFFERED' reply with out-of-order - this.mJingleStatus = JINGLE_STATUS_INITIATED; - final Conversation conversation = this.xmppConnectionService.findOrCreateConversation(id.account, id.with.asBareJid(), false, false); - this.message = new Message(conversation, "", Message.ENCRYPTION_NONE); - this.message.setStatus(Message.STATUS_RECEIVED); - this.mStatus = Transferable.STATUS_OFFER; - this.message.setTransferable(this); - this.message.setCounterpart(this.id.with); - this.responder = this.id.account.getJid(); - final Content content = packet.getJingleContent(); - final GenericTransportInfo transportInfo = content.getTransport(); - this.contentCreator = content.getCreator(); - Content.Senders senders; - try { - senders = content.getSenders(); - } catch (final Exception e) { - senders = Content.Senders.INITIATOR; - } - this.contentSenders = senders; - this.contentName = content.getAttribute("name"); - - if (transportInfo instanceof S5BTransportInfo) { - final S5BTransportInfo s5BTransportInfo = (S5BTransportInfo) transportInfo; - this.transportId = s5BTransportInfo.getTransportId(); - this.initialTransport = s5BTransportInfo.getClass(); - this.mergeCandidates(s5BTransportInfo.getCandidates()); - } else if (transportInfo instanceof IbbTransportInfo) { - final IbbTransportInfo ibbTransportInfo = (IbbTransportInfo) transportInfo; - this.initialTransport = ibbTransportInfo.getClass(); - this.transportId = ibbTransportInfo.getTransportId(); - final int remoteBlockSize = ibbTransportInfo.getBlockSize(); - if (remoteBlockSize <= 0) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": remote party requested invalid ibb block size"); - respondToIq(packet, false); - this.fail(); - } - this.ibbBlockSize = Math.min(MAX_IBB_BLOCK_SIZE, ibbTransportInfo.getBlockSize()); - } else { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": remote tried to use unknown transport " + transportInfo.getNamespace()); - respondToIq(packet, false); - this.fail(); + private void failureToAcceptSession(final Throwable throwable) { + if (isTerminated()) { return; } + final Throwable rootCause = Throwables.getRootCause(throwable); + Log.d(Config.LOGTAG, "unable to send session accept", rootCause); + sendSessionTerminate(Reason.ofThrowable(rootCause), rootCause.getMessage()); + } - this.description = (FileTransferDescription) content.getDescription(); + private void receiveSessionInfo(final JinglePacket jinglePacket) { + Log.d(Config.LOGTAG, "<-- " + jinglePacket); + respondOk(jinglePacket); + final var sessionInfo = FileTransferDescription.getSessionInfo(jinglePacket); + if (sessionInfo instanceof FileTransferDescription.Checksum checksum) { + receiveSessionInfoChecksum(checksum); + } else if (sessionInfo instanceof FileTransferDescription.Received received) { + receiveSessionInfoReceived(received); + } + } - final Element fileOffer = this.description.getFileOffer(); + private void receiveSessionInfoChecksum(final FileTransferDescription.Checksum checksum) { + Log.d(Config.LOGTAG, "received checksum " + checksum); + } - if (fileOffer != null) { - boolean remoteIsUsingJet = false; - Element encrypted = fileOffer.findChild("encrypted", AxolotlService.PEP_PREFIX); - if (encrypted == null) { - final Element security = content.findChild("security", Namespace.JINGLE_ENCRYPTED_TRANSPORT); - if (security != null && AxolotlService.PEP_PREFIX.equals(security.getAttribute("type"))) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received jingle file offer with JET"); - encrypted = security.findChild("encrypted", AxolotlService.PEP_PREFIX); - remoteIsUsingJet = true; - } - } - if (encrypted != null) { - this.mXmppAxolotlMessage = XmppAxolotlMessage.fromElement(encrypted, packet.getFrom().asBareJid()); - } - Element fileSize = fileOffer.findChild("size"); - final String path = fileOffer.findChildContent("name"); - if (path != null) { - AbstractConnectionManager.Extension extension = AbstractConnectionManager.Extension.of(path); - if (VALID_IMAGE_EXTENSIONS.contains(extension.main)) { - message.setType(Message.TYPE_IMAGE); - xmppConnectionService.getFileBackend().setupRelativeFilePath(message, message.getUuid() + "." + extension.main); - } else if (VALID_CRYPTO_EXTENSIONS.contains(extension.main)) { - if (VALID_IMAGE_EXTENSIONS.contains(extension.secondary)) { - message.setType(Message.TYPE_IMAGE); - xmppConnectionService.getFileBackend().setupRelativeFilePath(message,message.getUuid() + "." + extension.secondary); - } else { - message.setType(Message.TYPE_FILE); - xmppConnectionService.getFileBackend().setupRelativeFilePath(message,message.getUuid() + (extension.secondary != null ? ("." + extension.secondary) : "")); - } - message.setEncryption(Message.ENCRYPTION_PGP); - } else { - message.setType(Message.TYPE_FILE); - xmppConnectionService.getFileBackend().setupRelativeFilePath(message,message.getUuid() + (extension.main != null ? ("." + extension.main) : "")); - } - long size = parseLong(fileSize, 0); - Message.FileParams fp = new Message.FileParams(); - fp.size = new Long(size); - message.setFileParams(fp); - conversation.add(message); - jingleConnectionManager.updateConversationUi(true); - this.file = this.xmppConnectionService.getFileBackend().getFile(message, false); - if (mXmppAxolotlMessage != null) { - XmppAxolotlMessage.XmppAxolotlKeyTransportMessage transportMessage = id.account.getAxolotlService().processReceivingKeyTransportMessage(mXmppAxolotlMessage, false); - if (transportMessage != null) { - message.setEncryption(Message.ENCRYPTION_AXOLOTL); - this.file.setKey(transportMessage.getKey()); - this.file.setIv(transportMessage.getIv()); - message.setFingerprint(transportMessage.getFingerprint()); - } else { - Log.d(Config.LOGTAG, "could not process KeyTransportMessage"); - } - } - //legacy OMEMO encrypted file transfers reported the file size after encryption - //JET reports the plain text size. however lower levels of our receiving code still - //expect the cipher text size. so we just + 16 bytes (auth tag size) here - this.file.setExpectedSize(size + (remoteIsUsingJet ? 16 : 0)); - - respondToIq(packet, true); - - if (id.account.getRoster().getContact(id.with).showInContactList() - && jingleConnectionManager.hasStoragePermission() - && size < this.jingleConnectionManager.getAutoAcceptFileSize() - && xmppConnectionService.isDataSaverDisabled()) { - Log.d(Config.LOGTAG, "auto accepting file from " + id.with); - this.acceptedAutomatically = true; - this.sendAccept(); - } else { - message.markUnread(); - Log.d(Config.LOGTAG, - "not auto accepting new file offer with size: " - + size - + " allowed size:" - + this.jingleConnectionManager - .getAutoAcceptFileSize()); - this.xmppConnectionService.getNotificationService().push(message); - } - Log.d(Config.LOGTAG, "receiving file: expecting size of " + this.file.getExpectedSize()); - return; - } - respondToIq(packet, false); + private void receiveSessionInfoReceived(final FileTransferDescription.Received received) { + Log.d(Config.LOGTAG, "peer confirmed received " + received); + } + + private void receiveSessionTerminate(final JinglePacket jinglePacket) { + final JinglePacket.ReasonWrapper wrapper = jinglePacket.getReason(); + final State previous = this.state; + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + + ": received session terminate reason=" + + wrapper.reason + + "(" + + Strings.nullToEmpty(wrapper.text) + + ") while in state " + + previous); + if (TERMINATED.contains(previous)) { + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + + ": ignoring session terminate because already in " + + previous); + return; } + if (isInitiator()) { + this.message.setErrorMessage( + Strings.isNullOrEmpty(wrapper.text) ? wrapper.reason.toString() : wrapper.text); + } + terminateTransport(); + final State target = reasonToState(wrapper.reason); + transitionOrThrow(target); + finish(); } - private void setupDescription(final FileTransferDescription.Version version) { - this.file = this.xmppConnectionService.getFileBackend().getFile(message, false); - final FileTransferDescription description; - if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) { - this.file.setKey(mXmppAxolotlMessage.getInnerKey()); - this.file.setIv(mXmppAxolotlMessage.getIV()); - //legacy OMEMO encrypted file transfer reported file size of the encrypted file - //JET uses the file size of the plain text file. The difference is only 16 bytes (auth tag) - this.file.setExpectedSize(file.getSize() + (this.remoteSupportsOmemoJet ? 0 : 16)); - if (remoteSupportsOmemoJet) { - description = FileTransferDescription.of(this.file, version, null); - } else { - description = FileTransferDescription.of(this.file, version, this.mXmppAxolotlMessage); - } + private void receiveTransportAccept(final JinglePacket jinglePacket) { + if (isResponder()) { + receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.TRANSPORT_ACCEPT); + return; + } + Log.d(Config.LOGTAG, "receive transport accept " + jinglePacket); + final GenericTransportInfo transportInfo; + try { + transportInfo = FileTransferContentMap.of(jinglePacket).requireOnlyTransportInfo(); + } catch (final RuntimeException e) { + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + ": improperly formatted contents", + Throwables.getRootCause(e)); + respondOk(jinglePacket); + sendSessionTerminate(Reason.of(e), e.getMessage()); + return; + } + if (isInState(State.SESSION_ACCEPTED)) { + final var group = jinglePacket.getGroup(); + receiveTransportAccept(jinglePacket, new Transport.TransportInfo(transportInfo, group)); } else { - this.file.setExpectedSize(file.getSize()); - description = FileTransferDescription.of(this.file, version, null); + receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.TRANSPORT_ACCEPT); } - this.description = description; } - private void sendInitRequest() { - final JinglePacket packet = this.bootstrapPacket(JinglePacket.Action.SESSION_INITIATE); - final Content content = new Content(this.contentCreator, this.contentSenders, this.contentName); - if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL && remoteSupportsOmemoJet) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": remote announced support for JET"); - final Element security = new Element("security", Namespace.JINGLE_ENCRYPTED_TRANSPORT); - security.setAttribute("name", this.contentName); - security.setAttribute("cipher", JET_OMEMO_CIPHER); - security.setAttribute("type", AxolotlService.PEP_PREFIX); - security.addChild(mXmppAxolotlMessage.toElement()); - content.addChild(security); + private void receiveTransportAccept( + final JinglePacket jinglePacket, final Transport.TransportInfo transportInfo) { + final FileTransferContentMap remoteContentMap = + getRemoteContentMap().withTransport(transportInfo); + setRemoteContentMap(remoteContentMap); + respondOk(jinglePacket); + final var transport = this.transport; + if (configureTransportWithPeerInfo(transport, remoteContentMap)) { + transport.connect(); + } else { + Log.e( + Config.LOGTAG, + "Transport in transport-accept did not match our transport-replace"); + terminateTransport(); + sendSessionTerminate( + Reason.FAILED_APPLICATION, + "Transport in transport-accept did not match our transport-replace"); } - content.setDescription(this.description); + } + + private void receiveTransportInfo(final JinglePacket jinglePacket) { + final FileTransferContentMap contentMap; + final GenericTransportInfo transportInfo; try { - this.mFileInputStream = new FileInputStream(file); - } catch (FileNotFoundException e) { - fail(e.getMessage()); + contentMap = FileTransferContentMap.of(jinglePacket); + transportInfo = contentMap.requireOnlyTransportInfo(); + } catch (final RuntimeException e) { + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + ": improperly formatted contents", + Throwables.getRootCause(e)); + respondOk(jinglePacket); + sendSessionTerminate(Reason.of(e), e.getMessage()); return; } - if (this.initialTransport == IbbTransportInfo.class) { - content.setTransport(new IbbTransportInfo(this.transportId, this.ibbBlockSize)); - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": sending IBB offer"); + respondOk(jinglePacket); + final var transport = this.transport; + if (transport instanceof SocksByteStreamsTransport socksBytestreamsTransport + && transportInfo + instanceof SocksByteStreamsTransportInfo socksBytestreamsTransportInfo) { + receiveTransportInfo(socksBytestreamsTransport, socksBytestreamsTransportInfo); + } else if (transport instanceof WebRTCDataChannelTransport webRTCDataChannelTransport + && transportInfo + instanceof WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) { + receiveTransportInfo( + Iterables.getOnlyElement(contentMap.contents.keySet()), + webRTCDataChannelTransport, + webRTCDataChannelTransportInfo); + } else if (transportInfo + instanceof WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) { + receiveTransportInfo( + Iterables.getOnlyElement(contentMap.contents.keySet()), + webRTCDataChannelTransportInfo); } else { - final Collection candidates = getOurCandidates(); - content.setTransport(new S5BTransportInfo(this.transportId, candidates)); - Log.d(Config.LOGTAG, String.format("%s: sending S5B offer with %d candidates", id.account.getJid().asBareJid(), candidates.size())); - } - packet.addJingleContent(content); - this.sendJinglePacket(packet, (account, response) -> { - if (response.getType() == IqPacket.TYPE.RESULT) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": other party received offer"); - if (mJingleStatus == JINGLE_STATUS_OFFERED) { - mJingleStatus = JINGLE_STATUS_INITIATED; - xmppConnectionService.markMessage(message, Message.STATUS_OFFERED); - } else { - Log.d(Config.LOGTAG, "received ack for offer when status was " + mJingleStatus); - } - } else { - fail(IqParser.extractErrorMessage(response)); - } - }); - + Log.d(Config.LOGTAG, "could not deliver transport-info to transport"); + } } - private void sendHash() { - final Element checksum = new Element("checksum", description.getVersion().getNamespace()); - checksum.setAttribute("creator", "initiator"); - checksum.setAttribute("name", "a-file-offer"); - Element hash = checksum.addChild("file").addChild("hash", "urn:xmpp:hashes:2"); - hash.setAttribute("algo", "sha-1").setContent(Base64.encodeToString(file.getSha1Sum(), Base64.NO_WRAP)); + private void receiveTransportInfo( + final String contentName, + final WebRTCDataChannelTransport webRTCDataChannelTransport, + final WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) { + final var credentials = webRTCDataChannelTransportInfo.getCredentials(); + final var iceCandidates = + WebRTCDataChannelTransport.iceCandidatesOf( + contentName, credentials, webRTCDataChannelTransportInfo.getCandidates()); + final var localContentMap = getLocalContentMap(); + if (localContentMap == null) { + Log.d(Config.LOGTAG, "transport not ready. add pending ice candidate"); + this.pendingIncomingIceCandidates.addAll(iceCandidates); + } else { + webRTCDataChannelTransport.addIceCandidates(iceCandidates); + } + } - final JinglePacket packet = this.bootstrapPacket(JinglePacket.Action.SESSION_INFO); - packet.addJingleChild(checksum); - xmppConnectionService.sendIqPacket(id.account, packet, (account, response) -> { - if (response.getType() == IqPacket.TYPE.ERROR) { - Log.d(Config.LOGTAG,account.getJid().asBareJid()+": ignoring error response to our session-info (hash transmission)"); - } - }); + private void receiveTransportInfo( + final String contentName, + final WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) { + final var credentials = webRTCDataChannelTransportInfo.getCredentials(); + final var iceCandidates = + WebRTCDataChannelTransport.iceCandidatesOf( + contentName, credentials, webRTCDataChannelTransportInfo.getCandidates()); + this.pendingIncomingIceCandidates.addAll(iceCandidates); } - private Collection getOurCandidates() { - return Collections2.filter(this.candidates, c -> c != null && c.isOurs()); + private void receiveTransportInfo( + final SocksByteStreamsTransport socksBytestreamsTransport, + final SocksByteStreamsTransportInfo socksBytestreamsTransportInfo) { + final var transportInfo = socksBytestreamsTransportInfo.getTransportInfo(); + if (transportInfo instanceof SocksByteStreamsTransportInfo.CandidateError) { + socksBytestreamsTransport.setCandidateError(); + } else if (transportInfo + instanceof SocksByteStreamsTransportInfo.CandidateUsed candidateUsed) { + if (!socksBytestreamsTransport.setCandidateUsed(candidateUsed.cid)) { + sendSessionTerminate( + Reason.FAILED_TRANSPORT, + String.format( + "Peer is not connected to our candidate %s", candidateUsed.cid)); + } + } else if (transportInfo instanceof SocksByteStreamsTransportInfo.Activated activated) { + socksBytestreamsTransport.setProxyActivated(activated.cid); + } else if (transportInfo instanceof SocksByteStreamsTransportInfo.ProxyError) { + socksBytestreamsTransport.setProxyError(); + } } - private void sendAccept() { - mJingleStatus = JINGLE_STATUS_ACCEPTED; - this.mStatus = Transferable.STATUS_DOWNLOADING; - this.jingleConnectionManager.updateConversationUi(true); - if (initialTransport == S5BTransportInfo.class) { - sendAcceptSocks(); + private void receiveTransportReplace(final JinglePacket jinglePacket) { + if (isInitiator()) { + receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.TRANSPORT_REPLACE); + return; + } + Log.d(Config.LOGTAG, "receive transport replace " + jinglePacket); + final GenericTransportInfo transportInfo; + try { + transportInfo = FileTransferContentMap.of(jinglePacket).requireOnlyTransportInfo(); + } catch (final RuntimeException e) { + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + ": improperly formatted contents", + Throwables.getRootCause(e)); + respondOk(jinglePacket); + sendSessionTerminate(Reason.of(e), e.getMessage()); + return; + } + if (isInState(State.SESSION_ACCEPTED)) { + receiveTransportReplace(jinglePacket, transportInfo); } else { - sendAcceptIbb(); + receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.TRANSPORT_REPLACE); } } - private void sendAcceptSocks() { - gatherAndConnectDirectCandidates(); - this.jingleConnectionManager.getPrimaryCandidate(this.id.account, isInitiator(), (success, candidate) -> { - final JinglePacket packet = bootstrapPacket(JinglePacket.Action.SESSION_ACCEPT); - final Content content = new Content(contentCreator, contentSenders, contentName); - content.setDescription(this.description); - if (success && candidate != null && !equalCandidateExists(candidate)) { - final JingleSocks5Transport socksConnection = new JingleSocks5Transport(this, candidate); - connections.put(candidate.getCid(), socksConnection); - socksConnection.connect(new OnTransportConnected() { - + private void receiveTransportReplace( + final JinglePacket jinglePacket, final GenericTransportInfo transportInfo) { + respondOk(jinglePacket); + final Transport transport; + try { + transport = setupTransport(transportInfo); + } catch (final RuntimeException e) { + sendSessionTerminate(Reason.of(e), e.getMessage()); + return; + } + this.transport = transport; + this.transport.setTransportCallback(this); + final var transportInfoFuture = transport.asTransportInfo(); + Futures.addCallback( + transportInfoFuture, + new FutureCallback<>() { @Override - public void failed() { - Log.d(Config.LOGTAG, "connection to our own proxy65 candidate failed"); - content.setTransport(new S5BTransportInfo(transportId, getOurCandidates())); - packet.addJingleContent(content); - sendJinglePacket(packet); - connectNextCandidate(); + public void onSuccess(final Transport.TransportInfo transportWrapper) { + final FileTransferContentMap contentMap = + getLocalContentMap().withTransport(transportWrapper); + sendTransportAccept(contentMap); } @Override - public void established() { - Log.d(Config.LOGTAG, "connected to proxy65 candidate"); - mergeCandidate(candidate); - content.setTransport(new S5BTransportInfo(transportId, getOurCandidates())); - packet.addJingleContent(content); - sendJinglePacket(packet); - connectNextCandidate(); + public void onFailure(@NonNull Throwable throwable) { + // transition into application failed (analogues to failureToAccept } - }); - } else { - Log.d(Config.LOGTAG, "did not find a proxy65 candidate for ourselves"); - content.setTransport(new S5BTransportInfo(transportId, getOurCandidates())); - packet.addJingleContent(content); - sendJinglePacket(packet); - connectNextCandidate(); - } - }); + }, + MoreExecutors.directExecutor()); + } + + private void sendTransportAccept(final FileTransferContentMap contentMap) { + setLocalContentMap(contentMap); + final var jinglePacket = + contentMap + .transportInfo() + .toJinglePacket(JinglePacket.Action.TRANSPORT_ACCEPT, id.sessionId); + Log.d(Config.LOGTAG, "sending transport accept " + jinglePacket); + send(jinglePacket); + transport.connect(); + } + + protected void sendSessionTerminate(final Reason reason, final String text) { + if (isInitiator()) { + this.message.setErrorMessage(Strings.isNullOrEmpty(text) ? reason.toString() : text); + } + sendSessionTerminate(reason, text, null); + } + + private FileTransferContentMap getLocalContentMap() { + return isInitiator() + ? this.initiatorFileTransferContentMap + : this.responderFileTransferContentMap; } - private void sendAcceptIbb() { - this.transport = new JingleInBandTransport(this, this.transportId, this.ibbBlockSize); - final JinglePacket packet = bootstrapPacket(JinglePacket.Action.SESSION_ACCEPT); - final Content content = new Content(contentCreator, contentSenders, contentName); - content.setDescription(this.description); - content.setTransport(new IbbTransportInfo(this.transportId, this.ibbBlockSize)); - packet.addJingleContent(content); - this.transport.receive(file, onFileTransmissionStatusChanged); - this.sendJinglePacket(packet); + private FileTransferContentMap getRemoteContentMap() { + return isInitiator() + ? this.responderFileTransferContentMap + : this.initiatorFileTransferContentMap; } - private JinglePacket bootstrapPacket(JinglePacket.Action action) { - final JinglePacket packet = new JinglePacket(action, this.id.sessionId); - packet.setTo(id.with); - return packet; + private void setLocalContentMap(final FileTransferContentMap contentMap) { + if (isInitiator()) { + this.initiatorFileTransferContentMap = contentMap; + } else { + this.responderFileTransferContentMap = contentMap; + } } - private void sendJinglePacket(JinglePacket packet) { - xmppConnectionService.sendIqPacket(id.account, packet, responseListener); + private void setRemoteContentMap(final FileTransferContentMap contentMap) { + if (isInitiator()) { + this.responderFileTransferContentMap = contentMap; + } else { + this.initiatorFileTransferContentMap = contentMap; + } } - private void sendJinglePacket(JinglePacket packet, OnIqPacketReceived callback) { - xmppConnectionService.sendIqPacket(id.account, packet, callback); + public Transport getTransport() { + return this.transport; } - private void receiveAccept(JinglePacket packet) { - if (responding()) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received out of order session-accept (we were responding)"); - respondToIqWithOutOfOrder(packet); + @Override + protected void terminateTransport() { + final var transport = this.transport; + if (transport == null) { return; } - if (this.mJingleStatus != JINGLE_STATUS_INITIATED) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received out of order session-accept"); - respondToIqWithOutOfOrder(packet); + transport.terminate(); + this.transport = null; + } + + @Override + void notifyRebound() {} + + @Override + public void onTransportEstablished() { + Log.d(Config.LOGTAG, "on transport established"); + final AbstractFileTransceiver fileTransceiver; + try { + fileTransceiver = setupTransceiver(isResponder()); + } catch (final Exception e) { + Log.d(Config.LOGTAG, "failed to set up file transceiver", e); + sendSessionTerminate(Reason.ofThrowable(e), e.getMessage()); return; } - this.mJingleStatus = JINGLE_STATUS_ACCEPTED; - xmppConnectionService.markMessage(message, Message.STATUS_UNSEND); - final Content content = packet.getJingleContent(); - final GenericTransportInfo transportInfo = content.getTransport(); - //TODO we want to fail if transportInfo doesn’t match our intialTransport and/or our id - if (transportInfo instanceof S5BTransportInfo) { - final S5BTransportInfo s5BTransportInfo = (S5BTransportInfo) transportInfo; - respondToIq(packet, true); - //TODO calling merge is probably a bug because that might eliminate candidates of the other party and lead to us not sending accept/deny - //TODO: we probably just want to call add - mergeCandidates(s5BTransportInfo.getCandidates()); - this.connectNextCandidate(); - } else if (transportInfo instanceof IbbTransportInfo) { - final IbbTransportInfo ibbTransportInfo = (IbbTransportInfo) transportInfo; - final int remoteBlockSize = ibbTransportInfo.getBlockSize(); - if (remoteBlockSize > 0) { - this.ibbBlockSize = Math.min(ibbBlockSize, remoteBlockSize); - } - respondToIq(packet, true); - this.transport = new JingleInBandTransport(this, this.transportId, this.ibbBlockSize); - this.transport.connect(onIbbTransportConnected); + this.fileTransceiver = fileTransceiver; + final var fileTransceiverThread = new Thread(fileTransceiver); + fileTransceiverThread.start(); + Futures.addCallback( + fileTransceiver.complete, + new FutureCallback<>() { + @Override + public void onSuccess(final List hashes) { + onFileTransmissionComplete(hashes); + } + + @Override + public void onFailure(@NonNull Throwable throwable) { + onFileTransmissionFailed(throwable); + } + }, + MoreExecutors.directExecutor()); + } + + private void onFileTransmissionComplete(final List hashes) { + // TODO if we ever support receiving files this should become isSending(); isReceiving() + if (isInitiator()) { + sendSessionInfoChecksum(hashes); } else { - respondToIq(packet, false); + Log.d(Config.LOGTAG, "file transfer complete " + hashes); + sendFileSessionInfoReceived(); + terminateTransport(); + messageReceivedSuccess(); + sendSessionTerminate(Reason.SUCCESS, null); } } - private void receiveTransportInfo(JinglePacket packet) { - final Content content = packet.getJingleContent(); - final GenericTransportInfo transportInfo = content.getTransport(); - if (transportInfo instanceof S5BTransportInfo) { - final S5BTransportInfo s5BTransportInfo = (S5BTransportInfo) transportInfo; - if (s5BTransportInfo.hasChild("activated")) { - respondToIq(packet, true); - if ((this.transport != null) && (this.transport instanceof JingleSocks5Transport)) { - onProxyActivated.success(); - } else { - String cid = s5BTransportInfo.findChild("activated").getAttribute("cid"); - Log.d(Config.LOGTAG, "received proxy activated (" + cid - + ")prior to choosing our own transport"); - JingleSocks5Transport connection = this.connections.get(cid); - if (connection != null) { - connection.setActivated(true); - } else { - Log.d(Config.LOGTAG, "activated connection not found"); - sendSessionTerminate(Reason.FAILED_TRANSPORT); - this.fail(); - } - } - } else if (s5BTransportInfo.hasChild("proxy-error")) { - respondToIq(packet, true); - onProxyActivated.failed(); - } else if (s5BTransportInfo.hasChild("candidate-error")) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received candidate error"); - respondToIq(packet, true); - this.receivedCandidate = true; - if (mJingleStatus == JINGLE_STATUS_ACCEPTED && this.sentCandidate) { - this.connect(); - } - } else if (s5BTransportInfo.hasChild("candidate-used")) { - String cid = s5BTransportInfo.findChild("candidate-used").getAttribute("cid"); - if (cid != null) { - Log.d(Config.LOGTAG, "candidate used by counterpart:" + cid); - JingleCandidate candidate = getCandidate(cid); - if (candidate == null) { - Log.d(Config.LOGTAG, "could not find candidate with cid=" + cid); - respondToIq(packet, false); - return; - } - respondToIq(packet, true); - candidate.flagAsUsedByCounterpart(); - this.receivedCandidate = true; - if (mJingleStatus == JINGLE_STATUS_ACCEPTED && this.sentCandidate) { - this.connect(); - } else { - Log.d(Config.LOGTAG, "ignoring because file is already in transmission or we haven't sent our candidate yet status=" + mJingleStatus + " sentCandidate=" + sentCandidate); - } - } else { - respondToIq(packet, false); - } + private void messageReceivedSuccess() { + this.message.setTransferable(null); + xmppConnectionService.getFileBackend().updateFileParams(message); + xmppConnectionService.databaseBackend.createMessage(message); + final File file = xmppConnectionService.getFileBackend().getFile(message); + if (acceptedAutomatically) { + message.markUnread(); + if (message.getEncryption() == Message.ENCRYPTION_PGP) { + id.account.getPgpDecryptionService().decrypt(message, true); } else { - respondToIq(packet, false); + xmppConnectionService + .getFileBackend() + .updateMediaScanner( + file, + () -> + JingleFileTransferConnection.this + .xmppConnectionService + .getNotificationService() + .push(message)); } + } else if (message.getEncryption() == Message.ENCRYPTION_PGP) { + id.account.getPgpDecryptionService().decrypt(message, false); } else { - respondToIq(packet, true); + xmppConnectionService.getFileBackend().updateMediaScanner(file); } } - private void connect() { - final JingleSocks5Transport connection = chooseConnection(); - this.transport = connection; - if (connection == null) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": could not find suitable candidate"); - this.disconnectSocks5Connections(); - if (isInitiator()) { - this.sendFallbackToIbb(); - } + private void onFileTransmissionFailed(final Throwable throwable) { + if (isTerminated()) { + Log.d( + Config.LOGTAG, + "file transfer failed but session is already terminated", + throwable); } else { - //TODO at this point we can already close other connections to free some resources - final JingleCandidate candidate = connection.getCandidate(); - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": elected candidate " + candidate.toString()); - this.mJingleStatus = JINGLE_STATUS_TRANSMITTING; - if (connection.needsActivation()) { - if (connection.getCandidate().isOurs()) { - final String sid; - if (description.getVersion() == FileTransferDescription.Version.FT_3) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": use session ID instead of transport ID to activate proxy"); - sid = id.sessionId; - } else { - sid = getTransportId(); - } - Log.d(Config.LOGTAG, "candidate " - + connection.getCandidate().getCid() - + " was our proxy. going to activate"); - IqPacket activation = new IqPacket(IqPacket.TYPE.SET); - activation.setTo(connection.getCandidate().getJid()); - activation.query("http://jabber.org/protocol/bytestreams") - .setAttribute("sid", sid); - activation.query().addChild("activate") - .setContent(this.id.with.toEscapedString()); - xmppConnectionService.sendIqPacket(this.id.account, activation, (account, response) -> { - if (response.getType() != IqPacket.TYPE.RESULT) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": " + response.toString()); - sendProxyError(); - onProxyActivated.failed(); - } else { - sendProxyActivated(connection.getCandidate().getCid()); - onProxyActivated.success(); - } - }); - } else { - Log.d(Config.LOGTAG, - "candidate " - + connection.getCandidate().getCid() - + " was a proxy. waiting for other party to activate"); - } - } else { - if (isInitiator()) { - Log.d(Config.LOGTAG, "we were initiating. sending file"); - connection.send(file, onFileTransmissionStatusChanged); - } else { - Log.d(Config.LOGTAG, "we were responding. receiving file"); - connection.receive(file, onFileTransmissionStatusChanged); - } - } + terminateTransport(); + Log.d(Config.LOGTAG, "on file transmission failed", throwable); + sendSessionTerminate(Reason.CONNECTIVITY_ERROR, null); } } - private JingleSocks5Transport chooseConnection() { - final List establishedConnections = FluentIterable.from(connections.entrySet()) - .transform(Entry::getValue) - .filter(c -> (c != null && c.isEstablished() && (c.getCandidate().isUsedByCounterpart() || !c.getCandidate().isOurs()))) - .toSortedList((a, b) -> { - final int compare = Integer.compare(b.getCandidate().getPriority(), a.getCandidate().getPriority()); - if (compare == 0) { - if (isInitiator()) { - //pick the one we sent a candidate-used for (meaning not ours) - return a.getCandidate().isOurs() ? 1 : -1; - } else { - //pick the one they sent a candidate-used for (meaning ours) - return a.getCandidate().isOurs() ? -1 : 1; - } - } - return compare; - }); - return Iterables.getFirst(establishedConnections, null); + private AbstractFileTransceiver setupTransceiver(final boolean receiving) throws IOException { + final var fileDescription = getLocalContentMap().requireOnlyFile(); + final File file = xmppConnectionService.getFileBackend().getFile(message); + final Runnable updateRunnable = () -> jingleConnectionManager.updateConversationUi(false); + if (receiving) { + return new FileReceiver( + file, + this.transportSecurity, + transport.getInputStream(), + transport.getTerminationLatch(), + fileDescription.size, + updateRunnable); + } else { + return new FileTransmitter( + file, + this.transportSecurity, + transport.getOutputStream(), + transport.getTerminationLatch(), + fileDescription.size, + updateRunnable); + } } - private void sendSuccess() { - sendSessionTerminate(Reason.SUCCESS); - this.disconnectSocks5Connections(); - this.mJingleStatus = JINGLE_STATUS_FINISHED; - this.message.setStatus(Message.STATUS_RECEIVED); - this.message.setTransferable(null); - this.xmppConnectionService.updateMessage(message, false); - this.jingleConnectionManager.finishConnection(this); + private void sendFileSessionInfoReceived() { + final var contentMap = getLocalContentMap(); + final String name = Iterables.getOnlyElement(contentMap.contents.keySet()); + sendSessionInfo(new FileTransferDescription.Received(name)); } - private void sendFallbackToIbb() { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": sending fallback to ibb"); - final JinglePacket packet = this.bootstrapPacket(JinglePacket.Action.TRANSPORT_REPLACE); - final Content content = new Content(this.contentCreator, this.contentSenders, this.contentName); - this.transportId = JingleConnectionManager.nextRandomId(); - content.setTransport(new IbbTransportInfo(this.transportId, this.ibbBlockSize)); - packet.addJingleContent(content); - this.sendJinglePacket(packet); + private void sendSessionInfoChecksum(List hashes) { + final var contentMap = getLocalContentMap(); + final String name = Iterables.getOnlyElement(contentMap.contents.keySet()); + sendSessionInfo(new FileTransferDescription.Checksum(name, hashes)); } + private void sendSessionInfo(final FileTransferDescription.SessionInfo sessionInfo) { + final var jinglePacket = + new JinglePacket(JinglePacket.Action.SESSION_INFO, this.id.sessionId); + jinglePacket.addJingleChild(sessionInfo.asElement()); + jinglePacket.setTo(this.id.with); + Log.d(Config.LOGTAG, "--> " + jinglePacket); + send(jinglePacket); + } - private void receiveFallbackToIbb(final JinglePacket packet, final IbbTransportInfo transportInfo) { - if (isInitiator()) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received out of order transport-replace (we were initiating)"); - respondToIqWithOutOfOrder(packet); + @Override + public void onTransportSetupFailed() { + final var transport = this.transport; + if (transport == null) { + // this really is not supposed to happen + sendSessionTerminate(Reason.FAILED_APPLICATION, null); return; } - final boolean validState = mJingleStatus == JINGLE_STATUS_ACCEPTED || (proxyActivationFailed && mJingleStatus == JINGLE_STATUS_TRANSMITTING); - if (!validState) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received out of order transport-replace"); - respondToIqWithOutOfOrder(packet); + Log.d(Config.LOGTAG, "onTransportSetupFailed"); + final var isTransportInBand = transport instanceof InbandBytestreamsTransport; + if (isTransportInBand) { + terminateTransport(); + sendSessionTerminate(Reason.CONNECTIVITY_ERROR, "Failed to setup IBB transport"); return; } - this.proxyActivationFailed = false; //fallback received; now we no longer need to accept another one; - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": receiving fallback to ibb"); - final int remoteBlockSize = transportInfo.getBlockSize(); - if (remoteBlockSize > 0) { - this.ibbBlockSize = Math.min(MAX_IBB_BLOCK_SIZE, remoteBlockSize); + // terminate the current transport + transport.terminate(); + if (isInitiator()) { + this.transport = setupLastResortTransport(); + this.transport.setTransportCallback(this); + final var transportInfoFuture = this.transport.asTransportInfo(); + Futures.addCallback( + transportInfoFuture, + new FutureCallback<>() { + @Override + public void onSuccess(final Transport.TransportInfo transportWrapper) { + final FileTransferContentMap contentMap = getLocalContentMap(); + sendTransportReplace(contentMap.withTransport(transportWrapper)); + } + + @Override + public void onFailure(@NonNull Throwable throwable) { + // TODO send application failure; + } + }, + MoreExecutors.directExecutor()); + } else { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to parse block size in transport-replace"); + Log.d(Config.LOGTAG, "transport setup failed. waiting for initiator to replace"); } - this.transportId = transportInfo.getTransportId(); //TODO: handle the case where this is null by the remote party - this.transport = new JingleInBandTransport(this, this.transportId, this.ibbBlockSize); - - final JinglePacket answer = bootstrapPacket(JinglePacket.Action.TRANSPORT_ACCEPT); + } - final Content content = new Content(contentCreator, contentSenders, contentName); - content.setTransport(new IbbTransportInfo(this.transportId, this.ibbBlockSize)); - answer.addJingleContent(content); + private void sendTransportReplace(final FileTransferContentMap contentMap) { + setLocalContentMap(contentMap); + final var jinglePacket = + contentMap + .transportInfo() + .toJinglePacket(JinglePacket.Action.TRANSPORT_REPLACE, id.sessionId); + Log.d(Config.LOGTAG, "sending transport replace " + jinglePacket); + send(jinglePacket); + } - respondToIq(packet, true); + @Override + public void onAdditionalCandidate( + final String contentName, final Transport.Candidate candidate) { + if (candidate instanceof IceUdpTransportInfo.Candidate iceCandidate) { + sendTransportInfo(contentName, iceCandidate); + } + } - if (isInitiator()) { - this.sendJinglePacket(answer, (account, response) -> { - if (response.getType() == IqPacket.TYPE.RESULT) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + " recipient ACKed our transport-accept. creating ibb"); - transport.connect(onIbbTransportConnected); - } - }); - } else { - this.transport.receive(file, onFileTransmissionStatusChanged); - this.sendJinglePacket(answer); + public void sendTransportInfo( + final String contentName, final IceUdpTransportInfo.Candidate candidate) { + final FileTransferContentMap transportInfo; + try { + final FileTransferContentMap rtpContentMap = getLocalContentMap(); + transportInfo = rtpContentMap.transportInfo(contentName, candidate); + } catch (final Exception e) { + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + + ": unable to prepare transport-info from candidate for content=" + + contentName); + return; } + final JinglePacket jinglePacket = + transportInfo.toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId); + Log.d(Config.LOGTAG, "--> " + jinglePacket); + send(jinglePacket); } - private void receiveTransportAccept(JinglePacket packet) { - if (responding()) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received out of order transport-accept (we were responding)"); - respondToIqWithOutOfOrder(packet); + @Override + public void onCandidateUsed( + final String streamId, final SocksByteStreamsTransport.Candidate candidate) { + final FileTransferContentMap contentMap = getLocalContentMap(); + if (contentMap == null) { + Log.e(Config.LOGTAG, "local content map is null on candidate used"); return; } - final boolean validState = mJingleStatus == JINGLE_STATUS_ACCEPTED || (proxyActivationFailed && mJingleStatus == JINGLE_STATUS_TRANSMITTING); - if (!validState) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received out of order transport-accept"); - respondToIqWithOutOfOrder(packet); + final var jinglePacket = + contentMap + .candidateUsed(streamId, candidate.cid) + .toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId); + Log.d(Config.LOGTAG, "sending candidate used " + jinglePacket); + send(jinglePacket); + } + + @Override + public void onCandidateError(final String streamId) { + final FileTransferContentMap contentMap = getLocalContentMap(); + if (contentMap == null) { + Log.e(Config.LOGTAG, "local content map is null on candidate used"); return; } - this.proxyActivationFailed = false; //fallback accepted; now we no longer need to accept another one; - final Content content = packet.getJingleContent(); - final GenericTransportInfo transportInfo = content == null ? null : content.getTransport(); - if (transportInfo instanceof IbbTransportInfo) { - final IbbTransportInfo ibbTransportInfo = (IbbTransportInfo) transportInfo; - final int remoteBlockSize = ibbTransportInfo.getBlockSize(); - if (remoteBlockSize > 0) { - this.ibbBlockSize = Math.min(MAX_IBB_BLOCK_SIZE, remoteBlockSize); - } - final String sid = ibbTransportInfo.getTransportId(); - this.transport = new JingleInBandTransport(this, this.transportId, this.ibbBlockSize); + final var jinglePacket = + contentMap + .candidateError(streamId) + .toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId); + Log.d(Config.LOGTAG, "sending candidate error " + jinglePacket); + send(jinglePacket); + } - if (sid == null || !sid.equals(this.transportId)) { - Log.w(Config.LOGTAG, String.format("%s: sid in transport-accept (%s) did not match our sid (%s) ", id.account.getJid().asBareJid(), sid, transportId)); - } - respondToIq(packet, true); - //might be receive instead if we are not initiating - if (isInitiator()) { - this.transport.connect(onIbbTransportConnected); - } - } else { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received invalid transport-accept"); - respondToIq(packet, false); + @Override + public void onProxyActivated(String streamId, SocksByteStreamsTransport.Candidate candidate) { + final FileTransferContentMap contentMap = getLocalContentMap(); + if (contentMap == null) { + Log.e(Config.LOGTAG, "local content map is null on candidate used"); + return; } + final var jinglePacket = + contentMap + .proxyActivated(streamId, candidate.cid) + .toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId); + send(jinglePacket); } - private void receiveSuccess() { - if (isInitiator()) { - this.mJingleStatus = JINGLE_STATUS_FINISHED; - this.xmppConnectionService.markMessage(this.message, Message.STATUS_SEND_RECEIVED); - this.disconnectSocks5Connections(); - if (this.transport instanceof JingleInBandTransport) { - this.transport.disconnect(); + @Override + protected boolean transition(final State target, final Runnable runnable) { + final boolean transitioned = super.transition(target, runnable); + if (transitioned && isInitiator()) { + Log.d(Config.LOGTAG, "running mark message hooks"); + if (target == State.SESSION_ACCEPTED) { + xmppConnectionService.markMessage(message, Message.STATUS_UNSEND); + } else if (target == State.TERMINATED_SUCCESS) { + xmppConnectionService.markMessage(message, Message.STATUS_SEND_RECEIVED); + } else if (TERMINATED.contains(target)) { + xmppConnectionService.markMessage( + message, Message.STATUS_SEND_FAILED, message.getErrorMessage()); + } else { + xmppConnectionService.updateConversationUi(); } - this.message.setTransferable(null); - this.jingleConnectionManager.finishConnection(this); } else { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received session-terminate/success while responding"); + if (Arrays.asList(State.TERMINATED_CANCEL_OR_TIMEOUT, State.TERMINATED_DECLINED_OR_BUSY) + .contains(target)) { + this.message.setTransferable( + new TransferablePlaceholder(Transferable.STATUS_CANCELLED)); + } else if (target != State.TERMINATED_SUCCESS && TERMINATED.contains(target)) { + this.message.setTransferable( + new TransferablePlaceholder(Transferable.STATUS_FAILED)); + } + xmppConnectionService.updateConversationUi(); } + return transitioned; } @Override - public void cancel() { - this.cancelled = true; - abort(Reason.CANCEL); - } - - private void abort(final Reason reason) { - this.disconnectSocks5Connections(); - if (this.transport instanceof JingleInBandTransport) { - this.transport.disconnect(); + protected void finish() { + if (transport != null) { + throw new AssertionError( + "finish MUST not be called without terminating the transport first"); } - sendSessionTerminate(reason); - this.jingleConnectionManager.finishConnection(this); - if (responding()) { - this.message.setTransferable(new TransferablePlaceholder(cancelled ? Transferable.STATUS_CANCELLED : Transferable.STATUS_FAILED)); - if (this.file != null) { - file.delete(); - } - this.jingleConnectionManager.updateConversationUi(true); - } else { - this.xmppConnectionService.markMessage(this.message, Message.STATUS_SEND_FAILED, cancelled ? Message.ERROR_MESSAGE_CANCELLED : null); + // we don't want to remove TransferablePlaceholder + if (message.getTransferable() instanceof JingleFileTransferConnection) { + Log.d(Config.LOGTAG, "nulling transferable on message"); this.message.setTransferable(null); } + super.finish(); } - private void fail() { - fail(null); - } - - private void fail(String errorMessage) { - this.mJingleStatus = JINGLE_STATUS_FAILED; - this.disconnectSocks5Connections(); - if (this.transport instanceof JingleInBandTransport) { - this.transport.disconnect(); - } - FileBackend.close(mFileInputStream); - FileBackend.close(mFileOutputStream); - if (this.message != null) { - if (responding()) { - this.message.setTransferable(new TransferablePlaceholder(cancelled ? Transferable.STATUS_CANCELLED : Transferable.STATUS_FAILED)); - if (this.file != null) { - file.delete(); - } - this.jingleConnectionManager.updateConversationUi(true); - } else { - this.xmppConnectionService.markMessage(this.message, - Message.STATUS_SEND_FAILED, - cancelled ? Message.ERROR_MESSAGE_CANCELLED : errorMessage); - this.message.setTransferable(null); - } + private int getTransferableStatus() { + // status in file transfer is a bit weird. for sending it is mostly handled via + // Message.STATUS_* (offered, unsend (sic) send_received) the transferable status is just + // uploading + // for receiving the message status remains at 'received' but Transferable goes through + // various status + if (isInitiator()) { + return Transferable.STATUS_UPLOADING; } - this.jingleConnectionManager.finishConnection(this); + final var state = getState(); + return switch (state) { + case NULL, SESSION_INITIALIZED, SESSION_INITIALIZED_PRE_APPROVED -> Transferable + .STATUS_OFFER; + case TERMINATED_APPLICATION_FAILURE, + TERMINATED_CONNECTIVITY_ERROR, + TERMINATED_DECLINED_OR_BUSY, + TERMINATED_SECURITY_ERROR -> Transferable.STATUS_FAILED; + case TERMINATED_CANCEL_OR_TIMEOUT -> Transferable.STATUS_CANCELLED; + case SESSION_ACCEPTED -> Transferable.STATUS_DOWNLOADING; + default -> Transferable.STATUS_UNKNOWN; + }; } - private void sendSessionTerminate(Reason reason) { - final JinglePacket packet = bootstrapPacket(JinglePacket.Action.SESSION_TERMINATE); - packet.setReason(reason, null); - this.sendJinglePacket(packet); - } + // these methods are for interacting with 'Transferable' - we might want to remove the concept + // at some point - private void connectNextCandidate() { - for (JingleCandidate candidate : this.candidates) { - if ((!connections.containsKey(candidate.getCid()) && (!candidate - .isOurs()))) { - this.connectWithCandidate(candidate); - return; - } + @Override + public boolean start() { + Log.d(Config.LOGTAG, "user pressed start()"); + // TODO there is a 'connected' check apparently? + if (isInState(State.SESSION_INITIALIZED)) { + sendSessionAccept(); } - this.sendCandidateError(); + return true; } - private void connectWithCandidate(final JingleCandidate candidate) { - final JingleSocks5Transport socksConnection = new JingleSocks5Transport( - this, candidate); - connections.put(candidate.getCid(), socksConnection); - socksConnection.connect(new OnTransportConnected() { - - @Override - public void failed() { - Log.d(Config.LOGTAG, - "connection failed with " + candidate.getHost() + ":" - + candidate.getPort()); - connectNextCandidate(); - } - - @Override - public void established() { - Log.d(Config.LOGTAG, - "established connection with " + candidate.getHost() - + ":" + candidate.getPort()); - sendCandidateUsed(candidate.getCid()); - } - }); + @Override + public int getStatus() { + return getTransferableStatus(); } - private void disconnectSocks5Connections() { - Iterator> it = this.connections - .entrySet().iterator(); - while (it.hasNext()) { - Entry pairs = it.next(); - pairs.getValue().disconnect(); - it.remove(); + @Override + public Long getFileSize() { + final var transceiver = this.fileTransceiver; + if (transceiver != null) { + return transceiver.total; + } + final var contentMap = this.initiatorFileTransferContentMap; + if (contentMap != null) { + return contentMap.requireOnlyFile().size; } + return null; } - private void sendProxyActivated(String cid) { - final JinglePacket packet = bootstrapPacket(JinglePacket.Action.TRANSPORT_INFO); - final Content content = new Content(this.contentCreator, this.contentSenders, this.contentName); - content.setTransport(new S5BTransportInfo(this.transportId, new Element("activated").setAttribute("cid", cid))); - packet.addJingleContent(content); - this.sendJinglePacket(packet); + @Override + public int getProgress() { + final var transceiver = this.fileTransceiver; + return transceiver != null ? transceiver.getProgress() : 0; } - private void sendProxyError() { - final JinglePacket packet = bootstrapPacket(JinglePacket.Action.TRANSPORT_INFO); - final Content content = new Content(this.contentCreator, this.contentSenders, this.contentName); - content.setTransport(new S5BTransportInfo(this.transportId, new Element("proxy-error"))); - packet.addJingleContent(content); - this.sendJinglePacket(packet); + @Override + public void cancel() { + if (stopFileTransfer()) { + Log.d(Config.LOGTAG, "user has stopped file transfer"); + } else { + Log.d(Config.LOGTAG, "user pressed cancel but file transfer was already terminated?"); + } } - private void sendCandidateUsed(final String cid) { - JinglePacket packet = bootstrapPacket(JinglePacket.Action.TRANSPORT_INFO); - final Content content = new Content(this.contentCreator, this.contentSenders, this.contentName); - content.setTransport(new S5BTransportInfo(this.transportId, new Element("candidate-used").setAttribute("cid", cid))); - packet.addJingleContent(content); - this.sentCandidate = true; - if ((receivedCandidate) && (mJingleStatus == JINGLE_STATUS_ACCEPTED)) { - connect(); + private boolean stopFileTransfer() { + if (isInitiator()) { + return stopFileTransfer(Reason.CANCEL); + } else { + return stopFileTransfer(Reason.DECLINE); } - this.sendJinglePacket(packet); } - private void sendCandidateError() { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": sending candidate error"); - JinglePacket packet = bootstrapPacket(JinglePacket.Action.TRANSPORT_INFO); - Content content = new Content(this.contentCreator, this.contentSenders, this.contentName); - content.setTransport(new S5BTransportInfo(this.transportId, new Element("candidate-error"))); - packet.addJingleContent(content); - this.sentCandidate = true; - this.sendJinglePacket(packet); - if (receivedCandidate && mJingleStatus == JINGLE_STATUS_ACCEPTED) { - connect(); + private boolean stopFileTransfer(final Reason reason) { + final State target = reasonToState(reason); + if (transition(target)) { + // we change state before terminating transport so we don't consume the following + // IOException and turn it into a connectivity error + terminateTransport(); + final JinglePacket jinglePacket = + new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId); + jinglePacket.setReason(reason, "User requested to stop file transfer"); + send(jinglePacket); + finish(); + return true; + } else { + return false; } } - private int getJingleStatus() { - return this.mJingleStatus; - } + private abstract static class AbstractFileTransceiver implements Runnable { + + protected final SettableFuture> complete = + SettableFuture.create(); + + protected final File file; + protected final TransportSecurity transportSecurity; + + protected final CountDownLatch transportTerminationLatch; + protected final long total; + protected long transmitted = 0; + private int progress = Integer.MIN_VALUE; + private final Runnable updateRunnable; + + private AbstractFileTransceiver( + final File file, + final TransportSecurity transportSecurity, + final CountDownLatch transportTerminationLatch, + final long total, + final Runnable updateRunnable) { + this.file = file; + this.transportSecurity = transportSecurity; + this.transportTerminationLatch = transportTerminationLatch; + this.total = transportSecurity == null ? total : (total + 16); + this.updateRunnable = updateRunnable; + } - private boolean equalCandidateExists(JingleCandidate candidate) { - for (JingleCandidate c : this.candidates) { - if (c.equalValues(candidate)) { - return true; + static void closeTransport(final Closeable stream) { + try { + stream.close(); + } catch (final IOException e) { + Log.d(Config.LOGTAG, "transport has already been closed. good"); } } - return false; - } - private void mergeCandidate(JingleCandidate candidate) { - for (JingleCandidate c : this.candidates) { - if (c.equals(candidate)) { - return; - } + public int getProgress() { + return Ints.saturatedCast(Math.round((1.0 * transmitted / total) * 100)); } - this.candidates.add(candidate); - } - private void mergeCandidates(List candidates) { - Collections.sort(candidates, (a, b) -> Integer.compare(b.getPriority(), a.getPriority())); - for (JingleCandidate c : candidates) { - mergeCandidate(c); + public void updateProgress() { + final int current = getProgress(); + final boolean update; + synchronized (this) { + if (this.progress != current) { + this.progress = current; + update = true; + } else { + update = false; + } + if (update) { + this.updateRunnable.run(); + } + } } - } - private JingleCandidate getCandidate(String cid) { - for (JingleCandidate c : this.candidates) { - if (c.getCid().equals(cid)) { - return c; + protected void awaitTransportTermination() { + try { + this.transportTerminationLatch.await(); + } catch (final InterruptedException ignored) { + return; } + Log.d(Config.LOGTAG, getClass().getSimpleName() + " says Goodbye!"); } - return null; } - void updateProgress(int i) { - this.mProgress = i; - jingleConnectionManager.updateConversationUi(false); - } + private static class FileTransmitter extends AbstractFileTransceiver { - String getTransportId() { - return this.transportId; - } + private final OutputStream outputStream; - FileTransferDescription.Version getFtVersion() { - return this.description.getVersion(); - } + private FileTransmitter( + final File file, + final TransportSecurity transportSecurity, + final OutputStream outputStream, + final CountDownLatch transportTerminationLatch, + final long total, + final Runnable updateRunnable) { + super(file, transportSecurity, transportTerminationLatch, total, updateRunnable); + this.outputStream = outputStream; + } - public JingleTransport getTransport() { - return this.transport; - } + private InputStream openFileInputStream() throws FileNotFoundException { + final var fileInputStream = new FileInputStream(this.file); + if (this.transportSecurity == null) { + return fileInputStream; + } else { + final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine()); + cipher.init( + true, + new AEADParameters( + new KeyParameter(transportSecurity.key), + 128, + transportSecurity.iv)); + Log.d(Config.LOGTAG, "setting up CipherInputStream"); + return new CipherInputStream(fileInputStream, cipher); + } + } - public boolean start() { - if (id.account.getStatus() == Account.State.ONLINE) { - if (mJingleStatus == JINGLE_STATUS_INITIATED) { - new Thread(this::sendAccept).start(); + @Override + public void run() { + Log.d(Config.LOGTAG, "file transmitter attempting to send " + total + " bytes"); + final var sha1Hasher = Hashing.sha1().newHasher(); + final var sha256Hasher = Hashing.sha256().newHasher(); + try (final var fileInputStream = openFileInputStream()) { + final var buffer = new byte[4096]; + while (total - transmitted > 0) { + final int count = fileInputStream.read(buffer); + if (count == -1) { + throw new EOFException( + String.format("reached EOF after %d/%d", transmitted, total)); + } + outputStream.write(buffer, 0, count); + sha1Hasher.putBytes(buffer, 0, count); + sha256Hasher.putBytes(buffer, 0, count); + transmitted += count; + updateProgress(); + } + outputStream.flush(); + Log.d( + Config.LOGTAG, + "transmitted " + transmitted + " bytes from " + file.getAbsolutePath()); + final List hashes = + ImmutableList.of( + new FileTransferDescription.Hash( + sha1Hasher.hash().asBytes(), + FileTransferDescription.Algorithm.SHA_1), + new FileTransferDescription.Hash( + sha256Hasher.hash().asBytes(), + FileTransferDescription.Algorithm.SHA_256)); + complete.set(hashes); + } catch (final Exception e) { + complete.setException(e); } - return true; - } else { - return false; + // the transport implementations backed by PipedOutputStreams do not like it when + // the writing Thread (this thread) goes away. so we just wait until the other peer + // has received our file and we are shutting down the transport + Log.d(Config.LOGTAG, "waiting for transport to terminate before stopping thread"); + awaitTransportTermination(); + closeTransport(outputStream); } } - @Override - public int getStatus() { - return this.mStatus; - } + private static class FileReceiver extends AbstractFileTransceiver { - @Override - public Long getFileSize() { - if (this.file != null) { - return this.file.getExpectedSize(); - } else { - return null; + private final InputStream inputStream; + + private FileReceiver( + final File file, + final TransportSecurity transportSecurity, + final InputStream inputStream, + final CountDownLatch transportTerminationLatch, + final long total, + final Runnable updateRunnable) { + super(file, transportSecurity, transportTerminationLatch, total, updateRunnable); + this.inputStream = inputStream; } - } - @Override - public int getProgress() { - return this.mProgress; - } + private OutputStream openFileOutputStream() throws FileNotFoundException { + final var directory = this.file.getParentFile(); + if (directory != null && directory.mkdirs()) { + Log.d(Config.LOGTAG, "created directory " + directory.getAbsolutePath()); + } + final var fileOutputStream = new FileOutputStream(this.file); + if (this.transportSecurity == null) { + return fileOutputStream; + } else { + final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine()); + cipher.init( + false, + new AEADParameters( + new KeyParameter(transportSecurity.key), + 128, + transportSecurity.iv)); + Log.d(Config.LOGTAG, "setting up CipherOutputStream"); + return new CipherOutputStream(fileOutputStream, cipher); + } + } - AbstractConnectionManager getConnectionManager() { - return this.jingleConnectionManager; + @Override + public void run() { + Log.d(Config.LOGTAG, "file receiver attempting to receive " + total + " bytes"); + final var sha1Hasher = Hashing.sha1().newHasher(); + final var sha256Hasher = Hashing.sha256().newHasher(); + try (final var fileOutputStream = openFileOutputStream()) { + final var buffer = new byte[4096]; + while (total - transmitted > 0) { + final int count = inputStream.read(buffer); + if (count == -1) { + throw new EOFException( + String.format("reached EOF after %d/%d", transmitted, total)); + } + fileOutputStream.write(buffer, 0, count); + sha1Hasher.putBytes(buffer, 0, count); + sha256Hasher.putBytes(buffer, 0, count); + transmitted += count; + updateProgress(); + } + Log.d( + Config.LOGTAG, + "written " + transmitted + " bytes to " + file.getAbsolutePath()); + final List hashes = + ImmutableList.of( + new FileTransferDescription.Hash( + sha1Hasher.hash().asBytes(), + FileTransferDescription.Algorithm.SHA_1), + new FileTransferDescription.Hash( + sha256Hasher.hash().asBytes(), + FileTransferDescription.Algorithm.SHA_256)); + complete.set(hashes); + } catch (final Exception e) { + complete.setException(e); + } + Log.d(Config.LOGTAG, "waiting for transport to terminate before stopping thread"); + awaitTransportTermination(); + closeTransport(inputStream); + } } - interface OnProxyActivated { - void success(); + private static final class TransportSecurity { + final byte[] key; + final byte[] iv; - void failed(); + private TransportSecurity(byte[] key, byte[] iv) { + this.key = key; + this.iv = iv; + } } } diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleInBandTransport.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleInBandTransport.java deleted file mode 100644 index c68941928f9939a8b248a4674eae9b959784bb34..0000000000000000000000000000000000000000 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleInBandTransport.java +++ /dev/null @@ -1,265 +0,0 @@ -package eu.siacs.conversations.xmpp.jingle; - -import android.util.Base64; -import android.util.Log; - -import com.google.common.base.Preconditions; - -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Arrays; - -import eu.siacs.conversations.Config; -import eu.siacs.conversations.entities.Account; -import eu.siacs.conversations.entities.DownloadableFile; -import eu.siacs.conversations.persistance.FileBackend; -import eu.siacs.conversations.services.AbstractConnectionManager; -import eu.siacs.conversations.xml.Element; -import eu.siacs.conversations.xmpp.Jid; -import eu.siacs.conversations.xmpp.OnIqPacketReceived; -import eu.siacs.conversations.xmpp.stanzas.IqPacket; - -public class JingleInBandTransport extends JingleTransport { - - private final Account account; - private final Jid counterpart; - private final int blockSize; - private int seq = 0; - private final String sessionId; - - private boolean established = false; - - private boolean connected = true; - - private DownloadableFile file; - private final JingleFileTransferConnection connection; - - private InputStream fileInputStream = null; - private InputStream innerInputStream = null; - private OutputStream fileOutputStream = null; - private long remainingSize = 0; - private long fileSize = 0; - private MessageDigest digest; - - private OnFileTransmissionStatusChanged onFileTransmissionStatusChanged; - - private final OnIqPacketReceived onAckReceived = new OnIqPacketReceived() { - @Override - public void onIqPacketReceived(Account account, IqPacket packet) { - if (!connected) { - return; - } - if (packet.getType() == IqPacket.TYPE.RESULT) { - if (remainingSize > 0) { - sendNextBlock(); - } - } else if (packet.getType() == IqPacket.TYPE.ERROR) { - onFileTransmissionStatusChanged.onFileTransferAborted(); - } - } - }; - - JingleInBandTransport(final JingleFileTransferConnection connection, final String sid, final int blockSize) { - this.connection = connection; - this.account = connection.getId().account; - this.counterpart = connection.getId().with; - this.blockSize = blockSize; - this.sessionId = sid; - } - - private void sendClose() { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": sending ibb close"); - IqPacket iq = new IqPacket(IqPacket.TYPE.SET); - iq.setTo(this.counterpart); - Element close = iq.addChild("close", "http://jabber.org/protocol/ibb"); - close.setAttribute("sid", this.sessionId); - this.account.getXmppConnection().sendIqPacket(iq, null); - } - - public boolean matches(final Account account, final String sessionId) { - return this.account == account && this.sessionId.equals(sessionId); - } - - public void connect(final OnTransportConnected callback) { - IqPacket iq = new IqPacket(IqPacket.TYPE.SET); - iq.setTo(this.counterpart); - Element open = iq.addChild("open", "http://jabber.org/protocol/ibb"); - open.setAttribute("sid", this.sessionId); - open.setAttribute("stanza", "iq"); - open.setAttribute("block-size", Integer.toString(this.blockSize)); - this.connected = true; - this.account.getXmppConnection().sendIqPacket(iq, (account, packet) -> { - if (packet.getType() != IqPacket.TYPE.RESULT) { - callback.failed(); - } else { - callback.established(); - } - }); - } - - @Override - public void receive(DownloadableFile file, OnFileTransmissionStatusChanged callback) { - this.onFileTransmissionStatusChanged = Preconditions.checkNotNull(callback); - this.file = file; - try { - this.digest = MessageDigest.getInstance("SHA-1"); - digest.reset(); - this.fileOutputStream = connection.getFileOutputStream(); - if (this.fileOutputStream == null) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": could not create output stream"); - callback.onFileTransferAborted(); - return; - } - this.remainingSize = this.fileSize = file.getExpectedSize(); - } catch (final NoSuchAlgorithmException | IOException e) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + " " + e.getMessage()); - callback.onFileTransferAborted(); - } - } - - @Override - public void send(DownloadableFile file, OnFileTransmissionStatusChanged callback) { - this.onFileTransmissionStatusChanged = Preconditions.checkNotNull(callback); - this.file = file; - try { - this.remainingSize = this.file.getExpectedSize(); - this.fileSize = this.remainingSize; - this.digest = MessageDigest.getInstance("SHA-1"); - this.digest.reset(); - fileInputStream = connection.getFileInputStream(); - if (fileInputStream == null) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": could no create input stream"); - callback.onFileTransferAborted(); - return; - } - innerInputStream = AbstractConnectionManager.upgrade(file, fileInputStream); - if (this.connected) { - this.sendNextBlock(); - } - } catch (Exception e) { - callback.onFileTransferAborted(); - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": " + e.getMessage()); - } - } - - @Override - public void disconnect() { - this.connected = false; - FileBackend.close(fileOutputStream); - FileBackend.close(fileInputStream); - } - - private void sendNextBlock() { - byte[] buffer = new byte[this.blockSize]; - try { - int count = innerInputStream.read(buffer); - if (count == -1) { - sendClose(); - file.setSha1Sum(digest.digest()); - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": sendNextBlock() count was -1"); - this.onFileTransmissionStatusChanged.onFileTransmitted(file); - fileInputStream.close(); - return; - } else if (count != buffer.length) { - int rem = innerInputStream.read(buffer, count, buffer.length - count); - if (rem > 0) { - count += rem; - } - } - this.remainingSize -= count; - this.digest.update(buffer, 0, count); - String base64 = Base64.encodeToString(buffer, 0, count, Base64.NO_WRAP); - IqPacket iq = new IqPacket(IqPacket.TYPE.SET); - iq.setTo(this.counterpart); - Element data = iq.addChild("data", "http://jabber.org/protocol/ibb"); - data.setAttribute("seq", Integer.toString(this.seq)); - data.setAttribute("block-size", Integer.toString(this.blockSize)); - data.setAttribute("sid", this.sessionId); - data.setContent(base64); - this.account.getXmppConnection().sendIqPacket(iq, this.onAckReceived); - this.account.getXmppConnection().r(); //don't fill up stanza queue too much - this.seq++; - connection.updateProgress((int) ((((double) (this.fileSize - this.remainingSize)) / this.fileSize) * 100)); - if (this.remainingSize <= 0) { - file.setSha1Sum(digest.digest()); - this.onFileTransmissionStatusChanged.onFileTransmitted(file); - sendClose(); - fileInputStream.close(); - } - } catch (IOException e) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": io exception during sendNextBlock() " + e.getMessage()); - FileBackend.close(fileInputStream); - this.onFileTransmissionStatusChanged.onFileTransferAborted(); - } - } - - private void receiveNextBlock(String data) { - try { - byte[] buffer = Base64.decode(data, Base64.NO_WRAP); - if (this.remainingSize < buffer.length) { - buffer = Arrays.copyOfRange(buffer, 0, (int) this.remainingSize); - } - this.remainingSize -= buffer.length; - this.fileOutputStream.write(buffer); - this.digest.update(buffer); - if (this.remainingSize <= 0) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": received last block. waiting for close"); - } else { - connection.updateProgress((int) ((((double) (this.fileSize - this.remainingSize)) / this.fileSize) * 100)); - } - } catch (Exception e) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": " + e.getMessage(), e); - FileBackend.close(fileOutputStream); - this.onFileTransmissionStatusChanged.onFileTransferAborted(); - } - } - - private void done() { - try { - file.setSha1Sum(digest.digest()); - fileOutputStream.flush(); - fileOutputStream.close(); - this.onFileTransmissionStatusChanged.onFileTransmitted(file); - } catch (Exception e) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": " + e.getMessage()); - FileBackend.close(fileOutputStream); - this.onFileTransmissionStatusChanged.onFileTransferAborted(); - } - } - - void deliverPayload(IqPacket packet, Element payload) { - if (payload.getName().equals("open")) { - if (!established) { - established = true; - connected = true; - this.receiveNextBlock(""); - this.account.getXmppConnection().sendIqPacket( - packet.generateResponse(IqPacket.TYPE.RESULT), null); - } else { - this.account.getXmppConnection().sendIqPacket( - packet.generateResponse(IqPacket.TYPE.ERROR), null); - } - } else if (connected && payload.getName().equals("data")) { - this.receiveNextBlock(payload.getContent()); - this.account.getXmppConnection().sendIqPacket( - packet.generateResponse(IqPacket.TYPE.RESULT), null); - } else if (connected && payload.getName().equals("close")) { - this.connected = false; - this.account.getXmppConnection().sendIqPacket( - packet.generateResponse(IqPacket.TYPE.RESULT), null); - if (this.remainingSize <= 0) { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": received ibb close. done"); - done(); - } else { - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": received ibb close with " + this.remainingSize + " remaining"); - FileBackend.close(fileOutputStream); - this.onFileTransmissionStatusChanged.onFileTransferAborted(); - } - } else { - this.account.getXmppConnection().sendIqPacket(packet.generateResponse(IqPacket.TYPE.ERROR), null); - } - } -} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java index cb59b385caf2cb35def9036229ac68ce73e54533..d5e70d8a793016d0c74aa79528e22fe481926d54 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java @@ -14,7 +14,6 @@ import com.google.common.base.Strings; import com.google.common.base.Throwables; import com.google.common.collect.Collections2; import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableMultimap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; @@ -31,14 +30,10 @@ import eu.siacs.conversations.Config; import eu.siacs.conversations.crypto.axolotl.AxolotlService; import eu.siacs.conversations.crypto.axolotl.CryptoFailedException; import eu.siacs.conversations.crypto.axolotl.FingerprintStatus; -import eu.siacs.conversations.entities.Account; -import eu.siacs.conversations.entities.Contact; import eu.siacs.conversations.entities.Conversation; import eu.siacs.conversations.entities.Conversational; import eu.siacs.conversations.entities.Message; -import eu.siacs.conversations.entities.Presence; import eu.siacs.conversations.entities.RtpSessionStatus; -import eu.siacs.conversations.entities.ServiceDiscoveryResult; import eu.siacs.conversations.services.AppRTCAudioManager; import eu.siacs.conversations.utils.IP; import eu.siacs.conversations.xml.Element; @@ -82,96 +77,13 @@ public class JingleRtpConnection extends AbstractJingleConnection Arrays.asList( State.PROCEED, State.SESSION_INITIALIZED_PRE_APPROVED, State.SESSION_ACCEPTED); private static final long BUSY_TIME_OUT = 30; - private static final List TERMINATED = - Arrays.asList( - State.ACCEPTED, - State.REJECTED, - State.REJECTED_RACED, - State.RETRACTED, - State.RETRACTED_RACED, - State.TERMINATED_SUCCESS, - State.TERMINATED_DECLINED_OR_BUSY, - State.TERMINATED_CONNECTIVITY_ERROR, - State.TERMINATED_CANCEL_OR_TIMEOUT, - State.TERMINATED_APPLICATION_FAILURE, - State.TERMINATED_SECURITY_ERROR); - - private static final Map> VALID_TRANSITIONS; - - static { - final ImmutableMap.Builder> transitionBuilder = - new ImmutableMap.Builder<>(); - transitionBuilder.put( - State.NULL, - ImmutableList.of( - State.PROPOSED, - State.SESSION_INITIALIZED, - State.TERMINATED_APPLICATION_FAILURE, - State.TERMINATED_SECURITY_ERROR)); - transitionBuilder.put( - State.PROPOSED, - ImmutableList.of( - State.ACCEPTED, - State.PROCEED, - State.REJECTED, - State.RETRACTED, - State.TERMINATED_APPLICATION_FAILURE, - State.TERMINATED_SECURITY_ERROR, - State.TERMINATED_CONNECTIVITY_ERROR // only used when the xmpp connection - // rebinds - )); - transitionBuilder.put( - State.PROCEED, - ImmutableList.of( - State.REJECTED_RACED, - State.RETRACTED_RACED, - State.SESSION_INITIALIZED_PRE_APPROVED, - State.TERMINATED_SUCCESS, - State.TERMINATED_APPLICATION_FAILURE, - State.TERMINATED_SECURITY_ERROR, - State.TERMINATED_CONNECTIVITY_ERROR // at this state used for error - // bounces of the proceed message - )); - transitionBuilder.put( - State.SESSION_INITIALIZED, - ImmutableList.of( - State.SESSION_ACCEPTED, - State.TERMINATED_SUCCESS, - State.TERMINATED_DECLINED_OR_BUSY, - State.TERMINATED_CONNECTIVITY_ERROR, // at this state used for IQ errors - // and IQ timeouts - State.TERMINATED_CANCEL_OR_TIMEOUT, - State.TERMINATED_APPLICATION_FAILURE, - State.TERMINATED_SECURITY_ERROR)); - transitionBuilder.put( - State.SESSION_INITIALIZED_PRE_APPROVED, - ImmutableList.of( - State.SESSION_ACCEPTED, - State.TERMINATED_SUCCESS, - State.TERMINATED_DECLINED_OR_BUSY, - State.TERMINATED_CONNECTIVITY_ERROR, // at this state used for IQ errors - // and IQ timeouts - State.TERMINATED_CANCEL_OR_TIMEOUT, - State.TERMINATED_APPLICATION_FAILURE, - State.TERMINATED_SECURITY_ERROR)); - transitionBuilder.put( - State.SESSION_ACCEPTED, - ImmutableList.of( - State.TERMINATED_SUCCESS, - State.TERMINATED_DECLINED_OR_BUSY, - State.TERMINATED_CONNECTIVITY_ERROR, - State.TERMINATED_CANCEL_OR_TIMEOUT, - State.TERMINATED_APPLICATION_FAILURE, - State.TERMINATED_SECURITY_ERROR)); - VALID_TRANSITIONS = transitionBuilder.build(); - } private final WebRTCWrapper webRTCWrapper = new WebRTCWrapper(this); - private final Queue> + private final Queue>> pendingIceCandidates = new LinkedList<>(); private final OmemoVerification omemoVerification = new OmemoVerification(); private final Message message; - private State state = State.NULL; + private Set proposedMedia; private RtpContentMap initiatorRtpContentMap; private RtpContentMap responderRtpContentMap; @@ -197,18 +109,6 @@ public class JingleRtpConnection extends AbstractJingleConnection id.sessionId); } - private static State reasonToState(Reason reason) { - return switch (reason) { - case SUCCESS -> State.TERMINATED_SUCCESS; - case DECLINE, BUSY -> State.TERMINATED_DECLINED_OR_BUSY; - case CANCEL, TIMEOUT -> State.TERMINATED_CANCEL_OR_TIMEOUT; - case SECURITY_ERROR -> State.TERMINATED_SECURITY_ERROR; - case FAILED_APPLICATION, UNSUPPORTED_TRANSPORTS, UNSUPPORTED_APPLICATIONS -> State - .TERMINATED_APPLICATION_FAILURE; - default -> State.TERMINATED_CONNECTIVITY_ERROR; - }; - } - @Override synchronized void deliverPacket(final JinglePacket jinglePacket) { switch (jinglePacket.getAction()) { @@ -238,7 +138,7 @@ public class JingleRtpConnection extends AbstractJingleConnection return; } webRTCWrapper.close(); - if (!isInitiator() && isInState(State.PROPOSED, State.SESSION_INITIALIZED)) { + if (isResponder() && isInState(State.PROPOSED, State.SESSION_INITIALIZED)) { xmppConnectionService.getNotificationService().cancelIncomingCallNotification(); } if (isInState( @@ -331,7 +231,7 @@ public class JingleRtpConnection extends AbstractJingleConnection private void receiveTransportInfo( final JinglePacket jinglePacket, final RtpContentMap contentMap) { - final Set> candidates = + final Set>> candidates = contentMap.contents.entrySet(); final RtpContentMap remote = getRemoteContentMap(); final Set remoteContentIds = @@ -531,7 +431,7 @@ public class JingleRtpConnection extends AbstractJingleConnection setRemoteContentMap(modifiedContentMap); - final SessionDescription answer = SessionDescription.of(modifiedContentMap, !isInitiator()); + final SessionDescription answer = SessionDescription.of(modifiedContentMap, isResponder()); final org.webrtc.SessionDescription sdp = new org.webrtc.SessionDescription( @@ -605,7 +505,7 @@ public class JingleRtpConnection extends AbstractJingleConnection } final SessionDescription offer; try { - offer = SessionDescription.of(modifiedRemoteContentMap, !isInitiator()); + offer = SessionDescription.of(modifiedRemoteContentMap, isResponder()); } catch (final IllegalArgumentException | NullPointerException e) { Log.d( Config.LOGTAG, @@ -824,7 +724,7 @@ public class JingleRtpConnection extends AbstractJingleConnection final RtpContentMap nextRemote = currentRemote.addContent( patch.modifiedSenders(Content.Senders.NONE), getPeerDtlsSetup()); - return SessionDescription.of(nextRemote, !isInitiator()); + return SessionDescription.of(nextRemote, isResponder()); } throw new IllegalStateException( "Unexpected rollback condition. Senders were not uniformly none"); @@ -890,7 +790,7 @@ public class JingleRtpConnection extends AbstractJingleConnection final SessionDescription offer; try { - offer = SessionDescription.of(modifiedContentMap, !isInitiator()); + offer = SessionDescription.of(modifiedContentMap, isResponder()); } catch (final IllegalArgumentException | NullPointerException e) { Log.d( Config.LOGTAG, @@ -1075,7 +975,7 @@ public class JingleRtpConnection extends AbstractJingleConnection final boolean isOffer) throws ExecutionException, InterruptedException { final SessionDescription sessionDescription = - SessionDescription.of(restartContentMap, !isInitiator()); + SessionDescription.of(restartContentMap, isResponder()); final org.webrtc.SessionDescription.Type type = isOffer ? org.webrtc.SessionDescription.Type.OFFER @@ -1104,14 +1004,14 @@ public class JingleRtpConnection extends AbstractJingleConnection } private void processCandidates( - final Set> contents) { - for (final Map.Entry content : contents) { + final Set>> contents) { + for (final Map.Entry> content : contents) { processCandidate(content); } } private void processCandidate( - final Map.Entry content) { + final Map.Entry> content) { final RtpContentMap rtpContentMap = getRemoteContentMap(); final List indices = toIdentificationTags(rtpContentMap); final String sdpMid = content.getKey(); // aka content name @@ -1213,21 +1113,7 @@ public class JingleRtpConnection extends AbstractJingleConnection private void receiveSessionInitiate(final JinglePacket jinglePacket) { if (isInitiator()) { - Log.d( - Config.LOGTAG, - String.format( - "%s: received session-initiate even though we were initiating", - id.account.getJid().asBareJid())); - if (isTerminated()) { - Log.d( - Config.LOGTAG, - String.format( - "%s: got a reason to terminate with out-of-order. but already in state %s", - id.account.getJid().asBareJid(), getState())); - respondWithOutOfOrder(jinglePacket); - } else { - terminateWithOutOfOrder(jinglePacket); - } + receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.SESSION_INITIATE); return; } final ListenableFuture future = receiveRtpContentMap(jinglePacket, false); @@ -1309,13 +1195,8 @@ public class JingleRtpConnection extends AbstractJingleConnection } private void receiveSessionAccept(final JinglePacket jinglePacket) { - if (!isInitiator()) { - Log.d( - Config.LOGTAG, - String.format( - "%s: received session-accept even though we were responding", - id.account.getJid().asBareJid())); - terminateWithOutOfOrder(jinglePacket); + if (isResponder()) { + receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.SESSION_ACCEPT); return; } final ListenableFuture future = @@ -1500,7 +1381,7 @@ public class JingleRtpConnection extends AbstractJingleConnection } private void addIceCandidatesFromBlackLog() { - Map.Entry foo; + Map.Entry> foo; while ((foo = this.pendingIceCandidates.poll()) != null) { processCandidate(foo); Log.d( @@ -1537,11 +1418,10 @@ public class JingleRtpConnection extends AbstractJingleConnection + candidates.size() + " candidates in session accept"); sendSessionAccept(outgoingContentMap.withCandidates(candidates)); - webRTCWrapper.resetPendingCandidates(); } else { sendSessionAccept(outgoingContentMap); - webRTCWrapper.setIsReadyToReceiveIceCandidates(true); } + webRTCWrapper.setIsReadyToReceiveIceCandidates(true); } @Override @@ -2003,11 +1883,10 @@ public class JingleRtpConnection extends AbstractJingleConnection + " candidates in session initiate"); sendSessionInitiate( outgoingContentMap.withCandidates(candidates), targetState); - webRTCWrapper.resetPendingCandidates(); } else { sendSessionInitiate(outgoingContentMap, targetState); - webRTCWrapper.setIsReadyToReceiveIceCandidates(true); } + webRTCWrapper.setIsReadyToReceiveIceCandidates(true); } @Override @@ -2072,24 +1951,16 @@ public class JingleRtpConnection extends AbstractJingleConnection } } - private void sendSessionTerminate(final Reason reason) { + protected void sendSessionTerminate(final Reason reason) { sendSessionTerminate(reason, null); } - private void sendSessionTerminate(final Reason reason, final String text) { - final State previous = this.state; - final State target = reasonToState(reason); - transitionOrThrow(target); - if (previous != State.NULL) { - writeLogMessage(target); - } - final JinglePacket jinglePacket = - new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId); - jinglePacket.setReason(reason, text); - send(jinglePacket); - finish(); + + protected void sendSessionTerminate(final Reason reason, final String text) { + sendSessionTerminate(reason,text, this::writeLogMessage); } + private void sendTransportInfo( final String contentName, IceUdpTransportInfo.Candidate candidate) { final RtpContentMap transportInfo; @@ -2110,110 +1981,6 @@ public class JingleRtpConnection extends AbstractJingleConnection send(jinglePacket); } - private void send(final JinglePacket jinglePacket) { - jinglePacket.setTo(id.with); - xmppConnectionService.sendIqPacket(id.account, jinglePacket, this::handleIqResponse); - } - - private synchronized void handleIqResponse(final Account account, final IqPacket response) { - if (response.getType() == IqPacket.TYPE.ERROR) { - handleIqErrorResponse(response); - return; - } - if (response.getType() == IqPacket.TYPE.TIMEOUT) { - handleIqTimeoutResponse(response); - } - } - - private void handleIqErrorResponse(final IqPacket response) { - Preconditions.checkArgument(response.getType() == IqPacket.TYPE.ERROR); - final String errorCondition = response.getErrorCondition(); - Log.d( - Config.LOGTAG, - id.account.getJid().asBareJid() - + ": received IQ-error from " - + response.getFrom() - + " in RTP session. " - + errorCondition); - if (isTerminated()) { - Log.i( - Config.LOGTAG, - id.account.getJid().asBareJid() - + ": ignoring error because session was already terminated"); - return; - } - this.webRTCWrapper.close(); - final State target; - if (Arrays.asList( - "service-unavailable", - "recipient-unavailable", - "remote-server-not-found", - "remote-server-timeout") - .contains(errorCondition)) { - target = State.TERMINATED_CONNECTIVITY_ERROR; - } else { - target = State.TERMINATED_APPLICATION_FAILURE; - } - transitionOrThrow(target); - this.finish(); - } - - private void handleIqTimeoutResponse(final IqPacket response) { - Preconditions.checkArgument(response.getType() == IqPacket.TYPE.TIMEOUT); - Log.d( - Config.LOGTAG, - id.account.getJid().asBareJid() - + ": received IQ timeout in RTP session with " - + id.with - + ". terminating with connectivity error"); - if (isTerminated()) { - Log.i( - Config.LOGTAG, - id.account.getJid().asBareJid() - + ": ignoring error because session was already terminated"); - return; - } - this.webRTCWrapper.close(); - transitionOrThrow(State.TERMINATED_CONNECTIVITY_ERROR); - this.finish(); - } - - private void terminateWithOutOfOrder(final JinglePacket jinglePacket) { - Log.d( - Config.LOGTAG, - id.account.getJid().asBareJid() + ": terminating session with out-of-order"); - this.webRTCWrapper.close(); - transitionOrThrow(State.TERMINATED_APPLICATION_FAILURE); - respondWithOutOfOrder(jinglePacket); - this.finish(); - } - - private void respondWithTieBreak(final JinglePacket jinglePacket) { - respondWithJingleError(jinglePacket, "tie-break", "conflict", "cancel"); - } - - private void respondWithOutOfOrder(final JinglePacket jinglePacket) { - respondWithJingleError(jinglePacket, "out-of-order", "unexpected-request", "wait"); - } - - private void respondWithItemNotFound(final JinglePacket jinglePacket) { - respondWithJingleError(jinglePacket, null, "item-not-found", "cancel"); - } - - void respondWithJingleError( - final IqPacket original, - String jingleCondition, - String condition, - String conditionType) { - jingleConnectionManager.respondWithJingleError( - id.account, original, jingleCondition, condition, conditionType); - } - - private void respondOk(final JinglePacket jinglePacket) { - xmppConnectionService.sendIqPacket( - id.account, jinglePacket.generateResponse(IqPacket.TYPE.RESULT), null); - } - public RtpEndUserState getEndUserState() { switch (this.state) { case NULL, PROPOSED, SESSION_INITIALIZED -> { @@ -2409,7 +2176,7 @@ public class JingleRtpConnection extends AbstractJingleConnection + ": received endCall() when session has already been terminated. nothing to do"); return; } - if (isInState(State.PROPOSED) && !isInitiator()) { + if (isInState(State.PROPOSED) && isResponder()) { rejectCallFromProposed(); return; } @@ -2538,22 +2305,10 @@ public class JingleRtpConnection extends AbstractJingleConnection sendSessionAccept(); } - private synchronized boolean isInState(State... state) { - return Arrays.asList(state).contains(this.state); - } - - private boolean transition(final State target) { - return transition(target, null); - } - private synchronized boolean transition(final State target, final Runnable runnable) { - final Collection validTransitions = VALID_TRANSITIONS.get(this.state); - if (validTransitions != null && validTransitions.contains(target)) { - this.state = target; - if (runnable != null) { - runnable.run(); - } - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": transitioned into " + target); + @Override + protected synchronized boolean transition(final State target, final Runnable runnable) { + if (super.transition(target, runnable)) { updateEndUserState(); updateOngoingCallNotification(); return true; @@ -2562,13 +2317,6 @@ public class JingleRtpConnection extends AbstractJingleConnection } } - void transitionOrThrow(final State target) { - if (!transition(target)) { - throw new IllegalStateException( - String.format("Unable to transition from %s to %s", this.state, target)); - } - } - @Override public void onIceCandidate(final IceCandidate iceCandidate) { final RtpContentMap rtpContentMap = @@ -2904,98 +2652,7 @@ public class JingleRtpConnection extends AbstractJingleConnection id.account, request, (account, response) -> { - ImmutableList.Builder listBuilder = - new ImmutableList.Builder<>(); - if (response.getType() == IqPacket.TYPE.RESULT) { - final Element services = - response.findChild( - "services", Namespace.EXTERNAL_SERVICE_DISCOVERY); - final List children = - services == null - ? Collections.emptyList() - : services.getChildren(); - for (final Element child : children) { - if ("service".equals(child.getName())) { - final String type = child.getAttribute("type"); - final String host = child.getAttribute("host"); - final String sport = child.getAttribute("port"); - final Integer port = - sport == null ? null : Ints.tryParse(sport); - final String transport = child.getAttribute("transport"); - final String username = child.getAttribute("username"); - final String password = child.getAttribute("password"); - if (Strings.isNullOrEmpty(host) || port == null) { - continue; - } - if (port < 0 || port > 65535) { - continue; - } - - if (Arrays.asList("stun", "stuns", "turn", "turns") - .contains(type) - && Arrays.asList("udp", "tcp").contains(transport)) { - if (Arrays.asList("stuns", "turns").contains(type) - && "udp".equals(transport)) { - Log.d( - Config.LOGTAG, - id.account.getJid().asBareJid() - + ": skipping invalid combination of udp/tls in external services"); - continue; - } - - // STUN URLs do not support a query section since M110 - final String uri; - if (Arrays.asList("stun", "stuns").contains(type)) { - uri = - String.format( - "%s:%s:%s", - type, IP.wrapIPv6(host), port); - } else { - uri = - String.format( - "%s:%s:%s?transport=%s", - type, - IP.wrapIPv6(host), - port, - transport); - } - - final PeerConnection.IceServer.Builder iceServerBuilder = - PeerConnection.IceServer.builder(uri); - iceServerBuilder.setTlsCertPolicy( - PeerConnection.TlsCertPolicy - .TLS_CERT_POLICY_INSECURE_NO_CHECK); - if (username != null && password != null) { - iceServerBuilder.setUsername(username); - iceServerBuilder.setPassword(password); - } else if (Arrays.asList("turn", "turns").contains(type)) { - // The WebRTC spec requires throwing an - // InvalidAccessError when username (from libwebrtc - // source coder) - // https://chromium.googlesource.com/external/webrtc/+/master/pc/ice_server_parsing.cc - Log.d( - Config.LOGTAG, - id.account.getJid().asBareJid() - + ": skipping " - + type - + "/" - + transport - + " without username and password"); - continue; - } - final PeerConnection.IceServer iceServer = - iceServerBuilder.createIceServer(); - Log.d( - Config.LOGTAG, - id.account.getJid().asBareJid() - + ": discovered ICE Server: " - + iceServer); - listBuilder.add(iceServer); - } - } - } - } - final List iceServers = listBuilder.build(); + final var iceServers = IceServers.parse(response); if (iceServers.size() == 0) { Log.w( Config.LOGTAG, @@ -3012,13 +2669,19 @@ public class JingleRtpConnection extends AbstractJingleConnection onIceServersDiscovered.onIceServersDiscovered(Collections.emptyList()); } } + + @Override + protected void terminateTransport() { + this.webRTCWrapper.close(); + } - private void finish() { + @Override + protected void finish() { if (isTerminated()) { this.cancelRingingTimeout(); this.webRTCWrapper.verifyClosed(); this.jingleConnectionManager.setTerminalSessionState(id, getEndUserState(), getMedia()); - this.jingleConnectionManager.finishConnectionOrThrow(this); + super.finish(); try { File log = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "Cheogram/calls/" + id.getWith().asBareJid() + "." + id.getSessionId() + "." + created + ".log"); log.getParentFile().mkdirs(); @@ -3061,14 +2724,6 @@ public class JingleRtpConnection extends AbstractJingleConnection } } - public State getState() { - return this.state; - } - - boolean isTerminated() { - return TERMINATED.contains(this.state); - } - public Optional getLocalVideoTrack() { return webRTCWrapper.getLocalVideoTrack(); } @@ -3107,17 +2762,6 @@ public class JingleRtpConnection extends AbstractJingleConnection return remoteHasFeature(Namespace.SDP_OFFER_ANSWER); } - private boolean remoteHasFeature(final String feature) { - final Contact contact = id.getContact(); - final Presence presence = - contact.getPresences().get(Strings.nullToEmpty(id.with.getResource())); - final ServiceDiscoveryResult serviceDiscoveryResult = - presence == null ? null : presence.getServiceDiscoveryResult(); - final List features = - serviceDiscoveryResult == null ? null : serviceDiscoveryResult.getFeatures(); - return features != null && features.contains(feature); - } - private interface OnIceServersDiscovered { void onIceServersDiscovered(List iceServers); } diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleSocks5Transport.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleSocks5Transport.java deleted file mode 100644 index a57f4927ff8d65108d4563e14af7882eda8d5cc9..0000000000000000000000000000000000000000 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleSocks5Transport.java +++ /dev/null @@ -1,305 +0,0 @@ -package eu.siacs.conversations.xmpp.jingle; - -import android.os.PowerManager; -import android.util.Log; - -import com.google.common.io.ByteStreams; - -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.ServerSocket; -import java.net.Socket; -import java.net.SocketAddress; -import java.nio.ByteBuffer; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; - -import eu.siacs.conversations.Config; -import eu.siacs.conversations.entities.Account; -import eu.siacs.conversations.entities.DownloadableFile; -import eu.siacs.conversations.persistance.FileBackend; -import eu.siacs.conversations.services.AbstractConnectionManager; -import eu.siacs.conversations.utils.CryptoHelper; -import eu.siacs.conversations.utils.SocksSocketFactory; -import eu.siacs.conversations.utils.WakeLockHelper; -import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription; - -public class JingleSocks5Transport extends JingleTransport { - - private static final int SOCKET_TIMEOUT_DIRECT = 3000; - private static final int SOCKET_TIMEOUT_PROXY = 5000; - - private final JingleCandidate candidate; - private final JingleFileTransferConnection connection; - private final String destination; - private final Account account; - private OutputStream outputStream; - private InputStream inputStream; - private boolean isEstablished = false; - private boolean activated = false; - private ServerSocket serverSocket; - private Socket socket; - - JingleSocks5Transport(JingleFileTransferConnection jingleConnection, JingleCandidate candidate) { - final MessageDigest messageDigest; - try { - messageDigest = MessageDigest.getInstance("SHA-1"); - } catch (NoSuchAlgorithmException e) { - throw new AssertionError(e); - } - this.candidate = candidate; - this.connection = jingleConnection; - this.account = jingleConnection.getId().account; - final StringBuilder destBuilder = new StringBuilder(); - if (this.connection.getFtVersion() == FileTransferDescription.Version.FT_3) { - Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": using session Id instead of transport Id for proxy destination"); - destBuilder.append(this.connection.getId().sessionId); - } else { - destBuilder.append(this.connection.getTransportId()); - } - if (candidate.isOurs()) { - destBuilder.append(this.account.getJid()); - destBuilder.append(this.connection.getId().with); - } else { - destBuilder.append(this.connection.getId().with); - destBuilder.append(this.account.getJid()); - } - messageDigest.reset(); - this.destination = CryptoHelper.bytesToHex(messageDigest.digest(destBuilder.toString().getBytes())); - if (candidate.isOurs() && candidate.getType() == JingleCandidate.TYPE_DIRECT) { - createServerSocket(); - } - } - - private void createServerSocket() { - try { - serverSocket = new ServerSocket(); - serverSocket.bind(new InetSocketAddress(InetAddress.getByName(candidate.getHost()), candidate.getPort())); - new Thread(() -> { - try { - final Socket socket = serverSocket.accept(); - new Thread(() -> { - try { - acceptIncomingSocketConnection(socket); - } catch (IOException e) { - Log.d(Config.LOGTAG, "unable to read from socket", e); - - } - }).start(); - } catch (IOException e) { - if (!serverSocket.isClosed()) { - Log.d(Config.LOGTAG, "unable to accept socket", e); - } - } - }).start(); - } catch (IOException e) { - Log.d(Config.LOGTAG, "unable to bind server socket ", e); - } - } - - private void acceptIncomingSocketConnection(final Socket socket) throws IOException { - Log.d(Config.LOGTAG, "accepted connection from " + socket.getInetAddress().getHostAddress()); - socket.setSoTimeout(SOCKET_TIMEOUT_DIRECT); - final byte[] authBegin = new byte[2]; - final InputStream inputStream = socket.getInputStream(); - final OutputStream outputStream = socket.getOutputStream(); - ByteStreams.readFully(inputStream, authBegin); - if (authBegin[0] != 0x5) { - socket.close(); - } - final short methodCount = authBegin[1]; - final byte[] methods = new byte[methodCount]; - ByteStreams.readFully(inputStream, methods); - if (SocksSocketFactory.contains((byte) 0x00, methods)) { - outputStream.write(new byte[]{0x05, 0x00}); - } else { - outputStream.write(new byte[]{0x05, (byte) 0xff}); - } - final byte[] connectCommand = new byte[4]; - ByteStreams.readFully(inputStream, connectCommand); - if (connectCommand[0] == 0x05 && connectCommand[1] == 0x01 && connectCommand[3] == 0x03) { - int destinationCount = inputStream.read(); - final byte[] destination = new byte[destinationCount]; - ByteStreams.readFully(inputStream, destination); - final byte[] port = new byte[2]; - ByteStreams.readFully(inputStream, port); - final String receivedDestination = new String(destination); - final ByteBuffer response = ByteBuffer.allocate(7 + destination.length); - final byte[] responseHeader; - final boolean success; - if (receivedDestination.equals(this.destination) && this.socket == null) { - responseHeader = new byte[]{0x05, 0x00, 0x00, 0x03}; - success = true; - } else { - Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": destination mismatch. received " + receivedDestination + " (expected " + this.destination + ")"); - responseHeader = new byte[]{0x05, 0x04, 0x00, 0x03}; - success = false; - } - response.put(responseHeader); - response.put((byte) destination.length); - response.put(destination); - response.put(port); - outputStream.write(response.array()); - outputStream.flush(); - if (success) { - Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": successfully processed connection to candidate " + candidate.getHost() + ":" + candidate.getPort()); - socket.setSoTimeout(0); - this.socket = socket; - this.inputStream = inputStream; - this.outputStream = outputStream; - this.isEstablished = true; - FileBackend.close(serverSocket); - } else { - FileBackend.close(socket); - } - } else { - socket.close(); - } - } - - public void connect(final OnTransportConnected callback) { - new Thread(() -> { - final int timeout = candidate.getType() == JingleCandidate.TYPE_DIRECT ? SOCKET_TIMEOUT_DIRECT : SOCKET_TIMEOUT_PROXY; - try { - final boolean useTor = this.account.isOnion() || connection.getConnectionManager().getXmppConnectionService().useTorToConnect(); - if (useTor) { - socket = SocksSocketFactory.createSocketOverTor(candidate.getHost(), candidate.getPort()); - } else { - socket = new Socket(); - SocketAddress address = new InetSocketAddress(candidate.getHost(), candidate.getPort()); - socket.connect(address, timeout); - } - inputStream = socket.getInputStream(); - outputStream = socket.getOutputStream(); - socket.setSoTimeout(timeout); - SocksSocketFactory.createSocksConnection(socket, destination, 0); - socket.setSoTimeout(0); - isEstablished = true; - callback.established(); - } catch (final IOException e) { - callback.failed(); - } - }).start(); - - } - - public void send(final DownloadableFile file, final OnFileTransmissionStatusChanged callback) { - new Thread(() -> { - InputStream fileInputStream = null; - final PowerManager.WakeLock wakeLock = connection.getConnectionManager().createWakeLock("jingle_send_" + connection.getId().sessionId); - long transmitted = 0; - try { - wakeLock.acquire(); - MessageDigest digest = MessageDigest.getInstance("SHA-1"); - digest.reset(); - fileInputStream = connection.getFileInputStream(); - if (fileInputStream == null) { - Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": could not create input stream"); - callback.onFileTransferAborted(); - return; - } - final InputStream innerInputStream = AbstractConnectionManager.upgrade(file, fileInputStream); - long size = file.getExpectedSize(); - int count; - byte[] buffer = new byte[8192]; - while ((count = innerInputStream.read(buffer)) > 0) { - outputStream.write(buffer, 0, count); - digest.update(buffer, 0, count); - transmitted += count; - connection.updateProgress((int) ((((double) transmitted) / size) * 100)); - } - outputStream.flush(); - file.setSha1Sum(digest.digest()); - if (callback != null) { - callback.onFileTransmitted(file); - } - } catch (Exception e) { - final Account account = this.account; - Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": failed sending file after " + transmitted + "/" + file.getExpectedSize() + " (" + socket.getInetAddress() + ":" + socket.getPort() + ")", e); - callback.onFileTransferAborted(); - } finally { - FileBackend.close(fileInputStream); - WakeLockHelper.release(wakeLock); - } - }).start(); - - } - - public void receive(final DownloadableFile file, final OnFileTransmissionStatusChanged callback) { - new Thread(() -> { - OutputStream fileOutputStream = null; - final PowerManager.WakeLock wakeLock = connection.getConnectionManager().createWakeLock("jingle_receive_" + connection.getId().sessionId); - try { - wakeLock.acquire(); - MessageDigest digest = MessageDigest.getInstance("SHA-1"); - digest.reset(); - //inputStream.skip(45); - socket.setSoTimeout(30000); - fileOutputStream = connection.getFileOutputStream(); - if (fileOutputStream == null) { - callback.onFileTransferAborted(); - Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": could not create output stream"); - return; - } - double size = file.getExpectedSize(); - long remainingSize = file.getExpectedSize(); - byte[] buffer = new byte[8192]; - int count; - while (remainingSize > 0) { - count = inputStream.read(buffer); - if (count == -1) { - callback.onFileTransferAborted(); - Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": file ended prematurely with " + remainingSize + " bytes remaining"); - return; - } else { - fileOutputStream.write(buffer, 0, count); - digest.update(buffer, 0, count); - remainingSize -= count; - } - connection.updateProgress((int) (((size - remainingSize) / size) * 100)); - } - fileOutputStream.flush(); - fileOutputStream.close(); - file.setSha1Sum(digest.digest()); - callback.onFileTransmitted(file); - } catch (Exception e) { - Log.d(Config.LOGTAG, this.account.getJid().asBareJid() + ": " + e.getMessage()); - callback.onFileTransferAborted(); - } finally { - WakeLockHelper.release(wakeLock); - FileBackend.close(fileOutputStream); - FileBackend.close(inputStream); - } - }).start(); - } - - public boolean isProxy() { - return this.candidate.getType() == JingleCandidate.TYPE_PROXY; - } - - public boolean needsActivation() { - return (this.isProxy() && !this.activated); - } - - public void disconnect() { - FileBackend.close(inputStream); - FileBackend.close(outputStream); - FileBackend.close(socket); - FileBackend.close(serverSocket); - } - - public boolean isEstablished() { - return this.isEstablished; - } - - public JingleCandidate getCandidate() { - return this.candidate; - } - - public void setActivated(boolean activated) { - this.activated = activated; - } -} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleTransport.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleTransport.java deleted file mode 100644 index e832d3f584044237e937c70016d2e6358c1f843a..0000000000000000000000000000000000000000 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleTransport.java +++ /dev/null @@ -1,15 +0,0 @@ -package eu.siacs.conversations.xmpp.jingle; - -import eu.siacs.conversations.entities.DownloadableFile; - -public abstract class JingleTransport { - public abstract void connect(final OnTransportConnected callback); - - public abstract void receive(final DownloadableFile file, - final OnFileTransmissionStatusChanged callback); - - public abstract void send(final DownloadableFile file, - final OnFileTransmissionStatusChanged callback); - - public abstract void disconnect(); -} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/MediaBuilder.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/MediaBuilder.java index 67e275414652e1ff7791190584b2a6b23dfb6f89..db33666cb1fc4c2cd7f536650e40f2ba3e3085f9 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/MediaBuilder.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/MediaBuilder.java @@ -1,6 +1,7 @@ package eu.siacs.conversations.xmpp.jingle; -import com.google.common.collect.ArrayListMultimap; +import com.google.common.base.Joiner; +import com.google.common.collect.Multimap; import java.util.List; @@ -8,9 +9,9 @@ public class MediaBuilder { private String media; private int port; private String protocol; - private List formats; + private String format; private String connectionData; - private ArrayListMultimap attributes; + private Multimap attributes; public MediaBuilder setMedia(String media) { this.media = media; @@ -27,8 +28,13 @@ public class MediaBuilder { return this; } - public MediaBuilder setFormats(List formats) { - this.formats = formats; + public MediaBuilder setFormats(final List formats) { + this.format = Joiner.on(' ').join(formats); + return this; + } + + public MediaBuilder setFormat(final String format) { + this.format = format; return this; } @@ -37,12 +43,13 @@ public class MediaBuilder { return this; } - public MediaBuilder setAttributes(ArrayListMultimap attributes) { + public MediaBuilder setAttributes(Multimap attributes) { this.attributes = attributes; return this; } public SessionDescription.Media createMedia() { - return new SessionDescription.Media(media, port, protocol, formats, connectionData, attributes); + return new SessionDescription.Media( + media, port, protocol, format, connectionData, attributes); } -} \ No newline at end of file +} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/OmemoVerifiedRtpContentMap.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/OmemoVerifiedRtpContentMap.java index f5e041014c95a78841179074bd05cc35288eb53d..0d5d32d5089bf240a9814b904fa2f06d25e99792 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/OmemoVerifiedRtpContentMap.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/OmemoVerifiedRtpContentMap.java @@ -3,12 +3,14 @@ package eu.siacs.conversations.xmpp.jingle; import java.util.Map; import eu.siacs.conversations.xmpp.jingle.stanzas.Group; +import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo; import eu.siacs.conversations.xmpp.jingle.stanzas.OmemoVerifiedIceUdpTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription; public class OmemoVerifiedRtpContentMap extends RtpContentMap { - public OmemoVerifiedRtpContentMap(Group group, Map contents) { + public OmemoVerifiedRtpContentMap(Group group, Map> contents) { super(group, contents); - for(final DescriptionTransport descriptionTransport : contents.values()) { + for(final DescriptionTransport descriptionTransport : contents.values()) { if (descriptionTransport.transport instanceof OmemoVerifiedIceUdpTransportInfo) { ((OmemoVerifiedIceUdpTransportInfo) descriptionTransport.transport).ensureNoPlaintextFingerprint(); continue; diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/OnPrimaryCandidateFound.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/OnPrimaryCandidateFound.java deleted file mode 100644 index 76e337177e682c1aed2d06690ec14595b60641f6..0000000000000000000000000000000000000000 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/OnPrimaryCandidateFound.java +++ /dev/null @@ -1,5 +0,0 @@ -package eu.siacs.conversations.xmpp.jingle; - -public interface OnPrimaryCandidateFound { - void onPrimaryCandidateFound(boolean success, JingleCandidate canditate); -} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpContentMap.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpContentMap.java index 2e548d60ada2d52cb996e228e5be3c175cf8e331..b151af17e02fc837a599433ce568e1b630d35dbf 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpContentMap.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/RtpContentMap.java @@ -6,7 +6,6 @@ import com.google.common.base.Preconditions; import com.google.common.base.Predicates; import com.google.common.base.Strings; import com.google.common.collect.Collections2; -import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableMultimap; import com.google.common.collect.ImmutableSet; @@ -32,19 +31,17 @@ import java.util.Set; import javax.annotation.Nonnull; -public class RtpContentMap { +public class RtpContentMap extends AbstractContentMap { - public final Group group; - public final Map contents; - - public RtpContentMap(Group group, Map contents) { - this.group = group; - this.contents = contents; + public RtpContentMap( + Group group, + Map> contents) { + super(group, contents); } public static RtpContentMap of(final JinglePacket jinglePacket) { - final Map contents = - DescriptionTransport.of(jinglePacket.getJingleContents()); + final Map> contents = + of(jinglePacket.getJingleContents()); if (isOmemoVerified(contents)) { return new OmemoVerifiedRtpContentMap(jinglePacket.getGroup(), contents); } else { @@ -52,12 +49,15 @@ public class RtpContentMap { } } - private static boolean isOmemoVerified(Map contents) { - final Collection values = contents.values(); + private static boolean isOmemoVerified( + Map> contents) { + final Collection> values = + contents.values(); if (values.size() == 0) { return false; } - for (final DescriptionTransport descriptionTransport : values) { + for (final DescriptionTransport descriptionTransport : + values) { if (descriptionTransport.transport instanceof OmemoVerifiedIceUdpTransportInfo) { continue; } @@ -68,13 +68,13 @@ public class RtpContentMap { public static RtpContentMap of( final SessionDescription sessionDescription, final boolean isInitiator) { - final ImmutableMap.Builder contentMapBuilder = - new ImmutableMap.Builder<>(); + final ImmutableMap.Builder< + String, DescriptionTransport> + contentMapBuilder = new ImmutableMap.Builder<>(); for (SessionDescription.Media media : sessionDescription.media) { final String id = Iterables.getFirst(media.attributes.get("mid"), null); Preconditions.checkNotNull(id, "media has no mid"); - contentMapBuilder.put( - id, DescriptionTransport.of(sessionDescription, isInitiator, media)); + contentMapBuilder.put(id, of(sessionDescription, isInitiator, media)); } final String groupAttribute = Iterables.getFirst(sessionDescription.attributes.get("group"), null); @@ -95,26 +95,6 @@ public class RtpContentMap { })); } - public Set getSenders() { - return ImmutableSet.copyOf(Collections2.transform(contents.values(), dt -> dt.senders)); - } - - public List getNames() { - return ImmutableList.copyOf(contents.keySet()); - } - - void requireContentDescriptions() { - if (this.contents.size() == 0) { - throw new IllegalStateException("No contents available"); - } - for (Map.Entry entry : this.contents.entrySet()) { - if (entry.getValue().description == null) { - throw new IllegalStateException( - String.format("%s is lacking content description", entry.getKey())); - } - } - } - void requireDTLSFingerprint() { requireDTLSFingerprint(false); } @@ -123,7 +103,8 @@ public class RtpContentMap { if (this.contents.size() == 0) { throw new IllegalStateException("No contents available"); } - for (Map.Entry entry : this.contents.entrySet()) { + for (Map.Entry> entry : + this.contents.entrySet()) { final IceUdpTransportInfo transport = entry.getValue().transport; final IceUdpTransportInfo.Fingerprint fingerprint = transport.getFingerprint(); if (fingerprint == null @@ -147,31 +128,10 @@ public class RtpContentMap { } } } - - JinglePacket toJinglePacket(final JinglePacket.Action action, final String sessionId) { - final JinglePacket jinglePacket = new JinglePacket(action, sessionId); - if (this.group != null) { - jinglePacket.addGroup(this.group); - } - for (Map.Entry entry : this.contents.entrySet()) { - final DescriptionTransport descriptionTransport = entry.getValue(); - final Content content = - new Content( - Content.Creator.INITIATOR, - descriptionTransport.senders, - entry.getKey()); - if (descriptionTransport.description != null) { - content.addChild(descriptionTransport.description); - } - content.addChild(descriptionTransport.transport); - jinglePacket.addJingleContent(content); - } - return jinglePacket; - } - RtpContentMap transportInfo( final String contentName, final IceUdpTransportInfo.Candidate candidate) { - final RtpContentMap.DescriptionTransport descriptionTransport = contents.get(contentName); + final DescriptionTransport descriptionTransport = + contents.get(contentName); final IceUdpTransportInfo transportInfo = descriptionTransport == null ? null : descriptionTransport.transport; if (transportInfo == null) { @@ -184,7 +144,7 @@ public class RtpContentMap { null, ImmutableMap.of( contentName, - new DescriptionTransport( + new DescriptionTransport<>( descriptionTransport.senders, null, newTransportInfo))); } @@ -194,21 +154,24 @@ public class RtpContentMap { Maps.transformValues( contents, dt -> - new DescriptionTransport( + new DescriptionTransport<>( dt.senders, null, dt.transport.cloneWrapper()))); } RtpContentMap withCandidates( ImmutableMultimap candidates) { - final ImmutableMap.Builder contentBuilder = - new ImmutableMap.Builder<>(); - for (final Map.Entry entry : this.contents.entrySet()) { + final ImmutableMap.Builder< + String, DescriptionTransport> + contentBuilder = new ImmutableMap.Builder<>(); + for (final Map.Entry> + entry : this.contents.entrySet()) { final String name = entry.getKey(); - final DescriptionTransport descriptionTransport = entry.getValue(); + final DescriptionTransport descriptionTransport = + entry.getValue(); final var transport = descriptionTransport.transport; contentBuilder.put( name, - new DescriptionTransport( + new DescriptionTransport<>( descriptionTransport.senders, descriptionTransport.description, transport.withCandidates(candidates.get(name)))); @@ -248,7 +211,7 @@ public class RtpContentMap { } public IceUdpTransportInfo.Credentials getCredentials(final String contentName) { - final DescriptionTransport descriptionTransport = this.contents.get(contentName); + final var descriptionTransport = this.contents.get(contentName); if (descriptionTransport == null) { throw new IllegalArgumentException( String.format( @@ -288,7 +251,7 @@ public class RtpContentMap { public boolean emptyCandidates() { int count = 0; - for (DescriptionTransport descriptionTransport : contents.values()) { + for (final var descriptionTransport : contents.values()) { count += descriptionTransport.transport.getCandidates().size(); } return count == 0; @@ -301,17 +264,19 @@ public class RtpContentMap { public RtpContentMap modifiedCredentials( IceUdpTransportInfo.Credentials credentials, final IceUdpTransportInfo.Setup setup) { - final ImmutableMap.Builder contentMapBuilder = - new ImmutableMap.Builder<>(); - for (final Map.Entry content : contents.entrySet()) { - final DescriptionTransport descriptionTransport = content.getValue(); + final ImmutableMap.Builder< + String, DescriptionTransport> + contentMapBuilder = new ImmutableMap.Builder<>(); + for (final Map.Entry> + content : contents.entrySet()) { + final var descriptionTransport = content.getValue(); final RtpDescription rtpDescription = descriptionTransport.description; final IceUdpTransportInfo transportInfo = descriptionTransport.transport; final IceUdpTransportInfo modifiedTransportInfo = transportInfo.modifyCredentials(credentials, setup); contentMapBuilder.put( content.getKey(), - new DescriptionTransport( + new DescriptionTransport<>( descriptionTransport.senders, rtpDescription, modifiedTransportInfo)); } return new RtpContentMap(this.group, contentMapBuilder.build()); @@ -322,16 +287,18 @@ public class RtpContentMap { this.group, Maps.transformValues( contents, - dt -> new DescriptionTransport(senders, dt.description, dt.transport))); + dt -> new DescriptionTransport<>(senders, dt.description, dt.transport))); } public RtpContentMap modifiedSendersChecked( final boolean isInitiator, final Map modification) { - final ImmutableMap.Builder contentMapBuilder = - new ImmutableMap.Builder<>(); - for (final Map.Entry content : contents.entrySet()) { + final ImmutableMap.Builder< + String, DescriptionTransport> + contentMapBuilder = new ImmutableMap.Builder<>(); + for (final Map.Entry> + content : contents.entrySet()) { final String id = content.getKey(); - final DescriptionTransport descriptionTransport = content.getValue(); + final var descriptionTransport = content.getValue(); final Content.Senders currentSenders = descriptionTransport.senders; final Content.Senders targetSenders = modification.get(id); if (targetSenders == null || currentSenders == targetSenders) { @@ -340,7 +307,7 @@ public class RtpContentMap { checkSenderModification(isInitiator, currentSenders, targetSenders); contentMapBuilder.put( id, - new DescriptionTransport( + new DescriptionTransport<>( targetSenders, descriptionTransport.description, descriptionTransport.transport)); @@ -387,7 +354,7 @@ public class RtpContentMap { Maps.transformValues( this.contents, dt -> - new DescriptionTransport( + new DescriptionTransport<>( dt.senders, RtpDescription.stub(dt.description.getMedia()), IceUdpTransportInfo.STUB))); @@ -416,120 +383,96 @@ public class RtpContentMap { public RtpContentMap addContent( final RtpContentMap modification, final IceUdpTransportInfo.Setup setupOverwrite) { - final Map combined = merge(contents, modification.contents); - final Map combinedFixedTransport = - Maps.transformValues( - combined, - dt -> { - final IceUdpTransportInfo iceUdpTransportInfo; - if (dt.transport.isStub()) { - final IceUdpTransportInfo.Credentials credentials = - getDistinctCredentials(); - final Collection iceOptions = getCombinedIceOptions(); - final DTLS dtls = getDistinctDtls(); - iceUdpTransportInfo = - IceUdpTransportInfo.of( - credentials, - iceOptions, - setupOverwrite, - dtls.hash, - dtls.fingerprint); - } else { - final IceUdpTransportInfo.Fingerprint fp = - dt.transport.getFingerprint(); - final IceUdpTransportInfo.Setup setup = fp.getSetup(); - iceUdpTransportInfo = - IceUdpTransportInfo.of( - dt.transport.getCredentials(), - dt.transport.getIceOptions(), - setup == IceUdpTransportInfo.Setup.ACTPASS - ? setupOverwrite - : setup, - fp.getHash(), - fp.getContent()); - } - return new DescriptionTransport( - dt.senders, dt.description, iceUdpTransportInfo); - }); + final Map> combined = + merge(contents, modification.contents); + final Map> + combinedFixedTransport = + Maps.transformValues( + combined, + dt -> { + final IceUdpTransportInfo iceUdpTransportInfo; + if (dt.transport.isStub()) { + final IceUdpTransportInfo.Credentials credentials = + getDistinctCredentials(); + final Collection iceOptions = + getCombinedIceOptions(); + final DTLS dtls = getDistinctDtls(); + iceUdpTransportInfo = + IceUdpTransportInfo.of( + credentials, + iceOptions, + setupOverwrite, + dtls.hash, + dtls.fingerprint); + } else { + final IceUdpTransportInfo.Fingerprint fp = + dt.transport.getFingerprint(); + final IceUdpTransportInfo.Setup setup = fp.getSetup(); + iceUdpTransportInfo = + IceUdpTransportInfo.of( + dt.transport.getCredentials(), + dt.transport.getIceOptions(), + setup == IceUdpTransportInfo.Setup.ACTPASS + ? setupOverwrite + : setup, + fp.getHash(), + fp.getContent()); + } + return new DescriptionTransport<>( + dt.senders, dt.description, iceUdpTransportInfo); + }); return new RtpContentMap(modification.group, ImmutableMap.copyOf(combinedFixedTransport)); } - private static Map merge( - final Map a, final Map b) { - final Map combined = new LinkedHashMap<>(); + private static Map> merge( + final Map> a, + final Map> b) { + final Map> combined = + new LinkedHashMap<>(); combined.putAll(a); combined.putAll(b); return ImmutableMap.copyOf(combined); } - public static class DescriptionTransport { - public final Content.Senders senders; - public final RtpDescription description; - public final IceUdpTransportInfo transport; - - public DescriptionTransport( - final Content.Senders senders, - final RtpDescription description, - final IceUdpTransportInfo transport) { - this.senders = senders; - this.description = description; - this.transport = transport; - } - - public static DescriptionTransport of(final Content content) { - final GenericDescription description = content.getDescription(); - final GenericTransportInfo transportInfo = content.getTransport(); - final Content.Senders senders = content.getSenders(); - final RtpDescription rtpDescription; - final IceUdpTransportInfo iceUdpTransportInfo; - if (description == null) { - rtpDescription = null; - } else if (description instanceof RtpDescription) { - rtpDescription = (RtpDescription) description; - } else { - throw new UnsupportedApplicationException( - "Content does not contain rtp description"); - } - if (transportInfo instanceof IceUdpTransportInfo) { - iceUdpTransportInfo = (IceUdpTransportInfo) transportInfo; - } else { - throw new UnsupportedTransportException( - "Content does not contain ICE-UDP transport"); - } - return new DescriptionTransport( - senders, - rtpDescription, - OmemoVerifiedIceUdpTransportInfo.upgrade(iceUdpTransportInfo)); - } - - private static DescriptionTransport of( - final SessionDescription sessionDescription, - final boolean isInitiator, - final SessionDescription.Media media) { - final Content.Senders senders = Content.Senders.of(media, isInitiator); - final RtpDescription rtpDescription = RtpDescription.of(sessionDescription, media); - final IceUdpTransportInfo transportInfo = - IceUdpTransportInfo.of(sessionDescription, media); - return new DescriptionTransport(senders, rtpDescription, transportInfo); + public static DescriptionTransport of( + final Content content) { + final GenericDescription description = content.getDescription(); + final GenericTransportInfo transportInfo = content.getTransport(); + final Content.Senders senders = content.getSenders(); + final RtpDescription rtpDescription; + final IceUdpTransportInfo iceUdpTransportInfo; + if (description == null) { + rtpDescription = null; + } else if (description instanceof RtpDescription) { + rtpDescription = (RtpDescription) description; + } else { + throw new UnsupportedApplicationException("Content does not contain rtp description"); } - - public static Map of(final Map contents) { - return ImmutableMap.copyOf( - Maps.transformValues( - contents, content -> content == null ? null : of(content))); + if (transportInfo instanceof IceUdpTransportInfo) { + iceUdpTransportInfo = (IceUdpTransportInfo) transportInfo; + } else { + throw new UnsupportedTransportException("Content does not contain ICE-UDP transport"); } + return new DescriptionTransport<>( + senders, + rtpDescription, + OmemoVerifiedIceUdpTransportInfo.upgrade(iceUdpTransportInfo)); } - public static class UnsupportedApplicationException extends IllegalArgumentException { - UnsupportedApplicationException(String message) { - super(message); - } + private static DescriptionTransport of( + final SessionDescription sessionDescription, + final boolean isInitiator, + final SessionDescription.Media media) { + final Content.Senders senders = Content.Senders.of(media, isInitiator); + final RtpDescription rtpDescription = RtpDescription.of(sessionDescription, media); + final IceUdpTransportInfo transportInfo = IceUdpTransportInfo.of(sessionDescription, media); + return new DescriptionTransport<>(senders, rtpDescription, transportInfo); } - public static class UnsupportedTransportException extends IllegalArgumentException { - UnsupportedTransportException(String message) { - super(message); - } + private static Map> of( + final Map contents) { + return ImmutableMap.copyOf( + Maps.transformValues(contents, content -> content == null ? null : of(content))); } public static final class Diff { diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/SessionDescription.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/SessionDescription.java index 2d2dc95709f22e86b5d88645bb14540205eaff12..025a7acc917fd0defb95953556b9b15f99cb271a 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/SessionDescription.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/SessionDescription.java @@ -10,12 +10,17 @@ import com.google.common.base.Joiner; import com.google.common.base.Strings; import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMultimap; +import com.google.common.collect.Multimap; import eu.siacs.conversations.Config; import eu.siacs.conversations.xml.Namespace; +import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription; +import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo; import eu.siacs.conversations.xmpp.jingle.stanzas.Group; import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo; import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription; +import eu.siacs.conversations.xmpp.jingle.stanzas.WebRTCDataChannelTransportInfo; import java.util.Collection; import java.util.Collections; @@ -28,6 +33,8 @@ public class SessionDescription { public static final String LINE_DIVIDER = "\r\n"; private static final String HARDCODED_MEDIA_PROTOCOL = "UDP/TLS/RTP/SAVPF"; // probably only true for DTLS-SRTP aka when we have a fingerprint + private static final String HARDCODED_APPLICATION_PROTOCOL = "UDP/DTLS/SCTP"; + private static final String FORMAT_WEBRTC_DATA_CHANNEL = "webrtc-datachannel"; private static final int HARDCODED_MEDIA_PORT = 9; private static final Collection HARDCODED_ICE_OPTIONS = Collections.singleton("trickle"); @@ -52,9 +59,8 @@ public class SessionDescription { this.media = media; } - private static void appendAttributes( - StringBuilder s, ArrayListMultimap attributes) { - for (Map.Entry attribute : attributes.entries()) { + private static void appendAttributes(StringBuilder s, Multimap attributes) { + for (final Map.Entry attribute : attributes.entries()) { final String key = attribute.getKey(); final String value = attribute.getValue(); s.append("a=").append(key); @@ -79,24 +85,20 @@ public class SessionDescription { final char key = pair[0].charAt(0); final String value = pair[1]; switch (key) { - case 'v': - sessionDescriptionBuilder.setVersion(ignorantIntParser(value)); - break; - case 'c': + case 'v' -> sessionDescriptionBuilder.setVersion(ignorantIntParser(value)); + case 'c' -> { if (currentMediaBuilder != null) { currentMediaBuilder.setConnectionData(value); } else { sessionDescriptionBuilder.setConnectionData(value); } - break; - case 's': - sessionDescriptionBuilder.setName(value); - break; - case 'a': + } + case 's' -> sessionDescriptionBuilder.setName(value); + case 'a' -> { final Pair attribute = parseAttribute(value); attributeMap.put(attribute.first, attribute.second); - break; - case 'm': + } + case 'm' -> { if (currentMediaBuilder == null) { sessionDescriptionBuilder.setAttributes(attributeMap); } else { @@ -118,7 +120,7 @@ public class SessionDescription { } else { Log.d(Config.LOGTAG, "skipping media line " + line); } - break; + } } } if (currentMediaBuilder != null) { @@ -131,6 +133,56 @@ public class SessionDescription { return sessionDescriptionBuilder.createSessionDescription(); } + public static SessionDescription of(final FileTransferContentMap contentMap) { + final SessionDescriptionBuilder sessionDescriptionBuilder = new SessionDescriptionBuilder(); + final ArrayListMultimap attributeMap = ArrayListMultimap.create(); + final ImmutableList.Builder mediaListBuilder = new ImmutableList.Builder<>(); + + final Group group = contentMap.group; + if (group != null) { + final String semantics = group.getSemantics(); + checkNoWhitespace(semantics, "group semantics value must not contain any whitespace"); + final var idTags = group.getIdentificationTags(); + for (final String content : idTags) { + checkNoWhitespace(content, "group content names must not contain any whitespace"); + } + attributeMap.put("group", group.getSemantics() + " " + Joiner.on(' ').join(idTags)); + } + + // TODO my-media-stream can be removed I think + attributeMap.put("msid-semantic", " WMS my-media-stream"); + + for (final Map.Entry< + String, DescriptionTransport> + entry : contentMap.contents.entrySet()) { + final var dt = entry.getValue(); + final WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo; + if (dt.transport instanceof WebRTCDataChannelTransportInfo transportInfo) { + webRTCDataChannelTransportInfo = transportInfo; + } else { + throw new IllegalArgumentException("Transport is not of type WebRTCDataChannel"); + } + final String name = entry.getKey(); + checkNoWhitespace(name, "content name must not contain any whitespace"); + + final MediaBuilder mediaBuilder = new MediaBuilder(); + mediaBuilder.setMedia("application"); + mediaBuilder.setConnectionData(HARDCODED_CONNECTION); + mediaBuilder.setPort(HARDCODED_MEDIA_PORT); + mediaBuilder.setProtocol(HARDCODED_APPLICATION_PROTOCOL); + mediaBuilder.setAttributes( + transportInfoMediaAttributes(webRTCDataChannelTransportInfo)); + mediaBuilder.setFormat(FORMAT_WEBRTC_DATA_CHANNEL); + mediaListBuilder.add(mediaBuilder.createMedia()); + } + + sessionDescriptionBuilder.setVersion(0); + sessionDescriptionBuilder.setName("-"); + sessionDescriptionBuilder.setMedia(mediaListBuilder.build()); + sessionDescriptionBuilder.setAttributes(attributeMap); + return sessionDescriptionBuilder.createSessionDescription(); + } + public static SessionDescription of( final RtpContentMap contentMap, final boolean isInitiatorContentMap) { final SessionDescriptionBuilder sessionDescriptionBuilder = new SessionDescriptionBuilder(); @@ -140,58 +192,27 @@ public class SessionDescription { if (group != null) { final String semantics = group.getSemantics(); checkNoWhitespace(semantics, "group semantics value must not contain any whitespace"); - attributeMap.put( - "group", - group.getSemantics() - + " " - + Joiner.on(' ').join(group.getIdentificationTags())); + final var idTags = group.getIdentificationTags(); + for (final String content : idTags) { + checkNoWhitespace(content, "group content names must not contain any whitespace"); + } + attributeMap.put("group", group.getSemantics() + " " + Joiner.on(' ').join(idTags)); } + // TODO my-media-stream can be removed I think attributeMap.put("msid-semantic", " WMS my-media-stream"); - for (final Map.Entry entry : - contentMap.contents.entrySet()) { + for (final Map.Entry> + entry : contentMap.contents.entrySet()) { final String name = entry.getKey(); - RtpContentMap.DescriptionTransport descriptionTransport = entry.getValue(); - RtpDescription description = descriptionTransport.description; - IceUdpTransportInfo transport = descriptionTransport.transport; + checkNoWhitespace(name, "content name must not contain any whitespace"); + final DescriptionTransport descriptionTransport = + entry.getValue(); + final RtpDescription description = descriptionTransport.description; final ArrayListMultimap mediaAttributes = ArrayListMultimap.create(); - final String ufrag = transport.getAttribute("ufrag"); - final String pwd = transport.getAttribute("pwd"); - if (Strings.isNullOrEmpty(ufrag)) { - throw new IllegalArgumentException( - "Transport element is missing required ufrag attribute"); - } - checkNoWhitespace(ufrag, "ufrag value must not contain any whitespaces"); - mediaAttributes.put("ice-ufrag", ufrag); - if (Strings.isNullOrEmpty(pwd)) { - throw new IllegalArgumentException( - "Transport element is missing required pwd attribute"); - } - checkNoWhitespace(pwd, "pwd value must not contain any whitespaces"); - mediaAttributes.put("ice-pwd", pwd); - final List negotiatedIceOptions = transport.getIceOptions(); - final Collection iceOptions = - negotiatedIceOptions.isEmpty() ? HARDCODED_ICE_OPTIONS : negotiatedIceOptions; - mediaAttributes.put("ice-options", Joiner.on(' ').join(iceOptions)); - final IceUdpTransportInfo.Fingerprint fingerprint = transport.getFingerprint(); - if (fingerprint != null) { - final String hashFunction = fingerprint.getHash(); - final String hash = fingerprint.getContent(); - if (Strings.isNullOrEmpty(hashFunction) || Strings.isNullOrEmpty(hash)) { - throw new IllegalArgumentException("DTLS-SRTP missing hash"); - } - checkNoWhitespace( - hashFunction, "DTLS-SRTP hash function must not contain whitespace"); - checkNoWhitespace(hash, "DTLS-SRTP hash must not contain whitespace"); - mediaAttributes.put("fingerprint", hashFunction + " " + hash); - final IceUdpTransportInfo.Setup setup = fingerprint.getSetup(); - if (setup != null) { - mediaAttributes.put("setup", setup.toString().toLowerCase(Locale.ROOT)); - } - } + mediaAttributes.putAll(transportInfoMediaAttributes(descriptionTransport.transport)); final ImmutableList.Builder formatBuilder = new ImmutableList.Builder<>(); - for (RtpDescription.PayloadType payloadType : description.getPayloadTypes()) { + for (final RtpDescription.PayloadType payloadType : description.getPayloadTypes()) { final String id = payloadType.getId(); if (Strings.isNullOrEmpty(id)) { throw new IllegalArgumentException("Payload type is missing id"); @@ -353,6 +374,69 @@ public class SessionDescription { return sessionDescriptionBuilder.createSessionDescription(); } + private static Multimap transportInfoMediaAttributes( + final IceUdpTransportInfo transport) { + final ArrayListMultimap mediaAttributes = ArrayListMultimap.create(); + final String ufrag = transport.getAttribute("ufrag"); + final String pwd = transport.getAttribute("pwd"); + if (Strings.isNullOrEmpty(ufrag)) { + throw new IllegalArgumentException( + "Transport element is missing required ufrag attribute"); + } + checkNoWhitespace(ufrag, "ufrag value must not contain any whitespaces"); + mediaAttributes.put("ice-ufrag", ufrag); + if (Strings.isNullOrEmpty(pwd)) { + throw new IllegalArgumentException( + "Transport element is missing required pwd attribute"); + } + checkNoWhitespace(pwd, "pwd value must not contain any whitespaces"); + mediaAttributes.put("ice-pwd", pwd); + final List negotiatedIceOptions = transport.getIceOptions(); + final Collection iceOptions = + negotiatedIceOptions.isEmpty() ? HARDCODED_ICE_OPTIONS : negotiatedIceOptions; + mediaAttributes.put("ice-options", Joiner.on(' ').join(iceOptions)); + final IceUdpTransportInfo.Fingerprint fingerprint = transport.getFingerprint(); + if (fingerprint != null) { + final String hashFunction = fingerprint.getHash(); + final String hash = fingerprint.getContent(); + if (Strings.isNullOrEmpty(hashFunction) || Strings.isNullOrEmpty(hash)) { + throw new IllegalArgumentException("DTLS-SRTP missing hash"); + } + checkNoWhitespace(hashFunction, "DTLS-SRTP hash function must not contain whitespace"); + checkNoWhitespace(hash, "DTLS-SRTP hash must not contain whitespace"); + mediaAttributes.put("fingerprint", hashFunction + " " + hash); + final IceUdpTransportInfo.Setup setup = fingerprint.getSetup(); + if (setup != null) { + mediaAttributes.put("setup", setup.toString().toLowerCase(Locale.ROOT)); + } + } + return ImmutableMultimap.copyOf(mediaAttributes); + } + + private static Multimap transportInfoMediaAttributes( + final WebRTCDataChannelTransportInfo transport) { + final ArrayListMultimap mediaAttributes = ArrayListMultimap.create(); + final var iceUdpTransportInfo = transport.innerIceUdpTransportInfo(); + if (iceUdpTransportInfo == null) { + throw new IllegalArgumentException( + "Transport element is missing inner ice-udp transport"); + } + mediaAttributes.putAll(transportInfoMediaAttributes(iceUdpTransportInfo)); + final Integer sctpPort = transport.getSctpPort(); + if (sctpPort == null) { + throw new IllegalArgumentException( + "Transport element is missing required sctp-port attribute"); + } + mediaAttributes.put("sctp-port", String.valueOf(sctpPort)); + final Integer maxMessageSize = transport.getMaxMessageSize(); + if (maxMessageSize == null) { + throw new IllegalArgumentException( + "Transport element is missing required max-message-size"); + } + mediaAttributes.put("max-message-size", String.valueOf(maxMessageSize)); + return ImmutableMultimap.copyOf(mediaAttributes); + } + public static String checkNoWhitespace(final String input, final String message) { if (CharMatcher.whitespace().matchesAnyOf(input)) { throw new IllegalArgumentException(message); @@ -421,7 +505,7 @@ public class SessionDescription { .append(' ') .append(media.protocol) .append(' ') - .append(Joiner.on(' ').join(media.formats)) + .append(media.format) .append(LINE_DIVIDER); s.append("c=").append(media.connectionData).append(LINE_DIVIDER); appendAttributes(s, media.attributes); @@ -433,21 +517,21 @@ public class SessionDescription { public final String media; public final int port; public final String protocol; - public final List formats; + public final String format; public final String connectionData; - public final ArrayListMultimap attributes; + public final Multimap attributes; public Media( String media, int port, String protocol, - List formats, + String format, String connectionData, - ArrayListMultimap attributes) { + Multimap attributes) { this.media = media; this.port = port; this.protocol = protocol; - this.formats = formats; + this.format = format; this.connectionData = connectionData; this.attributes = attributes; } diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java index 7356477eed1883551cccf2c6dd4ff2308be4b582..58f87fd924e790b8661c0671f711b4be23e2628f 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java @@ -50,6 +50,7 @@ import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import javax.annotation.Nonnull; @@ -429,7 +430,7 @@ public class WebRTCWrapper { } } - private static PeerConnection.RTCConfiguration buildConfiguration( + public static PeerConnection.RTCConfiguration buildConfiguration( final List iceServers, final boolean trickle) { final PeerConnection.RTCConfiguration rtcConfig = new PeerConnection.RTCConfiguration(iceServers); @@ -481,11 +482,6 @@ public class WebRTCWrapper { "setIsReadyToReceiveCandidates(" + ready + ") was=" + was + " is=" + is); } - public void resetPendingCandidates() { - this.readyToReceivedIceCandidates.set(true); - this.iceCandidates.clear(); - } - synchronized void close() { final PeerConnection peerConnection = this.peerConnection; final PeerConnectionFactory peerConnectionFactory = this.peerConnectionFactory; @@ -616,7 +612,8 @@ public class WebRTCWrapper { throw new IllegalStateException("Local video track does not exist"); } - synchronized ListenableFuture setLocalDescription(final boolean waitForCandidates) { + synchronized ListenableFuture setLocalDescription( + final boolean waitForCandidates) { this.setIsReadyToReceiveIceCandidates(false); return Futures.transformAsync( getPeerConnectionFuture(), @@ -630,16 +627,20 @@ public class WebRTCWrapper { new SetSdpObserver() { @Override public void onSetSuccess() { - final var delay = - waitForCandidates - ? Futures.catching(Futures.withTimeout(iceGatheringComplete, 2, TimeUnit.SECONDS, JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE), Exception.class, (Exception e) -> { return null; }, MoreExecutors.directExecutor()) - : Futures.immediateVoidFuture(); - final var delayedSessionDescription = - Futures.transformAsync( - delay, - v -> getLocalDescriptionFuture(), - MoreExecutors.directExecutor()); - future.setFuture(delayedSessionDescription); + if (waitForCandidates) { + final var delay = getIceGatheringCompleteOrTimeout(); + final var delayedSessionDescription = + Futures.transformAsync( + delay, + v -> { + iceCandidates.clear(); + return getLocalDescriptionFuture(); + }, + MoreExecutors.directExecutor()); + future.setFuture(delayedSessionDescription); + } else { + future.setFuture(getLocalDescriptionFuture()); + } } @Override @@ -653,6 +654,23 @@ public class WebRTCWrapper { MoreExecutors.directExecutor()); } + private ListenableFuture getIceGatheringCompleteOrTimeout() { + return Futures.catching( + Futures.withTimeout( + iceGatheringComplete, + 2, + TimeUnit.SECONDS, + JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE), + TimeoutException.class, + ex -> { + Log.d( + EXTENDED_LOGGING_TAG, + "timeout while waiting for ICE gathering to complete"); + return null; + }, + MoreExecutors.directExecutor()); + } + private ListenableFuture getLocalDescriptionFuture() { return Futures.submit( () -> { @@ -801,7 +819,7 @@ public class WebRTCWrapper { void onRenegotiationNeeded(); } - private abstract static class SetSdpObserver implements SdpObserver { + public abstract static class SetSdpObserver implements SdpObserver { @Override public void onCreateSuccess(org.webrtc.SessionDescription sessionDescription) { @@ -827,12 +845,12 @@ public class WebRTCWrapper { public static class PeerConnectionNotInitialized extends IllegalStateException { - private PeerConnectionNotInitialized() { + public PeerConnectionNotInitialized() { super("initialize PeerConnection first"); } } - private static class FailureToSetDescriptionException extends IllegalArgumentException { + public static class FailureToSetDescriptionException extends IllegalArgumentException { public FailureToSetDescriptionException(String message) { super(message); } diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Content.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Content.java index 0cca6527abf8b7c38cba68966efd331354efe478..fbde212a3bd2516685dc5bc41900055984903acd 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Content.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Content.java @@ -8,14 +8,14 @@ import com.google.common.base.Preconditions; import com.google.common.base.Strings; import com.google.common.collect.ImmutableSet; -import java.util.Locale; -import java.util.Set; - import eu.siacs.conversations.Config; import eu.siacs.conversations.xml.Element; import eu.siacs.conversations.xml.Namespace; import eu.siacs.conversations.xmpp.jingle.SessionDescription; +import java.util.Locale; +import java.util.Set; + public class Content extends Element { public Content(final Creator creator, final Senders senders, final String name) { @@ -65,7 +65,7 @@ public class Content extends Element { return null; } final String namespace = description.getNamespace(); - if (FileTransferDescription.NAMESPACES.contains(namespace)) { + if (Namespace.JINGLE_APPS_FILE_TRANSFER.equals(namespace)) { return FileTransferDescription.upgrade(description); } else if (Namespace.JINGLE_APPS_RTP.equals(namespace)) { return RtpDescription.upgrade(description); @@ -90,9 +90,11 @@ public class Content extends Element { if (Namespace.JINGLE_TRANSPORTS_IBB.equals(namespace)) { return IbbTransportInfo.upgrade(transport); } else if (Namespace.JINGLE_TRANSPORTS_S5B.equals(namespace)) { - return S5BTransportInfo.upgrade(transport); + return SocksByteStreamsTransportInfo.upgrade(transport); } else if (Namespace.JINGLE_TRANSPORT_ICE_UDP.equals(namespace)) { return IceUdpTransportInfo.upgrade(transport); + } else if (Namespace.JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL.equals(namespace)) { + return WebRTCDataChannelTransportInfo.upgrade(transport); } else if (transport != null) { return GenericTransportInfo.upgrade(transport); } else { @@ -100,7 +102,6 @@ public class Content extends Element { } } - public void setTransport(GenericTransportInfo transportInfo) { this.addChild(transportInfo); } @@ -141,7 +142,7 @@ public class Content extends Element { } else if (attributes.contains("recvonly")) { return initiator ? RESPONDER : INITIATOR; } - Log.w(Config.LOGTAG,"assuming default value for senders"); + Log.w(Config.LOGTAG, "assuming default value for senders"); // If none of the attributes "sendonly", "recvonly", "inactive", and "sendrecv" is // present, "sendrecv" SHOULD be assumed as the default // https://www.rfc-editor.org/rfc/rfc4566 diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/FileTransferDescription.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/FileTransferDescription.java index 8e0f2ebadc0321cbd90f22c29d62739680803a8c..3878d98d94554a539280bc402cff9b2079df60ba 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/FileTransferDescription.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/FileTransferDescription.java @@ -1,89 +1,233 @@ package eu.siacs.conversations.xmpp.jingle.stanzas; -import com.google.common.base.Preconditions; +import android.util.Log; -import java.util.Arrays; -import java.util.List; +import androidx.annotation.NonNull; -import eu.siacs.conversations.crypto.axolotl.XmppAxolotlMessage; -import eu.siacs.conversations.entities.DownloadableFile; -import eu.siacs.conversations.xml.Element; +import com.google.common.base.CaseFormat; +import com.google.common.base.MoreObjects; +import com.google.common.base.Preconditions; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.io.BaseEncoding; +import com.google.common.primitives.Longs; -public class FileTransferDescription extends GenericDescription { +import eu.siacs.conversations.Config; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xml.Namespace; - public static List NAMESPACES = Arrays.asList( - Version.FT_3.namespace, - Version.FT_4.namespace, - Version.FT_5.namespace - ); +import java.util.List; +public class FileTransferDescription extends GenericDescription { - private FileTransferDescription(String name, String namespace) { - super(name, namespace); + private FileTransferDescription() { + super("description", Namespace.JINGLE_APPS_FILE_TRANSFER); } - public Version getVersion() { - final String namespace = getNamespace(); - if (namespace.equals(Version.FT_3.namespace)) { - return Version.FT_3; - } else if (namespace.equals(Version.FT_4.namespace)) { - return Version.FT_4; - } else if (namespace.equals(Version.FT_5.namespace)) { - return Version.FT_5; - } else { - throw new IllegalStateException("Unknown namespace"); + public static FileTransferDescription of(final File fileDescription) { + final var description = new FileTransferDescription(); + final var file = description.addChild("file", Namespace.JINGLE_APPS_FILE_TRANSFER); + file.addChild("name").setContent(fileDescription.name); + file.addChild("size").setContent(Long.toString(fileDescription.size)); + if (fileDescription.mediaType != null) { + file.addChild("mediaType").setContent(fileDescription.mediaType); } + return description; } - public Element getFileOffer() { - final Version version = getVersion(); - if (version == Version.FT_3) { - final Element offer = this.findChild("offer"); - return offer == null ? null : offer.findChild("file"); - } else { - return this.findChild("file"); + public File getFile() { + final Element fileElement = this.findChild("file", Namespace.JINGLE_APPS_FILE_TRANSFER); + if (fileElement == null) { + Log.d(Config.LOGTAG,"no file? "+this); + throw new IllegalStateException("file transfer description has no file"); } + final String name = fileElement.findChildContent("name"); + final String sizeAsString = fileElement.findChildContent("size"); + final String mediaType = fileElement.findChildContent("mediaType"); + if (Strings.isNullOrEmpty(name) || Strings.isNullOrEmpty(sizeAsString)) { + throw new IllegalStateException("File definition is missing name and/or size"); + } + final Long size = Longs.tryParse(sizeAsString); + if (size == null) { + throw new IllegalStateException("Invalid file size"); + } + final List hashes = findHashes(fileElement.getChildren()); + return new File(size, name, mediaType, hashes); } - public static FileTransferDescription of(DownloadableFile file, Version version, XmppAxolotlMessage axolotlMessage) { - final FileTransferDescription description = new FileTransferDescription("description", version.getNamespace()); - final Element fileElement; - if (version == Version.FT_3) { - Element offer = description.addChild("offer"); - fileElement = offer.addChild("file"); - } else { - fileElement = description.addChild("file"); + public static SessionInfo getSessionInfo(@NonNull final JinglePacket jinglePacket) { + Preconditions.checkNotNull(jinglePacket); + Preconditions.checkArgument( + jinglePacket.getAction() == JinglePacket.Action.SESSION_INFO, + "jingle packet is not a session-info"); + final Element jingle = jinglePacket.findChild("jingle", Namespace.JINGLE); + if (jingle == null) { + return null; } - fileElement.addChild("size").setContent(Long.toString(file.getExpectedSize())); - fileElement.addChild("name").setContent(file.getName()); - if (axolotlMessage != null) { - fileElement.addChild(axolotlMessage.toElement()); + final Element checksum = jingle.findChild("checksum", Namespace.JINGLE_APPS_FILE_TRANSFER); + if (checksum != null) { + final Element file = checksum.findChild("file", Namespace.JINGLE_APPS_FILE_TRANSFER); + final String name = checksum.getAttribute("name"); + if (file == null || Strings.isNullOrEmpty(name)) { + return null; + } + return new Checksum(name, findHashes(file.getChildren())); } - return description; + final Element received = jingle.findChild("received", Namespace.JINGLE_APPS_FILE_TRANSFER); + if (received != null) { + final String name = received.getAttribute("name"); + if (Strings.isNullOrEmpty(name)) { + return new Received(name); + } + } + return null; + } + + private static List findHashes(final List elements) { + final ImmutableList.Builder hashes = new ImmutableList.Builder<>(); + for (final Element child : elements) { + if ("hash".equals(child.getName()) && Namespace.HASHES.equals(child.getNamespace())) { + final Algorithm algorithm; + try { + algorithm = Algorithm.of(child.getAttribute("algo")); + } catch (final IllegalArgumentException e) { + continue; + } + final String content = child.getContent(); + if (Strings.isNullOrEmpty(content)) { + continue; + } + if (BaseEncoding.base64().canDecode(content)) { + hashes.add(new Hash(BaseEncoding.base64().decode(content), algorithm)); + } + } + } + return hashes.build(); } public static FileTransferDescription upgrade(final Element element) { - Preconditions.checkArgument("description".equals(element.getName()), "Name of provided element is not description"); - Preconditions.checkArgument(NAMESPACES.contains(element.getNamespace()), "Element does not match a file transfer namespace"); - final FileTransferDescription description = new FileTransferDescription("description", element.getNamespace()); + Preconditions.checkArgument( + "description".equals(element.getName()), + "Name of provided element is not description"); + Preconditions.checkArgument( + element.getNamespace().equals(Namespace.JINGLE_APPS_FILE_TRANSFER), + "Element does not match a file transfer namespace"); + final FileTransferDescription description = new FileTransferDescription(); description.setAttributes(element.getAttributes()); description.setChildren(element.getChildren()); return description; } - public enum Version { - FT_3("urn:xmpp:jingle:apps:file-transfer:3"), - FT_4("urn:xmpp:jingle:apps:file-transfer:4"), - FT_5("urn:xmpp:jingle:apps:file-transfer:5"); + public static final class Checksum extends SessionInfo { + public final List hashes; + + public Checksum(final String name, List hashes) { + super(name); + this.hashes = hashes; + } + + @Override + @NonNull + public String toString() { + return MoreObjects.toStringHelper(this).add("hashes", hashes).toString(); + } + + @Override + public Element asElement() { + final var checksum = new Element("checksum", Namespace.JINGLE_APPS_FILE_TRANSFER); + checksum.setAttribute("name", name); + final var file = checksum.addChild("file", Namespace.JINGLE_APPS_FILE_TRANSFER); + for (final Hash hash : hashes) { + final var element = file.addChild("hash", Namespace.HASHES); + element.setAttribute( + "algo", + CaseFormat.UPPER_UNDERSCORE.to( + CaseFormat.LOWER_HYPHEN, hash.algorithm.toString())); + element.setContent(BaseEncoding.base64().encode(hash.hash)); + } + return checksum; + } + } + + public static final class Received extends SessionInfo { + + public Received(String name) { + super(name); + } + + @Override + public Element asElement() { + final var element = new Element("received", Namespace.JINGLE_APPS_FILE_TRANSFER); + element.setAttribute("name", name); + return element; + } + } + + public abstract static sealed class SessionInfo permits Checksum, Received { - private final String namespace; + public final String name; - Version(String namespace) { - this.namespace = namespace; + protected SessionInfo(final String name) { + this.name = name; } - public String getNamespace() { - return namespace; + public abstract Element asElement(); + } + + public static class File { + public final long size; + public final String name; + public final String mediaType; + + public final List hashes; + + public File(long size, String name, String mediaType, List hashes) { + this.size = size; + this.name = name; + this.mediaType = mediaType; + this.hashes = hashes; + } + + @Override + @NonNull + public String toString() { + return MoreObjects.toStringHelper(this) + .add("size", size) + .add("name", name) + .add("mediaType", mediaType) + .add("hashes", hashes) + .toString(); + } + } + + public static class Hash { + public final byte[] hash; + public final Algorithm algorithm; + + public Hash(byte[] hash, Algorithm algorithm) { + this.hash = hash; + this.algorithm = algorithm; + } + + @Override + @NonNull + public String toString() { + return MoreObjects.toStringHelper(this) + .add("hash", hash) + .add("algorithm", algorithm) + .toString(); + } + } + + public enum Algorithm { + SHA_1, + SHA_256; + + public static Algorithm of(final String value) { + if (Strings.isNullOrEmpty(value)) { + return null; + } + return valueOf(CaseFormat.LOWER_HYPHEN.to(CaseFormat.UPPER_UNDERSCORE, value)); } } } diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/GenericDescription.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/GenericDescription.java index a8db0d09f9ee37dde138c9915f4c30163eb6c62e..3bb3076a7d38e327e67b0f63df62089fd3a98f69 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/GenericDescription.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/GenericDescription.java @@ -8,6 +8,7 @@ public class GenericDescription extends Element { GenericDescription(String name, final String namespace) { super(name, namespace); + Preconditions.checkArgument("description".equals(name)); } public static GenericDescription upgrade(final Element element) { diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Group.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Group.java index 2f7873a860fc2d23dcabc75251578c333fc27b01..17f0b4567ea858ee16a7613a101139b86540ca7d 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Group.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Group.java @@ -41,7 +41,7 @@ public class Group extends Element { } public static Group ofSdpString(final String input) { - ImmutableList.Builder tagBuilder = new ImmutableList.Builder<>(); + final ImmutableList.Builder tagBuilder = new ImmutableList.Builder<>(); final String[] parts = input.split(" "); if (parts.length >= 2) { final String semantics = parts[0]; diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IbbTransportInfo.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IbbTransportInfo.java index 90fb32903f87c55c5709cfaaeefbb8a7582a2847..ddab9640f62d901ff3def8262bcbb1d72bb8178f 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IbbTransportInfo.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IbbTransportInfo.java @@ -1,6 +1,8 @@ package eu.siacs.conversations.xmpp.jingle.stanzas; import com.google.common.base.Preconditions; +import com.google.common.base.Strings; +import com.google.common.primitives.Longs; import eu.siacs.conversations.xml.Element; import eu.siacs.conversations.xml.Namespace; @@ -23,16 +25,9 @@ public class IbbTransportInfo extends GenericTransportInfo { return this.getAttribute("sid"); } - public int getBlockSize() { + public Long getBlockSize() { final String blockSize = this.getAttribute("block-size"); - if (blockSize == null) { - return 0; - } - try { - return Integer.parseInt(blockSize); - } catch (NumberFormatException e) { - return 0; - } + return Strings.isNullOrEmpty(blockSize) ? null : Longs.tryParse(blockSize); } public static IbbTransportInfo upgrade(final Element element) { diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IceUdpTransportInfo.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IceUdpTransportInfo.java index 8c8a9683d09f08bd1c0b666c0d59335a8b1250f2..c2afe72f625614e09d727952ee1b7d0cc625b5dc 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IceUdpTransportInfo.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IceUdpTransportInfo.java @@ -15,11 +15,13 @@ import com.google.common.collect.Collections2; import com.google.common.collect.ImmutableCollection; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; +import com.google.common.collect.Multimap; import eu.siacs.conversations.Config; import eu.siacs.conversations.xml.Element; import eu.siacs.conversations.xml.Namespace; import eu.siacs.conversations.xmpp.jingle.SessionDescription; +import eu.siacs.conversations.xmpp.jingle.transports.Transport; import java.util.Arrays; import java.util.Collection; @@ -198,7 +200,7 @@ public class IceUdpTransportInfo extends GenericTransportInfo { } } - public static class Candidate extends Element { + public static class Candidate extends Element implements Transport.Candidate { private Candidate() { super("candidate"); @@ -399,7 +401,7 @@ public class IceUdpTransportInfo extends GenericTransportInfo { return fingerprint; } - private static Fingerprint of(ArrayListMultimap attributes) { + private static Fingerprint of(final Multimap attributes) { final String fingerprint = Iterables.getFirst(attributes.get("fingerprint"), null); final String setup = Iterables.getFirst(attributes.get("setup"), null); if (setup != null && fingerprint != null) { diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/JinglePacket.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/JinglePacket.java index 0863b29df83e9c10b65939f6fe84a01d2cb51812..552046fb855e2106ed037997f9e2b09fab57b02c 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/JinglePacket.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/JinglePacket.java @@ -1,5 +1,7 @@ package eu.siacs.conversations.xmpp.jingle.stanzas; +import android.util.Log; + import androidx.annotation.NonNull; import com.google.common.base.CaseFormat; @@ -7,13 +9,16 @@ import com.google.common.base.Preconditions; import com.google.common.base.Strings; import com.google.common.collect.ImmutableMap; -import java.util.Map; - +import eu.siacs.conversations.Config; +import eu.siacs.conversations.crypto.axolotl.AxolotlService; +import eu.siacs.conversations.crypto.axolotl.XmppAxolotlMessage; import eu.siacs.conversations.xml.Element; import eu.siacs.conversations.xml.Namespace; import eu.siacs.conversations.xmpp.Jid; import eu.siacs.conversations.xmpp.stanzas.IqPacket; +import java.util.Map; + public class JinglePacket extends IqPacket { private JinglePacket() { @@ -36,7 +41,7 @@ public class JinglePacket extends IqPacket { return jinglePacket; } - //TODO deprecate this somehow and make file transfer fail if there are multiple (or something) + // TODO deprecate this somehow and make file transfer fail if there are multiple (or something) public Content getJingleContent() { final Element content = getJingleChild("content"); return content == null ? null : Content.upgrade(content); @@ -64,7 +69,7 @@ public class JinglePacket extends IqPacket { return builder.build(); } - public void addJingleContent(final Content content) { //take content interface + public void addJingleContent(final Content content) { // take content interface addJingleChild(content); } @@ -94,13 +99,13 @@ public class JinglePacket extends IqPacket { } } - //RECOMMENDED for session-initiate, NOT RECOMMENDED otherwise + // RECOMMENDED for session-initiate, NOT RECOMMENDED otherwise public void setInitiator(final Jid initiator) { Preconditions.checkArgument(initiator.isFullJid(), "initiator should be a full JID"); findChild("jingle", Namespace.JINGLE).setAttribute("initiator", initiator); } - //RECOMMENDED for session-accept, NOT RECOMMENDED otherwise + // RECOMMENDED for session-accept, NOT RECOMMENDED otherwise public void setResponder(Jid responder) { Preconditions.checkArgument(responder.isFullJid(), "responder should be a full JID"); findChild("jingle", Namespace.JINGLE).setAttribute("responder", responder); @@ -116,6 +121,39 @@ public class JinglePacket extends IqPacket { jingle.addChild(child); } + public void setSecurity(final String name, final XmppAxolotlMessage xmppAxolotlMessage) { + final Element security = new Element("security", Namespace.JINGLE_ENCRYPTED_TRANSPORT); + security.setAttribute("name", name); + security.setAttribute("cipher", "urn:xmpp:ciphers:aes-128-gcm-nopadding"); + security.setAttribute("type", AxolotlService.PEP_PREFIX); + security.addChild(xmppAxolotlMessage.toElement()); + addJingleChild(security); + } + + public XmppAxolotlMessage getSecurity(final String nameNeedle) { + final Element jingle = findChild("jingle", Namespace.JINGLE); + if (jingle == null) { + return null; + } + for (final Element child : jingle.getChildren()) { + if ("security".equals(child.getName()) + && Namespace.JINGLE_ENCRYPTED_TRANSPORT.equals(child.getNamespace())) { + final String name = child.getAttribute("name"); + final String type = child.getAttribute("type"); + final String cipher = child.getAttribute("cipher"); + if (nameNeedle.equals(name) + && AxolotlService.PEP_PREFIX.equals(type) + && "urn:xmpp:ciphers:aes-128-gcm-nopadding".equals(cipher)) { + final var encrypted = child.findChild("encrypted", AxolotlService.PEP_PREFIX); + if (encrypted != null) { + return XmppAxolotlMessage.fromElement(encrypted, getFrom().asBareJid()); + } + } + } + } + return null; + } + public String getSessionId() { return findChild("jingle", Namespace.JINGLE).getAttribute("sid"); } @@ -142,7 +180,7 @@ public class JinglePacket extends IqPacket { TRANSPORT_REPLACE; public static Action of(final String value) { - //TODO handle invalid + // TODO handle invalid return Action.valueOf(CaseFormat.LOWER_HYPHEN.to(CaseFormat.UPPER_UNDERSCORE, value)); } @@ -153,7 +191,6 @@ public class JinglePacket extends IqPacket { } } - public static class ReasonWrapper { public final Reason reason; public final String text; diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Propose.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Propose.java index f72162be8e5896e22184ee41f699c1caafa0e13a..5236e7bc9fd4033f516e4775f42f9b3c2b504098 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Propose.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/Propose.java @@ -18,7 +18,7 @@ public class Propose extends Element { for (final Element child : getChildren()) { if ("description".equals(child.getName())) { final String namespace = child.getNamespace(); - if (FileTransferDescription.NAMESPACES.contains(namespace)) { + if (Namespace.JINGLE_APPS_FILE_TRANSFER.equals(namespace)) { builder.add(FileTransferDescription.upgrade(child)); } else if (Namespace.JINGLE_APPS_RTP.equals(namespace)) { builder.add(RtpDescription.upgrade(child)); diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/S5BTransportInfo.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/S5BTransportInfo.java deleted file mode 100644 index 8f8f13416e207003e67c8a0961ea07936bf68724..0000000000000000000000000000000000000000 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/S5BTransportInfo.java +++ /dev/null @@ -1,50 +0,0 @@ -package eu.siacs.conversations.xmpp.jingle.stanzas; - -import com.google.common.base.Preconditions; - -import java.util.Collection; -import java.util.List; - -import eu.siacs.conversations.xml.Element; -import eu.siacs.conversations.xml.Namespace; -import eu.siacs.conversations.xmpp.jingle.JingleCandidate; - -public class S5BTransportInfo extends GenericTransportInfo { - - private S5BTransportInfo(final String name, final String xmlns) { - super(name, xmlns); - } - - public String getTransportId() { - return this.getAttribute("sid"); - } - - public S5BTransportInfo(final String transportId, final Collection candidates) { - super("transport", Namespace.JINGLE_TRANSPORTS_S5B); - Preconditions.checkNotNull(transportId,"transport id must not be null"); - for(JingleCandidate candidate : candidates) { - this.addChild(candidate.toElement()); - } - this.setAttribute("sid", transportId); - } - - public S5BTransportInfo(final String transportId, final Element child) { - super("transport", Namespace.JINGLE_TRANSPORTS_S5B); - Preconditions.checkNotNull(transportId,"transport id must not be null"); - this.addChild(child); - this.setAttribute("sid", transportId); - } - - public List getCandidates() { - return JingleCandidate.parse(this.getChildren()); - } - - public static S5BTransportInfo upgrade(final Element element) { - Preconditions.checkArgument("transport".equals(element.getName()), "Name of provided element is not transport"); - Preconditions.checkArgument(Namespace.JINGLE_TRANSPORTS_S5B.equals(element.getNamespace()), "Element does not match s5b transport namespace"); - final S5BTransportInfo transportInfo = new S5BTransportInfo("transport", Namespace.JINGLE_TRANSPORTS_S5B); - transportInfo.setAttributes(element.getAttributes()); - transportInfo.setChildren(element.getChildren()); - return transportInfo; - } -} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/SocksByteStreamsTransportInfo.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/SocksByteStreamsTransportInfo.java new file mode 100644 index 0000000000000000000000000000000000000000..fa5cee1b16bca82a192446020d909f948a0aedb4 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/SocksByteStreamsTransportInfo.java @@ -0,0 +1,117 @@ +package eu.siacs.conversations.xmpp.jingle.stanzas; + +import android.util.Log; + +import com.google.common.base.Preconditions; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xml.Namespace; +import eu.siacs.conversations.xmpp.jingle.transports.SocksByteStreamsTransport; + +import java.util.Collection; +import java.util.List; + +public class SocksByteStreamsTransportInfo extends GenericTransportInfo { + + private SocksByteStreamsTransportInfo() { + super("transport", Namespace.JINGLE_TRANSPORTS_S5B); + } + + public String getTransportId() { + return this.getAttribute("sid"); + } + + public SocksByteStreamsTransportInfo( + final String transportId, + final Collection candidates) { + super("transport", Namespace.JINGLE_TRANSPORTS_S5B); + Preconditions.checkNotNull(transportId, "transport id must not be null"); + for (SocksByteStreamsTransport.Candidate candidate : candidates) { + this.addChild(candidate.asElement()); + } + this.setAttribute("sid", transportId); + } + + public TransportInfo getTransportInfo() { + if (hasChild("proxy-error")) { + return new ProxyError(); + } else if (hasChild("candidate-error")) { + return new CandidateError(); + } else if (hasChild("candidate-used")) { + final Element candidateUsed = findChild("candidate-used"); + final String cid = candidateUsed == null ? null : candidateUsed.getAttribute("cid"); + if (Strings.isNullOrEmpty(cid)) { + return null; + } else { + return new CandidateUsed(cid); + } + } else if (hasChild("activated")) { + final Element activated = findChild("activated"); + final String cid = activated == null ? null : activated.getAttribute("cid"); + if (Strings.isNullOrEmpty(cid)) { + return null; + } else { + return new Activated(cid); + } + } else { + return null; + } + } + + public List getCandidates() { + final ImmutableList.Builder candidateBuilder = + new ImmutableList.Builder<>(); + for (final Element child : getChildren()) { + if ("candidate".equals(child.getName()) + && Namespace.JINGLE_TRANSPORTS_S5B.equals(child.getNamespace())) { + try { + candidateBuilder.add(SocksByteStreamsTransport.Candidate.of(child)); + } catch (final Exception e) { + Log.d(Config.LOGTAG, "skip over broken candidate", e); + } + } + } + return candidateBuilder.build(); + } + + public static SocksByteStreamsTransportInfo upgrade(final Element element) { + Preconditions.checkArgument( + "transport".equals(element.getName()), "Name of provided element is not transport"); + Preconditions.checkArgument( + Namespace.JINGLE_TRANSPORTS_S5B.equals(element.getNamespace()), + "Element does not match s5b transport namespace"); + final SocksByteStreamsTransportInfo transportInfo = new SocksByteStreamsTransportInfo(); + transportInfo.setAttributes(element.getAttributes()); + transportInfo.setChildren(element.getChildren()); + return transportInfo; + } + + public String getDestinationAddress() { + return this.getAttribute("dstaddr"); + } + + public abstract static class TransportInfo {} + + public static class CandidateUsed extends TransportInfo { + public final String cid; + + public CandidateUsed(String cid) { + this.cid = cid; + } + } + + public static class Activated extends TransportInfo { + public final String cid; + + public Activated(final String cid) { + this.cid = cid; + } + } + + public static class CandidateError extends TransportInfo {} + + public static class ProxyError extends TransportInfo {} +} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/WebRTCDataChannelTransportInfo.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/WebRTCDataChannelTransportInfo.java new file mode 100644 index 0000000000000000000000000000000000000000..88c4f9f000f72d8fc7e461d28608c98098070907 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/WebRTCDataChannelTransportInfo.java @@ -0,0 +1,111 @@ +package eu.siacs.conversations.xmpp.jingle.stanzas; + +import com.google.common.base.Preconditions; +import com.google.common.collect.Iterables; +import com.google.common.primitives.Ints; + +import java.util.Collections; +import java.util.Hashtable; +import java.util.List; + +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xml.Namespace; +import eu.siacs.conversations.xmpp.jingle.SessionDescription; +import eu.siacs.conversations.xmpp.jingle.transports.Transport; + +public class WebRTCDataChannelTransportInfo extends GenericTransportInfo { + + public static final WebRTCDataChannelTransportInfo STUB = new WebRTCDataChannelTransportInfo(); + + public WebRTCDataChannelTransportInfo() { + super("transport", Namespace.JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL); + } + + public static WebRTCDataChannelTransportInfo upgrade(final Element element) { + Preconditions.checkArgument( + "transport".equals(element.getName()), "Name of provided element is not transport"); + Preconditions.checkArgument( + Namespace.JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL.equals(element.getNamespace()), + "Element does not match ice-udp transport namespace"); + final WebRTCDataChannelTransportInfo transportInfo = new WebRTCDataChannelTransportInfo(); + transportInfo.setAttributes(element.getAttributes()); + transportInfo.setChildren(element.getChildren()); + return transportInfo; + } + + public IceUdpTransportInfo innerIceUdpTransportInfo() { + final var iceUdpTransportInfo = + this.findChild("transport", Namespace.JINGLE_TRANSPORT_ICE_UDP); + if (iceUdpTransportInfo != null) { + return IceUdpTransportInfo.upgrade(iceUdpTransportInfo); + } + return null; + } + + public static Transport.InitialTransportInfo of(final SessionDescription sessionDescription) { + final SessionDescription.Media media = Iterables.getOnlyElement(sessionDescription.media); + final String id = Iterables.getFirst(media.attributes.get("mid"), null); + Preconditions.checkNotNull(id, "media has no mid"); + final String maxMessageSize = + Iterables.getFirst(media.attributes.get("max-message-size"), null); + final Integer maxMessageSizeInt = + maxMessageSize == null ? null : Ints.tryParse(maxMessageSize); + final String sctpPort = Iterables.getFirst(media.attributes.get("sctp-port"), null); + final Integer sctpPortInt = sctpPort == null ? null : Ints.tryParse(sctpPort); + final WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo = + new WebRTCDataChannelTransportInfo(); + if (maxMessageSizeInt != null) { + webRTCDataChannelTransportInfo.setAttribute("max-message-size", maxMessageSizeInt); + } + if (sctpPortInt != null) { + webRTCDataChannelTransportInfo.setAttribute("sctp-port", sctpPortInt); + } + webRTCDataChannelTransportInfo.addChild(IceUdpTransportInfo.of(sessionDescription, media)); + + final String groupAttribute = + Iterables.getFirst(sessionDescription.attributes.get("group"), null); + final Group group = groupAttribute == null ? null : Group.ofSdpString(groupAttribute); + return new Transport.InitialTransportInfo(id, webRTCDataChannelTransportInfo, group); + } + + public Integer getSctpPort() { + final var attribute = this.getAttribute("sctp-port"); + if (attribute == null) { + return null; + } + return Ints.tryParse(attribute); + } + + public Integer getMaxMessageSize() { + final var attribute = this.getAttribute("max-message-size"); + if (attribute == null) { + return null; + } + return Ints.tryParse(attribute); + } + + public WebRTCDataChannelTransportInfo cloneWrapper() { + final var iceUdpTransport = this.innerIceUdpTransportInfo(); + final WebRTCDataChannelTransportInfo transportInfo = new WebRTCDataChannelTransportInfo(); + transportInfo.setAttributes(new Hashtable<>(getAttributes())); + transportInfo.addChild(iceUdpTransport.cloneWrapper()); + return transportInfo; + } + + public void addCandidate(final IceUdpTransportInfo.Candidate candidate) { + this.innerIceUdpTransportInfo().addChild(candidate); + } + + public List getCandidates() { + final var innerTransportInfo = this.innerIceUdpTransportInfo(); + if (innerTransportInfo == null) { + return Collections.emptyList(); + } + return innerTransportInfo.getCandidates(); + } + + public IceUdpTransportInfo.Credentials getCredentials() { + final var innerTransportInfo = this.innerIceUdpTransportInfo(); + return innerTransportInfo == null ? null : innerTransportInfo.getCredentials(); + } +} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/transports/InbandBytestreamsTransport.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/transports/InbandBytestreamsTransport.java new file mode 100644 index 0000000000000000000000000000000000000000..ce2d4b31f2a61298ca733bcb1fd1abff523fc73e --- /dev/null +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/transports/InbandBytestreamsTransport.java @@ -0,0 +1,321 @@ +package eu.siacs.conversations.xmpp.jingle.transports; + +import android.util.Log; + +import com.google.common.base.Strings; +import com.google.common.io.BaseEncoding; +import com.google.common.io.Closeables; +import com.google.common.primitives.Ints; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xml.Namespace; +import eu.siacs.conversations.xmpp.Jid; +import eu.siacs.conversations.xmpp.XmppConnection; +import eu.siacs.conversations.xmpp.jingle.stanzas.IbbTransportInfo; +import eu.siacs.conversations.xmpp.stanzas.IqPacket; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.io.OutputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +public class InbandBytestreamsTransport implements Transport { + + private static final int DEFAULT_BLOCK_SIZE = 8192; + + private final PipedInputStream pipedInputStream = new PipedInputStream(DEFAULT_BLOCK_SIZE); + private final PipedOutputStream pipedOutputStream = new PipedOutputStream(); + private final CountDownLatch terminationLatch = new CountDownLatch(1); + + private final XmppConnection xmppConnection; + + private final Jid with; + + private final boolean initiator; + + private final String streamId; + + private int blockSize; + private Callback transportCallback; + private final BlockSender blockSender; + + private final Thread blockSenderThread; + + private final AtomicBoolean isReceiving = new AtomicBoolean(false); + + public InbandBytestreamsTransport( + final XmppConnection xmppConnection, final Jid with, final boolean initiator) { + this(xmppConnection, with, initiator, UUID.randomUUID().toString(), DEFAULT_BLOCK_SIZE); + } + + public InbandBytestreamsTransport( + final XmppConnection xmppConnection, + final Jid with, + final boolean initiator, + final String streamId, + final int blockSize) { + this.xmppConnection = xmppConnection; + this.with = with; + this.initiator = initiator; + this.streamId = streamId; + this.blockSize = Math.min(DEFAULT_BLOCK_SIZE, blockSize); + this.blockSender = + new BlockSender(xmppConnection, with, streamId, this.blockSize, pipedInputStream); + this.blockSenderThread = new Thread(blockSender); + } + + public void setTransportCallback(final Callback callback) { + this.transportCallback = callback; + } + + public String getStreamId() { + return this.streamId; + } + + public void connect() { + if (initiator) { + openInBandTransport(); + } + } + + @Override + public CountDownLatch getTerminationLatch() { + return this.terminationLatch; + } + + private void openInBandTransport() { + final var iqPacket = new IqPacket(IqPacket.TYPE.SET); + iqPacket.setTo(with); + final var open = iqPacket.addChild("open", Namespace.IBB); + open.setAttribute("block-size", this.blockSize); + open.setAttribute("sid", this.streamId); + Log.d(Config.LOGTAG, "sending ibb open"); + Log.d(Config.LOGTAG, iqPacket.toString()); + xmppConnection.sendIqPacket(iqPacket, this::receiveResponseToOpen); + } + + private void receiveResponseToOpen(final Account account, final IqPacket response) { + if (response.getType() == IqPacket.TYPE.RESULT) { + Log.d(Config.LOGTAG, "ibb open was accepted"); + this.transportCallback.onTransportEstablished(); + this.blockSenderThread.start(); + } else { + this.transportCallback.onTransportSetupFailed(); + } + } + + public boolean deliverPacket( + final PacketType packetType, final Jid from, final Element payload) { + if (from == null || !from.equals(with)) { + Log.d( + Config.LOGTAG, + "ibb packet received from wrong address. was " + from + " expected " + with); + return false; + } + return switch (packetType) { + case OPEN -> receiveOpen(); + case DATA -> receiveData(payload.getContent()); + case CLOSE -> receiveClose(); + default -> throw new IllegalArgumentException("Invalid packet type"); + }; + } + + private boolean receiveData(final String encoded) { + final byte[] buffer; + if (Strings.isNullOrEmpty(encoded)) { + buffer = new byte[0]; + } else { + buffer = BaseEncoding.base64().decode(encoded); + } + Log.d(Config.LOGTAG, "ibb received " + buffer.length + " bytes"); + try { + pipedOutputStream.write(buffer); + pipedOutputStream.flush(); + return true; + } catch (final IOException e) { + Log.d(Config.LOGTAG, "unable to receive ibb data", e); + return false; + } + } + + private boolean receiveClose() { + if (this.isReceiving.compareAndSet(true, false)) { + try { + this.pipedOutputStream.close(); + return true; + } catch (final IOException e) { + Log.d(Config.LOGTAG, "could not close pipedOutStream"); + return false; + } + } else { + Log.d(Config.LOGTAG, "received ibb close but was not receiving"); + return false; + } + } + + private boolean receiveOpen() { + Log.d(Config.LOGTAG, "receiveOpen()"); + if (this.isReceiving.get()) { + Log.d(Config.LOGTAG, "ibb received open even though we were already open"); + return false; + } + this.isReceiving.set(true); + transportCallback.onTransportEstablished(); + return true; + } + + public void terminate() { + // TODO send close + Log.d(Config.LOGTAG, "IbbTransport.terminate()"); + this.terminationLatch.countDown(); + this.blockSender.close(); + this.blockSenderThread.interrupt(); + closeQuietly(this.pipedOutputStream); + } + + private static void closeQuietly(final OutputStream outputStream) { + try { + outputStream.close(); + } catch (final IOException ignored) { + + } + } + + @Override + public OutputStream getOutputStream() throws IOException { + final var outputStream = new PipedOutputStream(); + this.pipedInputStream.connect(outputStream); + return outputStream; + } + + @Override + public InputStream getInputStream() throws IOException { + final var inputStream = new PipedInputStream(); + this.pipedOutputStream.connect(inputStream); + return inputStream; + } + + @Override + public ListenableFuture asTransportInfo() { + return Futures.immediateFuture( + new TransportInfo(new IbbTransportInfo(streamId, blockSize), null)); + } + + @Override + public ListenableFuture asInitialTransportInfo() { + return Futures.immediateFuture( + new InitialTransportInfo( + UUID.randomUUID().toString(), + new IbbTransportInfo(streamId, blockSize), + null)); + } + + public void setPeerBlockSize(long peerBlockSize) { + this.blockSize = Math.min(Ints.saturatedCast(peerBlockSize), DEFAULT_BLOCK_SIZE); + if (this.blockSize < DEFAULT_BLOCK_SIZE) { + Log.d(Config.LOGTAG, "peer reconfigured IBB block size to " + this.blockSize); + } + this.blockSender.setBlockSize(this.blockSize); + } + + private static class BlockSender implements Runnable, Closeable { + + private final XmppConnection xmppConnection; + + private final Jid with; + private final String streamId; + + private int blockSize; + private final PipedInputStream inputStream; + private final Semaphore semaphore = new Semaphore(3); + private final AtomicInteger sequencer = new AtomicInteger(); + private final AtomicBoolean isSending = new AtomicBoolean(true); + + private BlockSender( + XmppConnection xmppConnection, + final Jid with, + String streamId, + int blockSize, + PipedInputStream inputStream) { + this.xmppConnection = xmppConnection; + this.with = with; + this.streamId = streamId; + this.blockSize = blockSize; + this.inputStream = inputStream; + } + + @Override + public void run() { + final var buffer = new byte[blockSize]; + try { + while (isSending.get()) { + final int count = this.inputStream.read(buffer); + if (count < 0) { + Log.d(Config.LOGTAG, "block sender reached EOF"); + return; + } + this.semaphore.acquire(); + final var block = new byte[count]; + System.arraycopy(buffer, 0, block, 0, block.length); + sendIbbBlock(sequencer.getAndIncrement(), block); + } + } catch (final InterruptedException | InterruptedIOException e) { + if (isSending.get()) { + Log.w(Config.LOGTAG, "IbbBlockSender got interrupted while sending", e); + } + } catch (final IOException e) { + Log.d(Config.LOGTAG, "block sender terminated", e); + } finally { + Closeables.closeQuietly(inputStream); + } + } + + private void sendIbbBlock(final int sequence, final byte[] block) { + Log.d(Config.LOGTAG, "sending ibb block #" + sequence + " " + block.length + " bytes"); + final var iqPacket = new IqPacket(IqPacket.TYPE.SET); + iqPacket.setTo(with); + final var data = iqPacket.addChild("data", Namespace.IBB); + data.setAttribute("sid", this.streamId); + data.setAttribute("seq", sequence); + data.setContent(BaseEncoding.base64().encode(block)); + this.xmppConnection.sendIqPacket( + iqPacket, + (a, response) -> { + if (response.getType() != IqPacket.TYPE.RESULT) { + Log.d( + Config.LOGTAG, + "received iq error in response to data block #" + sequence); + isSending.set(false); + } + semaphore.release(); + }); + } + + @Override + public void close() { + this.isSending.set(false); + } + + public void setBlockSize(final int blockSize) { + this.blockSize = blockSize; + } + } + + public enum PacketType { + OPEN, + DATA, + CLOSE + } +} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/transports/SocksByteStreamsTransport.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/transports/SocksByteStreamsTransport.java new file mode 100644 index 0000000000000000000000000000000000000000..bbda1c62271125a9d0af2753c724c903aa05d5ec --- /dev/null +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/transports/SocksByteStreamsTransport.java @@ -0,0 +1,901 @@ +package eu.siacs.conversations.xmpp.jingle.transports; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.common.base.Joiner; +import com.google.common.base.MoreObjects; +import com.google.common.base.Optional; +import com.google.common.base.Preconditions; +import com.google.common.base.Strings; +import com.google.common.collect.Collections2; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; +import com.google.common.collect.Ordering; +import com.google.common.hash.Hashing; +import com.google.common.io.ByteStreams; +import com.google.common.primitives.Ints; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.SettableFuture; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.utils.SocksSocketFactory; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xml.Namespace; +import eu.siacs.conversations.xmpp.Jid; +import eu.siacs.conversations.xmpp.XmppConnection; +import eu.siacs.conversations.xmpp.jingle.AbstractJingleConnection; +import eu.siacs.conversations.xmpp.jingle.DirectConnectionUtils; +import eu.siacs.conversations.xmpp.jingle.stanzas.SocksByteStreamsTransportInfo; +import eu.siacs.conversations.xmpp.stanzas.IqPacket; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketAddress; +import java.net.SocketException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; + +public class SocksByteStreamsTransport implements Transport { + + private final XmppConnection xmppConnection; + + private final AbstractJingleConnection.Id id; + + private final boolean initiator; + private final boolean useTor; + + private final String streamId; + + private ImmutableList theirCandidates; + private final String theirDestination; + private final SettableFuture selectedByThemCandidate = SettableFuture.create(); + private final SettableFuture theirProxyActivation = SettableFuture.create(); + + private final CountDownLatch terminationLatch = new CountDownLatch(1); + + private final ConnectionProvider connectionProvider; + private final ListenableFuture ourProxyConnection; + + private Connection connection; + + private Callback transportCallback; + + public SocksByteStreamsTransport( + final XmppConnection xmppConnection, + final AbstractJingleConnection.Id id, + final boolean initiator, + final boolean useTor, + final String streamId, + final Collection theirCandidates) { + this.xmppConnection = xmppConnection; + this.id = id; + this.initiator = initiator; + this.useTor = useTor; + this.streamId = streamId; + this.theirDestination = + Hashing.sha1() + .hashString( + Joiner.on("") + .join( + Arrays.asList( + streamId, + id.with.toEscapedString(), + id.account.getJid().toEscapedString())), + StandardCharsets.UTF_8) + .toString(); + final var ourDestination = + Hashing.sha1() + .hashString( + Joiner.on("") + .join( + Arrays.asList( + streamId, + id.account.getJid().toEscapedString(), + id.with.toEscapedString())), + StandardCharsets.UTF_8) + .toString(); + + this.connectionProvider = + new ConnectionProvider(id.account.getJid(), ourDestination, useTor); + new Thread(connectionProvider).start(); + this.ourProxyConnection = getOurProxyConnection(ourDestination); + setTheirCandidates(theirCandidates); + } + + public SocksByteStreamsTransport( + final XmppConnection xmppConnection, + final AbstractJingleConnection.Id id, + final boolean initiator, + final boolean useTor) { + this( + xmppConnection, + id, + initiator, + useTor, + UUID.randomUUID().toString(), + Collections.emptyList()); + } + + public void connectTheirCandidates() { + Preconditions.checkState( + this.transportCallback != null, "transport callback needs to be set"); + // TODO this needs to go into a variable so we can cancel it + final var connectionFinder = + new ConnectionFinder( + theirCandidates, theirDestination, selectedByThemCandidate, useTor); + new Thread(connectionFinder).start(); + Futures.addCallback( + connectionFinder.connectionFuture, + new FutureCallback<>() { + @Override + public void onSuccess(final Connection connection) { + final Candidate candidate = connection.candidate; + transportCallback.onCandidateUsed(streamId, candidate); + establishTransport(connection); + } + + @Override + public void onFailure(@NonNull final Throwable throwable) { + if (throwable instanceof CandidateErrorException) { + transportCallback.onCandidateError(streamId); + } + establishTransport(null); + } + }, + MoreExecutors.directExecutor()); + } + + private void establishTransport(final Connection selectedByUs) { + Futures.addCallback( + selectedByThemCandidate, + new FutureCallback<>() { + @Override + public void onSuccess(Connection result) { + establishTransport(selectedByUs, result); + } + + @Override + public void onFailure(@NonNull Throwable throwable) { + establishTransport(selectedByUs, null); + } + }, + MoreExecutors.directExecutor()); + } + + private void establishTransport( + final Connection selectedByUs, final Connection selectedByThem) { + final var selection = selectConnection(selectedByUs, selectedByThem); + if (selection == null) { + transportCallback.onTransportSetupFailed(); + return; + } + if (selection.connection.candidate.type == CandidateType.DIRECT) { + Log.d(Config.LOGTAG, "final selection " + selection.connection.candidate); + this.connection = selection.connection; + this.transportCallback.onTransportEstablished(); + } else { + final ListenableFuture proxyActivation; + if (selection.owner == Owner.THEIRS) { + proxyActivation = this.theirProxyActivation; + } else { + proxyActivation = activateProxy(selection.connection.candidate); + } + Log.d(Config.LOGTAG, "waiting for proxy activation"); + Futures.addCallback( + proxyActivation, + new FutureCallback<>() { + @Override + public void onSuccess(final String cid) { + // TODO compare cid to selection.connection.candidate + connection = selection.connection; + transportCallback.onTransportEstablished(); + } + + @Override + public void onFailure(@NonNull Throwable throwable) { + Log.d(Config.LOGTAG, "failed to activate proxy"); + } + }, + MoreExecutors.directExecutor()); + } + } + + private ConnectionWithOwner selectConnection( + final Connection selectedByUs, final Connection selectedByThem) { + if (selectedByUs != null && selectedByThem != null) { + if (selectedByUs.candidate.priority == selectedByThem.candidate.priority) { + return initiator + ? new ConnectionWithOwner(selectedByUs, Owner.THEIRS) + : new ConnectionWithOwner(selectedByThem, Owner.OURS); + } else if (selectedByUs.candidate.priority > selectedByThem.candidate.priority) { + return new ConnectionWithOwner(selectedByUs, Owner.THEIRS); + } else { + return new ConnectionWithOwner(selectedByThem, Owner.OURS); + } + } + if (selectedByUs != null) { + return new ConnectionWithOwner(selectedByUs, Owner.THEIRS); + } + if (selectedByThem != null) { + return new ConnectionWithOwner(selectedByThem, Owner.OURS); + } + return null; + } + + private ListenableFuture activateProxy(final Candidate candidate) { + Log.d(Config.LOGTAG, "trying to activate our proxy " + candidate); + final SettableFuture iqFuture = SettableFuture.create(); + final IqPacket proxyActivation = new IqPacket(IqPacket.TYPE.SET); + proxyActivation.setTo(candidate.jid); + final Element query = proxyActivation.addChild("query", Namespace.BYTE_STREAMS); + query.setAttribute("sid", this.streamId); + final Element activate = query.addChild("activate"); + activate.setContent(id.with.toEscapedString()); + xmppConnection.sendIqPacket( + proxyActivation, + (a, response) -> { + if (response.getType() == IqPacket.TYPE.RESULT) { + Log.d(Config.LOGTAG, "our proxy has been activated"); + transportCallback.onProxyActivated(this.streamId, candidate); + iqFuture.set(candidate.cid); + } else if (response.getType() == IqPacket.TYPE.TIMEOUT) { + iqFuture.setException(new TimeoutException()); + } else { + Log.d( + Config.LOGTAG, + a.getJid().asBareJid() + + ": failed to activate proxy on " + + candidate.jid); + iqFuture.setException(new IllegalStateException("Proxy activation failed")); + } + }); + return iqFuture; + } + + private ListenableFuture getOurProxyConnection(final String ourDestination) { + final var proxyFuture = getProxyCandidate(); + return Futures.transformAsync( + proxyFuture, + proxy -> { + final var connectionFinder = + new ConnectionFinder( + ImmutableList.of(proxy), ourDestination, null, useTor); + new Thread(connectionFinder).start(); + return Futures.transform( + connectionFinder.connectionFuture, + c -> { + try { + c.socket.setKeepAlive(true); + Log.d( + Config.LOGTAG, + "set keep alive on our own proxy connection"); + } catch (final SocketException e) { + throw new RuntimeException(e); + } + return c; + }, + MoreExecutors.directExecutor()); + }, + MoreExecutors.directExecutor()); + } + + private ListenableFuture getProxyCandidate() { + if (Config.DISABLE_PROXY_LOOKUP) { + return Futures.immediateFailedFuture( + new IllegalStateException("Proxy look up is disabled")); + } + final Jid streamer = xmppConnection.findDiscoItemByFeature(Namespace.BYTE_STREAMS); + if (streamer == null) { + return Futures.immediateFailedFuture( + new IllegalStateException("No proxy/streamer found")); + } + final IqPacket iqRequest = new IqPacket(IqPacket.TYPE.GET); + iqRequest.setTo(streamer); + iqRequest.query(Namespace.BYTE_STREAMS); + final SettableFuture candidateFuture = SettableFuture.create(); + xmppConnection.sendIqPacket( + iqRequest, + (a, response) -> { + if (response.getType() == IqPacket.TYPE.RESULT) { + final Element query = response.findChild("query", Namespace.BYTE_STREAMS); + final Element streamHost = + query == null + ? null + : query.findChild("streamhost", Namespace.BYTE_STREAMS); + final String host = + streamHost == null ? null : streamHost.getAttribute("host"); + final Integer port = + Ints.tryParse( + Strings.nullToEmpty( + streamHost == null + ? null + : streamHost.getAttribute("port"))); + if (Strings.isNullOrEmpty(host) || port == null) { + candidateFuture.setException( + new IOException("Proxy response is missing attributes")); + return; + } + candidateFuture.set( + new Candidate( + UUID.randomUUID().toString(), + host, + streamer, + port, + 655360 + (initiator ? 0 : 15), + CandidateType.PROXY)); + + } else if (response.getType() == IqPacket.TYPE.TIMEOUT) { + candidateFuture.setException(new TimeoutException()); + } else { + candidateFuture.setException( + new IOException( + "received iq error in response to proxy discovery")); + } + }); + return candidateFuture; + } + + @Override + public OutputStream getOutputStream() throws IOException { + final var connection = this.connection; + if (connection == null) { + throw new IOException("No candidate has been selected yet"); + } + return connection.socket.getOutputStream(); + } + + @Override + public InputStream getInputStream() throws IOException { + final var connection = this.connection; + if (connection == null) { + throw new IOException("No candidate has been selected yet"); + } + return connection.socket.getInputStream(); + } + + @Override + public ListenableFuture asTransportInfo() { + final ListenableFuture> proxyConnections = + getOurProxyConnectionsFuture(); + return Futures.transform( + proxyConnections, + proxies -> { + final var candidateBuilder = new ImmutableList.Builder(); + candidateBuilder.addAll(this.connectionProvider.candidates); + candidateBuilder.addAll(Collections2.transform(proxies, p -> p.candidate)); + final var transportInfo = + new SocksByteStreamsTransportInfo( + this.streamId, candidateBuilder.build()); + return new TransportInfo(transportInfo, null); + }, + MoreExecutors.directExecutor()); + } + + @Override + public ListenableFuture asInitialTransportInfo() { + return Futures.transform( + asTransportInfo(), + ti -> + new InitialTransportInfo( + UUID.randomUUID().toString(), ti.transportInfo, ti.group), + MoreExecutors.directExecutor()); + } + + private ListenableFuture> getOurProxyConnectionsFuture() { + return Futures.catching( + Futures.transform( + this.ourProxyConnection, + Collections::singleton, + MoreExecutors.directExecutor()), + Exception.class, + ex -> { + Log.d(Config.LOGTAG, "could not find a proxy of our own", ex); + return Collections.emptyList(); + }, + MoreExecutors.directExecutor()); + } + + private Collection getOurProxyConnections() { + final var future = getOurProxyConnectionsFuture(); + if (future.isDone()) { + try { + return future.get(); + } catch (final Exception e) { + return Collections.emptyList(); + } + } else { + return Collections.emptyList(); + } + } + + @Override + public void terminate() { + Log.d(Config.LOGTAG, "terminating socks transport"); + this.terminationLatch.countDown(); + final var connection = this.connection; + if (connection != null) { + closeSocket(connection.socket); + } + this.connectionProvider.close(); + } + + @Override + public void setTransportCallback(final Callback callback) { + this.transportCallback = callback; + } + + @Override + public void connect() { + this.connectTheirCandidates(); + } + + @Override + public CountDownLatch getTerminationLatch() { + return this.terminationLatch; + } + + public boolean setCandidateUsed(final String cid) { + final var ourProxyConnections = getOurProxyConnections(); + final var proxyConnection = + Iterables.tryFind(ourProxyConnections, c -> c.candidate.cid.equals(cid)); + if (proxyConnection.isPresent()) { + this.selectedByThemCandidate.set(proxyConnection.get()); + return true; + } + + // the peer selected a connection that is not our proxy. so we can close our proxies + closeConnections(ourProxyConnections); + + final var connection = this.connectionProvider.findPeerConnection(cid); + if (connection.isPresent()) { + this.selectedByThemCandidate.set(connection.get()); + return true; + } else { + Log.d(Config.LOGTAG, "none of the connected candidates has cid " + cid); + return false; + } + } + + public void setCandidateError() { + this.selectedByThemCandidate.setException( + new CandidateErrorException("Remote could not connect to any of our candidates")); + } + + public void setProxyActivated(final String cid) { + this.theirProxyActivation.set(cid); + } + + public void setProxyError() { + this.theirProxyActivation.setException( + new IllegalStateException("Remote could not activate their proxy")); + } + + public void setTheirCandidates(Collection candidates) { + this.theirCandidates = + Ordering.from( + (Comparator) + (o1, o2) -> Integer.compare(o2.priority, o1.priority)) + .immutableSortedCopy(candidates); + } + + private static void closeSocket(final Socket socket) { + try { + socket.close(); + } catch (final IOException e) { + Log.w(Config.LOGTAG, "error closing socket", e); + } + } + + private static class ConnectionProvider implements Runnable { + + private final ExecutorService clientConnectionExecutorService = + Executors.newFixedThreadPool(4); + + private final ImmutableList candidates; + + private final int port; + + private final AtomicBoolean acceptingConnections = new AtomicBoolean(true); + + private ServerSocket serverSocket; + + private final String destination; + + private final ArrayList peerConnections = new ArrayList<>(); + + private ConnectionProvider( + final Jid account, final String destination, final boolean useTor) { + final SecureRandom secureRandom = new SecureRandom(); + this.port = secureRandom.nextInt(60_000) + 1024; + this.destination = destination; + final InetAddress[] localAddresses; + if (Config.USE_DIRECT_JINGLE_CANDIDATES && !useTor) { + localAddresses = + DirectConnectionUtils.getLocalAddresses().toArray(new InetAddress[0]); + } else { + localAddresses = new InetAddress[0]; + } + final var candidateBuilder = new ImmutableList.Builder(); + for (int i = 0; i < localAddresses.length; ++i) { + final var inetAddress = localAddresses[i]; + candidateBuilder.add( + new Candidate( + UUID.randomUUID().toString(), + inetAddress.getHostAddress(), + account, + port, + 8257536 + i, + CandidateType.DIRECT)); + } + this.candidates = candidateBuilder.build(); + } + + @Override + public void run() { + if (this.candidates.isEmpty()) { + Log.d(Config.LOGTAG, "no direct candidates. stopping ConnectionProvider"); + return; + } + try (final ServerSocket serverSocket = new ServerSocket(this.port)) { + this.serverSocket = serverSocket; + while (acceptingConnections.get()) { + final Socket clientSocket; + try { + clientSocket = serverSocket.accept(); + } catch (final SocketException ignored) { + Log.d(Config.LOGTAG, "server socket has been closed."); + return; + } + clientConnectionExecutorService.execute( + () -> acceptClientConnection(clientSocket)); + } + } catch (final IOException e) { + Log.d(Config.LOGTAG, "could not create server socket", e); + } + } + + private void acceptClientConnection(final Socket socket) { + final var localAddress = socket.getLocalAddress(); + final var hostAddress = localAddress == null ? null : localAddress.getHostAddress(); + final var candidate = + Iterables.tryFind(this.candidates, c -> c.host.equals(hostAddress)); + if (candidate.isPresent()) { + acceptingConnections(socket, candidate.get()); + + } else { + closeSocket(socket); + Log.d(Config.LOGTAG, "no local candidate found for connection on " + hostAddress); + } + } + + private void acceptingConnections(final Socket socket, final Candidate candidate) { + final var remoteAddress = socket.getRemoteSocketAddress(); + Log.d( + Config.LOGTAG, + "accepted client connection from " + remoteAddress + " to " + candidate); + try { + socket.setSoTimeout(3000); + final byte[] authBegin = new byte[2]; + final InputStream inputStream = socket.getInputStream(); + final OutputStream outputStream = socket.getOutputStream(); + ByteStreams.readFully(inputStream, authBegin); + if (authBegin[0] != 0x5) { + socket.close(); + } + final short methodCount = authBegin[1]; + final byte[] methods = new byte[methodCount]; + ByteStreams.readFully(inputStream, methods); + if (SocksSocketFactory.contains((byte) 0x00, methods)) { + outputStream.write(new byte[] {0x05, 0x00}); + } else { + outputStream.write(new byte[] {0x05, (byte) 0xff}); + } + final byte[] connectCommand = new byte[4]; + ByteStreams.readFully(inputStream, connectCommand); + if (connectCommand[0] == 0x05 + && connectCommand[1] == 0x01 + && connectCommand[3] == 0x03) { + int destinationCount = inputStream.read(); + final byte[] destination = new byte[destinationCount]; + ByteStreams.readFully(inputStream, destination); + final byte[] port = new byte[2]; + ByteStreams.readFully(inputStream, port); + final String receivedDestination = new String(destination); + final ByteBuffer response = ByteBuffer.allocate(7 + destination.length); + final byte[] responseHeader; + final boolean success; + if (receivedDestination.equals(this.destination)) { + responseHeader = new byte[] {0x05, 0x00, 0x00, 0x03}; + synchronized (this.peerConnections) { + peerConnections.add(new Connection(candidate, socket)); + } + success = true; + } else { + Log.d( + Config.LOGTAG, + "destination mismatch. received " + + receivedDestination + + " (expected " + + this.destination + + ")"); + responseHeader = new byte[] {0x05, 0x04, 0x00, 0x03}; + success = false; + } + response.put(responseHeader); + response.put((byte) destination.length); + response.put(destination); + response.put(port); + outputStream.write(response.array()); + outputStream.flush(); + if (success) { + Log.d( + Config.LOGTAG, + remoteAddress + " successfully connected to " + candidate); + } else { + closeSocket(socket); + } + } + } catch (final IOException e) { + Log.d(Config.LOGTAG, "failed to accept client connection to " + candidate, e); + closeSocket(socket); + } + } + + private static void closeServerSocket(@Nullable final ServerSocket serverSocket) { + if (serverSocket == null) { + return; + } + try { + serverSocket.close(); + } catch (final IOException ignored) { + + } + } + + public Optional findPeerConnection(String cid) { + synchronized (this.peerConnections) { + return Iterables.tryFind( + this.peerConnections, connection -> connection.candidate.cid.equals(cid)); + } + } + + public void close() { + this.acceptingConnections.set(false); // we have probably done this earlier already + closeServerSocket(this.serverSocket); + synchronized (this.peerConnections) { + closeConnections(this.peerConnections); + this.peerConnections.clear(); + } + } + } + + private static void closeConnections(final Iterable connections) { + for (final var connection : connections) { + closeSocket(connection.socket); + } + } + + private static class ConnectionFinder implements Runnable { + + private final SettableFuture connectionFuture = SettableFuture.create(); + + private final ImmutableList candidates; + private final String destination; + + private final ListenableFuture selectedByThemCandidate; + private final boolean useTor; + + private ConnectionFinder( + final ImmutableList candidates, + final String destination, + final ListenableFuture selectedByThemCandidate, + final boolean useTor) { + this.candidates = candidates; + this.destination = destination; + this.selectedByThemCandidate = selectedByThemCandidate; + this.useTor = useTor; + } + + @Override + public void run() { + for (final Candidate candidate : this.candidates) { + final Integer selectedByThemCandidatePriority = + getSelectedByThemCandidatePriority(); + if (selectedByThemCandidatePriority != null + && selectedByThemCandidatePriority > candidate.priority) { + Log.d( + Config.LOGTAG, + "The candidate selected by peer had a higher priority then anything we could try"); + connectionFuture.setException( + new CandidateErrorException( + "The candidate selected by peer had a higher priority then anything we could try")); + return; + } + try { + connectionFuture.set(connect(candidate)); + Log.d(Config.LOGTAG, "connected to " + candidate); + return; + } catch (final IOException e) { + Log.d(Config.LOGTAG, "could not connect to candidate " + candidate); + } + } + connectionFuture.setException( + new CandidateErrorException( + String.format( + Locale.US, + "Gave up after %d candidates", + this.candidates.size()))); + } + + private Connection connect(final Candidate candidate) throws IOException { + final var timeout = 3000; + final Socket socket; + if (useTor) { + Log.d(Config.LOGTAG, "using Tor to connect to candidate " + candidate.host); + socket = SocksSocketFactory.createSocketOverTor(candidate.host, candidate.port); + } else { + socket = new Socket(); + final SocketAddress address = new InetSocketAddress(candidate.host, candidate.port); + socket.connect(address, timeout); + } + socket.setSoTimeout(timeout); + SocksSocketFactory.createSocksConnection(socket, destination, 0); + socket.setSoTimeout(0); + return new Connection(candidate, socket); + } + + private Integer getSelectedByThemCandidatePriority() { + final var future = this.selectedByThemCandidate; + if (future != null && future.isDone()) { + try { + final var connection = future.get(); + return connection.candidate.priority; + } catch (ExecutionException | InterruptedException e) { + return null; + } + } else { + return null; + } + } + } + + public static class CandidateErrorException extends IllegalStateException { + private CandidateErrorException(final String message) { + super(message); + } + } + + private enum Owner { + THEIRS, + OURS + } + + public static class ConnectionWithOwner { + public final Connection connection; + public final Owner owner; + + public ConnectionWithOwner(Connection connection, Owner owner) { + this.connection = connection; + this.owner = owner; + } + } + + public static class Connection { + + public final Candidate candidate; + public final Socket socket; + + public Connection(Candidate candidate, Socket socket) { + this.candidate = candidate; + this.socket = socket; + } + } + + public static class Candidate implements Transport.Candidate { + public final String cid; + public final String host; + public final Jid jid; + public final int port; + public final int priority; + public final CandidateType type; + + public Candidate( + final String cid, + final String host, + final Jid jid, + int port, + int priority, + final CandidateType type) { + this.cid = cid; + this.host = host; + this.jid = jid; + this.port = port; + this.priority = priority; + this.type = type; + } + + public static Candidate of(final Element element) { + Preconditions.checkArgument( + "candidate".equals(element.getName()), + "trying to construct candidate from non candidate element"); + Preconditions.checkArgument( + Namespace.JINGLE_TRANSPORTS_S5B.equals(element.getNamespace()), + "candidate element is in correct namespace"); + final String cid = element.getAttribute("cid"); + final String host = element.getAttribute("host"); + final String jid = element.getAttribute("jid"); + final String port = element.getAttribute("port"); + final String priority = element.getAttribute("priority"); + final String type = element.getAttribute("type"); + if (Strings.isNullOrEmpty(cid) + || Strings.isNullOrEmpty(host) + || Strings.isNullOrEmpty(jid) + || Strings.isNullOrEmpty(port) + || Strings.isNullOrEmpty(priority) + || Strings.isNullOrEmpty(type)) { + throw new IllegalArgumentException("Candidate is missing non optional attribute"); + } + return new Candidate( + cid, + host, + Jid.ofEscaped(jid), + Integer.parseInt(port), + Integer.parseInt(priority), + CandidateType.valueOf(type.toUpperCase(Locale.ROOT))); + } + + @Override + @NonNull + public String toString() { + return MoreObjects.toStringHelper(this) + .add("cid", cid) + .add("host", host) + .add("jid", jid) + .add("port", port) + .add("priority", priority) + .add("type", type) + .toString(); + } + + public Element asElement() { + final var element = new Element("candidate", Namespace.JINGLE_TRANSPORTS_S5B); + element.setAttribute("cid", this.cid); + element.setAttribute("host", this.host); + element.setAttribute("jid", this.jid); + element.setAttribute("port", this.port); + element.setAttribute("priority", this.priority); + element.setAttribute("type", this.type.toString().toLowerCase(Locale.ROOT)); + return element; + } + } + + public enum CandidateType { + DIRECT, + PROXY + } +} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/transports/Transport.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/transports/Transport.java new file mode 100644 index 0000000000000000000000000000000000000000..ce99ac8cc14e67b0523dd81613dd79bd69b75379 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/transports/Transport.java @@ -0,0 +1,80 @@ +package eu.siacs.conversations.xmpp.jingle.transports; + +import com.google.common.util.concurrent.ListenableFuture; + +import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.Group; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.concurrent.CountDownLatch; + +public interface Transport { + + OutputStream getOutputStream() throws IOException; + + InputStream getInputStream() throws IOException; + + ListenableFuture asTransportInfo(); + + ListenableFuture asInitialTransportInfo(); + + default void readyToSentAdditionalCandidates() {} + + void terminate(); + + void setTransportCallback(final Callback callback); + + void connect(); + + CountDownLatch getTerminationLatch(); + + interface Callback { + void onTransportEstablished(); + + void onTransportSetupFailed(); + + void onAdditionalCandidate(final String contentName, final Candidate candidate); + + void onCandidateUsed(String streamId, SocksByteStreamsTransport.Candidate candidate); + + void onCandidateError(String streamId); + + void onProxyActivated(String streamId, SocksByteStreamsTransport.Candidate candidate); + } + + enum Direction { + SEND, + RECEIVE, + SEND_RECEIVE + } + + class InitialTransportInfo extends TransportInfo { + public final String contentName; + + public InitialTransportInfo( + String contentName, GenericTransportInfo transportInfo, Group group) { + super(transportInfo, group); + this.contentName = contentName; + } + } + + class TransportInfo { + + public final GenericTransportInfo transportInfo; + public final Group group; + + public TransportInfo(final GenericTransportInfo transportInfo, final Group group) { + this.transportInfo = transportInfo; + this.group = group; + } + + public TransportInfo(final GenericTransportInfo transportInfo) { + this.transportInfo = transportInfo; + this.group = null; + } + } + + interface Candidate {} +} diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/transports/WebRTCDataChannelTransport.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/transports/WebRTCDataChannelTransport.java new file mode 100644 index 0000000000000000000000000000000000000000..0773610fbb07f13d174c5e6a39cbd87c0c768d80 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/transports/WebRTCDataChannelTransport.java @@ -0,0 +1,617 @@ +package eu.siacs.conversations.xmpp.jingle.transports; + +import static eu.siacs.conversations.xmpp.jingle.WebRTCWrapper.buildConfiguration; +import static eu.siacs.conversations.xmpp.jingle.WebRTCWrapper.logDescription; + +import android.content.Context; +import android.util.Log; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import com.google.common.io.Closeables; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.SettableFuture; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.xml.Namespace; +import eu.siacs.conversations.xmpp.XmppConnection; +import eu.siacs.conversations.xmpp.jingle.IceServers; +import eu.siacs.conversations.xmpp.jingle.WebRTCWrapper; +import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo; +import eu.siacs.conversations.xmpp.jingle.stanzas.WebRTCDataChannelTransportInfo; +import eu.siacs.conversations.xmpp.stanzas.IqPacket; + +import org.webrtc.CandidatePairChangeEvent; +import org.webrtc.DataChannel; +import org.webrtc.IceCandidate; +import org.webrtc.MediaStream; +import org.webrtc.PeerConnection; +import org.webrtc.PeerConnectionFactory; +import org.webrtc.SessionDescription; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.io.OutputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.channels.WritableByteChannel; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; + +import javax.annotation.Nonnull; + +public class WebRTCDataChannelTransport implements Transport { + + private static final int BUFFER_SIZE = 16_384; + private static final int MAX_SENT_BUFFER = 256 * 1024; + + private final ExecutorService executorService = Executors.newSingleThreadExecutor(); + private final ExecutorService localDescriptionExecutorService = + Executors.newSingleThreadExecutor(); + + private final AtomicBoolean readyToSentIceCandidates = new AtomicBoolean(false); + private final Queue pendingOutgoingIceCandidates = new LinkedList<>(); + + private final PipedOutputStream pipedOutputStream = new PipedOutputStream(); + private final WritableByteChannel writableByteChannel = Channels.newChannel(pipedOutputStream); + private final PipedInputStream pipedInputStream = new PipedInputStream(BUFFER_SIZE); + + private final AtomicBoolean connected = new AtomicBoolean(false); + + private final CountDownLatch terminationLatch = new CountDownLatch(1); + + private final Queue stateHistory = new LinkedList<>(); + + private final XmppConnection xmppConnection; + private final Account account; + private PeerConnectionFactory peerConnectionFactory; + private ListenableFuture peerConnectionFuture; + + private ListenableFuture localDescriptionFuture; + + private DataChannel dataChannel; + + private Callback transportCallback; + + private final PeerConnection.Observer peerConnectionObserver = + new PeerConnection.Observer() { + @Override + public void onSignalingChange(PeerConnection.SignalingState signalingState) { + Log.d(Config.LOGTAG, "onSignalChange(" + signalingState + ")"); + } + + @Override + public void onConnectionChange(final PeerConnection.PeerConnectionState state) { + stateHistory.add(state); + Log.d(Config.LOGTAG, "onConnectionChange(" + state + ")"); + if (state == PeerConnection.PeerConnectionState.CONNECTED) { + if (connected.compareAndSet(false, true)) { + executorService.execute(() -> onIceConnectionConnected()); + } + } + if (state == PeerConnection.PeerConnectionState.FAILED) { + final boolean neverConnected = + !stateHistory.contains( + PeerConnection.PeerConnectionState.CONNECTED); + // we want to terminate the connection a) to properly fail if a connection + // drops during a transfer and b) to avoid race conditions if we find a + // connection after failure while waiting for the initiator to replace + // transport + executorService.execute(() -> terminate()); + if (neverConnected) { + executorService.execute(() -> onIceConnectionFailed()); + } + } + } + + @Override + public void onIceConnectionChange( + final PeerConnection.IceConnectionState newState) {} + + @Override + public void onIceConnectionReceivingChange(boolean b) {} + + @Override + public void onIceGatheringChange( + final PeerConnection.IceGatheringState iceGatheringState) { + Log.d(Config.LOGTAG, "onIceGatheringChange(" + iceGatheringState + ")"); + } + + @Override + public void onIceCandidate(final IceCandidate iceCandidate) { + if (readyToSentIceCandidates.get()) { + WebRTCDataChannelTransport.this.onIceCandidate( + iceCandidate.sdpMid, iceCandidate.sdp); + } else { + pendingOutgoingIceCandidates.add(iceCandidate); + } + } + + @Override + public void onIceCandidatesRemoved(IceCandidate[] iceCandidates) {} + + @Override + public void onAddStream(MediaStream mediaStream) {} + + @Override + public void onRemoveStream(MediaStream mediaStream) {} + + @Override + public void onDataChannel(final DataChannel dataChannel) { + Log.d(Config.LOGTAG, "onDataChannel()"); + WebRTCDataChannelTransport.this.setDataChannel(dataChannel); + } + + @Override + public void onRenegotiationNeeded() { + Log.d(Config.LOGTAG, "onRenegotiationNeeded"); + } + + @Override + public void onSelectedCandidatePairChanged(CandidatePairChangeEvent event) { + Log.d(Config.LOGTAG, "remote candidate selected: " + event.remote); + Log.d(Config.LOGTAG, "local candidate selected: " + event.local); + } + }; + + private DataChannelWriter dataChannelWriter; + + private void onIceConnectionConnected() { + this.transportCallback.onTransportEstablished(); + } + + private void onIceConnectionFailed() { + this.transportCallback.onTransportSetupFailed(); + } + + private void setDataChannel(final DataChannel dataChannel) { + Log.d(Config.LOGTAG, "the 'receiving' data channel has id " + dataChannel.id()); + this.dataChannel = dataChannel; + this.dataChannel.registerObserver( + new OnMessageObserver() { + @Override + public void onMessage(final DataChannel.Buffer buffer) { + Log.d(Config.LOGTAG, "onMessage() (the other one)"); + try { + WebRTCDataChannelTransport.this.writableByteChannel.write(buffer.data); + } catch (final IOException e) { + Log.d(Config.LOGTAG, "error writing to output stream"); + } + } + }); + } + + protected void onIceCandidate(final String mid, final String sdp) { + final var candidate = IceUdpTransportInfo.Candidate.fromSdpAttribute(sdp, null); + this.transportCallback.onAdditionalCandidate(mid, candidate); + } + + public WebRTCDataChannelTransport( + final Context context, + final XmppConnection xmppConnection, + final Account account, + final boolean initiator) { + PeerConnectionFactory.initialize( + PeerConnectionFactory.InitializationOptions.builder(context) + .setFieldTrials("WebRTC-BindUsingInterfaceName/Enabled/") + .createInitializationOptions()); + this.peerConnectionFactory = PeerConnectionFactory.builder().createPeerConnectionFactory(); + this.xmppConnection = xmppConnection; + this.account = account; + this.peerConnectionFuture = + Futures.transform( + getIceServers(), + iceServers -> createPeerConnection(iceServers, true), + MoreExecutors.directExecutor()); + if (initiator) { + this.localDescriptionFuture = setLocalDescription(); + } + } + + private ListenableFuture> getIceServers() { + if (Config.DISABLE_PROXY_LOOKUP) { + return Futures.immediateFuture(Collections.emptyList()); + } + if (xmppConnection.getFeatures().externalServiceDiscovery()) { + final SettableFuture> iceServerFuture = + SettableFuture.create(); + final IqPacket request = new IqPacket(IqPacket.TYPE.GET); + request.setTo(this.account.getDomain()); + request.addChild("services", Namespace.EXTERNAL_SERVICE_DISCOVERY); + xmppConnection.sendIqPacket( + request, + (account, response) -> { + final var iceServers = IceServers.parse(response); + if (iceServers.size() == 0) { + Log.w( + Config.LOGTAG, + account.getJid().asBareJid() + + ": no ICE server found " + + response); + } + iceServerFuture.set(iceServers); + }); + return iceServerFuture; + } else { + return Futures.immediateFuture(Collections.emptyList()); + } + } + + private PeerConnection createPeerConnection( + final List iceServers, final boolean trickle) { + final PeerConnection.RTCConfiguration rtcConfig = buildConfiguration(iceServers, trickle); + final PeerConnection peerConnection = + requirePeerConnectionFactory() + .createPeerConnection(rtcConfig, peerConnectionObserver); + if (peerConnection == null) { + throw new IllegalStateException("Unable to create PeerConnection"); + } + final var dataChannelInit = new DataChannel.Init(); + dataChannelInit.protocol = "xmpp-jingle"; + final var dataChannel = peerConnection.createDataChannel("test", dataChannelInit); + this.dataChannelWriter = new DataChannelWriter(this.pipedInputStream, dataChannel); + Log.d(Config.LOGTAG, "the 'sending' data channel has id " + dataChannel.id()); + new Thread(this.dataChannelWriter).start(); + return peerConnection; + } + + @Override + public OutputStream getOutputStream() throws IOException { + final var outputStream = new PipedOutputStream(); + this.pipedInputStream.connect(outputStream); + this.dataChannelWriter.pipedInputStreamLatch.countDown(); + return outputStream; + } + + @Override + public InputStream getInputStream() throws IOException { + final var inputStream = new PipedInputStream(BUFFER_SIZE); + this.pipedOutputStream.connect(inputStream); + return inputStream; + } + + @Override + public ListenableFuture asTransportInfo() { + Preconditions.checkState( + this.localDescriptionFuture != null, + "Make sure you are setting initiator description first"); + return Futures.transform( + asInitialTransportInfo(), info -> info, MoreExecutors.directExecutor()); + } + + @Override + public ListenableFuture asInitialTransportInfo() { + return Futures.transform( + localDescriptionFuture, + sdp -> + WebRTCDataChannelTransportInfo.of( + eu.siacs.conversations.xmpp.jingle.SessionDescription.parse( + sdp.description)), + MoreExecutors.directExecutor()); + } + + @Override + public void readyToSentAdditionalCandidates() { + readyToSentIceCandidates.set(true); + while (this.pendingOutgoingIceCandidates.peek() != null) { + final var candidate = pendingOutgoingIceCandidates.poll(); + if (candidate == null) { + continue; + } + onIceCandidate(candidate.sdpMid, candidate.sdp); + } + } + + @Override + public void terminate() { + terminate(this.dataChannel); + this.dataChannel = null; + final var dataChannelWriter = this.dataChannelWriter; + if (dataChannelWriter != null) { + dataChannelWriter.close(); + } + this.dataChannelWriter = null; + final var future = this.peerConnectionFuture; + if (future != null) { + future.cancel(true); + } + try { + final PeerConnection peerConnection = requirePeerConnection(); + terminate(peerConnection); + } catch (final WebRTCWrapper.PeerConnectionNotInitialized e) { + Log.d(Config.LOGTAG, "peer connection was not initialized during termination"); + } + this.peerConnectionFuture = null; + final var peerConnectionFactory = this.peerConnectionFactory; + if (peerConnectionFactory != null) { + peerConnectionFactory.dispose(); + } + this.peerConnectionFactory = null; + closeQuietly(this.pipedOutputStream); + this.terminationLatch.countDown(); + Log.d(Config.LOGTAG, WebRTCDataChannelTransport.class.getSimpleName() + " terminated"); + } + + private static void closeQuietly(final OutputStream outputStream) { + try { + outputStream.close(); + } catch (final IOException ignored) { + + } + } + + private static void terminate(final DataChannel dataChannel) { + if (dataChannel == null) { + Log.d(Config.LOGTAG, "nothing to terminate. data channel is already null"); + return; + } + try { + dataChannel.close(); + } catch (final IllegalStateException e) { + Log.w(Config.LOGTAG, "could not close data channel"); + } + try { + dataChannel.dispose(); + } catch (final IllegalStateException e) { + Log.w(Config.LOGTAG, "could not dispose data channel"); + } + } + + private static void terminate(final PeerConnection peerConnection) { + if (peerConnection == null) { + return; + } + try { + peerConnection.dispose(); + Log.d(Config.LOGTAG, "terminated peer connection!"); + } catch (final IllegalStateException e) { + Log.w(Config.LOGTAG, "could not dispose of peer connection"); + } + } + + @Override + public void setTransportCallback(final Callback callback) { + this.transportCallback = callback; + } + + @Override + public void connect() {} + + @Override + public CountDownLatch getTerminationLatch() { + return this.terminationLatch; + } + + synchronized ListenableFuture setLocalDescription() { + return Futures.transformAsync( + peerConnectionFuture, + peerConnection -> { + if (peerConnection == null) { + return Futures.immediateFailedFuture( + new IllegalStateException("PeerConnection was null")); + } + final SettableFuture future = SettableFuture.create(); + peerConnection.setLocalDescription( + new WebRTCWrapper.SetSdpObserver() { + @Override + public void onSetSuccess() { + future.setFuture(getLocalDescriptionFuture(peerConnection)); + } + + @Override + public void onSetFailure(final String message) { + future.setException( + new WebRTCWrapper.FailureToSetDescriptionException( + message)); + } + }); + return future; + }, + MoreExecutors.directExecutor()); + } + + private ListenableFuture getLocalDescriptionFuture( + final PeerConnection peerConnection) { + return Futures.submit( + () -> { + final SessionDescription description = peerConnection.getLocalDescription(); + WebRTCWrapper.logDescription(description); + return description; + }, + localDescriptionExecutorService); + } + + @Nonnull + private PeerConnectionFactory requirePeerConnectionFactory() { + final PeerConnectionFactory peerConnectionFactory = this.peerConnectionFactory; + if (peerConnectionFactory == null) { + throw new IllegalStateException("Make sure PeerConnectionFactory is initialized"); + } + return peerConnectionFactory; + } + + @Nonnull + private PeerConnection requirePeerConnection() { + final var future = this.peerConnectionFuture; + if (future != null && future.isDone()) { + try { + return future.get(); + } catch (final InterruptedException | ExecutionException e) { + throw new WebRTCWrapper.PeerConnectionNotInitialized(); + } + } else { + throw new WebRTCWrapper.PeerConnectionNotInitialized(); + } + } + + public static List iceCandidatesOf( + final String contentName, + final IceUdpTransportInfo.Credentials credentials, + final List candidates) { + final ImmutableList.Builder iceCandidateBuilder = + new ImmutableList.Builder<>(); + for (final IceUdpTransportInfo.Candidate candidate : candidates) { + final String sdp; + try { + sdp = candidate.toSdpAttribute(credentials.ufrag); + } catch (final IllegalArgumentException e) { + continue; + } + // TODO mLneIndex should probably not be hard coded + iceCandidateBuilder.add(new IceCandidate(contentName, 0, sdp)); + } + return iceCandidateBuilder.build(); + } + + public void addIceCandidates(final List iceCandidates) { + try { + for (final var candidate : iceCandidates) { + requirePeerConnection().addIceCandidate(candidate); + } + } catch (WebRTCWrapper.PeerConnectionNotInitialized e) { + Log.w(Config.LOGTAG, "could not add ice candidate. peer connection is not initialized"); + } + } + + public void setInitiatorDescription( + final eu.siacs.conversations.xmpp.jingle.SessionDescription sessionDescription) { + final var sdp = + new SessionDescription( + SessionDescription.Type.OFFER, sessionDescription.toString()); + final var setFuture = setRemoteDescriptionFuture(sdp); + this.localDescriptionFuture = + Futures.transformAsync( + setFuture, v -> setLocalDescription(), MoreExecutors.directExecutor()); + } + + public void setResponderDescription( + final eu.siacs.conversations.xmpp.jingle.SessionDescription sessionDescription) { + Log.d(Config.LOGTAG, "setResponder description"); + final var sdp = + new SessionDescription( + SessionDescription.Type.ANSWER, sessionDescription.toString()); + logDescription(sdp); + setRemoteDescriptionFuture(sdp); + } + + synchronized ListenableFuture setRemoteDescriptionFuture( + final SessionDescription sessionDescription) { + return Futures.transformAsync( + this.peerConnectionFuture, + peerConnection -> { + if (peerConnection == null) { + return Futures.immediateFailedFuture( + new IllegalStateException("PeerConnection was null")); + } + final SettableFuture future = SettableFuture.create(); + peerConnection.setRemoteDescription( + new WebRTCWrapper.SetSdpObserver() { + @Override + public void onSetSuccess() { + future.set(null); + } + + @Override + public void onSetFailure(final String message) { + future.setException( + new WebRTCWrapper.FailureToSetDescriptionException( + message)); + } + }, + sessionDescription); + return future; + }, + MoreExecutors.directExecutor()); + } + + private static class DataChannelWriter implements Runnable { + + private final CountDownLatch pipedInputStreamLatch = new CountDownLatch(1); + private final CountDownLatch dataChannelLatch = new CountDownLatch(1); + private final AtomicBoolean isSending = new AtomicBoolean(true); + private final InputStream inputStream; + private final DataChannel dataChannel; + + private DataChannelWriter(InputStream inputStream, DataChannel dataChannel) { + this.inputStream = inputStream; + this.dataChannel = dataChannel; + final StateChangeObserver stateChangeObserver = + new StateChangeObserver() { + + @Override + public void onStateChange() { + if (dataChannel.state() == DataChannel.State.OPEN) { + dataChannelLatch.countDown(); + } + } + }; + this.dataChannel.registerObserver(stateChangeObserver); + } + + public void run() { + try { + this.pipedInputStreamLatch.await(); + this.dataChannelLatch.await(); + final var buffer = new byte[4096]; + while (isSending.get()) { + final long bufferedAmount = dataChannel.bufferedAmount(); + if (bufferedAmount > MAX_SENT_BUFFER) { + Thread.sleep(50); + continue; + } + final int count = this.inputStream.read(buffer); + if (count < 0) { + Log.d(Config.LOGTAG, "DataChannelWriter reached EOF"); + return; + } + dataChannel.send( + new DataChannel.Buffer(ByteBuffer.wrap(buffer, 0, count), true)); + } + } catch (final InterruptedException | InterruptedIOException e) { + if (isSending.get()) { + Log.w(Config.LOGTAG, "DataChannelWriter got interrupted while sending", e); + } + } catch (final IOException e) { + Log.d(Config.LOGTAG, "DataChannelWriter terminated", e); + } finally { + Closeables.closeQuietly(inputStream); + } + } + + public void close() { + this.isSending.set(false); + terminate(this.dataChannel); + } + } + + private abstract static class StateChangeObserver implements DataChannel.Observer { + + @Override + public void onBufferedAmountChange(final long change) {} + + @Override + public void onMessage(final DataChannel.Buffer buffer) {} + } + + private abstract static class OnMessageObserver implements DataChannel.Observer { + + @Override + public void onBufferedAmountChange(long l) {} + + @Override + public void onStateChange() {} + } +} diff --git a/src/main/res/values-bg/strings.xml b/src/main/res/values-bg/strings.xml index 04f3c7369f178b830b98b43543b0df0e0a49374d..f8c8c886019dcb1760b5e1e7931baccbf833c238 100644 --- a/src/main/res/values-bg/strings.xml +++ b/src/main/res/values-bg/strings.xml @@ -31,10 +31,7 @@ преди %d минути %d непрочетен разговор - - %d непрочетени разговора - изпращане… Дешифроване на съобщението. Моля, изчакайте… @@ -519,7 +516,9 @@ Само за големи изображения Оптимизациите за използв. на батерията са вкл. Устройството Ви прилага сериозни оптимизации за използването на батерията върху %1$s, които може да доведат до забавени известия и дори пропуснати съобщения.\nПрепоръчително е да ги изключите. - Устройството Ви прилага сериозни оптимизации за използването на батерията върху %1$s, които може да доведат до забавени известия и дори пропуснати съобщения.\nСега ще бъдете помолен(а) да ги изключите. + Устройството Ви прилага сериозни оптимизации за използването на батерията върху %1$s, които може да доведат до забавени известия и дори пропуснати съобщения. +\n +\nСега ще бъдете помолен(а) да ги изключите. Изключване Избраната област е твърде голяма (Няма активирани профили) @@ -953,4 +952,4 @@ Създаването на резервно копие е стартирано. Ще получите известие, когато приключи. Видеото не може да бъде включено. Обикновен текстов документ - + \ No newline at end of file diff --git a/src/main/res/values-de/strings.xml b/src/main/res/values-de/strings.xml index d58659252c77a71e9ac4a1d9022bfadc0c0d865a..945062f64d23760458b918bc6d45d4dfb5fac358 100644 --- a/src/main/res/values-de/strings.xml +++ b/src/main/res/values-de/strings.xml @@ -128,7 +128,7 @@ Die Zeitspanne, in der Benachrichtigungen nach der Erkennung von Aktivitäten auf einem deiner anderen Geräte unterdrückt werden. Erweitert Niemals Absturzberichte senden - Mit dem Einsenden von Absturzberichten hilfst du bei der Weiterentwicklung + Durch das Einsenden von Absturzberichten hilfst du bei der Weiterentwicklung Lese- und Empfangsbestätigung senden Informiere deine Kontakte, wenn du eine Nachricht empfangen und gelesen hast Screenshots verhindern @@ -264,7 +264,7 @@ Profilbild kann nicht gespeichert werden (Oder klicke lange, um den Standard wiederherzustellen) Dein Server unterstützt die Veröffentlichung von Profilbildern nicht - private Nachricht: + private Nachricht an %s Private Nachricht an %s senden Verbinden @@ -534,7 +534,7 @@ Dieses Feld ist erforderlich Nachricht korrigieren Korrigierte Nachricht senden - Du hast den Fingerabdruck dieser Person bereits sicher verifiziert, um das Vertrauen zu bestätigen. Durch Auswählen von \"Fertig\" bestätigst du, dass %s Teil dieses Gruppenchats ist. + Du hast den Fingerabdruck dieser Person bereits verifiziert. Durch Auswählen von \"Fertig\" bestätigst du nur, dass %s Teil dieses Gruppenchats ist. Du hast dieses Konto deaktiviert Sicherheitsfehler: Dateizugriff nicht erlaubt! Keine App zum Teilen der URI gefunden @@ -905,7 +905,7 @@ Eingehender Videoanruf Umschalten auf Videoanruf? Zusätzliche Audiospuren hinzufügen? - Verbinden + Verbindet Verbunden Erneut verbinden Anruf annehmen @@ -1017,5 +1017,7 @@ Dein Kontakt verwendet nicht verifizierte Geräte. Scanne deren 2D-Barcode, um eine Verifizierung durchzuführen und aktive MITM-Angriffe zu verhindern. Abmelden Abgemeldet - Du verwendest nicht verifizierte Geräte. Scanne die 2D-Barcodes deiner anderen Geräte, um eine Verifizierung durchzuführen und aktive MITM-Angriffe zu verhindern. + Du verwendest nicht verifizierte Geräte. Scanne den 2D-Barcode auf deinen anderen Geräten, um eine Verifizierung durchzuführen und aktive MITM-Angriffe zu verhindern. + Spam melden + Spam melden und Spammer blockieren \ No newline at end of file diff --git a/src/main/res/values-eo/strings.xml b/src/main/res/values-eo/strings.xml new file mode 100644 index 0000000000000000000000000000000000000000..a6b3daec9354f9ae75cdf8d94a67446c6227dd96 --- /dev/null +++ b/src/main/res/values-eo/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/main/res/values-es/strings.xml b/src/main/res/values-es/strings.xml index 7393ed624683ea767d33a1a7601ba15565a559a7..633ac545863a9a9676bf68145617f684e8874c7d 100644 --- a/src/main/res/values-es/strings.xml +++ b/src/main/res/values-es/strings.xml @@ -1032,4 +1032,6 @@ Desconectarse Desconectado Está utilizando dispositivos no verificados. Escanea el código de barras 2D en tus otros dispositivos para realizar la verificación e impedir los ataques MITM activos. + Informar de spam y bloquear al spammer + Informar sobre spam \ No newline at end of file diff --git a/src/main/res/values-fi/strings.xml b/src/main/res/values-fi/strings.xml index 9bd0cde7ac3d0cce1036608e95497467e5f8e01c..8645cad810a2e48af147dc2755cbb3cd102f3bd6 100644 --- a/src/main/res/values-fi/strings.xml +++ b/src/main/res/values-fi/strings.xml @@ -30,13 +30,10 @@ %d minuuttia sitten %d lukematon keskustelu - - %d lukematonta keskustelua - - lähettää... - Puretaan viestin salausta. Odota hetki... + lähettää… + Puretaan viestin salausta. Odota hetki… OpenPGP-salattu viesti Nimimerkki on jo käytössä Nimimerkki on virheellinen @@ -82,12 +79,14 @@ toimitus epäonnistui Valmistaudutaan lähettämään kuva Valmistaudutaan lähettämään kuvat - Jaetaan tiedostoja. Odota hetki... + Jaetaan tiedostoja. Odota hetki… Pyyhi historia Pyyhi keskusteluhistoria Poistetaanko kaikki keskustelun viestit?\n\nVaroitus: Muilla laitteilla tai palvelimilla säilytettyjä kopioita ei poisteta. Poista tiedosto - Haluatko varmasti poistaa tämän tiedoston?\n\nVaroitus: Muilla laitteilla tai palvelimilla olevia kopioita ei poisteta. + Haluatko varmasti poistaa tämän tiedoston\? +\n +\nVaroitus: Muilla laitteilla tai palvelimilla olevia kopioita ei poisteta. Päätä keskustelu myös Valitse laite Lähetä salaamaton viesti @@ -104,15 +103,15 @@ Käynnistä uudelleen Asenna Asenna OpenKeychain - tarjotaan... - odotetaan... + tarjotaan… + odotetaan… OpenPGP-avainta ei löydy Viestin salaaminen ei onnistu koska vastaanottaja ei mainosta julkista avaintaan.\n\nPyydä kontaktiasi ottamaan OpenPGP käyttöön. OpenPGP-avaimia ei löydy Viestin salaaminen ei onnistu koska kontaktisi eivät mainosta julkisia avaimiaan.\n\nPyydä heitä ottamaan OpenPGP käyttöön. Yleinen Lataa tiedostot - Lataa automaattisesti tiedostot jotka ovat pienempiä kuin... + Lataa automaattisesti tiedostot jotka ovat pienempiä kuin… Liitteet Ilmoitus Värinä @@ -154,7 +153,7 @@ Tuntematon Väliaikaisesti poistettu käytöstä Paikalla - Yhdistäää\u2026 + Yhdistää… Poissa Ei sallittu Palvelinta ei löydy @@ -179,7 +178,7 @@ Haluatko varmasti poistaa OpenPGP-avaimesi tilamainostuksistasi?\nYhteystietosi eivät voi enää lähettää sinulle OpenPGP-salattuja viestejä. OpenPGP julkinen avain julkaistu. Ota tunnus käyttöön - Tilin poistaminen pyyhkii koko keskusteluhistoriasi + Haluatko varmasti poistaa tilisi\? Tilin poistaminen pyyhkii koko keskusteluhistoriasi Nauhoita ääntä XMPP-osoite Estä XMPP-osoite @@ -218,7 +217,7 @@ v\\OMEMO-sormenjälki (viestin lähettäjä) Muut laitteet Luota OMEMO-sormenjälkiin - Haetaan avaimia... + Haetaan avaimia… Valmis Pura salaus Haku @@ -243,7 +242,7 @@ Kanavan tuhoaminen epäonnistui Muokkaa ryhmäkeskustelun aihetta Aihe - Liitytään ryhmäkeskusteluun... + Liitytään ryhmäkeskusteluun… Poistu Yhteystieto lisätty luetteloon Lisää takaisin @@ -253,7 +252,7 @@ Kaikki ovat lukeneet tähän asti Julkaise Napauta profiilikuvaa valitaksesi kuvan galleriasta - Julkaistaan... + Julkaistaan… Palvelin hylkäsi julkaisusi Kuvan muuntaminen epäonnistui Profiilikuvan tallentaminen levylle epäonnistui @@ -355,7 +354,7 @@ Jokin meni pieleen Haetaan historiaa palvelimelta Palvelimella ei ollut enempää historiaa - Päivitetään... + Päivitetään… Salasana vaihdettu! Salasanan vaihto epäonnistui Vaihda salasana @@ -410,9 +409,9 @@ Lähetetään %s Tarjotaan %s Piilota poissaolevat - %s kirjoittaa... + %s kirjoittaa… %s lopetti kirjoittamisen - %s kirjoittavat... + %s kirjoittavat… %s lopettivat kirjoittamisen Kirjoitusilmoitukset Näyttää ytheystiedoillesi kun kirjoitat heille viestiä @@ -454,7 +453,7 @@ Poissa kun laite on lukittu Näytä minut poissaolevana kun näyttö on lukittu Kiireinen kun laite on äänetön - Näytä minut kiireisenä kun laite on äänettömänäq + Näytä minut kiireisenä kun laite on äänettömänä Kohtele vain värinä -tilaa äänettömän lailla Näytä minut kiireisenä kun laite on vain värinä -tilassa Laajemmat yhteysasetukset @@ -464,7 +463,7 @@ Varmenteen jäsennys epäonnistui Arkistointiasetukset Palvelimen arkitsointiasetukset - Kysytään arkistointiasetuksia. Odota hetki... + Kysytään arkistointiasetuksia. Odota hetki… Arkistointiasetusten haku epäonnistui CAPTCHA vaaditaan Kirjoita ylläolevassa kuvassa näkyvä teksti @@ -510,10 +509,10 @@ Tämä kenttä on pakollinen Korjaa viestiä Lähetä korjattu viesti - Olet jo varmistanut tämän henkilön OMEMO-sormenjäljen turvallisesti luottamuksen varmistamikseksi. Hyväksymällä varmistat vain että %s on osa tätä ryhmäkeskustelua. + Olet jo varmistanut tämän henkilön OMEMO-sormenjäljen. Hyväksymällä vahvistat vain että %s on osa tätä ryhmäkeskustelua. Olet poistanut tämän tilin käytöstä URI:n jakamiseen sopivaa sovellusta ei löytynyt - Jaa URI sovelluksella... + Jaa URI sovelluksella… Hyväksy ja jatka XMPP-osoitteesi tulee olemaan kokonaisuudessaan: %s Luo tunnus @@ -532,7 +531,7 @@ Rekisteröinti epäonnistui: Yritä myöhemmin uudelleen Rekisteröinti epäonnistui: Salasana on liian heikko Valitse osallistujat - Luodaan ryhmää... + Luodaan ryhmää… Kutsu uudestaan Poista käytöstä Lyhyt @@ -568,7 +567,8 @@ Näytä virheilmoitus Virheilmoitus Datansäästö käytössä - Käyttöjärjestelmäsi estää %1$s:tä käyttämästä nettiä ollessaan taustalla. Vastaanottaaksesi ilmoitukset uusista viesteistä, salli %1$s:n käyttää esteettä verkkoa datansäästön ollessa käytössä. %1$s tekee silti parhaansa käyttääkseen mahdollisimman vähän dataa. + Käyttöjärjestelmäsi estää %1$s:tä käyttämästä nettiä ollessaan taustalla. Vastaanottaaksesi ilmoitukset uusista viesteistä, salli %1$s:n käyttää esteettä verkkoa datansäästön ollessa käytössä. +\n%1$s tekee silti parhaansa käyttääkseen mahdollisimman vähän dataa. Laitteesi ei tue datansäästön poistamista käytöstä sovellukselle %1$s. Väliaikaisen tiedoston luominen epäonnistui Laite on varmennettu @@ -650,7 +650,7 @@ Hyväksytäänkö tuntematon varmenne? Palvelimen varmenne ei ole luotetun myöntäjän allekirjoittama. Hyväksytäänkö eriävä palvelimen nimi? - Palvelin ei voinut tunnistautua olevansa verkkotunnusta \"%s\". Varmenne sisältää vain seuraavat verkkotunnukset: + Palvelin ei voinut tunnistautua olevansa verkkotunnusta \"%s\". Varmenne sisältää vain seuraavat verkkotunnukset: Haluatko yhdistää joka tapauksessa? Varmenteen tiedot: Kerran @@ -668,7 +668,7 @@ Poista käytöstä nyt Luonnos: OMEMO-salaus - OMEMO:a ei koskaan käytetä oletuksena uusissa keskusteluissa. + Uusissa keskusteluissa OMEMO otetaan oletuksena käyttöön. Luo pikakuvake Kirjasinkoko Kirjasimen suhteellinen koko sovelluksen sisällä. @@ -677,8 +677,8 @@ Pieni Keksikokoinen Suuri - Viestiä ei salattu tälle laitteelle - OMEMO-salatun viestin purku epäonnistui + Viestiä ei salattu tälle laitteelle. + OMEMO-salatun viestin purku epäonnistui. peru Sijainnin jakaminen on pois käytöstä Kopioi sijainti @@ -687,7 +687,7 @@ Jaa sijainti Näytä sijainti Jaa - Odota hetki... + Odota hetki… Salli %1$s:n käyttää mikrofonia GIF Näytä keskustelu @@ -725,7 +725,7 @@ Keski (360p) Korkea (720p) peruutettu - Olet jo aloittanut viestin luonnostelun + Olet jo aloittanut viestin luonnostelun. Ominaisuutta ei ole toteutettu Virheellinen maakoodi Valitse maa @@ -743,13 +743,13 @@ Lähetä uusi tekstivieti (%s) Odota (%s) takaisin - Mahdollinen PIN-koodi liitettiin leikepöydältä automaattisesti + Mahdollinen PIN-koodi liitettiin leikepöydältä automaattisesti. Syötä 6-numeroinen PIN-koodisi. Haluatko varmasti perua rekisteröintiprosessin? Kyllä Ei - Varmistetaan... - Pyydetään tekstiviestiä + Varmistetaan… + Pyydetään tekstiviestiä… Syöttämäsi PIN-koodi on väärä. Lähettämämme PIN-koodi on vanhentunut. Tuntematon verkkovirhe. @@ -758,7 +758,7 @@ Turvallinen yhteys epäonnistui. Palvelinta ei löytynyt. Pyyntösi käsittelyssä tapahtui jokin virhe. - Ei verkkoyhteyttä + Ei verkkoyhteyttä. Odota %s ja yritä uudelleen Liian monta yritystä Käytät vanhentunutta versiota tästä sovelluksesta. @@ -774,7 +774,7 @@ Tämä kanava julkaisee XMPP-osoitteesi e-kirja Alkuperäinen (pakkaamaton) - Avaa sovelluksella... + Avaa sovelluksella… Conversations-profiilikuva Valitse tili Palauta varmuuskopiosta @@ -794,7 +794,7 @@ Anna kanavalle nimi Anna XMPP-osoite Tämä on XMPP-osoite. Anna nimi sen sijaan. - Luodaan julkista kanavaa... + Luodaan julkista kanavaa… Kanava on jo olemassa Liityit olemassa olevalle kanavalle Kanavan asetuksia ei saatu tallennettua @@ -828,7 +828,7 @@ Tämä tili on jo asennettu Syötä tämän tilin salasana Toiminnon suorittaminen epäonnistui - Liity julkiselle kanavalle... + Liity julkiselle kanavalle… Jakava sovellus ei antanut tarvittavaa lupaa lukea tiedostoa. jabber.network @@ -903,6 +903,45 @@ Palvelin ei tue kutsujen luomista Yksikään aktiivinen tili ei tue tätä toimintoa Varmuuskopion teko aloitettu. Saat ilmoituksen kun se on valmis. - Videon käyttöönotto epäonnistui + Videon käyttöönotto epäonnistui. Perustekstiasiakirja - + OMEMO:a käytetään aina kaikissa yksityisissä keskusteluissa. + Etsi yhteystiedoista + OMEMO täytyy ottaa käyttöön käsin uusissa keskusteluissa. + Ryhmäkeskustelut + Etsi ryhmäkeskusteluista + Suoraan hakuun + Kanavien löytö käyttää kolmannen osapuolen palvelua nimeltä <a href=https://search.jabber.network>search.jabber.network</a>.<br><br>Tämän ominaisuuden käyttö lähettää IP-osoitteesi ja hakusanasi palvelulle. Lue lisää heidän <a href=https://search.jabber.network/privacy>yksityisyyskäytännöstään</a> (englanniksi). + Hae viesteistä + Olet kirjautunut ulos tältä tililtä + Vastaamattomat puhelut + Saapuvien puheluiden ilmoitusasetukset + Ei käytössä hetkellisesti. Yritä myöhemmin uudestaan. + Yhdistetään videopuhelua uudestaan + Saapuva puhelu (%s) · %s + Yhdistetään puhelua uudestaan + Synkronoi kirjanmerkit + Lähtevä puhelu (%s) · %s + Lataus epäonnistui: Kelvoton tiedosto + Julkaise käyttö + Jatka + Kirjautunut ulos + + %d vastaamaton puhelu + %d vastaamatonta puhelua + + Yhdistetään uudelleen + Lähtevä puhelu · %s + + %1$d vastaamaton puhelu henkilöltä %2$s + %1$d vastaamatonta puhelua henkilöltä %2$s + + Poista valinta + + %1$d vastaamatonta puhelua %2$d henkilöltä + %1$d vastaamatonta puhelua %2$d henkilöltä + + Äänikirja + Hiljaiset viestit + Vaihdetaanko videopuheluun\? + \ No newline at end of file diff --git a/src/main/res/values-it/strings.xml b/src/main/res/values-it/strings.xml index c35b8f171b2e3a6efe861e751c7047c580cd0194..bf87cffbac37c5d8c153afda4257d76adab4a5b0 100644 --- a/src/main/res/values-it/strings.xml +++ b/src/main/res/values-it/strings.xml @@ -537,7 +537,7 @@ Questo campo è obbligatorio Correggi messaggio Invia messaggio corretto - Hai già validato l\'impronta di questa persona in modo sicuro per confermarne la fiducia. Selezionando “Fatto” stai solo confermando che %s fa parte di questa chat di gruppo. + Ti stai già fidando dell\'impronta di questa persona. Selezionando “Fatto” stai solo confermando che %s fa parte di questa chat di gruppo. Hai disattivato questo profilo Errore di sicurezza: accesso file non valido! Nessuna app trovata per condividere l\'URI @@ -1032,4 +1032,6 @@ Disconnetti Disconnesso Stai usando dispositivi non verificati. Scansiona il codice a barre 2D nei tuoi altri dispositivi per effettuare la verifica e impedire attacchi MITM attivi. + Segnala spam e blocca l\'utente + Segnala spam \ No newline at end of file diff --git a/src/main/res/values-pl/strings.xml b/src/main/res/values-pl/strings.xml index f7e9f4a79be80e627b375419538fe1fbec204506..07f3eae93ecd19958a1db6d476a9bd786a71f74b 100644 --- a/src/main/res/values-pl/strings.xml +++ b/src/main/res/values-pl/strings.xml @@ -544,7 +544,7 @@ To pole jest wymagane Popraw wiadomość Wyślij poprawioną wiadomość - Już zaufałeś temu kontaktowi. Wybierając \'zrobione\' potwierdzasz, że %s jest członkiem tej rozmowy grupowej. + Już zaufano temu osobistemu odciskowi palca. Wybierając \"Zrobione\" potwierdzasz, że %s jest członkiem tej rozmowy grupowej. Wyłączyłeś to konto Błąd bezpieczeństwa: nieprawidłowy dostęp do pliku! Nie odnaleziono aplikacji do udostępnienia URI @@ -1044,4 +1044,13 @@ Próbujesz zaimportować plik kopii zapasowej o przestarzałym formacie Audiobook Połącz się ponownie na innym hoście + Wylogowano się z tego konta + Zaloguj się + Ukryj powiadomienie + Twój kontakt korzysta z niezweryfikowanych urządzeń. Zeskanuj ich kod kreskowy 2D, aby przeprowadzić weryfikację i uniemożliwić aktywne ataki MITM. + Zgłoś spam i zablokuj nadawcę + Wyloguj się + Wylogowano + Używasz z niezweryfikowanych urządzeń. Zeskanuj kod kreskowy 2D na innych urządzeniach, aby przeprowadzić weryfikację i uniemożliwić aktywne ataki MITM. + Zgłoś spam \ No newline at end of file diff --git a/src/main/res/values-ru/strings.xml b/src/main/res/values-ru/strings.xml index fe454a0791594f24e466b0c15c8bf11eba7a831a..d6e0c971456645849b06fcc634cf4e64973a15d4 100644 --- a/src/main/res/values-ru/strings.xml +++ b/src/main/res/values-ru/strings.xml @@ -530,7 +530,7 @@ Незаполненное поле Исправить сообщение Отправить исправленное сообщение - Вы уже подтвердили, что электронный отпечаток принадлежит этому человеку. Выбрав \"Готово\", вы только подтвердите, что %s является участником конференции. + Вы уже пометили отпечаток этого человека как доверенный. Выбрав \"Готово\", вы только подтвердите, что %s является участником конференции. Вы отключили эту учётную запись Ошибка безопасности: недействительный доступ к файлу Не найдено приложения для передачи URI @@ -1026,7 +1026,7 @@ %1$d пропущенных вызовов от %2$s %1$d пропущенных вызовов от %2$s - Переподключиться на другой сервер + Переподключиться на другом сервере Вы попытались импортировать резервную копию в устаревшем формате %1$d пропущенный вызов от %2$d контакта @@ -1042,4 +1042,6 @@ Выйти Деавторизован Вы используете неподтверждённые устройства. Отсканируйте штрих-код на подтверждённом устройстве для проверки и предотвращения атаки посредника. + Пожаловаться на спам и заблокировать + Пожаловаться на спам \ No newline at end of file diff --git a/src/main/res/values-sv/strings.xml b/src/main/res/values-sv/strings.xml index 676e24ffd5d45248ad972c4cf4d4f8c984878e29..3aa9587a7d743854480f3d5b08522cdd9446ae6a 100644 --- a/src/main/res/values-sv/strings.xml +++ b/src/main/res/values-sv/strings.xml @@ -549,7 +549,7 @@ Detta fält är obligatoriskt Korrigera meddelande Skicka korrigerat meddelande - Du har redan validerat den här personens fingeravtryck på ett säkert sätt, för att bekräfta förtroendet. Genom att välja \"Klar\" bekräftar du bara att %s är en del av den här gruppchatten. + Du har redan litat på den här personens fingeravtryck. Genom att välja \"Klar\", bekräftar du bara att %s är en del av den här gruppchatten. Du har inaktiverat det här kontot Säkerhetsfel: Ogiltig filåtkomst! Ingen app hittades för att dela URI @@ -796,79 +796,79 @@ Bekräftar… Okänt nätverksfel. För många försök - Du använder en föråldrad version av denna app. + Du använder en inaktuell version av den här appen. Uppdatera Ditt namn - Skriv in ditt namn - Avslå begäran + Ange ditt namn + Avvisa begäran Installera Orbot Starta Orbot e-bok - Öppna med … - Conversations-profilbild + Öppna med… + Profilbild för Conversations Välj konto - Återställa säkerhetskopiering - Återställa - Ange ditt lösenord till kontot %s för att återställa säkerhetskopian. + Återställ säkerhetskopia + Återställ + Ange ditt lösenord för kontot %s, för att återställa säkerhetskopian. Det gick inte att återställa säkerhetskopian. - Säkerhetskopia & Återställ + Säkerhetskopiering & Återställning Ange XMPP-adress Skapa gruppchatt - Anslut till publik gruppkonversation - Skapa sluten gruppchatt - Skapa publik gruppkonversation - Kanalnamn + Anslut till en offentlig gruppchatt + Skapa en privat gruppchatt + Skapa en publik gruppchatt + Gruppchattens namn XMPP-adress - Vänligen ange ett namn på kanalen - Ange en XMPP-adress - Detta är en XMPP-adress. Ange ett namn. - Skapar publik gruppkonversation … - Denna kanal finns redan - Du har gått med i en befintlig kanal - Det gick inte att spara kanalkonfigurationen - Tillåt vem som helst att ändra ämnet + Var god ange ett namn på gruppchatten + Var god och ange en XMPP-adress + Det här är en XMPP-adress. Var god och ange ett namn. + Skapar publik gruppchatt… + Den här gruppchatten finns redan + Du har gått med i en befintlig gruppchatt + Det gick inte att spara inställningarna för gruppchatten + Tillåt vem som helst att redigera ämnet Tillåt vem som helst att bjuda in andra - Vem som helst kan ändra ämnet. - Ägaren kan ändra ämnet. - Administratörer kan ändra ämnet. + Vem som helst kan redigera ämnet. + Ägare kan redigera ämnet. + Administratörer kan redigera ämnet. Ägare kan bjuda in andra. Vem som helst kan bjuda in andra. XMPP-adresser är synliga för administratörer. - XMPP-adresser är synliga för alla. - Den här publika gruppkonversationen har inga deltagare. Bjud in dina kontakter eller använd \'dela-knappen\' för att dela XMPP-adressen. - Denna slutna gruppchatt har inga deltagare. + XMPP-adresser är synliga för vem som helst. + Den här offentliga gruppchatten har inga deltagare. Bjud in dina kontakter, eller använd dela-knappen för att distribuera dess XMPP-adress. + Den här privata gruppchatten har inga deltagare. Hantera rättigheter Sök efter deltagare - För stor fil + Filen är för stor Bifoga - Upptäck kanaler - Sök efter gruppkonversationer + Upptäck gruppchattar + Sök efter gruppchattar Möjlig integritetskränkning! Jag har redan ett konto - Lägg till befintligt konto - Skapa nytt konto - Detta verkar vara ett domännamn + Lägg till ett befintligt konto + Skapa ett nytt konto + Det här ser ut som en domänadress Lägg till ändå - Detta ser ut som en kanaladress + Det här ser ut som en gruppchattadress Dela säkerhetskopior Säkerhetskopior för Conversations Händelse Öppna säkerhetskopia - Filen du valde är inte en säkerhetskopia till Conversations + Filen du valde är inte en säkerhetskopia för Conversations Det här kontot har redan konfigurerats - Var god ange lösenordet för det här kontot + Var god och ange lösenordet för det här kontot Det gick inte att utföra den här åtgärden - Anslut till publik gruppkonversation … - Delnings-appen gav inte behörighet till att komma åt den här filen. - + Anslut till publik gruppchatt… + Delningsappen gav inte behörighet att komma åt den här filen. + Gruppchattar & Kanaler jabber.network Lokal server - De flesta användare bör välja \"jabber.network\" för bättre förslag från hela det offentliga XMPP-ekosystemet. + De flesta användare bör välja \"jabber.network\", för att få bättre förslag från hela det offentliga XMPP-ekosystemet. Metod för kanalupptäckt Säkerhetskopiering Om - Aktivera ett konto - Ring + Var god och aktivera ett konto + Ring ett samtal Inkommande samtal Inkommande videosamtal Ansluter @@ -881,11 +881,11 @@ Upptäcker enheter Ringer Upptagen - Kunde inte koppla samtal - Anslutning bröts + Det gick inte att koppla samtalet + Anslutningen avbröts Återkallat samtal - Appmisslyckande - Verifikationsproblem + Appfel + Verifieringsproblem Lägg på Pågående samtal Pågående videosamtal @@ -903,11 +903,11 @@ Din mikrofon är inte tillgänglig Du kan bara ha ett samtal åt gången. Återgå till pågående samtal - Kunde inte växla kamera + Det gick inte att byta kamera Fäst flik till toppen - Ta bort flik från toppen + Lossa flik från toppen GPX-spår - Kunde inte korrigera meddelandet + Det gick inte att korrigera meddelandet Alla konversationer Den här konversationen Din visningsbild @@ -919,7 +919,7 @@ Spela in ett röstmeddelande Spela upp ljud Pausa ljud - Lägg till kontakt, skapa eller gå med i gruppchatt eller upptäck kanaler + Lägg till kontakt, skapa eller gå med i gruppchatt, eller upptäck kanaler Visa %1$d deltagare Visa %1$d deltagare @@ -943,15 +943,15 @@ Inställningar för meddelandeaviseringar Aviseringsinställningar för inkommande samtal Betydelse, ljud, vibrera - Kunde inte upprätta en säker anslutning. - Ogiltig inmatning - Tillfälligt otillgänglig. Försök igen om en stund. - Ingen kontakt med nätverket. - Prova igen om %s + Det gick inte att upprätta en säker anslutning. + Ogiltig användarinmatning + Tillfälligt otillgänglig. Försök igen senare. + Ingen nätverksanslutning. + Var god försök igen om %s Begär sms… Den angivna PIN-koden är felaktig. PIN-koden som vi skickade till dig, är inte längre giltig. - Kunde inte hitta servern. + Det gick inte att hitta servern. Möjligtvis automatiskt inklistrad PIN från urklipp. Var god ange din 6-siffriga pinkod. Är du säker på att du vill avbryta registreringsproceduren\? @@ -969,21 +969,21 @@ Lägre kvalitet resulterar i mindre filer Funktionen är inte implementerad Ogiltig landskod - Du är begränsad - Använd redigera-knappen för att ange ditt namn. - Ingen butiksapp installerad. - Den här kanalen gör din XMPP-adress publik + Du är anropsbegränsad + Använd redigeringsknappen för att ange ditt namn. + Ingen appbutik är installerad. + Den här kanalen kommer att göra din XMPP-adress offentlig Original (okomprimerad) - Kunde inte dekryptera säkerhetskopian. Är lösenordet rätt\? - Byta till videosamtal\? + Det gick inte att dekryptera säkerhetskopian. Är lösenordet korrekt\? + Växla till videosamtal\? Lägg till ytterligare spår\? - Inga aktiva konton har stöd för denna funktion - Säkerhetskopieringen har startat. Du får en notifikation när den är färdig. - Video kan inte aktiveras. - Textdokument + Inga aktiva konton stöder den här funktionen + Säkerhetskopieringen har påbörjats. Du får ett notis när det är klart. + Det gick inte att aktivera video. + Oformaterat textdokument Tillfälligt autentiseringsfel - Radera avatar - Samtal är inaktiverat när Tor används + Ta bort visningsbild + Samtal är inaktiverade när du använder Tor Växla till video Avböj förfrågan om att växla till video UnifiedPush-distributör @@ -991,11 +991,11 @@ Kontot genom vilket push-meddelanden tas emot. Push-server En användarvald push-server för att vidarebefordra push-meddelanden via XMPP till din enhet. - Ingen (avaktiverad) - Det här telefonnumret är inloggat på en annan enhet. - Ange ditt namn så att folk som inte har dig i sin adressbok vet vem du är. - Kunde inte kontakta servern. - Något blev fel när din förfrågan hanterades. + Ingen (inaktiverad) + Det här telefonnumret är för närvarande inloggat med en annan enhet. + Var god ange ditt namn för att låta personer som inte har dig i sina adressböcker, veta vem du är. + Det gick inte att ansluta till servern. + Något gick fel när din begäran behandlades. Okänt svar från servern. Vi har skickat ett SMS till %s. Quicksy kommer att skicka ett SMS (operatörsavgifter kan tillkomma) för att verifiera ditt telefonnummer. Ange din landskod och ditt telefonnummer: @@ -1007,17 +1007,40 @@ Synkronisera bokmärken Du lämnade den här gruppchatten på grund av tekniska skäl multimediafil - Använd inte funktionen för återställning av säkerhetskopia för att försöka klona (köra samtidigt) en installation. Återställning av en säkerhetskopia är avsedd för migreringar eller om du har tappat bort den ursprungliga enheten. + Använd inte funktionen för återställning av säkerhetskopiering i ett försök att klona en installation, för att kunna köra två lika installationer samtidigt. Att återställa en säkerhetskopia är endast avsedd för migrering, eller om du har tappat bort den ursprungliga enheten. %1$d missat samtal från %2$d kontakt %1$d missade samtal från %2$d kontakter Ett meddelande kunde inte levereras - Några meddelanden kunde inte levereras + Flera meddelanden kunde inte levereras - Kunde inte tolka inbjudan - Servern har inte stöd för att skapa inbjudningar - Det finns inget stöd för att registrera konto + Kunde inte hantera inbjudan + Servern stöder inte generering av inbjudningar + Kontoregistreringar stöds inte Ställ in \"autojoin\"-flaggan när du går in i, eller lämnar en MUC, samt reagera på ändringar gjorda av andra klienter. + Du har loggat ut från detta konto + Logga in + Dölj notis + Återanslut på annan värd + Din kontakt använder overifierade enheter. Skanna deras 2D-streckkod för att utföra en verifiering och för att förhindra aktiva MITM-attacker. + Ta bort kontot från servern + Rapportera spam och blockera spammaren + Inkommande samtal (%s) · %s + Logga ut + Gruppchattar + Du försöker importera ett föråldrat filformat för säkerhetskopiering + Sök gruppchattar + Neka + Utgående samtal (%s) · %s + Utloggad + Du använder overifierade enheter. Skanna 2D-streckkoden på dina andra enheter, för att utföra en verifiering och för att förhindra aktiva MITM-attacker. + Utgående samtal · %s + Spara som gruppchatt + Ljudbok + Rapportera spam + Funktionen Channel Discovery, använder en tredjepartstjänst som heter <a href=https://search.jabber.network>search.jabber.network</a>.<br><br>Om du använder den här funktionen, överförs din IP-adress och din söktermer till den tjänsten. Se deras <a href=https://search.jabber.network/privacy>sekretesspolicy</a> för mer information. + Försök inte att återställa säkerhetskopior som du inte har skapat själv! + Det gick inte att ta bort kontot från servern \ No newline at end of file diff --git a/src/main/res/values-zh-rCN/strings.xml b/src/main/res/values-zh-rCN/strings.xml index 9397cd9652c0599112799a97ee03019c5ba8a6be..0890e3f5a128505016f1f4c69437fe74e9cb3751 100644 --- a/src/main/res/values-zh-rCN/strings.xml +++ b/src/main/res/values-zh-rCN/strings.xml @@ -102,8 +102,8 @@ 发送未加密的 解密失败。也许您没有正确的私钥。 OpenKeychain - %1$s 使用 <b>OpenKeychain</b> 来加密和解密消息并管理公钥。<br><br>它在 GPLv3+ 许可证下授权并可在 F-Droid 和 Google Play 上获得。<br><br><small>(请之后重新启动 %1$s。)</small> - 重新启动 + %1$s 使用 <b>OpenKeychain</b> 来加密和解密消息并管理公钥。<br><br>它在 GPLv3+ 许可证下授权并可在 F-Droid 和 Google Play 上获得。<br><br><small>(请之后重启 %1$s。)</small> + 重启 安装 请安装 OpenKeychain 正在提供… @@ -641,8 +641,8 @@ 清理私人存储空间 清理保存文件的私人存储 (它们可以从服务器重新下载) 我从可信来源收到此链接 - 单击链接后,您将验证 %1$s 的 OMEMO 密钥。只有从可信来源(只有 %2$s 可以发布此链接)收到此链接才是安全的。 - 您将验证自己账号的 OMEMO 密钥。只有从可信来源(只有您可以发布此链接)收到此链接才是安全的。 + 单击链接后,您将验证 %1$s 的 OMEMO 密钥。只有从可信来源(只有 %2$s 可以发布此链接)收到此链接才是安全的。 + 您将验证自己账号的 OMEMO 密钥。只有从可信来源(只有您可以发布此链接)收到此链接才是安全的。 继续 验证 OMEMO 密钥 显示非活动设备 @@ -782,7 +782,7 @@ 正在进行的通话 未接来电 静音消息 - 此通知组用于显示不应触发任何声音的通知。例如,在另一台设备上处于活动状态时(静默期)。 + 此通知组用于显示不应触发任何声音的通知。例如,在另一台设备上处于活动状态时(静默期)。 传递失败 消息通知设置 来电通知设置 @@ -803,7 +803,7 @@ 选择国家/地区 电话号码 验证您的电话号码 - Quicksy 将发送短信(运营商可能收费)来验证电话号码。请输入您的国家/地区代码和电话号码: + Quicksy 将发送短信(运营商可能收费)来验证电话号码。请输入您的国家/地区代码和电话号码: 我们将验证这个电话号码

%s

可以吗?是否编辑号码?
%s 不是有效的电话号码。 请输入您的电话号码。 @@ -857,7 +857,7 @@ 恢复备份 恢复 输入 %s 账号的密码以恢复备份。 - 请勿使用恢复备份功能尝试克隆(同时运行)安装。恢复备份仅适用于迁移或您丢失原始设备的情况。 + 请勿使用恢复备份功能尝试克隆(同时运行)安装。恢复备份仅适用于迁移或您丢失原始设备的情况。 无法恢复备份。 无法解密备份。密码是否正确? 备份 & 恢复 @@ -924,7 +924,7 @@ 添加额外轨道? 正在连接 已连接 - 正在重新连接 + 正在重连 正在接受通话 正在结束通话 应答 @@ -940,8 +940,8 @@ 挂断 正在进行的通话 正在进行的视频通话 - 正在重新连接通话 - 正在重新连接视频通话 + 正在重连通话 + 正在重连视频通话 禁用 Tor 以进行通话 来电 未接来电 · %s diff --git a/src/quicksy/fastlane/metadata/android/eo/short_description.txt b/src/quicksy/fastlane/metadata/android/eo/short_description.txt new file mode 100644 index 0000000000000000000000000000000000000000..eebdbcbbfa953fea200c0d03b61a016fed66c878 --- /dev/null +++ b/src/quicksy/fastlane/metadata/android/eo/short_description.txt @@ -0,0 +1 @@ +Jabber/XMPP kun Facila Eniro kaj Facila Malkovro diff --git a/src/quicksy/fastlane/metadata/android/gl-ES/short_description.txt b/src/quicksy/fastlane/metadata/android/gl-ES/short_description.txt index 6367e207992318a823676049809f9522971d1606..9c1367265c9cdf43d3cd87fb199ca1d594dd4618 100644 --- a/src/quicksy/fastlane/metadata/android/gl-ES/short_description.txt +++ b/src/quicksy/fastlane/metadata/android/gl-ES/short_description.txt @@ -1 +1 @@ -Jabber/XMPP fácil de Usar e Atopar aos teus Contactos +Jabber/XMPP Fácil de usar e Atopar os teus contactos diff --git a/src/quicksy/fastlane/metadata/android/zh-CN/full_description.txt b/src/quicksy/fastlane/metadata/android/zh-CN/full_description.txt index c9edb69ec3cafe5ff2fe7c456661b15b369df527..476e37c777977ec08544b83c4b73c58e84816f91 100644 --- a/src/quicksy/fastlane/metadata/android/zh-CN/full_description.txt +++ b/src/quicksy/fastlane/metadata/android/zh-CN/full_description.txt @@ -4,10 +4,10 @@ Quicksy 是流行的 Jabber/XMPP 客户端 Conversations 的衍生品,具有 从本质上讲,Quicksy 是成熟的 Jabber 客户端,可让您与任何公共联合服务器上的任何用户进行交流。同样,只需将 +phonenumber@quicksy.im 添加到您的联系人列表中,即可从外部联系 Quicksy 上的用户。 -除了联系人同步之外,用户界面尽可能地接近 Conversations。这使得用户最终可以从 Quicksy 迁移到 Conversations,而无需重新了解应用程序的工作方式。 +除了联系人同步之外,用户界面尽可能地接近 Conversations。让用户最终可以从 Quicksy 迁移到 Conversations,而无需重新了解应用程序的工作方式。 -建议的联系人包括其他 Quicksy 用户和在 Quicksy 目录中输入 Jabber ID 的普通 Jabber/XMPP 用户(https://quicksy.im/#get-listed)。 +建议的联系人包括其他 Quicksy 用户和在 Quicksy 目录(https://quicksy.im/#get-listed)中输入 Jabber ID 的普通 Jabber/XMPP 用户。 注意:要在 Quicksy 目录中输入(https://quicksy.im/enter/)您的 Jabber ID 需要缴纳一次性注册费。 -请阅读隐私政策(https://quicksy.im/#privacy),了解更多信息。 +请阅读隐私政策(https://quicksy.im/#privacy)了解更多信息。 diff --git a/src/quicksy/res/values-eo/strings.xml b/src/quicksy/res/values-eo/strings.xml new file mode 100644 index 0000000000000000000000000000000000000000..b48b4fac15ae18bef7ecb5aab46c80b76e756aad --- /dev/null +++ b/src/quicksy/res/values-eo/strings.xml @@ -0,0 +1,12 @@ + + + Por daŭre ricevi sciigojn, eĉ kiam la ekrano estas malŝaltita, vi devas aldoni Quicksy al la listo de protektitaj programoj. + Sendante stakspurojn vi helpas la daŭran disvolviĝon de Quicksy + Ne eblas kontroli servilan identecon. + Nekonata sekureca eraro. + Eltempiĝo dum konektante al servilo. + Quicksy ne haveblas en via lando. + Quicksy profilbildo + La tempodaŭro Quicksy silentas post vidado de agado sur alia aparato + Sciigi ĉiujn viajn kontaktojn kiam vi uzas Quicksy + \ No newline at end of file