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        transitionOrThrow(target);
 593        finish();
 594    }
 595
 596    private void receiveTransportAccept(final Iq jinglePacket, final Jingle jingle) {
 597        if (isResponder()) {
 598            receiveOutOfOrderAction(jinglePacket, Jingle.Action.TRANSPORT_ACCEPT);
 599            return;
 600        }
 601        Log.d(Config.LOGTAG, "receive transport accept " + jinglePacket);
 602        final GenericTransportInfo transportInfo;
 603        try {
 604            transportInfo = FileTransferContentMap.of(jingle).requireOnlyTransportInfo();
 605        } catch (final RuntimeException e) {
 606            Log.d(
 607                    Config.LOGTAG,
 608                    id.account.getJid().asBareJid() + ": improperly formatted contents",
 609                    Throwables.getRootCause(e));
 610            respondOk(jinglePacket);
 611            terminateTransport();
 612            sendSessionTerminate(Reason.of(e), e.getMessage());
 613            return;
 614        }
 615        if (isInState(State.SESSION_ACCEPTED)) {
 616            final var group = jingle.getGroup();
 617            receiveTransportAccept(jinglePacket, new Transport.TransportInfo(transportInfo, group));
 618        } else {
 619            receiveOutOfOrderAction(jinglePacket, Jingle.Action.TRANSPORT_ACCEPT);
 620        }
 621    }
 622
 623    private void receiveTransportAccept(
 624            final Iq jinglePacket, final Transport.TransportInfo transportInfo) {
 625        final FileTransferContentMap remoteContentMap =
 626                getRemoteContentMap().withTransport(transportInfo);
 627        setRemoteContentMap(remoteContentMap);
 628        respondOk(jinglePacket);
 629        final var transport = this.transport;
 630        if (configureTransportWithPeerInfo(transport, remoteContentMap)) {
 631            transport.connect();
 632        } else {
 633            Log.e(
 634                    Config.LOGTAG,
 635                    "Transport in transport-accept did not match our transport-replace");
 636            terminateTransport();
 637            sendSessionTerminate(
 638                    Reason.FAILED_APPLICATION,
 639                    "Transport in transport-accept did not match our transport-replace");
 640        }
 641    }
 642
 643    private void receiveTransportInfo(final Iq jinglePacket, final Jingle jingle) {
 644        final FileTransferContentMap contentMap;
 645        final GenericTransportInfo transportInfo;
 646        try {
 647            contentMap = FileTransferContentMap.of(jingle);
 648            transportInfo = contentMap.requireOnlyTransportInfo();
 649        } catch (final RuntimeException e) {
 650            Log.d(
 651                    Config.LOGTAG,
 652                    id.account.getJid().asBareJid() + ": improperly formatted contents",
 653                    Throwables.getRootCause(e));
 654            respondOk(jinglePacket);
 655            terminateTransport();
 656            sendSessionTerminate(Reason.of(e), e.getMessage());
 657            return;
 658        }
 659        respondOk(jinglePacket);
 660        final var transport = this.transport;
 661        if (transport instanceof SocksByteStreamsTransport socksBytestreamsTransport
 662                && transportInfo
 663                        instanceof SocksByteStreamsTransportInfo socksBytestreamsTransportInfo) {
 664            receiveTransportInfo(socksBytestreamsTransport, socksBytestreamsTransportInfo);
 665        } else if (transport instanceof WebRTCDataChannelTransport webRTCDataChannelTransport
 666                && transportInfo
 667                        instanceof WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) {
 668            receiveTransportInfo(
 669                    Iterables.getOnlyElement(contentMap.contents.keySet()),
 670                    webRTCDataChannelTransport,
 671                    webRTCDataChannelTransportInfo);
 672        } else if (transportInfo
 673                instanceof WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) {
 674            receiveTransportInfo(
 675                    Iterables.getOnlyElement(contentMap.contents.keySet()),
 676                    webRTCDataChannelTransportInfo);
 677        } else {
 678            Log.d(Config.LOGTAG, "could not deliver transport-info to transport");
 679        }
 680    }
 681
 682    private void receiveTransportInfo(
 683            final String contentName,
 684            final WebRTCDataChannelTransport webRTCDataChannelTransport,
 685            final WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) {
 686        final var credentials = webRTCDataChannelTransportInfo.getCredentials();
 687        final var iceCandidates =
 688                WebRTCDataChannelTransport.iceCandidatesOf(
 689                        contentName, credentials, webRTCDataChannelTransportInfo.getCandidates());
 690        final var localContentMap = getLocalContentMap();
 691        if (localContentMap == null) {
 692            Log.d(Config.LOGTAG, "transport not ready. add pending ice candidate");
 693            this.pendingIncomingIceCandidates.addAll(iceCandidates);
 694        } else {
 695            webRTCDataChannelTransport.addIceCandidates(iceCandidates);
 696        }
 697    }
 698
 699    private void receiveTransportInfo(
 700            final String contentName,
 701            final WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) {
 702        final var credentials = webRTCDataChannelTransportInfo.getCredentials();
 703        final var iceCandidates =
 704                WebRTCDataChannelTransport.iceCandidatesOf(
 705                        contentName, credentials, webRTCDataChannelTransportInfo.getCandidates());
 706        this.pendingIncomingIceCandidates.addAll(iceCandidates);
 707    }
 708
 709    private void receiveTransportInfo(
 710            final SocksByteStreamsTransport socksBytestreamsTransport,
 711            final SocksByteStreamsTransportInfo socksBytestreamsTransportInfo) {
 712        final var transportInfo = socksBytestreamsTransportInfo.getTransportInfo();
 713        if (transportInfo instanceof SocksByteStreamsTransportInfo.CandidateError) {
 714            socksBytestreamsTransport.setCandidateError();
 715        } else if (transportInfo
 716                instanceof SocksByteStreamsTransportInfo.CandidateUsed candidateUsed) {
 717            if (!socksBytestreamsTransport.setCandidateUsed(candidateUsed.cid)) {
 718                terminateTransport();
 719                sendSessionTerminate(
 720                        Reason.FAILED_TRANSPORT,
 721                        String.format(
 722                                "Peer is not connected to our candidate %s", candidateUsed.cid));
 723            }
 724        } else if (transportInfo instanceof SocksByteStreamsTransportInfo.Activated activated) {
 725            socksBytestreamsTransport.setProxyActivated(activated.cid);
 726        } else if (transportInfo instanceof SocksByteStreamsTransportInfo.ProxyError) {
 727            socksBytestreamsTransport.setProxyError();
 728        }
 729    }
 730
 731    private void receiveTransportReplace(final Iq jinglePacket, final Jingle jingle) {
 732        if (isInitiator()) {
 733            receiveOutOfOrderAction(jinglePacket, Jingle.Action.TRANSPORT_REPLACE);
 734            return;
 735        }
 736        final GenericTransportInfo transportInfo;
 737        try {
 738            transportInfo = FileTransferContentMap.of(jingle).requireOnlyTransportInfo();
 739        } catch (final RuntimeException e) {
 740            Log.d(
 741                    Config.LOGTAG,
 742                    id.account.getJid().asBareJid() + ": improperly formatted contents",
 743                    Throwables.getRootCause(e));
 744            respondOk(jinglePacket);
 745            terminateTransport();
 746            sendSessionTerminate(Reason.of(e), e.getMessage());
 747            return;
 748        }
 749        if (isInState(State.SESSION_ACCEPTED)) {
 750            receiveTransportReplace(jinglePacket, transportInfo);
 751        } else {
 752            receiveOutOfOrderAction(jinglePacket, Jingle.Action.TRANSPORT_REPLACE);
 753        }
 754    }
 755
 756    private void receiveTransportReplace(
 757            final Iq jinglePacket, final GenericTransportInfo transportInfo) {
 758        respondOk(jinglePacket);
 759        final Transport currentTransport = this.transport;
 760        if (currentTransport != null) {
 761            Log.d(
 762                    Config.LOGTAG,
 763                    "terminating "
 764                            + currentTransport.getClass().getSimpleName()
 765                            + " upon receiving transport-replace");
 766            currentTransport.setTransportCallback(null);
 767            currentTransport.terminate();
 768        }
 769        final Transport nextTransport;
 770        try {
 771            nextTransport = setupTransport(transportInfo);
 772        } catch (final RuntimeException e) {
 773            sendSessionTerminate(Reason.of(e), e.getMessage());
 774            return;
 775        }
 776        this.transport = nextTransport;
 777        Log.d(
 778                Config.LOGTAG,
 779                "replacing transport with " + nextTransport.getClass().getSimpleName());
 780        this.transport.setTransportCallback(this);
 781        final var transportInfoFuture = nextTransport.asTransportInfo();
 782        Futures.addCallback(
 783                transportInfoFuture,
 784                new FutureCallback<>() {
 785                    @Override
 786                    public void onSuccess(final Transport.TransportInfo transportWrapper) {
 787                        final FileTransferContentMap contentMap =
 788                                getLocalContentMap().withTransport(transportWrapper);
 789                        sendTransportAccept(contentMap);
 790                    }
 791
 792                    @Override
 793                    public void onFailure(@NonNull Throwable throwable) {
 794                        // transition into application failed (analogues to failureToAccept
 795                    }
 796                },
 797                MoreExecutors.directExecutor());
 798    }
 799
 800    private void sendTransportAccept(final FileTransferContentMap contentMap) {
 801        setLocalContentMap(contentMap);
 802        final var iq =
 803                contentMap
 804                        .transportInfo()
 805                        .toJinglePacket(Jingle.Action.TRANSPORT_ACCEPT, id.sessionId);
 806        send(iq);
 807        transport.connect();
 808    }
 809
 810    protected void sendSessionTerminate(final Reason reason, final String text) {
 811        if (isInitiator()) {
 812            this.message.setErrorMessage(Strings.isNullOrEmpty(text) ? reason.toString() : text);
 813        }
 814        sendSessionTerminate(reason, text, null);
 815    }
 816
 817    private FileTransferContentMap getLocalContentMap() {
 818        return isInitiator()
 819                ? this.initiatorFileTransferContentMap
 820                : this.responderFileTransferContentMap;
 821    }
 822
 823    private FileTransferContentMap getRemoteContentMap() {
 824        return isInitiator()
 825                ? this.responderFileTransferContentMap
 826                : this.initiatorFileTransferContentMap;
 827    }
 828
 829    private void setLocalContentMap(final FileTransferContentMap contentMap) {
 830        if (isInitiator()) {
 831            this.initiatorFileTransferContentMap = contentMap;
 832        } else {
 833            this.responderFileTransferContentMap = contentMap;
 834        }
 835    }
 836
 837    private void setRemoteContentMap(final FileTransferContentMap contentMap) {
 838        if (isInitiator()) {
 839            this.responderFileTransferContentMap = contentMap;
 840        } else {
 841            this.initiatorFileTransferContentMap = contentMap;
 842        }
 843    }
 844
 845    public Transport getTransport() {
 846        return this.transport;
 847    }
 848
 849    @Override
 850    protected void terminateTransport() {
 851        final var transport = this.transport;
 852        if (transport == null) {
 853            return;
 854        }
 855        // TODO consider setting transport callback to null. requires transport to handle null
 856        // callback
 857        // transport.setTransportCallback(null);
 858        transport.terminate();
 859        this.transport = null;
 860    }
 861
 862    @Override
 863    void notifyRebound() {}
 864
 865    @Override
 866    public void onTransportEstablished() {
 867        Log.d(Config.LOGTAG, "on transport established");
 868        final AbstractFileTransceiver fileTransceiver;
 869        try {
 870            fileTransceiver = setupTransceiver(isResponder());
 871        } catch (final Exception e) {
 872            Log.d(Config.LOGTAG, "failed to set up file transceiver", e);
 873            sendSessionTerminate(Reason.ofThrowable(e), e.getMessage());
 874            return;
 875        }
 876        this.fileTransceiver = fileTransceiver;
 877        final var fileTransceiverThread = new Thread(fileTransceiver);
 878        fileTransceiverThread.start();
 879        Futures.addCallback(
 880                fileTransceiver.complete,
 881                new FutureCallback<>() {
 882                    @Override
 883                    public void onSuccess(final List<FileTransferDescription.Hash> hashes) {
 884                        onFileTransmissionComplete(hashes);
 885                    }
 886
 887                    @Override
 888                    public void onFailure(@NonNull final Throwable throwable) {
 889                        // The state transition in here should be synchronized to not race with the
 890                        // state transition in receiveSessionTerminate
 891                        synchronized (JingleFileTransferConnection.this) {
 892                            onFileTransmissionFailed(throwable);
 893                        }
 894                    }
 895                },
 896                MoreExecutors.directExecutor());
 897    }
 898
 899    private void onFileTransmissionComplete(final List<FileTransferDescription.Hash> hashes) {
 900        // TODO if we ever support receiving files this should become isSending(); isReceiving()
 901        if (isInitiator()) {
 902            sendSessionInfoChecksum(hashes);
 903        } else {
 904            Log.d(Config.LOGTAG, "file transfer complete " + hashes);
 905            sendFileSessionInfoReceived();
 906            terminateTransport();
 907            messageReceivedSuccess();
 908            sendSessionTerminate(Reason.SUCCESS, null);
 909        }
 910    }
 911
 912    private void messageReceivedSuccess() {
 913        this.message.setTransferable(null);
 914        xmppConnectionService.getFileBackend().updateFileParams(message);
 915        xmppConnectionService.databaseBackend.createMessage(message);
 916        final File file = xmppConnectionService.getFileBackend().getFile(message);
 917        if (acceptedAutomatically) {
 918            message.markUnread();
 919            if (message.getEncryption() == Message.ENCRYPTION_PGP) {
 920                id.account.getPgpDecryptionService().decrypt(message, true);
 921            } else {
 922                xmppConnectionService
 923                        .getFileBackend()
 924                        .updateMediaScanner(
 925                                file,
 926                                () ->
 927                                        JingleFileTransferConnection.this
 928                                                .xmppConnectionService
 929                                                .getNotificationService()
 930                                                .push(message));
 931            }
 932        } else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
 933            id.account.getPgpDecryptionService().decrypt(message, false);
 934        } else {
 935            xmppConnectionService.getFileBackend().updateMediaScanner(file);
 936        }
 937    }
 938
 939    private void onFileTransmissionFailed(final Throwable throwable) {
 940        if (isTerminated()) {
 941            Log.d(
 942                    Config.LOGTAG,
 943                    "file transfer failed but session is already terminated",
 944                    throwable);
 945        } else {
 946            terminateTransport();
 947            Log.d(Config.LOGTAG, "on file transmission failed", throwable);
 948            sendSessionTerminate(Reason.CONNECTIVITY_ERROR, null);
 949        }
 950    }
 951
 952    private AbstractFileTransceiver setupTransceiver(final boolean receiving) throws IOException {
 953        final var fileDescription = getLocalContentMap().requireOnlyFile();
 954        final File file = xmppConnectionService.getFileBackend().getFile(message);
 955        final Runnable updateRunnable = () -> jingleConnectionManager.updateConversationUi(false);
 956        if (receiving) {
 957            return new FileReceiver(
 958                    file,
 959                    this.transportSecurity,
 960                    transport.getInputStream(),
 961                    transport.getTerminationLatch(),
 962                    fileDescription.size,
 963                    updateRunnable);
 964        } else {
 965            return new FileTransmitter(
 966                    file,
 967                    this.transportSecurity,
 968                    transport.getOutputStream(),
 969                    transport.getTerminationLatch(),
 970                    fileDescription.size,
 971                    updateRunnable);
 972        }
 973    }
 974
 975    private void sendFileSessionInfoReceived() {
 976        final var contentMap = getLocalContentMap();
 977        final String name = Iterables.getOnlyElement(contentMap.contents.keySet());
 978        sendSessionInfo(new FileTransferDescription.Received(name));
 979    }
 980
 981    private void sendSessionInfoChecksum(List<FileTransferDescription.Hash> hashes) {
 982        final var contentMap = getLocalContentMap();
 983        final String name = Iterables.getOnlyElement(contentMap.contents.keySet());
 984        sendSessionInfo(new FileTransferDescription.Checksum(name, hashes));
 985    }
 986
 987    private void sendSessionInfo(final FileTransferDescription.SessionInfo sessionInfo) {
 988        final var iq = new Iq(Iq.Type.SET);
 989        final var jinglePacket = iq.addExtension(new Jingle(Jingle.Action.SESSION_INFO, this.id.sessionId));
 990        jinglePacket.addChild(sessionInfo.asElement());
 991        send(iq);
 992    }
 993
 994    @Override
 995    public void onTransportSetupFailed() {
 996        final var transport = this.transport;
 997        if (transport == null) {
 998            // this can happen on IQ timeouts
 999            if (isTerminated()) {
1000                return;
1001            }
1002            sendSessionTerminate(Reason.FAILED_APPLICATION, null);
1003            return;
1004        }
1005        Log.d(Config.LOGTAG, "onTransportSetupFailed");
1006        final var isTransportInBand = transport instanceof InbandBytestreamsTransport;
1007        if (isTransportInBand) {
1008            terminateTransport();
1009            sendSessionTerminate(Reason.CONNECTIVITY_ERROR, "Failed to setup IBB transport");
1010            return;
1011        }
1012        // terminate the current transport
1013        transport.terminate();
1014        if (isInitiator()) {
1015            this.transport = setupLastResortTransport();
1016            Log.d(
1017                    Config.LOGTAG,
1018                    "replacing transport with " + this.transport.getClass().getSimpleName());
1019            this.transport.setTransportCallback(this);
1020            final var transportInfoFuture = this.transport.asTransportInfo();
1021            Futures.addCallback(
1022                    transportInfoFuture,
1023                    new FutureCallback<>() {
1024                        @Override
1025                        public void onSuccess(final Transport.TransportInfo transportWrapper) {
1026                            final FileTransferContentMap contentMap = getLocalContentMap();
1027                            sendTransportReplace(contentMap.withTransport(transportWrapper));
1028                        }
1029
1030                        @Override
1031                        public void onFailure(@NonNull Throwable throwable) {
1032                            // TODO send application failure;
1033                        }
1034                    },
1035                    MoreExecutors.directExecutor());
1036
1037        } else {
1038            Log.d(Config.LOGTAG, "transport setup failed. waiting for initiator to replace");
1039        }
1040    }
1041
1042    private void sendTransportReplace(final FileTransferContentMap contentMap) {
1043        setLocalContentMap(contentMap);
1044        final var iq =
1045                contentMap
1046                        .transportInfo()
1047                        .toJinglePacket(Jingle.Action.TRANSPORT_REPLACE, id.sessionId);
1048        send(iq);
1049    }
1050
1051    @Override
1052    public void onAdditionalCandidate(
1053            final String contentName, final Transport.Candidate candidate) {
1054        if (candidate instanceof IceUdpTransportInfo.Candidate iceCandidate) {
1055            sendTransportInfo(contentName, iceCandidate);
1056        }
1057    }
1058
1059    public void sendTransportInfo(
1060            final String contentName, final IceUdpTransportInfo.Candidate candidate) {
1061        final FileTransferContentMap transportInfo;
1062        try {
1063            final FileTransferContentMap rtpContentMap = getLocalContentMap();
1064            transportInfo = rtpContentMap.transportInfo(contentName, candidate);
1065        } catch (final Exception e) {
1066            Log.d(
1067                    Config.LOGTAG,
1068                    id.account.getJid().asBareJid()
1069                            + ": unable to prepare transport-info from candidate for content="
1070                            + contentName);
1071            return;
1072        }
1073        final Iq iq =
1074                transportInfo.toJinglePacket(Jingle.Action.TRANSPORT_INFO, id.sessionId);
1075        send(iq);
1076    }
1077
1078    @Override
1079    public void onCandidateUsed(
1080            final String streamId, final SocksByteStreamsTransport.Candidate candidate) {
1081        final FileTransferContentMap contentMap = getLocalContentMap();
1082        if (contentMap == null) {
1083            Log.e(Config.LOGTAG, "local content map is null on candidate used");
1084            return;
1085        }
1086        final var iq =
1087                contentMap
1088                        .candidateUsed(streamId, candidate.cid)
1089                        .toJinglePacket(Jingle.Action.TRANSPORT_INFO, id.sessionId);
1090        Log.d(Config.LOGTAG, "sending candidate used " + iq);
1091        send(iq);
1092    }
1093
1094    @Override
1095    public void onCandidateError(final String streamId) {
1096        final FileTransferContentMap contentMap = getLocalContentMap();
1097        if (contentMap == null) {
1098            Log.e(Config.LOGTAG, "local content map is null on candidate used");
1099            return;
1100        }
1101        final var iq =
1102                contentMap
1103                        .candidateError(streamId)
1104                        .toJinglePacket(Jingle.Action.TRANSPORT_INFO, id.sessionId);
1105        Log.d(Config.LOGTAG, "sending candidate error " + iq);
1106        send(iq);
1107    }
1108
1109    @Override
1110    public void onProxyActivated(String streamId, SocksByteStreamsTransport.Candidate candidate) {
1111        final FileTransferContentMap contentMap = getLocalContentMap();
1112        if (contentMap == null) {
1113            Log.e(Config.LOGTAG, "local content map is null on candidate used");
1114            return;
1115        }
1116        final var iq =
1117                contentMap
1118                        .proxyActivated(streamId, candidate.cid)
1119                        .toJinglePacket(Jingle.Action.TRANSPORT_INFO, id.sessionId);
1120        send(iq);
1121    }
1122
1123    @Override
1124    protected boolean transition(final State target, final Runnable runnable) {
1125        final boolean transitioned = super.transition(target, runnable);
1126        if (transitioned && isInitiator()) {
1127            Log.d(Config.LOGTAG, "running mark message hooks");
1128            if (target == State.SESSION_ACCEPTED) {
1129                xmppConnectionService.markMessage(message, Message.STATUS_UNSEND);
1130            } else if (target == State.TERMINATED_SUCCESS) {
1131                xmppConnectionService.markMessage(message, Message.STATUS_SEND_RECEIVED);
1132            } else if (TERMINATED.contains(target)) {
1133                xmppConnectionService.markMessage(
1134                        message, Message.STATUS_SEND_FAILED, message.getErrorMessage());
1135            } else {
1136                xmppConnectionService.updateConversationUi();
1137            }
1138        } else {
1139            if (Arrays.asList(State.TERMINATED_CANCEL_OR_TIMEOUT, State.TERMINATED_DECLINED_OR_BUSY)
1140                    .contains(target)) {
1141                this.message.setTransferable(
1142                        new TransferablePlaceholder(Transferable.STATUS_CANCELLED));
1143            } else if (target != State.TERMINATED_SUCCESS && TERMINATED.contains(target)) {
1144                this.message.setTransferable(
1145                        new TransferablePlaceholder(Transferable.STATUS_FAILED));
1146            }
1147            xmppConnectionService.updateConversationUi();
1148        }
1149        return transitioned;
1150    }
1151
1152    @Override
1153    protected void finish() {
1154        if (transport != null) {
1155            throw new AssertionError(
1156                    "finish MUST not be called without terminating the transport first");
1157        }
1158        // we don't want to remove TransferablePlaceholder
1159        if (message.getTransferable() instanceof JingleFileTransferConnection) {
1160            Log.d(Config.LOGTAG, "nulling transferable on message");
1161            this.message.setTransferable(null);
1162        }
1163        super.finish();
1164    }
1165
1166    private int getTransferableStatus() {
1167        // status in file transfer is a bit weird. for sending it is mostly handled via
1168        // Message.STATUS_* (offered, unsend (sic) send_received) the transferable status is just
1169        // uploading
1170        // for receiving the message status remains at 'received' but Transferable goes through
1171        // various status
1172        if (isInitiator()) {
1173            return Transferable.STATUS_UPLOADING;
1174        }
1175        final var state = getState();
1176        return switch (state) {
1177            case NULL, SESSION_INITIALIZED, SESSION_INITIALIZED_PRE_APPROVED -> Transferable
1178                    .STATUS_OFFER;
1179            case TERMINATED_APPLICATION_FAILURE,
1180                    TERMINATED_CONNECTIVITY_ERROR,
1181                    TERMINATED_DECLINED_OR_BUSY,
1182                    TERMINATED_SECURITY_ERROR -> Transferable.STATUS_FAILED;
1183            case TERMINATED_CANCEL_OR_TIMEOUT -> Transferable.STATUS_CANCELLED;
1184            case SESSION_ACCEPTED -> Transferable.STATUS_DOWNLOADING;
1185            default -> Transferable.STATUS_UNKNOWN;
1186        };
1187    }
1188
1189    // these methods are for interacting with 'Transferable' - we might want to remove the concept
1190    // at some point
1191
1192    @Override
1193    public boolean start() {
1194        Log.d(Config.LOGTAG, "user pressed start()");
1195        // TODO there is a 'connected' check apparently?
1196        if (isInState(State.SESSION_INITIALIZED)) {
1197            sendSessionAccept();
1198        }
1199        return true;
1200    }
1201
1202    @Override
1203    public int getStatus() {
1204        return getTransferableStatus();
1205    }
1206
1207    @Override
1208    public Long getFileSize() {
1209        final var transceiver = this.fileTransceiver;
1210        if (transceiver != null) {
1211            return transceiver.total;
1212        }
1213        final var contentMap = this.initiatorFileTransferContentMap;
1214        if (contentMap != null) {
1215            return contentMap.requireOnlyFile().size;
1216        }
1217        return null;
1218    }
1219
1220    @Override
1221    public int getProgress() {
1222        final var transceiver = this.fileTransceiver;
1223        return transceiver != null ? transceiver.getProgress() : 0;
1224    }
1225
1226    @Override
1227    public void cancel() {
1228        if (stopFileTransfer()) {
1229            Log.d(Config.LOGTAG, "user has stopped file transfer");
1230        } else {
1231            Log.d(Config.LOGTAG, "user pressed cancel but file transfer was already terminated?");
1232        }
1233    }
1234
1235    private boolean stopFileTransfer() {
1236        if (isInitiator()) {
1237            return stopFileTransfer(Reason.CANCEL);
1238        } else {
1239            return stopFileTransfer(Reason.DECLINE);
1240        }
1241    }
1242
1243    private boolean stopFileTransfer(final Reason reason) {
1244        final State target = reasonToState(reason);
1245        if (transition(target)) {
1246            // we change state before terminating transport so we don't consume the following
1247            // IOException and turn it into a connectivity error
1248
1249            if (isInitiator() && reason == Reason.CANCEL) {
1250                // message hooks have already run so we need to mark to persist the 'cancelled'
1251                // status
1252                xmppConnectionService.markMessage(
1253                        message, Message.STATUS_SEND_FAILED, Message.ERROR_MESSAGE_CANCELLED);
1254            }
1255            terminateTransport();
1256            final Iq iq = new Iq(Iq.Type.SET);
1257            final var jingle = iq.addExtension(new Jingle(Jingle.Action.SESSION_TERMINATE, id.sessionId));
1258            jingle.setReason(reason, "User requested to stop file transfer");
1259            send(iq);
1260            finish();
1261            return true;
1262        } else {
1263            return false;
1264        }
1265    }
1266
1267    private abstract static class AbstractFileTransceiver implements Runnable {
1268
1269        protected final SettableFuture<List<FileTransferDescription.Hash>> complete =
1270                SettableFuture.create();
1271
1272        protected final File file;
1273        protected final TransportSecurity transportSecurity;
1274
1275        protected final CountDownLatch transportTerminationLatch;
1276        protected final long total;
1277        protected long transmitted = 0;
1278        private int progress = Integer.MIN_VALUE;
1279        private final Runnable updateRunnable;
1280
1281        private AbstractFileTransceiver(
1282                final File file,
1283                final TransportSecurity transportSecurity,
1284                final CountDownLatch transportTerminationLatch,
1285                final long total,
1286                final Runnable updateRunnable) {
1287            this.file = file;
1288            this.transportSecurity = transportSecurity;
1289            this.transportTerminationLatch = transportTerminationLatch;
1290            this.total = transportSecurity == null ? total : (total + 16);
1291            this.updateRunnable = updateRunnable;
1292        }
1293
1294        static void closeTransport(final Closeable stream) {
1295            try {
1296                stream.close();
1297            } catch (final IOException e) {
1298                Log.d(Config.LOGTAG, "transport has already been closed. good");
1299            }
1300        }
1301
1302        public int getProgress() {
1303            return Ints.saturatedCast(Math.round((1.0 * transmitted / total) * 100));
1304        }
1305
1306        public void updateProgress() {
1307            final int current = getProgress();
1308            final boolean update;
1309            synchronized (this) {
1310                if (this.progress != current) {
1311                    this.progress = current;
1312                    update = true;
1313                } else {
1314                    update = false;
1315                }
1316                if (update) {
1317                    this.updateRunnable.run();
1318                }
1319            }
1320        }
1321
1322        protected void awaitTransportTermination() {
1323            try {
1324                this.transportTerminationLatch.await();
1325            } catch (final InterruptedException ignored) {
1326                return;
1327            }
1328            Log.d(Config.LOGTAG, getClass().getSimpleName() + " says Goodbye!");
1329        }
1330    }
1331
1332    private static class FileTransmitter extends AbstractFileTransceiver {
1333
1334        private final OutputStream outputStream;
1335
1336        private FileTransmitter(
1337                final File file,
1338                final TransportSecurity transportSecurity,
1339                final OutputStream outputStream,
1340                final CountDownLatch transportTerminationLatch,
1341                final long total,
1342                final Runnable updateRunnable) {
1343            super(file, transportSecurity, transportTerminationLatch, total, updateRunnable);
1344            this.outputStream = outputStream;
1345        }
1346
1347        private InputStream openFileInputStream() throws FileNotFoundException {
1348            final var fileInputStream = new FileInputStream(this.file);
1349            if (this.transportSecurity == null) {
1350                return fileInputStream;
1351            } else {
1352                final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
1353                cipher.init(
1354                        true,
1355                        new AEADParameters(
1356                                new KeyParameter(transportSecurity.key),
1357                                128,
1358                                transportSecurity.iv));
1359                Log.d(Config.LOGTAG, "setting up CipherInputStream");
1360                return new CipherInputStream(fileInputStream, cipher);
1361            }
1362        }
1363
1364        @Override
1365        public void run() {
1366            Log.d(Config.LOGTAG, "file transmitter attempting to send " + total + " bytes");
1367            final var sha1Hasher = Hashing.sha1().newHasher();
1368            final var sha256Hasher = Hashing.sha256().newHasher();
1369            try (final var fileInputStream = openFileInputStream()) {
1370                final var buffer = new byte[4096];
1371                while (total - transmitted > 0) {
1372                    final int count = fileInputStream.read(buffer);
1373                    if (count == -1) {
1374                        throw new EOFException(
1375                                String.format("reached EOF after %d/%d", transmitted, total));
1376                    }
1377                    outputStream.write(buffer, 0, count);
1378                    sha1Hasher.putBytes(buffer, 0, count);
1379                    sha256Hasher.putBytes(buffer, 0, count);
1380                    transmitted += count;
1381                    updateProgress();
1382                }
1383                outputStream.flush();
1384                Log.d(
1385                        Config.LOGTAG,
1386                        "transmitted " + transmitted + " bytes from " + file.getAbsolutePath());
1387                final List<FileTransferDescription.Hash> hashes =
1388                        ImmutableList.of(
1389                                new FileTransferDescription.Hash(
1390                                        sha1Hasher.hash().asBytes(),
1391                                        FileTransferDescription.Algorithm.SHA_1),
1392                                new FileTransferDescription.Hash(
1393                                        sha256Hasher.hash().asBytes(),
1394                                        FileTransferDescription.Algorithm.SHA_256));
1395                complete.set(hashes);
1396            } catch (final Exception e) {
1397                complete.setException(e);
1398            }
1399            // the transport implementations backed by PipedOutputStreams do not like it when
1400            // the writing Thread (this thread) goes away. so we just wait until the other peer
1401            // has received our file and we are shutting down the transport
1402            Log.d(Config.LOGTAG, "waiting for transport to terminate before stopping thread");
1403            awaitTransportTermination();
1404            closeTransport(outputStream);
1405        }
1406    }
1407
1408    private static class FileReceiver extends AbstractFileTransceiver {
1409
1410        private final InputStream inputStream;
1411
1412        private FileReceiver(
1413                final File file,
1414                final TransportSecurity transportSecurity,
1415                final InputStream inputStream,
1416                final CountDownLatch transportTerminationLatch,
1417                final long total,
1418                final Runnable updateRunnable) {
1419            super(file, transportSecurity, transportTerminationLatch, total, updateRunnable);
1420            this.inputStream = inputStream;
1421        }
1422
1423        private OutputStream openFileOutputStream() throws FileNotFoundException {
1424            final var directory = this.file.getParentFile();
1425            if (directory != null && directory.mkdirs()) {
1426                Log.d(Config.LOGTAG, "created directory " + directory.getAbsolutePath());
1427            }
1428            final var fileOutputStream = new FileOutputStream(this.file);
1429            if (this.transportSecurity == null) {
1430                return fileOutputStream;
1431            } else {
1432                final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
1433                cipher.init(
1434                        false,
1435                        new AEADParameters(
1436                                new KeyParameter(transportSecurity.key),
1437                                128,
1438                                transportSecurity.iv));
1439                Log.d(Config.LOGTAG, "setting up CipherOutputStream");
1440                return new CipherOutputStream(fileOutputStream, cipher);
1441            }
1442        }
1443
1444        @Override
1445        public void run() {
1446            Log.d(Config.LOGTAG, "file receiver attempting to receive " + total + " bytes");
1447            final var sha1Hasher = Hashing.sha1().newHasher();
1448            final var sha256Hasher = Hashing.sha256().newHasher();
1449            try (final var fileOutputStream = openFileOutputStream()) {
1450                final var buffer = new byte[4096];
1451                while (total - transmitted > 0) {
1452                    final int count = inputStream.read(buffer);
1453                    if (count == -1) {
1454                        throw new EOFException(
1455                                String.format("reached EOF after %d/%d", transmitted, total));
1456                    }
1457                    fileOutputStream.write(buffer, 0, count);
1458                    sha1Hasher.putBytes(buffer, 0, count);
1459                    sha256Hasher.putBytes(buffer, 0, count);
1460                    transmitted += count;
1461                    updateProgress();
1462                }
1463                Log.d(
1464                        Config.LOGTAG,
1465                        "written " + transmitted + " bytes to " + file.getAbsolutePath());
1466                final List<FileTransferDescription.Hash> hashes =
1467                        ImmutableList.of(
1468                                new FileTransferDescription.Hash(
1469                                        sha1Hasher.hash().asBytes(),
1470                                        FileTransferDescription.Algorithm.SHA_1),
1471                                new FileTransferDescription.Hash(
1472                                        sha256Hasher.hash().asBytes(),
1473                                        FileTransferDescription.Algorithm.SHA_256));
1474                complete.set(hashes);
1475            } catch (final Exception e) {
1476                complete.setException(e);
1477            }
1478            Log.d(Config.LOGTAG, "waiting for transport to terminate before stopping thread");
1479            awaitTransportTermination();
1480            closeTransport(inputStream);
1481        }
1482    }
1483
1484    private static final class TransportSecurity {
1485        final byte[] key;
1486        final byte[] iv;
1487
1488        private TransportSecurity(byte[] key, byte[] iv) {
1489            this.key = key;
1490            this.iv = iv;
1491        }
1492    }
1493}