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