JingleFileTransferConnection.java

   1package eu.siacs.conversations.xmpp.jingle;
   2
   3import android.util.Log;
   4
   5import androidx.annotation.NonNull;
   6
   7import com.google.common.base.Preconditions;
   8import com.google.common.base.Strings;
   9import com.google.common.base.Throwables;
  10import com.google.common.collect.ImmutableList;
  11import com.google.common.collect.Iterables;
  12import com.google.common.hash.Hashing;
  13import com.google.common.primitives.Ints;
  14import com.google.common.util.concurrent.FutureCallback;
  15import com.google.common.util.concurrent.Futures;
  16import com.google.common.util.concurrent.ListenableFuture;
  17import com.google.common.util.concurrent.MoreExecutors;
  18import com.google.common.util.concurrent.SettableFuture;
  19
  20import eu.siacs.conversations.Config;
  21import eu.siacs.conversations.crypto.axolotl.XmppAxolotlMessage;
  22import eu.siacs.conversations.entities.Conversation;
  23import eu.siacs.conversations.entities.Message;
  24import eu.siacs.conversations.entities.Transferable;
  25import eu.siacs.conversations.entities.TransferablePlaceholder;
  26import eu.siacs.conversations.services.AbstractConnectionManager;
  27import eu.siacs.conversations.xml.Namespace;
  28import eu.siacs.conversations.xmpp.Jid;
  29import eu.siacs.conversations.xmpp.XmppConnection;
  30import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription;
  31import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo;
  32import eu.siacs.conversations.xmpp.jingle.stanzas.IbbTransportInfo;
  33import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
  34import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
  35import eu.siacs.conversations.xmpp.jingle.stanzas.SocksByteStreamsTransportInfo;
  36import eu.siacs.conversations.xmpp.jingle.stanzas.WebRTCDataChannelTransportInfo;
  37import eu.siacs.conversations.xmpp.jingle.transports.InbandBytestreamsTransport;
  38import eu.siacs.conversations.xmpp.jingle.transports.SocksByteStreamsTransport;
  39import eu.siacs.conversations.xmpp.jingle.transports.Transport;
  40import eu.siacs.conversations.xmpp.jingle.transports.WebRTCDataChannelTransport;
  41
  42import im.conversations.android.xmpp.model.jingle.Jingle;
  43import im.conversations.android.xmpp.model.stanza.Iq;
  44
  45import org.bouncycastle.crypto.engines.AESEngine;
  46import org.bouncycastle.crypto.io.CipherInputStream;
  47import org.bouncycastle.crypto.io.CipherOutputStream;
  48import org.bouncycastle.crypto.modes.AEADBlockCipher;
  49import org.bouncycastle.crypto.modes.GCMBlockCipher;
  50import org.bouncycastle.crypto.params.AEADParameters;
  51import org.bouncycastle.crypto.params.KeyParameter;
  52import org.webrtc.IceCandidate;
  53
  54import java.io.Closeable;
  55import java.io.EOFException;
  56import java.io.File;
  57import java.io.FileInputStream;
  58import java.io.FileNotFoundException;
  59import java.io.FileOutputStream;
  60import java.io.IOException;
  61import java.io.InputStream;
  62import java.io.OutputStream;
  63import java.util.Arrays;
  64import java.util.Collections;
  65import java.util.LinkedList;
  66import java.util.List;
  67import java.util.Objects;
  68import java.util.Optional;
  69import java.util.Queue;
  70import java.util.concurrent.CountDownLatch;
  71
  72public class JingleFileTransferConnection extends AbstractJingleConnection
  73        implements Transport.Callback, Transferable {
  74
  75    private final Message message;
  76
  77    private FileTransferContentMap initiatorFileTransferContentMap;
  78    private FileTransferContentMap responderFileTransferContentMap;
  79
  80    private Transport transport;
  81    private TransportSecurity transportSecurity;
  82    private AbstractFileTransceiver fileTransceiver;
  83
  84    private final Queue<IceCandidate> pendingIncomingIceCandidates = new LinkedList<>();
  85    private boolean acceptedAutomatically = false;
  86
  87    public JingleFileTransferConnection(
  88            final JingleConnectionManager jingleConnectionManager, final Message message) {
  89        super(
  90                jingleConnectionManager,
  91                AbstractJingleConnection.Id.of(message),
  92                message.getConversation().getAccount().getJid());
  93        Preconditions.checkArgument(
  94                message.isFileOrImage(),
  95                "only file or images messages can be transported via jingle");
  96        this.message = message;
  97        this.message.setTransferable(this);
  98        xmppConnectionService.markMessage(message, Message.STATUS_WAITING);
  99    }
 100
 101    public JingleFileTransferConnection(
 102            final JingleConnectionManager jingleConnectionManager,
 103            final Id id,
 104            final Jid initiator) {
 105        super(jingleConnectionManager, id, initiator);
 106        final Conversation conversation =
 107                this.xmppConnectionService.findOrCreateConversation(
 108                        id.account, id.with.asBareJid(), false, false);
 109        this.message = new Message(conversation, "", Message.ENCRYPTION_NONE);
 110        this.message.setStatus(Message.STATUS_RECEIVED);
 111        this.message.setErrorMessage(null);
 112        this.message.setTransferable(this);
 113    }
 114
 115    @Override
 116    void deliverPacket(final Iq iq) {
 117        final var jingle = iq.getExtension(Jingle.class);
 118        switch (jingle.getAction()) {
 119            case SESSION_ACCEPT -> receiveSessionAccept(iq, jingle);
 120            case SESSION_INITIATE -> receiveSessionInitiate(iq, jingle);
 121            case SESSION_INFO -> receiveSessionInfo(iq, jingle);
 122            case SESSION_TERMINATE -> receiveSessionTerminate(iq, jingle);
 123            case TRANSPORT_ACCEPT -> receiveTransportAccept(iq, jingle);
 124            case TRANSPORT_INFO -> receiveTransportInfo(iq, jingle);
 125            case TRANSPORT_REPLACE -> receiveTransportReplace(iq, jingle);
 126            default -> {
 127                respondOk(iq);
 128                Log.d(
 129                        Config.LOGTAG,
 130                        String.format(
 131                                "%s: received unhandled jingle action %s",
 132                                id.account.getJid().asBareJid(), jingle.getAction()));
 133            }
 134        }
 135    }
 136
 137    public void sendSessionInitialize() {
 138        final ListenableFuture<Optional<XmppAxolotlMessage>> keyTransportMessage;
 139        if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) {
 140            keyTransportMessage =
 141                    Futures.transform(
 142                            id.account
 143                                    .getAxolotlService()
 144                                    .prepareKeyTransportMessage(requireConversation()),
 145                            Optional::of,
 146                            MoreExecutors.directExecutor());
 147        } else {
 148            keyTransportMessage = Futures.immediateFuture(Optional.empty());
 149        }
 150        Futures.addCallback(
 151                keyTransportMessage,
 152                new FutureCallback<>() {
 153                    @Override
 154                    public void onSuccess(final Optional<XmppAxolotlMessage> xmppAxolotlMessage) {
 155                        sendSessionInitialize(xmppAxolotlMessage.orElse(null));
 156                    }
 157
 158                    @Override
 159                    public void onFailure(@NonNull Throwable throwable) {
 160                        Log.d(Config.LOGTAG, "can not send message");
 161                    }
 162                },
 163                MoreExecutors.directExecutor());
 164    }
 165
 166    private void sendSessionInitialize(final XmppAxolotlMessage xmppAxolotlMessage) {
 167        this.transport = setupTransport();
 168        this.transport.setTransportCallback(this);
 169        final File file = xmppConnectionService.getFileBackend().getFile(message);
 170        final var fileDescription =
 171                new FileTransferDescription.File(
 172                        file.length(),
 173                        file.getName(),
 174                        message.getMimeType(),
 175                        Collections.emptyList());
 176        final var transportInfoFuture = this.transport.asInitialTransportInfo();
 177        Futures.addCallback(
 178                transportInfoFuture,
 179                new FutureCallback<>() {
 180                    @Override
 181                    public void onSuccess(
 182                            final Transport.InitialTransportInfo initialTransportInfo) {
 183                        final FileTransferContentMap contentMap =
 184                                FileTransferContentMap.of(fileDescription, initialTransportInfo);
 185                        sendSessionInitialize(xmppAxolotlMessage, contentMap);
 186                    }
 187
 188                    @Override
 189                    public void onFailure(@NonNull Throwable throwable) {}
 190                },
 191                MoreExecutors.directExecutor());
 192    }
 193
 194    private Conversation requireConversation() {
 195        final var conversational = message.getConversation();
 196        if (conversational instanceof Conversation c) {
 197            return c;
 198        } else {
 199            throw new IllegalStateException("Message had no proper conversation attached");
 200        }
 201    }
 202
 203    private void sendSessionInitialize(
 204            final XmppAxolotlMessage xmppAxolotlMessage, final FileTransferContentMap contentMap) {
 205        if (transition(
 206                State.SESSION_INITIALIZED,
 207                () -> this.initiatorFileTransferContentMap = contentMap)) {
 208            final var iq =
 209                    contentMap.toJinglePacket(Jingle.Action.SESSION_INITIATE, id.sessionId);
 210            final var jingle = iq.getExtension(Jingle.class);
 211            if (xmppAxolotlMessage != null) {
 212                this.transportSecurity =
 213                        new TransportSecurity(
 214                                xmppAxolotlMessage.getInnerKey(), xmppAxolotlMessage.getIV());
 215                final var contents = jingle.getJingleContents();
 216                final var rawContent =
 217                        contents.get(Iterables.getOnlyElement(contentMap.contents.keySet()));
 218                if (rawContent != null) {
 219                    rawContent.setSecurity(xmppAxolotlMessage);
 220                }
 221            }
 222            iq.setTo(id.with);
 223            xmppConnectionService.sendIqPacket(
 224                    id.account,
 225                    iq,
 226                    (response) -> {
 227                        if (response.getType() == Iq.Type.RESULT) {
 228                            xmppConnectionService.markMessage(message, Message.STATUS_OFFERED);
 229                            return;
 230                        }
 231                        if (response.getType() == Iq.Type.ERROR) {
 232                            handleIqErrorResponse(response);
 233                            return;
 234                        }
 235                        if (response.getType() == Iq.Type.TIMEOUT) {
 236                            handleIqTimeoutResponse(response);
 237                        }
 238                    });
 239            this.transport.readyToSentAdditionalCandidates();
 240        }
 241    }
 242
 243    private void receiveSessionAccept(final Iq jinglePacket, final Jingle jingle) {
 244        Log.d(Config.LOGTAG, "receive file transfer session accept");
 245        if (isResponder()) {
 246            receiveOutOfOrderAction(jinglePacket, Jingle.Action.SESSION_ACCEPT);
 247            return;
 248        }
 249        final FileTransferContentMap contentMap;
 250        try {
 251            contentMap = FileTransferContentMap.of(jingle);
 252            contentMap.requireOnlyFileTransferDescription();
 253        } catch (final RuntimeException e) {
 254            Log.d(
 255                    Config.LOGTAG,
 256                    id.account.getJid().asBareJid() + ": improperly formatted contents",
 257                    Throwables.getRootCause(e));
 258            respondOk(jinglePacket);
 259            terminateTransport();
 260            sendSessionTerminate(Reason.of(e), e.getMessage());
 261            return;
 262        }
 263        receiveSessionAccept(jinglePacket, contentMap);
 264    }
 265
 266    private void receiveSessionAccept(
 267            final Iq jinglePacket, final FileTransferContentMap contentMap) {
 268        if (transition(State.SESSION_ACCEPTED, () -> setRemoteContentMap(contentMap))) {
 269            respondOk(jinglePacket);
 270            final var transport = this.transport;
 271            if (configureTransportWithPeerInfo(transport, contentMap)) {
 272                transport.connect();
 273            } else {
 274                Log.e(
 275                        Config.LOGTAG,
 276                        "Transport in session accept did not match our session-initialize");
 277                terminateTransport();
 278                sendSessionTerminate(
 279                        Reason.FAILED_APPLICATION,
 280                        "Transport in session accept did not match our session-initialize");
 281            }
 282        } else {
 283            Log.d(
 284                    Config.LOGTAG,
 285                    id.account.getJid().asBareJid() + ": receive out of order session-accept");
 286            receiveOutOfOrderAction(jinglePacket, Jingle.Action.SESSION_ACCEPT);
 287        }
 288    }
 289
 290    private static boolean configureTransportWithPeerInfo(
 291            final Transport transport, final FileTransferContentMap contentMap) {
 292        final GenericTransportInfo transportInfo = contentMap.requireOnlyTransportInfo();
 293        if (transport instanceof WebRTCDataChannelTransport webRTCDataChannelTransport
 294                && transportInfo instanceof WebRTCDataChannelTransportInfo) {
 295            webRTCDataChannelTransport.setResponderDescription(SessionDescription.of(contentMap));
 296            return true;
 297        } else if (transport instanceof SocksByteStreamsTransport socksBytestreamsTransport
 298                && transportInfo
 299                        instanceof SocksByteStreamsTransportInfo socksBytestreamsTransportInfo) {
 300            socksBytestreamsTransport.setTheirCandidates(
 301                    socksBytestreamsTransportInfo.getCandidates());
 302            return true;
 303        } else if (transport instanceof InbandBytestreamsTransport inbandBytestreamsTransport
 304                && transportInfo instanceof IbbTransportInfo ibbTransportInfo) {
 305            final var peerBlockSize = ibbTransportInfo.getBlockSize();
 306            if (peerBlockSize != null) {
 307                inbandBytestreamsTransport.setPeerBlockSize(peerBlockSize);
 308            }
 309            return true;
 310        } else {
 311            return false;
 312        }
 313    }
 314
 315    private void receiveSessionInitiate(final Iq jinglePacket, final Jingle jingle) {
 316        if (isInitiator()) {
 317            receiveOutOfOrderAction(jinglePacket, Jingle.Action.SESSION_INITIATE);
 318            return;
 319        }
 320        Log.d(Config.LOGTAG, "receive session initiate " + jinglePacket);
 321        final FileTransferContentMap contentMap;
 322        final FileTransferDescription.File file;
 323        try {
 324            contentMap = FileTransferContentMap.of(jingle);
 325            contentMap.requireContentDescriptions();
 326            file = contentMap.requireOnlyFile();
 327            // TODO check is offer
 328        } catch (final RuntimeException e) {
 329            Log.d(
 330                    Config.LOGTAG,
 331                    id.account.getJid().asBareJid() + ": improperly formatted contents",
 332                    Throwables.getRootCause(e));
 333            respondOk(jinglePacket);
 334            sendSessionTerminate(Reason.of(e), e.getMessage());
 335            return;
 336        }
 337        final XmppAxolotlMessage.XmppAxolotlKeyTransportMessage keyTransportMessage;
 338        final var contents = jingle.getJingleContents();
 339        final var rawContent = contents.get(Iterables.getOnlyElement(contentMap.contents.keySet()));
 340        final var security =
 341                rawContent == null ? null : rawContent.getSecurity(jinglePacket.getFrom());
 342        if (security != null) {
 343            Log.d(Config.LOGTAG, "found security element!");
 344            keyTransportMessage =
 345                    id.account
 346                            .getAxolotlService()
 347                            .processReceivingKeyTransportMessage(security, false);
 348        } else {
 349            keyTransportMessage = null;
 350        }
 351        receiveSessionInitiate(jinglePacket, contentMap, file, keyTransportMessage);
 352    }
 353
 354    private void receiveSessionInitiate(
 355            final Iq jinglePacket,
 356            final FileTransferContentMap contentMap,
 357            final FileTransferDescription.File file,
 358            final XmppAxolotlMessage.XmppAxolotlKeyTransportMessage keyTransportMessage) {
 359
 360        if (transition(State.SESSION_INITIALIZED, () -> setRemoteContentMap(contentMap))) {
 361            respondOk(jinglePacket);
 362            Log.d(
 363                    Config.LOGTAG,
 364                    "got file offer " + file + " jet=" + Objects.nonNull(keyTransportMessage));
 365            setFileOffer(file);
 366            if (keyTransportMessage != null) {
 367                this.transportSecurity =
 368                        new TransportSecurity(
 369                                keyTransportMessage.getKey(), keyTransportMessage.getIv());
 370                this.message.setFingerprint(keyTransportMessage.getFingerprint());
 371                this.message.setEncryption(Message.ENCRYPTION_AXOLOTL);
 372            } else {
 373                this.transportSecurity = null;
 374                this.message.setFingerprint(null);
 375            }
 376            final var conversation = (Conversation) message.getConversation();
 377            conversation.add(message);
 378
 379            // make auto accept decision
 380            if (id.account.getRoster().getContact(id.with).showInContactList()
 381                    && jingleConnectionManager.hasStoragePermission()
 382                    && file.size <= this.jingleConnectionManager.getAutoAcceptFileSize()
 383                    && xmppConnectionService.isDataSaverDisabled()) {
 384                Log.d(Config.LOGTAG, "auto accepting file from " + id.with);
 385                this.acceptedAutomatically = true;
 386                this.sendSessionAccept();
 387            } else {
 388                Log.d(
 389                        Config.LOGTAG,
 390                        "not auto accepting new file offer with size: "
 391                                + file.size
 392                                + " allowed size:"
 393                                + this.jingleConnectionManager.getAutoAcceptFileSize());
 394                message.markUnread();
 395                this.xmppConnectionService.updateConversationUi();
 396                this.xmppConnectionService.getNotificationService().push(message);
 397            }
 398        } else {
 399            Log.d(
 400                    Config.LOGTAG,
 401                    id.account.getJid().asBareJid() + ": receive out of order session-initiate");
 402            receiveOutOfOrderAction(jinglePacket, Jingle.Action.SESSION_INITIATE);
 403        }
 404    }
 405
 406    private void setFileOffer(final FileTransferDescription.File file) {
 407        final AbstractConnectionManager.Extension extension =
 408                AbstractConnectionManager.Extension.of(file.name);
 409        if (VALID_CRYPTO_EXTENSIONS.contains(extension.main)) {
 410            this.message.setEncryption(Message.ENCRYPTION_PGP);
 411        } else {
 412            this.message.setEncryption(Message.ENCRYPTION_NONE);
 413        }
 414        final String ext = extension.getExtension();
 415        final String filename =
 416                Strings.isNullOrEmpty(ext)
 417                        ? message.getUuid()
 418                        : String.format("%s.%s", message.getUuid(), ext);
 419        xmppConnectionService.getFileBackend().setupRelativeFilePath(message, filename);
 420    }
 421
 422    public void sendSessionAccept() {
 423        final FileTransferContentMap contentMap = this.initiatorFileTransferContentMap;
 424        final Transport transport;
 425        try {
 426            transport = setupTransport(contentMap.requireOnlyTransportInfo());
 427        } catch (final RuntimeException e) {
 428            sendSessionTerminate(Reason.of(e), e.getMessage());
 429            return;
 430        }
 431        transitionOrThrow(State.SESSION_ACCEPTED);
 432        this.transport = transport;
 433        this.transport.setTransportCallback(this);
 434        if (this.transport instanceof WebRTCDataChannelTransport webRTCDataChannelTransport) {
 435            final var sessionDescription = SessionDescription.of(contentMap);
 436            webRTCDataChannelTransport.setInitiatorDescription(sessionDescription);
 437        }
 438        final var transportInfoFuture = transport.asTransportInfo();
 439        Futures.addCallback(
 440                transportInfoFuture,
 441                new FutureCallback<>() {
 442                    @Override
 443                    public void onSuccess(final Transport.TransportInfo transportInfo) {
 444                        final FileTransferContentMap responderContentMap =
 445                                contentMap.withTransport(transportInfo);
 446                        sendSessionAccept(responderContentMap);
 447                    }
 448
 449                    @Override
 450                    public void onFailure(@NonNull Throwable throwable) {
 451                        failureToAcceptSession(throwable);
 452                    }
 453                },
 454                MoreExecutors.directExecutor());
 455    }
 456
 457    private void sendSessionAccept(final FileTransferContentMap contentMap) {
 458        setLocalContentMap(contentMap);
 459        final var iq =
 460                contentMap.toJinglePacket(Jingle.Action.SESSION_ACCEPT, id.sessionId);
 461        send(iq);
 462        // this needs to come after session-accept or else our candidate-error might arrive first
 463        this.transport.connect();
 464        this.transport.readyToSentAdditionalCandidates();
 465        if (this.transport instanceof WebRTCDataChannelTransport webRTCDataChannelTransport) {
 466            drainPendingIncomingIceCandidates(webRTCDataChannelTransport);
 467        }
 468    }
 469
 470    private void drainPendingIncomingIceCandidates(
 471            final WebRTCDataChannelTransport webRTCDataChannelTransport) {
 472        while (this.pendingIncomingIceCandidates.peek() != null) {
 473            final var candidate = this.pendingIncomingIceCandidates.poll();
 474            if (candidate == null) {
 475                continue;
 476            }
 477            webRTCDataChannelTransport.addIceCandidates(ImmutableList.of(candidate));
 478        }
 479    }
 480
 481    private Transport setupTransport(final GenericTransportInfo transportInfo) {
 482        final XmppConnection xmppConnection = id.account.getXmppConnection();
 483        final boolean useTor = id.account.isOnion() || xmppConnectionService.useTorToConnect();
 484        if (transportInfo instanceof IbbTransportInfo ibbTransportInfo) {
 485            final String streamId = ibbTransportInfo.getTransportId();
 486            final Long blockSize = ibbTransportInfo.getBlockSize();
 487            if (streamId == null || blockSize == null) {
 488                throw new IllegalStateException("ibb transport is missing sid and/or block-size");
 489            }
 490            return new InbandBytestreamsTransport(
 491                    xmppConnection,
 492                    id.with,
 493                    isInitiator(),
 494                    streamId,
 495                    Ints.saturatedCast(blockSize));
 496        } else if (transportInfo
 497                instanceof SocksByteStreamsTransportInfo socksBytestreamsTransportInfo) {
 498            final String streamId = socksBytestreamsTransportInfo.getTransportId();
 499            final String destination = socksBytestreamsTransportInfo.getDestinationAddress();
 500            final List<SocksByteStreamsTransport.Candidate> candidates =
 501                    socksBytestreamsTransportInfo.getCandidates();
 502            Log.d(Config.LOGTAG, "received socks candidates " + candidates);
 503            return new SocksByteStreamsTransport(
 504                    xmppConnection, id, isInitiator(), useTor, streamId, candidates);
 505        } else if (!useTor && transportInfo instanceof WebRTCDataChannelTransportInfo) {
 506            return new WebRTCDataChannelTransport(
 507                    xmppConnectionService.getApplicationContext(),
 508                    xmppConnection,
 509                    id.account,
 510                    isInitiator());
 511        } else {
 512            throw new IllegalArgumentException("Do not know how to create transport");
 513        }
 514    }
 515
 516    private Transport setupTransport() {
 517        final XmppConnection xmppConnection = id.account.getXmppConnection();
 518        final boolean useTor = id.account.isOnion() || xmppConnectionService.useTorToConnect();
 519        if (!useTor && remoteHasFeature(Namespace.JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL)) {
 520            return new WebRTCDataChannelTransport(
 521                    xmppConnectionService.getApplicationContext(),
 522                    xmppConnection,
 523                    id.account,
 524                    isInitiator());
 525        }
 526        if (remoteHasFeature(Namespace.JINGLE_TRANSPORTS_S5B)) {
 527            return new SocksByteStreamsTransport(xmppConnection, id, isInitiator(), useTor);
 528        }
 529        return setupLastResortTransport();
 530    }
 531
 532    private Transport setupLastResortTransport() {
 533        final XmppConnection xmppConnection = id.account.getXmppConnection();
 534        return new InbandBytestreamsTransport(xmppConnection, id.with, isInitiator());
 535    }
 536
 537    private void failureToAcceptSession(final Throwable throwable) {
 538        if (isTerminated()) {
 539            return;
 540        }
 541        terminateTransport();
 542        final Throwable rootCause = Throwables.getRootCause(throwable);
 543        Log.d(Config.LOGTAG, "unable to send session accept", rootCause);
 544        sendSessionTerminate(Reason.ofThrowable(rootCause), rootCause.getMessage());
 545    }
 546
 547    private void receiveSessionInfo(final Iq jinglePacket, final Jingle jingle) {
 548        respondOk(jinglePacket);
 549        final var sessionInfo = FileTransferDescription.getSessionInfo(jingle);
 550        if (sessionInfo instanceof FileTransferDescription.Checksum checksum) {
 551            receiveSessionInfoChecksum(checksum);
 552        } else if (sessionInfo instanceof FileTransferDescription.Received received) {
 553            receiveSessionInfoReceived(received);
 554        }
 555    }
 556
 557    private void receiveSessionInfoChecksum(final FileTransferDescription.Checksum checksum) {
 558        Log.d(Config.LOGTAG, "received checksum " + checksum);
 559    }
 560
 561    private void receiveSessionInfoReceived(final FileTransferDescription.Received received) {
 562        Log.d(Config.LOGTAG, "peer confirmed received " + received);
 563    }
 564
 565    private void receiveSessionTerminate(final Iq jinglePacket, final Jingle jingle) {
 566        respondOk(jinglePacket);
 567        final Jingle.ReasonWrapper wrapper = jingle.getReason();
 568        final State previous = this.state;
 569        Log.d(
 570                Config.LOGTAG,
 571                id.account.getJid().asBareJid()
 572                        + ": received session terminate reason="
 573                        + wrapper.reason
 574                        + "("
 575                        + Strings.nullToEmpty(wrapper.text)
 576                        + ") while in state "
 577                        + previous);
 578        if (TERMINATED.contains(previous)) {
 579            Log.d(
 580                    Config.LOGTAG,
 581                    id.account.getJid().asBareJid()
 582                            + ": ignoring session terminate because already in "
 583                            + previous);
 584            return;
 585        }
 586        if (isInitiator()) {
 587            this.message.setErrorMessage(
 588                    Strings.isNullOrEmpty(wrapper.text) ? wrapper.reason.toString() : wrapper.text);
 589        }
 590        terminateTransport();
 591        final State target = reasonToState(wrapper.reason);
 592        // TODO check if we were already terminated
 593        transitionOrThrow(target);
 594        finish();
 595    }
 596
 597    private void receiveTransportAccept(final Iq jinglePacket, final Jingle jingle) {
 598        if (isResponder()) {
 599            receiveOutOfOrderAction(jinglePacket, Jingle.Action.TRANSPORT_ACCEPT);
 600            return;
 601        }
 602        Log.d(Config.LOGTAG, "receive transport accept " + jinglePacket);
 603        final GenericTransportInfo transportInfo;
 604        try {
 605            transportInfo = FileTransferContentMap.of(jingle).requireOnlyTransportInfo();
 606        } catch (final RuntimeException e) {
 607            Log.d(
 608                    Config.LOGTAG,
 609                    id.account.getJid().asBareJid() + ": improperly formatted contents",
 610                    Throwables.getRootCause(e));
 611            respondOk(jinglePacket);
 612            terminateTransport();
 613            sendSessionTerminate(Reason.of(e), e.getMessage());
 614            return;
 615        }
 616        if (isInState(State.SESSION_ACCEPTED)) {
 617            final var group = jingle.getGroup();
 618            receiveTransportAccept(jinglePacket, new Transport.TransportInfo(transportInfo, group));
 619        } else {
 620            receiveOutOfOrderAction(jinglePacket, Jingle.Action.TRANSPORT_ACCEPT);
 621        }
 622    }
 623
 624    private void receiveTransportAccept(
 625            final Iq jinglePacket, final Transport.TransportInfo transportInfo) {
 626        final FileTransferContentMap remoteContentMap =
 627                getRemoteContentMap().withTransport(transportInfo);
 628        setRemoteContentMap(remoteContentMap);
 629        respondOk(jinglePacket);
 630        final var transport = this.transport;
 631        if (configureTransportWithPeerInfo(transport, remoteContentMap)) {
 632            transport.connect();
 633        } else {
 634            Log.e(
 635                    Config.LOGTAG,
 636                    "Transport in transport-accept did not match our transport-replace");
 637            terminateTransport();
 638            sendSessionTerminate(
 639                    Reason.FAILED_APPLICATION,
 640                    "Transport in transport-accept did not match our transport-replace");
 641        }
 642    }
 643
 644    private void receiveTransportInfo(final Iq jinglePacket, final Jingle jingle) {
 645        final FileTransferContentMap contentMap;
 646        final GenericTransportInfo transportInfo;
 647        try {
 648            contentMap = FileTransferContentMap.of(jingle);
 649            transportInfo = contentMap.requireOnlyTransportInfo();
 650        } catch (final RuntimeException e) {
 651            Log.d(
 652                    Config.LOGTAG,
 653                    id.account.getJid().asBareJid() + ": improperly formatted contents",
 654                    Throwables.getRootCause(e));
 655            respondOk(jinglePacket);
 656            terminateTransport();
 657            sendSessionTerminate(Reason.of(e), e.getMessage());
 658            return;
 659        }
 660        respondOk(jinglePacket);
 661        final var transport = this.transport;
 662        if (transport instanceof SocksByteStreamsTransport socksBytestreamsTransport
 663                && transportInfo
 664                        instanceof SocksByteStreamsTransportInfo socksBytestreamsTransportInfo) {
 665            receiveTransportInfo(socksBytestreamsTransport, socksBytestreamsTransportInfo);
 666        } else if (transport instanceof WebRTCDataChannelTransport webRTCDataChannelTransport
 667                && transportInfo
 668                        instanceof WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) {
 669            receiveTransportInfo(
 670                    Iterables.getOnlyElement(contentMap.contents.keySet()),
 671                    webRTCDataChannelTransport,
 672                    webRTCDataChannelTransportInfo);
 673        } else if (transportInfo
 674                instanceof WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) {
 675            receiveTransportInfo(
 676                    Iterables.getOnlyElement(contentMap.contents.keySet()),
 677                    webRTCDataChannelTransportInfo);
 678        } else {
 679            Log.d(Config.LOGTAG, "could not deliver transport-info to transport");
 680        }
 681    }
 682
 683    private void receiveTransportInfo(
 684            final String contentName,
 685            final WebRTCDataChannelTransport webRTCDataChannelTransport,
 686            final WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) {
 687        final var credentials = webRTCDataChannelTransportInfo.getCredentials();
 688        final var iceCandidates =
 689                WebRTCDataChannelTransport.iceCandidatesOf(
 690                        contentName, credentials, webRTCDataChannelTransportInfo.getCandidates());
 691        final var localContentMap = getLocalContentMap();
 692        if (localContentMap == null) {
 693            Log.d(Config.LOGTAG, "transport not ready. add pending ice candidate");
 694            this.pendingIncomingIceCandidates.addAll(iceCandidates);
 695        } else {
 696            webRTCDataChannelTransport.addIceCandidates(iceCandidates);
 697        }
 698    }
 699
 700    private void receiveTransportInfo(
 701            final String contentName,
 702            final WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) {
 703        final var credentials = webRTCDataChannelTransportInfo.getCredentials();
 704        final var iceCandidates =
 705                WebRTCDataChannelTransport.iceCandidatesOf(
 706                        contentName, credentials, webRTCDataChannelTransportInfo.getCandidates());
 707        this.pendingIncomingIceCandidates.addAll(iceCandidates);
 708    }
 709
 710    private void receiveTransportInfo(
 711            final SocksByteStreamsTransport socksBytestreamsTransport,
 712            final SocksByteStreamsTransportInfo socksBytestreamsTransportInfo) {
 713        final var transportInfo = socksBytestreamsTransportInfo.getTransportInfo();
 714        if (transportInfo instanceof SocksByteStreamsTransportInfo.CandidateError) {
 715            socksBytestreamsTransport.setCandidateError();
 716        } else if (transportInfo
 717                instanceof SocksByteStreamsTransportInfo.CandidateUsed candidateUsed) {
 718            if (!socksBytestreamsTransport.setCandidateUsed(candidateUsed.cid)) {
 719                terminateTransport();
 720                sendSessionTerminate(
 721                        Reason.FAILED_TRANSPORT,
 722                        String.format(
 723                                "Peer is not connected to our candidate %s", candidateUsed.cid));
 724            }
 725        } else if (transportInfo instanceof SocksByteStreamsTransportInfo.Activated activated) {
 726            socksBytestreamsTransport.setProxyActivated(activated.cid);
 727        } else if (transportInfo instanceof SocksByteStreamsTransportInfo.ProxyError) {
 728            socksBytestreamsTransport.setProxyError();
 729        }
 730    }
 731
 732    private void receiveTransportReplace(final Iq jinglePacket, final Jingle jingle) {
 733        if (isInitiator()) {
 734            receiveOutOfOrderAction(jinglePacket, Jingle.Action.TRANSPORT_REPLACE);
 735            return;
 736        }
 737        final GenericTransportInfo transportInfo;
 738        try {
 739            transportInfo = FileTransferContentMap.of(jingle).requireOnlyTransportInfo();
 740        } catch (final RuntimeException e) {
 741            Log.d(
 742                    Config.LOGTAG,
 743                    id.account.getJid().asBareJid() + ": improperly formatted contents",
 744                    Throwables.getRootCause(e));
 745            respondOk(jinglePacket);
 746            terminateTransport();
 747            sendSessionTerminate(Reason.of(e), e.getMessage());
 748            return;
 749        }
 750        if (isInState(State.SESSION_ACCEPTED)) {
 751            receiveTransportReplace(jinglePacket, transportInfo);
 752        } else {
 753            receiveOutOfOrderAction(jinglePacket, Jingle.Action.TRANSPORT_REPLACE);
 754        }
 755    }
 756
 757    private void receiveTransportReplace(
 758            final Iq jinglePacket, final GenericTransportInfo transportInfo) {
 759        respondOk(jinglePacket);
 760        final Transport currentTransport = this.transport;
 761        if (currentTransport != null) {
 762            Log.d(
 763                    Config.LOGTAG,
 764                    "terminating "
 765                            + currentTransport.getClass().getSimpleName()
 766                            + " upon receiving transport-replace");
 767            currentTransport.setTransportCallback(null);
 768            currentTransport.terminate();
 769        }
 770        final Transport nextTransport;
 771        try {
 772            nextTransport = setupTransport(transportInfo);
 773        } catch (final RuntimeException e) {
 774            sendSessionTerminate(Reason.of(e), e.getMessage());
 775            return;
 776        }
 777        this.transport = nextTransport;
 778        Log.d(
 779                Config.LOGTAG,
 780                "replacing transport with " + nextTransport.getClass().getSimpleName());
 781        this.transport.setTransportCallback(this);
 782        final var transportInfoFuture = nextTransport.asTransportInfo();
 783        Futures.addCallback(
 784                transportInfoFuture,
 785                new FutureCallback<>() {
 786                    @Override
 787                    public void onSuccess(final Transport.TransportInfo transportWrapper) {
 788                        final FileTransferContentMap contentMap =
 789                                getLocalContentMap().withTransport(transportWrapper);
 790                        sendTransportAccept(contentMap);
 791                    }
 792
 793                    @Override
 794                    public void onFailure(@NonNull Throwable throwable) {
 795                        // transition into application failed (analogues to failureToAccept
 796                    }
 797                },
 798                MoreExecutors.directExecutor());
 799    }
 800
 801    private void sendTransportAccept(final FileTransferContentMap contentMap) {
 802        setLocalContentMap(contentMap);
 803        final var iq =
 804                contentMap
 805                        .transportInfo()
 806                        .toJinglePacket(Jingle.Action.TRANSPORT_ACCEPT, id.sessionId);
 807        send(iq);
 808        transport.connect();
 809    }
 810
 811    protected void sendSessionTerminate(final Reason reason, final String text) {
 812        if (isInitiator()) {
 813            this.message.setErrorMessage(Strings.isNullOrEmpty(text) ? reason.toString() : text);
 814        }
 815        sendSessionTerminate(reason, text, null);
 816    }
 817
 818    private FileTransferContentMap getLocalContentMap() {
 819        return isInitiator()
 820                ? this.initiatorFileTransferContentMap
 821                : this.responderFileTransferContentMap;
 822    }
 823
 824    private FileTransferContentMap getRemoteContentMap() {
 825        return isInitiator()
 826                ? this.responderFileTransferContentMap
 827                : this.initiatorFileTransferContentMap;
 828    }
 829
 830    private void setLocalContentMap(final FileTransferContentMap contentMap) {
 831        if (isInitiator()) {
 832            this.initiatorFileTransferContentMap = contentMap;
 833        } else {
 834            this.responderFileTransferContentMap = contentMap;
 835        }
 836    }
 837
 838    private void setRemoteContentMap(final FileTransferContentMap contentMap) {
 839        if (isInitiator()) {
 840            this.responderFileTransferContentMap = contentMap;
 841        } else {
 842            this.initiatorFileTransferContentMap = contentMap;
 843        }
 844    }
 845
 846    public Transport getTransport() {
 847        return this.transport;
 848    }
 849
 850    @Override
 851    protected void terminateTransport() {
 852        final var transport = this.transport;
 853        if (transport == null) {
 854            return;
 855        }
 856        // TODO consider setting transport callback to null. requires transport to handle null
 857        // callback
 858        // transport.setTransportCallback(null);
 859        transport.terminate();
 860        this.transport = null;
 861    }
 862
 863    @Override
 864    void notifyRebound() {}
 865
 866    @Override
 867    public void onTransportEstablished() {
 868        Log.d(Config.LOGTAG, "on transport established");
 869        final AbstractFileTransceiver fileTransceiver;
 870        try {
 871            fileTransceiver = setupTransceiver(isResponder());
 872        } catch (final Exception e) {
 873            Log.d(Config.LOGTAG, "failed to set up file transceiver", e);
 874            sendSessionTerminate(Reason.ofThrowable(e), e.getMessage());
 875            return;
 876        }
 877        this.fileTransceiver = fileTransceiver;
 878        final var fileTransceiverThread = new Thread(fileTransceiver);
 879        fileTransceiverThread.start();
 880        Futures.addCallback(
 881                fileTransceiver.complete,
 882                new FutureCallback<>() {
 883                    @Override
 884                    public void onSuccess(final List<FileTransferDescription.Hash> hashes) {
 885                        onFileTransmissionComplete(hashes);
 886                    }
 887
 888                    @Override
 889                    public void onFailure(@NonNull final Throwable throwable) {
 890                        // The state transition in here should be synchronized to not race with the
 891                        // state transition in receiveSessionTerminate
 892                        synchronized (JingleFileTransferConnection.this) {
 893                            onFileTransmissionFailed(throwable);
 894                        }
 895                    }
 896                },
 897                MoreExecutors.directExecutor());
 898    }
 899
 900    private void onFileTransmissionComplete(final List<FileTransferDescription.Hash> hashes) {
 901        // TODO if we ever support receiving files this should become isSending(); isReceiving()
 902        if (isInitiator()) {
 903            sendSessionInfoChecksum(hashes);
 904        } else {
 905            Log.d(Config.LOGTAG, "file transfer complete " + hashes);
 906            sendFileSessionInfoReceived();
 907            terminateTransport();
 908            messageReceivedSuccess();
 909            sendSessionTerminate(Reason.SUCCESS, null);
 910        }
 911    }
 912
 913    private void messageReceivedSuccess() {
 914        this.message.setTransferable(null);
 915        xmppConnectionService.getFileBackend().updateFileParams(message);
 916        xmppConnectionService.databaseBackend.createMessage(message);
 917        final File file = xmppConnectionService.getFileBackend().getFile(message);
 918        if (acceptedAutomatically) {
 919            message.markUnread();
 920            if (message.getEncryption() == Message.ENCRYPTION_PGP) {
 921                id.account.getPgpDecryptionService().decrypt(message, true);
 922            } else {
 923                xmppConnectionService
 924                        .getFileBackend()
 925                        .updateMediaScanner(
 926                                file,
 927                                () ->
 928                                        JingleFileTransferConnection.this
 929                                                .xmppConnectionService
 930                                                .getNotificationService()
 931                                                .push(message));
 932            }
 933        } else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
 934            id.account.getPgpDecryptionService().decrypt(message, false);
 935        } else {
 936            xmppConnectionService.getFileBackend().updateMediaScanner(file);
 937        }
 938    }
 939
 940    private void onFileTransmissionFailed(final Throwable throwable) {
 941        if (isTerminated()) {
 942            Log.d(
 943                    Config.LOGTAG,
 944                    "file transfer failed but session is already terminated",
 945                    throwable);
 946        } else {
 947            terminateTransport();
 948            Log.d(Config.LOGTAG, "on file transmission failed", throwable);
 949            sendSessionTerminate(Reason.CONNECTIVITY_ERROR, null);
 950        }
 951    }
 952
 953    private AbstractFileTransceiver setupTransceiver(final boolean receiving) throws IOException {
 954        final var fileDescription = getLocalContentMap().requireOnlyFile();
 955        final File file = xmppConnectionService.getFileBackend().getFile(message);
 956        final Runnable updateRunnable = () -> jingleConnectionManager.updateConversationUi(false);
 957        if (receiving) {
 958            return new FileReceiver(
 959                    file,
 960                    this.transportSecurity,
 961                    transport.getInputStream(),
 962                    transport.getTerminationLatch(),
 963                    fileDescription.size,
 964                    updateRunnable);
 965        } else {
 966            return new FileTransmitter(
 967                    file,
 968                    this.transportSecurity,
 969                    transport.getOutputStream(),
 970                    transport.getTerminationLatch(),
 971                    fileDescription.size,
 972                    updateRunnable);
 973        }
 974    }
 975
 976    private void sendFileSessionInfoReceived() {
 977        final var contentMap = getLocalContentMap();
 978        final String name = Iterables.getOnlyElement(contentMap.contents.keySet());
 979        sendSessionInfo(new FileTransferDescription.Received(name));
 980    }
 981
 982    private void sendSessionInfoChecksum(List<FileTransferDescription.Hash> hashes) {
 983        final var contentMap = getLocalContentMap();
 984        final String name = Iterables.getOnlyElement(contentMap.contents.keySet());
 985        sendSessionInfo(new FileTransferDescription.Checksum(name, hashes));
 986    }
 987
 988    private void sendSessionInfo(final FileTransferDescription.SessionInfo sessionInfo) {
 989        final var iq = new Iq(Iq.Type.SET);
 990        final var jinglePacket = iq.addExtension(new Jingle(Jingle.Action.SESSION_INFO, this.id.sessionId));
 991        jinglePacket.addChild(sessionInfo.asElement());
 992        send(iq);
 993    }
 994
 995    @Override
 996    public void onTransportSetupFailed() {
 997        final var transport = this.transport;
 998        if (transport == null) {
 999            // this can happen on IQ timeouts
1000            if (isTerminated()) {
1001                return;
1002            }
1003            sendSessionTerminate(Reason.FAILED_APPLICATION, null);
1004            return;
1005        }
1006        Log.d(Config.LOGTAG, "onTransportSetupFailed");
1007        final var isTransportInBand = transport instanceof InbandBytestreamsTransport;
1008        if (isTransportInBand) {
1009            terminateTransport();
1010            sendSessionTerminate(Reason.CONNECTIVITY_ERROR, "Failed to setup IBB transport");
1011            return;
1012        }
1013        // terminate the current transport
1014        transport.terminate();
1015        if (isInitiator()) {
1016            this.transport = setupLastResortTransport();
1017            Log.d(
1018                    Config.LOGTAG,
1019                    "replacing transport with " + this.transport.getClass().getSimpleName());
1020            this.transport.setTransportCallback(this);
1021            final var transportInfoFuture = this.transport.asTransportInfo();
1022            Futures.addCallback(
1023                    transportInfoFuture,
1024                    new FutureCallback<>() {
1025                        @Override
1026                        public void onSuccess(final Transport.TransportInfo transportWrapper) {
1027                            final FileTransferContentMap contentMap = getLocalContentMap();
1028                            sendTransportReplace(contentMap.withTransport(transportWrapper));
1029                        }
1030
1031                        @Override
1032                        public void onFailure(@NonNull Throwable throwable) {
1033                            // TODO send application failure;
1034                        }
1035                    },
1036                    MoreExecutors.directExecutor());
1037
1038        } else {
1039            Log.d(Config.LOGTAG, "transport setup failed. waiting for initiator to replace");
1040        }
1041    }
1042
1043    private void sendTransportReplace(final FileTransferContentMap contentMap) {
1044        setLocalContentMap(contentMap);
1045        final var iq =
1046                contentMap
1047                        .transportInfo()
1048                        .toJinglePacket(Jingle.Action.TRANSPORT_REPLACE, id.sessionId);
1049        send(iq);
1050    }
1051
1052    @Override
1053    public void onAdditionalCandidate(
1054            final String contentName, final Transport.Candidate candidate) {
1055        if (candidate instanceof IceUdpTransportInfo.Candidate iceCandidate) {
1056            sendTransportInfo(contentName, iceCandidate);
1057        }
1058    }
1059
1060    public void sendTransportInfo(
1061            final String contentName, final IceUdpTransportInfo.Candidate candidate) {
1062        final FileTransferContentMap transportInfo;
1063        try {
1064            final FileTransferContentMap rtpContentMap = getLocalContentMap();
1065            transportInfo = rtpContentMap.transportInfo(contentName, candidate);
1066        } catch (final Exception e) {
1067            Log.d(
1068                    Config.LOGTAG,
1069                    id.account.getJid().asBareJid()
1070                            + ": unable to prepare transport-info from candidate for content="
1071                            + contentName);
1072            return;
1073        }
1074        final Iq iq =
1075                transportInfo.toJinglePacket(Jingle.Action.TRANSPORT_INFO, id.sessionId);
1076        send(iq);
1077    }
1078
1079    @Override
1080    public void onCandidateUsed(
1081            final String streamId, final SocksByteStreamsTransport.Candidate candidate) {
1082        final FileTransferContentMap contentMap = getLocalContentMap();
1083        if (contentMap == null) {
1084            Log.e(Config.LOGTAG, "local content map is null on candidate used");
1085            return;
1086        }
1087        final var iq =
1088                contentMap
1089                        .candidateUsed(streamId, candidate.cid)
1090                        .toJinglePacket(Jingle.Action.TRANSPORT_INFO, id.sessionId);
1091        Log.d(Config.LOGTAG, "sending candidate used " + iq);
1092        send(iq);
1093    }
1094
1095    @Override
1096    public void onCandidateError(final String streamId) {
1097        final FileTransferContentMap contentMap = getLocalContentMap();
1098        if (contentMap == null) {
1099            Log.e(Config.LOGTAG, "local content map is null on candidate used");
1100            return;
1101        }
1102        final var iq =
1103                contentMap
1104                        .candidateError(streamId)
1105                        .toJinglePacket(Jingle.Action.TRANSPORT_INFO, id.sessionId);
1106        Log.d(Config.LOGTAG, "sending candidate error " + iq);
1107        send(iq);
1108    }
1109
1110    @Override
1111    public void onProxyActivated(String streamId, SocksByteStreamsTransport.Candidate candidate) {
1112        final FileTransferContentMap contentMap = getLocalContentMap();
1113        if (contentMap == null) {
1114            Log.e(Config.LOGTAG, "local content map is null on candidate used");
1115            return;
1116        }
1117        final var iq =
1118                contentMap
1119                        .proxyActivated(streamId, candidate.cid)
1120                        .toJinglePacket(Jingle.Action.TRANSPORT_INFO, id.sessionId);
1121        send(iq);
1122    }
1123
1124    @Override
1125    protected boolean transition(final State target, final Runnable runnable) {
1126        final boolean transitioned = super.transition(target, runnable);
1127        if (transitioned && isInitiator()) {
1128            Log.d(Config.LOGTAG, "running mark message hooks");
1129            if (target == State.SESSION_ACCEPTED) {
1130                xmppConnectionService.markMessage(message, Message.STATUS_UNSEND);
1131            } else if (target == State.TERMINATED_SUCCESS) {
1132                xmppConnectionService.markMessage(message, Message.STATUS_SEND_RECEIVED);
1133            } else if (TERMINATED.contains(target)) {
1134                xmppConnectionService.markMessage(
1135                        message, Message.STATUS_SEND_FAILED, message.getErrorMessage());
1136            } else {
1137                xmppConnectionService.updateConversationUi();
1138            }
1139        } else {
1140            if (Arrays.asList(State.TERMINATED_CANCEL_OR_TIMEOUT, State.TERMINATED_DECLINED_OR_BUSY)
1141                    .contains(target)) {
1142                this.message.setTransferable(
1143                        new TransferablePlaceholder(Transferable.STATUS_CANCELLED));
1144            } else if (target != State.TERMINATED_SUCCESS && TERMINATED.contains(target)) {
1145                this.message.setTransferable(
1146                        new TransferablePlaceholder(Transferable.STATUS_FAILED));
1147            }
1148            xmppConnectionService.updateConversationUi();
1149        }
1150        return transitioned;
1151    }
1152
1153    @Override
1154    protected void finish() {
1155        if (transport != null) {
1156            throw new AssertionError(
1157                    "finish MUST not be called without terminating the transport first");
1158        }
1159        // we don't want to remove TransferablePlaceholder
1160        if (message.getTransferable() instanceof JingleFileTransferConnection) {
1161            Log.d(Config.LOGTAG, "nulling transferable on message");
1162            this.message.setTransferable(null);
1163        }
1164        super.finish();
1165    }
1166
1167    private int getTransferableStatus() {
1168        // status in file transfer is a bit weird. for sending it is mostly handled via
1169        // Message.STATUS_* (offered, unsend (sic) send_received) the transferable status is just
1170        // uploading
1171        // for receiving the message status remains at 'received' but Transferable goes through
1172        // various status
1173        if (isInitiator()) {
1174            return Transferable.STATUS_UPLOADING;
1175        }
1176        final var state = getState();
1177        return switch (state) {
1178            case NULL, SESSION_INITIALIZED, SESSION_INITIALIZED_PRE_APPROVED -> Transferable
1179                    .STATUS_OFFER;
1180            case TERMINATED_APPLICATION_FAILURE,
1181                    TERMINATED_CONNECTIVITY_ERROR,
1182                    TERMINATED_DECLINED_OR_BUSY,
1183                    TERMINATED_SECURITY_ERROR -> Transferable.STATUS_FAILED;
1184            case TERMINATED_CANCEL_OR_TIMEOUT -> Transferable.STATUS_CANCELLED;
1185            case SESSION_ACCEPTED -> Transferable.STATUS_DOWNLOADING;
1186            default -> Transferable.STATUS_UNKNOWN;
1187        };
1188    }
1189
1190    // these methods are for interacting with 'Transferable' - we might want to remove the concept
1191    // at some point
1192
1193    @Override
1194    public boolean start() {
1195        Log.d(Config.LOGTAG, "user pressed start()");
1196        // TODO there is a 'connected' check apparently?
1197        if (isInState(State.SESSION_INITIALIZED)) {
1198            sendSessionAccept();
1199        }
1200        return true;
1201    }
1202
1203    @Override
1204    public int getStatus() {
1205        return getTransferableStatus();
1206    }
1207
1208    @Override
1209    public Long getFileSize() {
1210        final var transceiver = this.fileTransceiver;
1211        if (transceiver != null) {
1212            return transceiver.total;
1213        }
1214        final var contentMap = this.initiatorFileTransferContentMap;
1215        if (contentMap != null) {
1216            return contentMap.requireOnlyFile().size;
1217        }
1218        return null;
1219    }
1220
1221    @Override
1222    public int getProgress() {
1223        final var transceiver = this.fileTransceiver;
1224        return transceiver != null ? transceiver.getProgress() : 0;
1225    }
1226
1227    @Override
1228    public void cancel() {
1229        if (stopFileTransfer()) {
1230            Log.d(Config.LOGTAG, "user has stopped file transfer");
1231        } else {
1232            Log.d(Config.LOGTAG, "user pressed cancel but file transfer was already terminated?");
1233        }
1234    }
1235
1236    private boolean stopFileTransfer() {
1237        if (isInitiator()) {
1238            return stopFileTransfer(Reason.CANCEL);
1239        } else {
1240            return stopFileTransfer(Reason.DECLINE);
1241        }
1242    }
1243
1244    private boolean stopFileTransfer(final Reason reason) {
1245        final State target = reasonToState(reason);
1246        if (transition(target)) {
1247            // we change state before terminating transport so we don't consume the following
1248            // IOException and turn it into a connectivity error
1249
1250            if (isInitiator() && reason == Reason.CANCEL) {
1251                // message hooks have already run so we need to mark to persist the 'cancelled'
1252                // status
1253                xmppConnectionService.markMessage(
1254                        message, Message.STATUS_SEND_FAILED, Message.ERROR_MESSAGE_CANCELLED);
1255            }
1256            terminateTransport();
1257            final Iq iq = new Iq(Iq.Type.SET);
1258            final var jingle = iq.addExtension(new Jingle(Jingle.Action.SESSION_TERMINATE, id.sessionId));
1259            jingle.setReason(reason, "User requested to stop file transfer");
1260            send(iq);
1261            finish();
1262            return true;
1263        } else {
1264            return false;
1265        }
1266    }
1267
1268    private abstract static class AbstractFileTransceiver implements Runnable {
1269
1270        protected final SettableFuture<List<FileTransferDescription.Hash>> complete =
1271                SettableFuture.create();
1272
1273        protected final File file;
1274        protected final TransportSecurity transportSecurity;
1275
1276        protected final CountDownLatch transportTerminationLatch;
1277        protected final long total;
1278        protected long transmitted = 0;
1279        private int progress = Integer.MIN_VALUE;
1280        private final Runnable updateRunnable;
1281
1282        private AbstractFileTransceiver(
1283                final File file,
1284                final TransportSecurity transportSecurity,
1285                final CountDownLatch transportTerminationLatch,
1286                final long total,
1287                final Runnable updateRunnable) {
1288            this.file = file;
1289            this.transportSecurity = transportSecurity;
1290            this.transportTerminationLatch = transportTerminationLatch;
1291            this.total = transportSecurity == null ? total : (total + 16);
1292            this.updateRunnable = updateRunnable;
1293        }
1294
1295        static void closeTransport(final Closeable stream) {
1296            try {
1297                stream.close();
1298            } catch (final IOException e) {
1299                Log.d(Config.LOGTAG, "transport has already been closed. good");
1300            }
1301        }
1302
1303        public int getProgress() {
1304            return Ints.saturatedCast(Math.round((1.0 * transmitted / total) * 100));
1305        }
1306
1307        public void updateProgress() {
1308            final int current = getProgress();
1309            final boolean update;
1310            synchronized (this) {
1311                if (this.progress != current) {
1312                    this.progress = current;
1313                    update = true;
1314                } else {
1315                    update = false;
1316                }
1317                if (update) {
1318                    this.updateRunnable.run();
1319                }
1320            }
1321        }
1322
1323        protected void awaitTransportTermination() {
1324            try {
1325                this.transportTerminationLatch.await();
1326            } catch (final InterruptedException ignored) {
1327                return;
1328            }
1329            Log.d(Config.LOGTAG, getClass().getSimpleName() + " says Goodbye!");
1330        }
1331    }
1332
1333    private static class FileTransmitter extends AbstractFileTransceiver {
1334
1335        private final OutputStream outputStream;
1336
1337        private FileTransmitter(
1338                final File file,
1339                final TransportSecurity transportSecurity,
1340                final OutputStream outputStream,
1341                final CountDownLatch transportTerminationLatch,
1342                final long total,
1343                final Runnable updateRunnable) {
1344            super(file, transportSecurity, transportTerminationLatch, total, updateRunnable);
1345            this.outputStream = outputStream;
1346        }
1347
1348        private InputStream openFileInputStream() throws FileNotFoundException {
1349            final var fileInputStream = new FileInputStream(this.file);
1350            if (this.transportSecurity == null) {
1351                return fileInputStream;
1352            } else {
1353                final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
1354                cipher.init(
1355                        true,
1356                        new AEADParameters(
1357                                new KeyParameter(transportSecurity.key),
1358                                128,
1359                                transportSecurity.iv));
1360                Log.d(Config.LOGTAG, "setting up CipherInputStream");
1361                return new CipherInputStream(fileInputStream, cipher);
1362            }
1363        }
1364
1365        @Override
1366        public void run() {
1367            Log.d(Config.LOGTAG, "file transmitter attempting to send " + total + " bytes");
1368            final var sha1Hasher = Hashing.sha1().newHasher();
1369            final var sha256Hasher = Hashing.sha256().newHasher();
1370            try (final var fileInputStream = openFileInputStream()) {
1371                final var buffer = new byte[4096];
1372                while (total - transmitted > 0) {
1373                    final int count = fileInputStream.read(buffer);
1374                    if (count == -1) {
1375                        throw new EOFException(
1376                                String.format("reached EOF after %d/%d", transmitted, total));
1377                    }
1378                    outputStream.write(buffer, 0, count);
1379                    sha1Hasher.putBytes(buffer, 0, count);
1380                    sha256Hasher.putBytes(buffer, 0, count);
1381                    transmitted += count;
1382                    updateProgress();
1383                }
1384                outputStream.flush();
1385                Log.d(
1386                        Config.LOGTAG,
1387                        "transmitted " + transmitted + " bytes from " + file.getAbsolutePath());
1388                final List<FileTransferDescription.Hash> hashes =
1389                        ImmutableList.of(
1390                                new FileTransferDescription.Hash(
1391                                        sha1Hasher.hash().asBytes(),
1392                                        FileTransferDescription.Algorithm.SHA_1),
1393                                new FileTransferDescription.Hash(
1394                                        sha256Hasher.hash().asBytes(),
1395                                        FileTransferDescription.Algorithm.SHA_256));
1396                complete.set(hashes);
1397            } catch (final Exception e) {
1398                complete.setException(e);
1399            }
1400            // the transport implementations backed by PipedOutputStreams do not like it when
1401            // the writing Thread (this thread) goes away. so we just wait until the other peer
1402            // has received our file and we are shutting down the transport
1403            Log.d(Config.LOGTAG, "waiting for transport to terminate before stopping thread");
1404            awaitTransportTermination();
1405            closeTransport(outputStream);
1406        }
1407    }
1408
1409    private static class FileReceiver extends AbstractFileTransceiver {
1410
1411        private final InputStream inputStream;
1412
1413        private FileReceiver(
1414                final File file,
1415                final TransportSecurity transportSecurity,
1416                final InputStream inputStream,
1417                final CountDownLatch transportTerminationLatch,
1418                final long total,
1419                final Runnable updateRunnable) {
1420            super(file, transportSecurity, transportTerminationLatch, total, updateRunnable);
1421            this.inputStream = inputStream;
1422        }
1423
1424        private OutputStream openFileOutputStream() throws FileNotFoundException {
1425            final var directory = this.file.getParentFile();
1426            if (directory != null && directory.mkdirs()) {
1427                Log.d(Config.LOGTAG, "created directory " + directory.getAbsolutePath());
1428            }
1429            final var fileOutputStream = new FileOutputStream(this.file);
1430            if (this.transportSecurity == null) {
1431                return fileOutputStream;
1432            } else {
1433                final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
1434                cipher.init(
1435                        false,
1436                        new AEADParameters(
1437                                new KeyParameter(transportSecurity.key),
1438                                128,
1439                                transportSecurity.iv));
1440                Log.d(Config.LOGTAG, "setting up CipherOutputStream");
1441                return new CipherOutputStream(fileOutputStream, cipher);
1442            }
1443        }
1444
1445        @Override
1446        public void run() {
1447            Log.d(Config.LOGTAG, "file receiver attempting to receive " + total + " bytes");
1448            final var sha1Hasher = Hashing.sha1().newHasher();
1449            final var sha256Hasher = Hashing.sha256().newHasher();
1450            try (final var fileOutputStream = openFileOutputStream()) {
1451                final var buffer = new byte[4096];
1452                while (total - transmitted > 0) {
1453                    final int count = inputStream.read(buffer);
1454                    if (count == -1) {
1455                        throw new EOFException(
1456                                String.format("reached EOF after %d/%d", transmitted, total));
1457                    }
1458                    fileOutputStream.write(buffer, 0, count);
1459                    sha1Hasher.putBytes(buffer, 0, count);
1460                    sha256Hasher.putBytes(buffer, 0, count);
1461                    transmitted += count;
1462                    updateProgress();
1463                }
1464                Log.d(
1465                        Config.LOGTAG,
1466                        "written " + transmitted + " bytes to " + file.getAbsolutePath());
1467                final List<FileTransferDescription.Hash> hashes =
1468                        ImmutableList.of(
1469                                new FileTransferDescription.Hash(
1470                                        sha1Hasher.hash().asBytes(),
1471                                        FileTransferDescription.Algorithm.SHA_1),
1472                                new FileTransferDescription.Hash(
1473                                        sha256Hasher.hash().asBytes(),
1474                                        FileTransferDescription.Algorithm.SHA_256));
1475                complete.set(hashes);
1476            } catch (final Exception e) {
1477                complete.setException(e);
1478            }
1479            Log.d(Config.LOGTAG, "waiting for transport to terminate before stopping thread");
1480            awaitTransportTermination();
1481            closeTransport(inputStream);
1482        }
1483    }
1484
1485    private static final class TransportSecurity {
1486        final byte[] key;
1487        final byte[] iv;
1488
1489        private TransportSecurity(byte[] key, byte[] iv) {
1490            this.key = key;
1491            this.iv = iv;
1492        }
1493    }
1494}