From d3b38a5273ed7bafc04bcf5b6c9d2eb4449e64e3 Mon Sep 17 00:00:00 2001 From: Daniel Gultsch Date: Tue, 19 Dec 2023 17:25:48 +0100 Subject: [PATCH] refactor Jingle File Transfer. add WebRTCDatachannel transport --- .../java/eu/siacs/conversations/Config.java | 4 +- .../crypto/axolotl/AxolotlService.java | 37 +- .../generator/AbstractGenerator.java | 7 +- .../services/UnifiedPushBroker.java | 7 +- .../siacs/conversations/utils/Checksum.java | 60 - .../eu/siacs/conversations/xml/Namespace.java | 5 + .../xmpp/jingle/AbstractContentMap.java | 82 + .../xmpp/jingle/AbstractJingleConnection.java | 359 ++- .../xmpp/jingle/ContentAddition.java | 8 +- .../xmpp/jingle/DescriptionTransport.java | 19 + .../xmpp/jingle/DirectConnectionUtils.java | 32 +- .../xmpp/jingle/FileTransferContentMap.java | 219 ++ .../conversations/xmpp/jingle/IceServers.java | 98 + .../xmpp/jingle/JingleCandidate.java | 152 - .../xmpp/jingle/JingleConnectionManager.java | 105 +- .../jingle/JingleFileTransferConnection.java | 2547 +++++++++-------- .../xmpp/jingle/JingleInBandTransport.java | 265 -- .../xmpp/jingle/JingleRtpConnection.java | 422 +-- .../xmpp/jingle/JingleSocks5Transport.java | 305 -- .../xmpp/jingle/JingleTransport.java | 15 - .../xmpp/jingle/MediaBuilder.java | 23 +- .../jingle/OmemoVerifiedRtpContentMap.java | 6 +- .../xmpp/jingle/OnPrimaryCandidateFound.java | 5 - .../xmpp/jingle/RtpContentMap.java | 307 +- .../xmpp/jingle/SessionDescription.java | 216 +- .../xmpp/jingle/WebRTCWrapper.java | 8 +- .../xmpp/jingle/stanzas/Content.java | 15 +- .../stanzas/FileTransferDescription.java | 270 +- .../jingle/stanzas/GenericDescription.java | 1 + .../xmpp/jingle/stanzas/Group.java | 2 +- .../xmpp/jingle/stanzas/IbbTransportInfo.java | 13 +- .../jingle/stanzas/IceUdpTransportInfo.java | 6 +- .../xmpp/jingle/stanzas/JinglePacket.java | 53 +- .../xmpp/jingle/stanzas/Propose.java | 2 +- .../xmpp/jingle/stanzas/S5BTransportInfo.java | 50 - .../SocksByteStreamsTransportInfo.java | 117 + .../WebRTCDataChannelTransportInfo.java | 111 + .../InbandBytestreamsTransport.java | 321 +++ .../transports/SocksByteStreamsTransport.java | 870 ++++++ .../xmpp/jingle/transports/Transport.java | 80 + .../WebRTCDataChannelTransport.java | 617 ++++ 41 files changed, 4930 insertions(+), 2911 deletions(-) delete mode 100644 src/main/java/eu/siacs/conversations/utils/Checksum.java create mode 100644 src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractContentMap.java create mode 100644 src/main/java/eu/siacs/conversations/xmpp/jingle/DescriptionTransport.java create mode 100644 src/main/java/eu/siacs/conversations/xmpp/jingle/FileTransferContentMap.java create mode 100644 src/main/java/eu/siacs/conversations/xmpp/jingle/IceServers.java delete mode 100644 src/main/java/eu/siacs/conversations/xmpp/jingle/JingleCandidate.java delete mode 100644 src/main/java/eu/siacs/conversations/xmpp/jingle/JingleInBandTransport.java delete mode 100644 src/main/java/eu/siacs/conversations/xmpp/jingle/JingleSocks5Transport.java delete mode 100644 src/main/java/eu/siacs/conversations/xmpp/jingle/JingleTransport.java delete mode 100644 src/main/java/eu/siacs/conversations/xmpp/jingle/OnPrimaryCandidateFound.java delete mode 100644 src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/S5BTransportInfo.java create mode 100644 src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/SocksByteStreamsTransportInfo.java create mode 100644 src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/WebRTCDataChannelTransportInfo.java create mode 100644 src/main/java/eu/siacs/conversations/xmpp/jingle/transports/InbandBytestreamsTransport.java create mode 100644 src/main/java/eu/siacs/conversations/xmpp/jingle/transports/SocksByteStreamsTransport.java create mode 100644 src/main/java/eu/siacs/conversations/xmpp/jingle/transports/Transport.java create mode 100644 src/main/java/eu/siacs/conversations/xmpp/jingle/transports/WebRTCDataChannelTransport.java diff --git a/src/main/java/eu/siacs/conversations/Config.java b/src/main/java/eu/siacs/conversations/Config.java index a1f7d1d41..343f3376a 100644 --- a/src/main/java/eu/siacs/conversations/Config.java +++ b/src/main/java/eu/siacs/conversations/Config.java @@ -41,7 +41,7 @@ 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 boolean QUICK_LOG = true; public static final Jid BUG_REPORTS = Jid.of("bugs@conversations.im"); public static final Uri HELP = Uri.parse("https://help.conversations.im"); @@ -117,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 9cf3d9091..3721f4cfe 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 706b50043..42fc3c00f 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, @@ -124,6 +120,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/services/UnifiedPushBroker.java b/src/main/java/eu/siacs/conversations/services/UnifiedPushBroker.java index d152c5d07..2562e13a6 100644 --- a/src/main/java/eu/siacs/conversations/services/UnifiedPushBroker.java +++ b/src/main/java/eu/siacs/conversations/services/UnifiedPushBroker.java @@ -403,7 +403,12 @@ public class UnifiedPushBroker { updateIntent.putExtra("token", target.instance); updateIntent.putExtra("bytesMessage", payload); updateIntent.putExtra("message", new String(payload, StandardCharsets.UTF_8)); - // TODO add distributor verification? + 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); } 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 407cb1944..000000000 --- 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/Namespace.java b/src/main/java/eu/siacs/conversations/xml/Namespace.java index 7ccaab739..85714c765 100644 --- a/src/main/java/eu/siacs/conversations/xml/Namespace.java +++ b/src/main/java/eu/siacs/conversations/xml/Namespace.java @@ -47,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 +75,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/xmpp/jingle/AbstractContentMap.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractContentMap.java new file mode 100644 index 000000000..847678a05 --- /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 d719c729e..efc32f5ff 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; } - boolean isInitiator() { - return initiator.equals(id.account.getJid()); - } - - abstract void deliverPacket(JinglePacket jinglePacket); - 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); + + 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 97bf802fd..ab2dffc6d 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 000000000..70d6c512c --- /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 83a2b95e4..a2a5c4032 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 000000000..c678c91cb --- /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 000000000..7b2f88457 --- /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 78ffb28be..000000000 --- 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 e334860bc..23d4f175b 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java @@ -29,10 +29,13 @@ 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; @@ -61,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); @@ -90,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)) { @@ -593,13 +594,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) { @@ -658,60 +656,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())) { - listener.onPrimaryCandidateFound( - true, this.primaryCandidates.get(account.getJid().asBareJid())); - return; - } - - final Jid proxy = - account.getXmppConnection().findDiscoItemByFeature(Namespace.BYTE_STREAMS); - if (proxy == null) { - listener.onPrimaryCandidateFound(false, null); - return; - } - final IqPacket iq = new IqPacket(IqPacket.TYPE.GET); - iq.setTo(proxy); - iq.query(Namespace.BYTE_STREAMS); - account.getXmppConnection() - .sendIqPacket( - iq, - (a, response) -> { - final Element streamhost = - response.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(a.getJid().asBareJid(), candidate); - listener.onPrimaryCandidateFound(true, candidate); - } catch (final NumberFormatException e) { - listener.onPrimaryCandidateFound(false, null); - } - } else { - listener.onPrimaryCandidateFound(false, null); - } - }); - } - public void retractSessionProposal(final Account account, final Jid with) { synchronized (this.rtpSessionProposals) { RtpSessionProposal matchingProposal = null; @@ -810,36 +754,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 fileTransfer) { - final JingleTransport transport = fileTransfer.getTransport(); - if (transport instanceof JingleInBandTransport inBandTransport) { - 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); + Log.d(Config.LOGTAG, account.getJid().asBareJid()+": unable to deliver ibb packet with sid="+sid); account.getXmppConnection() .sendIqPacket(packet.generateResponse(IqPacket.TYPE.ERROR), null); } 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 c4ed04bd0..983ce433f 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java @@ -1,1222 +1,303 @@ 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.utils.CryptoHelper; -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 { +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; - 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; +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 static final int MAX_IBB_BLOCK_SIZE = 8192; +public class JingleFileTransferConnection extends AbstractJingleConnection + implements Transport.Callback, Transferable { - private int ibbBlockSize = MAX_IBB_BLOCK_SIZE; + private final Message message; - 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 FileTransferContentMap initiatorFileTransferContentMap; + private FileTransferContentMap responderFileTransferContentMap; - private String transportId; - private FileTransferDescription description; - private DownloadableFile file = null; - - private boolean proxyActivationFailed = false; - - private String contentName; - private Content.Creator contentCreator; - private Content.Senders contentSenders; - private Class initialTransport; - private boolean remoteSupportsOmemoJet; - - private int mProgress = 0; - - 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; - - private OutputStream mFileOutputStream; - private InputStream mFileInputStream; - - 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()); - } - } - }; - private byte[] expectedHash = new byte[0]; - private final OnFileTransmissionStatusChanged onFileTransmissionStatusChanged = new OnFileTransmissionStatusChanged() { - - @Override - public void onFileTransmitted(DownloadableFile file) { - 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"); - } - } else { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": other party did not include file hash in file transfer"); - } - sendSuccess(); - xmppConnectionService.getFileBackend().updateFileParams(message); - 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)); - - } - 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 { - 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) { - file.delete(); - } - } - Log.d(Config.LOGTAG, "successfully transmitted file:" + file.getAbsolutePath() + " (" + CryptoHelper.bytesToHex(file.getSha1Sum()) + ")"); - if (message.getEncryption() != Message.ENCRYPTION_PGP) { - xmppConnectionService.getFileBackend().updateMediaScanner(file); - } - } - - @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"); - } - - @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 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"); - } - } - - @Override - public void failed() { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": proxy activation failed"); - proxyActivationFailed = true; - if (isInitiator()) { - sendFallbackToIbb(); - } - } - }; - - 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; - } - try { - return Long.parseLong(input); - } catch (Exception e) { - return l; - } - } - - //TODO get rid and use isInitiator() instead - private boolean responding() { - return responder != null && responder.equals(id.account.getJid()); - } - - - InputStream getFileInputStream() { - return this.mFileInputStream; - } - - OutputStream getFileOutputStream() throws IOException { - if (this.file == null) { - Log.d(Config.LOGTAG, "file object was not assigned"); - return null; - } - final File parent = this.file.getParentFile(); - if (parent != null && parent.mkdirs()) { - Log.d(Config.LOGTAG, "created parent directories for file " + file.getAbsolutePath()); - } - if (this.file.createNewFile()) { - Log.d(Config.LOGTAG, "created output file " + file.getAbsolutePath()); - } - this.mFileOutputStream = AbstractConnectionManager.createOutputStream(this.file, false, true); - return this.mFileOutputStream; - } - - @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]; - } - } - 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); - } else { - Log.d(Config.LOGTAG, "trying to fallback to something unknown" + packet.toString()); - respondToIq(packet, false); - } - } 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); - } - } - - @Override - void notifyRebound() { - if (getJingleStatus() == JINGLE_STATUS_TRANSMITTING) { - abort(Reason.CONNECTIVITY_ERROR); - } - } - - 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"); - } - xmppConnectionService.sendIqPacket(id.account, response, null); - } - - 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); - } - - 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 init(final Message message, final XmppAxolotlMessage xmppAxolotlMessage) { - this.mXmppAxolotlMessage = xmppAxolotlMessage; - this.contentCreator = Content.Creator.INITIATOR; - this.contentSenders = Content.Senders.INITIATOR; - this.contentName = JingleConnectionManager.nextRandomId(); + 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; - 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(); - } - }); - } - + xmppConnectionService.markMessage(message, Message.STATUS_WAITING); } - 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()); - } - } else { - directCandidates = Collections.emptyList(); - } - for (JingleCandidate directCandidate : directCandidates) { - final JingleSocks5Transport socksConnection = new JingleSocks5Transport(this, directCandidate); - connections.put(directCandidate.getCid(), socksConnection); - candidates.add(directCandidate); - } - } - - 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 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 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); + 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.mStatus = Transferable.STATUS_OFFER; + this.message.setErrorMessage(null); 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(); - return; - } - - this.description = (FileTransferDescription) content.getDescription(); - - final Element fileOffer = this.description.getFileOffer(); - - 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.setBody(Long.toString(size)); - 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"); - } - } - message.resetFileParams(); - //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 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); - } - } else { - this.file.setExpectedSize(file.getSize()); - description = FileTransferDescription.of(this.file, version, null); - } - 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); - } - content.setDescription(this.description); - message.resetFileParams(); - try { - this.mFileInputStream = new FileInputStream(file); - } catch (FileNotFoundException e) { - fail(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"); - } 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)); - } - }); - - } - - 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)); - - 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 Collection getOurCandidates() { - return Collections2.filter(this.candidates, c -> c != null && c.isOurs()); - } - - private void sendAccept() { - mJingleStatus = JINGLE_STATUS_ACCEPTED; - this.mStatus = Transferable.STATUS_DOWNLOADING; - this.jingleConnectionManager.updateConversationUi(true); - if (initialTransport == S5BTransportInfo.class) { - sendAcceptSocks(); - } else { - sendAcceptIbb(); - } - } - - 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() { - - @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(); - } - - @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(); - } - }); - } 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(); - } - }); - } - - 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 JinglePacket bootstrapPacket(JinglePacket.Action action) { - final JinglePacket packet = new JinglePacket(action, this.id.sessionId); - packet.setTo(id.with); - return packet; - } - - private void sendJinglePacket(JinglePacket packet) { - xmppConnectionService.sendIqPacket(id.account, packet, responseListener); - } - - private void sendJinglePacket(JinglePacket packet, OnIqPacketReceived callback) { - xmppConnectionService.sendIqPacket(id.account, packet, callback); - } - - 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); - return; - } - if (this.mJingleStatus != JINGLE_STATUS_INITIATED) { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received out of order session-accept"); - respondToIqWithOutOfOrder(packet); - 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); - } else { - respondToIq(packet, false); - } - } - - 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); - } - } else { - respondToIq(packet, false); - } - } else { - respondToIq(packet, true); - } - } - - 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(); - } - } 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); - } - } - } - } - - 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 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 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 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); - 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); - 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); - } else { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to parse block size in transport-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); - - respondToIq(packet, true); - - 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); - } - } - - 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); - 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); - 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); - - 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); - } - } - - 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(); - } - this.message.setTransferable(null); - this.jingleConnectionManager.finishConnection(this); - } else { - Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received session-terminate/success while responding"); - } } @Override - public void cancel() { - this.cancelled = true; - abort(Reason.CANCEL); + 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 void abort(final Reason reason) { - this.disconnectSocks5Connections(); - if (this.transport instanceof JingleInBandTransport) { - this.transport.disconnect(); - } - 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); + 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 { - this.xmppConnectionService.markMessage(this.message, Message.STATUS_SEND_FAILED, cancelled ? Message.ERROR_MESSAGE_CANCELLED : null); - this.message.setTransferable(null); + keyTransportMessage = Futures.immediateFuture(Optional.empty()); + } + Futures.addCallback( + keyTransportMessage, + new FutureCallback<>() { + @Override + public void onSuccess(final Optional xmppAxolotlMessage) { + sendSessionInitialize(xmppAxolotlMessage.orElse(null)); + } + + @Override + public void onFailure(@NonNull Throwable throwable) { + Log.d(Config.LOGTAG, "can not send message"); + } + }, + MoreExecutors.directExecutor()); + } + + 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 onFailure(@NonNull Throwable throwable) {} + }, + MoreExecutors.directExecutor()); + } + + 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"); } } - private void fail() { - fail(null); + 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(); + } } - private void fail(String errorMessage) { - this.mJingleStatus = JINGLE_STATUS_FAILED; - this.disconnectSocks5Connections(); - if (this.transport instanceof JingleInBandTransport) { - this.transport.disconnect(); + private void receiveSessionAccept(final JinglePacket jinglePacket) { + Log.d(Config.LOGTAG, "receive session accept " + jinglePacket); + if (isResponder()) { + receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.SESSION_ACCEPT); + return; } - 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); + final FileTransferContentMap contentMap; + try { + 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); + } + + 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 { - this.xmppConnectionService.markMessage(this.message, - Message.STATUS_SEND_FAILED, - cancelled ? Message.ERROR_MESSAGE_CANCELLED : errorMessage); - this.message.setTransferable(null); + 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"); } - } - this.jingleConnectionManager.finishConnection(this); - } - - private void sendSessionTerminate(Reason reason) { - final JinglePacket packet = bootstrapPacket(JinglePacket.Action.SESSION_TERMINATE); - packet.setReason(reason, null); - this.sendJinglePacket(packet); - } - - private void connectNextCandidate() { - for (JingleCandidate candidate : this.candidates) { - if ((!connections.containsKey(candidate.getCid()) && (!candidate - .isOurs()))) { - this.connectWithCandidate(candidate); - return; - } - } - this.sendCandidateError(); - } - - 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()); - } - }); - } - - private void disconnectSocks5Connections() { - Iterator> it = this.connections - .entrySet().iterator(); - while (it.hasNext()) { - Entry pairs = it.next(); - pairs.getValue().disconnect(); - it.remove(); + } else { + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + ": receive out of order session-accept"); + receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.SESSION_ACCEPT); } } - 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); - } - - 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); - } - - 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(); - } - 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 int getJingleStatus() { - return this.mJingleStatus; - } - - private boolean equalCandidateExists(JingleCandidate candidate) { - for (JingleCandidate c : this.candidates) { - if (c.equalValues(candidate)) { - return true; - } - } - return false; - } - - private void mergeCandidate(JingleCandidate candidate) { - for (JingleCandidate c : this.candidates) { - if (c.equals(candidate)) { - return; - } - } - 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); - } - } - - private JingleCandidate getCandidate(String cid) { - for (JingleCandidate c : this.candidates) { - if (c.getCid().equals(cid)) { - return c; - } - } - return null; - } - - void updateProgress(int i) { - this.mProgress = i; - jingleConnectionManager.updateConversationUi(false); - } - - String getTransportId() { - return this.transportId; - } - - FileTransferDescription.Version getFtVersion() { - return this.description.getVersion(); - } - - public JingleTransport getTransport() { - return this.transport; - } - - public boolean start() { - if (id.account.getStatus() == Account.State.ONLINE) { - if (mJingleStatus == JINGLE_STATUS_INITIATED) { - new Thread(this::sendAccept).start(); + 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 { @@ -1224,32 +305,1150 @@ public class JingleFileTransferConnection extends AbstractJingleConnection imple } } + private void receiveSessionInitiate(final JinglePacket jinglePacket) { + if (isInitiator()) { + receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.SESSION_INITIATE); + return; + } + 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; + } + 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; + } + receiveSessionInitiate(jinglePacket, contentMap, file, keyTransportMessage); + } + + 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); + } + 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, + "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 { + Log.d( + Config.LOGTAG, + id.account.getJid().asBareJid() + ": receive out of order session-initiate"); + receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.SESSION_INITIATE); + } + } + + 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); + } + + 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; + } + 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); + } + + @Override + public void onFailure(@NonNull Throwable throwable) { + failureToAcceptSession(throwable); + } + }, + MoreExecutors.directExecutor()); + } + + 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 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 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 { + throw new IllegalArgumentException("Do not know how to create transport"); + } + } + + 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 Transport setupLastResortTransport() { + final XmppConnection xmppConnection = id.account.getXmppConnection(); + return new InbandBytestreamsTransport(xmppConnection, id.with, isInitiator()); + } + + 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()); + } + + 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); + } + } + + private void receiveSessionInfoChecksum(final FileTransferDescription.Checksum checksum) { + Log.d(Config.LOGTAG, "received checksum " + checksum); + } + + 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 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 { + receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.TRANSPORT_ACCEPT); + } + } + + 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"); + } + } + + private void receiveTransportInfo(final JinglePacket jinglePacket) { + final FileTransferContentMap contentMap; + final GenericTransportInfo transportInfo; + try { + 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; + } + 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 { + Log.d(Config.LOGTAG, "could not deliver transport-info to transport"); + } + } + + 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); + } + } + + 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 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 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 { + receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.TRANSPORT_REPLACE); + } + } + + 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 onSuccess(final Transport.TransportInfo transportWrapper) { + final FileTransferContentMap contentMap = + getLocalContentMap().withTransport(transportWrapper); + sendTransportAccept(contentMap); + } + + @Override + public void onFailure(@NonNull Throwable throwable) { + // transition into application failed (analogues to failureToAccept + } + }, + 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 FileTransferContentMap getRemoteContentMap() { + return isInitiator() + ? this.responderFileTransferContentMap + : this.initiatorFileTransferContentMap; + } + + private void setLocalContentMap(final FileTransferContentMap contentMap) { + if (isInitiator()) { + this.initiatorFileTransferContentMap = contentMap; + } else { + this.responderFileTransferContentMap = contentMap; + } + } + + private void setRemoteContentMap(final FileTransferContentMap contentMap) { + if (isInitiator()) { + this.responderFileTransferContentMap = contentMap; + } else { + this.initiatorFileTransferContentMap = contentMap; + } + } + + public Transport getTransport() { + return this.transport; + } + + @Override + protected void terminateTransport() { + final var transport = this.transport; + if (transport == null) { + return; + } + 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.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 { + Log.d(Config.LOGTAG, "file transfer complete " + hashes); + sendFileSessionInfoReceived(); + terminateTransport(); + messageReceivedSuccess(); + sendSessionTerminate(Reason.SUCCESS, null); + } + } + + 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 { + xmppConnectionService + .getFileBackend() + .updateMediaScanner( + file, + () -> + JingleFileTransferConnection.this + .xmppConnectionService + .getNotificationService() + .push(message)); + } + } else if (message.getEncryption() == Message.ENCRYPTION_PGP) { + id.account.getPgpDecryptionService().decrypt(message, false); + } else { + xmppConnectionService.getFileBackend().updateMediaScanner(file); + } + } + + private void onFileTransmissionFailed(final Throwable throwable) { + if (isTerminated()) { + Log.d( + Config.LOGTAG, + "file transfer failed but session is already terminated", + throwable); + } else { + terminateTransport(); + Log.d(Config.LOGTAG, "on file transmission failed", throwable); + sendSessionTerminate(Reason.CONNECTIVITY_ERROR, 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 sendFileSessionInfoReceived() { + final var contentMap = getLocalContentMap(); + final String name = Iterables.getOnlyElement(contentMap.contents.keySet()); + sendSessionInfo(new FileTransferDescription.Received(name)); + } + + 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); + } + + @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; + } + Log.d(Config.LOGTAG, "onTransportSetupFailed"); + final var isTransportInBand = transport instanceof InbandBytestreamsTransport; + if (isTransportInBand) { + terminateTransport(); + sendSessionTerminate(Reason.CONNECTIVITY_ERROR, "Failed to setup IBB transport"); + return; + } + // 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, "transport setup failed. waiting for initiator to replace"); + } + } + + 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); + } + + @Override + public void onAdditionalCandidate( + final String contentName, final Transport.Candidate candidate) { + if (candidate instanceof IceUdpTransportInfo.Candidate iceCandidate) { + sendTransportInfo(contentName, iceCandidate); + } + } + + 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); + } + + @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 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; + } + final var jinglePacket = + contentMap + .candidateError(streamId) + .toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId); + Log.d(Config.LOGTAG, "sending candidate error " + jinglePacket); + send(jinglePacket); + } + + @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); + } + + @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(); + } + } else { + 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 + protected void finish() { + if (transport != null) { + throw new AssertionError( + "finish MUST not be called without terminating the transport first"); + } + // 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 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; + } + 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; + }; + } + + // these methods are for interacting with 'Transferable' - we might want to remove the concept + // at some point + + @Override + public boolean start() { + Log.d(Config.LOGTAG, "user pressed start()"); + // TODO there is a 'connected' check apparently? + if (isInState(State.SESSION_INITIALIZED)) { + sendSessionAccept(); + } + return true; + } + @Override public int getStatus() { - return this.mStatus; + return getTransferableStatus(); } @Override public Long getFileSize() { - if (this.file != null) { - return this.file.getExpectedSize(); - } else { - return null; + 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; } @Override public int getProgress() { - return this.mProgress; + final var transceiver = this.fileTransceiver; + return transceiver != null ? transceiver.getProgress() : 0; } - AbstractConnectionManager getConnectionManager() { - return this.jingleConnectionManager; + @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?"); + } } - interface OnProxyActivated { - void success(); + private boolean stopFileTransfer() { + if (isInitiator()) { + return stopFileTransfer(Reason.CANCEL); + } else { + return stopFileTransfer(Reason.DECLINE); + } + } - void failed(); + 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 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; + } + + static void closeTransport(final Closeable stream) { + try { + stream.close(); + } catch (final IOException e) { + Log.d(Config.LOGTAG, "transport has already been closed. good"); + } + } + + public int getProgress() { + return Ints.saturatedCast(Math.round((1.0 * transmitted / total) * 100)); + } + + 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(); + } + } + } + + protected void awaitTransportTermination() { + try { + this.transportTerminationLatch.await(); + } catch (final InterruptedException ignored) { + return; + } + Log.d(Config.LOGTAG, getClass().getSimpleName() + " says Goodbye!"); + } + } + + private static class FileTransmitter extends AbstractFileTransceiver { + + private final OutputStream outputStream; + + 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; + } + + 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); + } + } + + @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); + } + // 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); + } + } + + private static class FileReceiver extends AbstractFileTransceiver { + + 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; + } + + 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); + } + } + + @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); + } + } + + private static final class TransportSecurity { + final byte[] key; + final byte[] iv; + + 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 c68941928..000000000 --- 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 a7d781840..80d7d2118 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java @@ -13,7 +13,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; @@ -30,14 +29,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; @@ -78,96 +73,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; @@ -192,18 +104,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()) { @@ -233,7 +133,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( @@ -322,7 +222,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 = @@ -522,7 +422,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( @@ -596,7 +496,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, @@ -815,7 +715,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"); @@ -881,7 +781,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, @@ -1066,7 +966,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 @@ -1095,14 +995,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 @@ -1204,21 +1104,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); @@ -1300,13 +1186,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 = @@ -1491,7 +1372,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( @@ -2061,24 +1942,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; @@ -2099,110 +1972,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 -> { @@ -2398,7 +2167,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; } @@ -2527,22 +2296,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; @@ -2551,13 +2308,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 = @@ -2893,98 +2643,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, @@ -3001,13 +2660,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(); } else { throw new IllegalStateException( String.format("Unable to call finish from %s", this.state)); @@ -3045,14 +2710,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(); } @@ -3091,17 +2748,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 a57f4927f..000000000 --- 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 e832d3f58..000000000 --- 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 67e275414..db33666cb 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 f5e041014..0d5d32d50 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 76e337177..000000000 --- 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 cfd4bef78..94f8ca300 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; @@ -31,19 +30,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 { @@ -51,12 +48,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; } @@ -67,13 +67,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); @@ -94,26 +94,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); } @@ -122,7 +102,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 @@ -146,31 +127,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) { @@ -183,7 +143,7 @@ public class RtpContentMap { null, ImmutableMap.of( contentName, - new DescriptionTransport( + new DescriptionTransport<>( descriptionTransport.senders, null, newTransportInfo))); } @@ -193,21 +153,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)))); @@ -247,7 +210,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( @@ -287,7 +250,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; @@ -300,17 +263,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()); @@ -321,16 +286,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) { @@ -339,7 +306,7 @@ public class RtpContentMap { checkSenderModification(isInitiator, currentSenders, targetSenders); contentMapBuilder.put( id, - new DescriptionTransport( + new DescriptionTransport<>( targetSenders, descriptionTransport.description, descriptionTransport.transport)); @@ -386,7 +353,7 @@ public class RtpContentMap { Maps.transformValues( this.contents, dt -> - new DescriptionTransport( + new DescriptionTransport<>( dt.senders, RtpDescription.stub(dt.description.getMedia()), IceUdpTransportInfo.STUB))); @@ -415,120 +382,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"); } - - 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 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 2d2dc9570..025a7acc9 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 2e53e4252..cb0c8579d 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java @@ -406,7 +406,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); @@ -774,7 +774,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) { @@ -800,12 +800,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 0cca6527a..fbde212a3 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 8e0f2ebad..3878d98d9 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 android.util.Log; + +import androidx.annotation.NonNull; + +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; -import java.util.Arrays; -import java.util.List; - -import eu.siacs.conversations.crypto.axolotl.XmppAxolotlMessage; -import eu.siacs.conversations.entities.DownloadableFile; +import eu.siacs.conversations.Config; import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xml.Namespace; + +import java.util.List; public class FileTransferDescription extends GenericDescription { - public static List NAMESPACES = Arrays.asList( - Version.FT_3.namespace, - Version.FT_4.namespace, - Version.FT_5.namespace - ); - - - 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 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 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"); - } - fileElement.addChild("size").setContent(Long.toString(file.getExpectedSize())); - fileElement.addChild("name").setContent(file.getName()); - if (axolotlMessage != null) { - fileElement.addChild(axolotlMessage.toElement()); + 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 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 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; + } + 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())); + } + 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; - private final String namespace; - - Version(String namespace) { - this.namespace = namespace; + public Checksum(final String name, List hashes) { + super(name); + this.hashes = hashes; } - public String getNamespace() { - return namespace; + @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 { + + public final String name; + + protected SessionInfo(final String name) { + this.name = name; + } + + 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 a8db0d09f..3bb3076a7 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 eb5c32252..1d2f7515f 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 90fb32903..ddab9640f 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 ccaba56a6..8d90b1982 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; @@ -195,7 +197,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"); @@ -396,7 +398,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 0863b29df..552046fb8 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 da3a93da3..4b513c7b9 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 : this.children) { 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 8f8f13416..000000000 --- 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 000000000..4b1d85847 --- /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 : this.children) { + 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 000000000..88c4f9f00 --- /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 000000000..ce2d4b31f --- /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 000000000..2fe8949a1 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/xmpp/jingle/transports/SocksByteStreamsTransport.java @@ -0,0 +1,870 @@ +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.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, 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, 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 boolean useTor; + + private ConnectionFinder( + final ImmutableList candidates, + final String destination, + final boolean useTor) { + this.candidates = candidates; + this.destination = destination; + this.useTor = useTor; + } + + @Override + public void run() { + for (final Candidate candidate : this.candidates) { + // TODO we can check if there is already something in `selectedByThemCandidate` with + // a higher priority and abort + 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); + } + } + + 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 000000000..ce99ac8cc --- /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 000000000..0773610fb --- /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() {} + } +}