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