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        // TODO consider setting transport callback to null. requires transport to handle null callback
 847        //transport.setTransportCallback(null);
 848        transport.terminate();
 849        this.transport = null;
 850    }
 851
 852    @Override
 853    void notifyRebound() {}
 854
 855    @Override
 856    public void onTransportEstablished() {
 857        Log.d(Config.LOGTAG, "on transport established");
 858        final AbstractFileTransceiver fileTransceiver;
 859        try {
 860            fileTransceiver = setupTransceiver(isResponder());
 861        } catch (final Exception e) {
 862            Log.d(Config.LOGTAG, "failed to set up file transceiver", e);
 863            sendSessionTerminate(Reason.ofThrowable(e), e.getMessage());
 864            return;
 865        }
 866        this.fileTransceiver = fileTransceiver;
 867        final var fileTransceiverThread = new Thread(fileTransceiver);
 868        fileTransceiverThread.start();
 869        Futures.addCallback(
 870                fileTransceiver.complete,
 871                new FutureCallback<>() {
 872                    @Override
 873                    public void onSuccess(final List<FileTransferDescription.Hash> hashes) {
 874                        onFileTransmissionComplete(hashes);
 875                    }
 876
 877                    @Override
 878                    public void onFailure(@NonNull Throwable throwable) {
 879                        onFileTransmissionFailed(throwable);
 880                    }
 881                },
 882                MoreExecutors.directExecutor());
 883    }
 884
 885    private void onFileTransmissionComplete(final List<FileTransferDescription.Hash> hashes) {
 886        // TODO if we ever support receiving files this should become isSending(); isReceiving()
 887        if (isInitiator()) {
 888            sendSessionInfoChecksum(hashes);
 889        } else {
 890            Log.d(Config.LOGTAG, "file transfer complete " + hashes);
 891            sendFileSessionInfoReceived();
 892            terminateTransport();
 893            messageReceivedSuccess();
 894            sendSessionTerminate(Reason.SUCCESS, null);
 895        }
 896    }
 897
 898    private void messageReceivedSuccess() {
 899        this.message.setTransferable(null);
 900        xmppConnectionService.getFileBackend().updateFileParams(message);
 901        xmppConnectionService.databaseBackend.createMessage(message);
 902        final File file = xmppConnectionService.getFileBackend().getFile(message);
 903        if (acceptedAutomatically) {
 904            message.markUnread();
 905            if (message.getEncryption() == Message.ENCRYPTION_PGP) {
 906                id.account.getPgpDecryptionService().decrypt(message, true);
 907            } else {
 908                xmppConnectionService
 909                        .getFileBackend()
 910                        .updateMediaScanner(
 911                                file,
 912                                () ->
 913                                        JingleFileTransferConnection.this
 914                                                .xmppConnectionService
 915                                                .getNotificationService()
 916                                                .push(message));
 917            }
 918        } else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
 919            id.account.getPgpDecryptionService().decrypt(message, false);
 920        } else {
 921            xmppConnectionService.getFileBackend().updateMediaScanner(file);
 922        }
 923    }
 924
 925    private void onFileTransmissionFailed(final Throwable throwable) {
 926        if (isTerminated()) {
 927            Log.d(
 928                    Config.LOGTAG,
 929                    "file transfer failed but session is already terminated",
 930                    throwable);
 931        } else {
 932            terminateTransport();
 933            Log.d(Config.LOGTAG, "on file transmission failed", throwable);
 934            sendSessionTerminate(Reason.CONNECTIVITY_ERROR, null);
 935        }
 936    }
 937
 938    private AbstractFileTransceiver setupTransceiver(final boolean receiving) throws IOException {
 939        final var fileDescription = getLocalContentMap().requireOnlyFile();
 940        final File file = xmppConnectionService.getFileBackend().getFile(message);
 941        final Runnable updateRunnable = () -> jingleConnectionManager.updateConversationUi(false);
 942        if (receiving) {
 943            return new FileReceiver(
 944                    file,
 945                    this.transportSecurity,
 946                    transport.getInputStream(),
 947                    transport.getTerminationLatch(),
 948                    fileDescription.size,
 949                    updateRunnable);
 950        } else {
 951            return new FileTransmitter(
 952                    file,
 953                    this.transportSecurity,
 954                    transport.getOutputStream(),
 955                    transport.getTerminationLatch(),
 956                    fileDescription.size,
 957                    updateRunnable);
 958        }
 959    }
 960
 961    private void sendFileSessionInfoReceived() {
 962        final var contentMap = getLocalContentMap();
 963        final String name = Iterables.getOnlyElement(contentMap.contents.keySet());
 964        sendSessionInfo(new FileTransferDescription.Received(name));
 965    }
 966
 967    private void sendSessionInfoChecksum(List<FileTransferDescription.Hash> hashes) {
 968        final var contentMap = getLocalContentMap();
 969        final String name = Iterables.getOnlyElement(contentMap.contents.keySet());
 970        sendSessionInfo(new FileTransferDescription.Checksum(name, hashes));
 971    }
 972
 973    private void sendSessionInfo(final FileTransferDescription.SessionInfo sessionInfo) {
 974        final var jinglePacket =
 975                new JinglePacket(JinglePacket.Action.SESSION_INFO, this.id.sessionId);
 976        jinglePacket.addJingleChild(sessionInfo.asElement());
 977        jinglePacket.setTo(this.id.with);
 978        send(jinglePacket);
 979    }
 980
 981    @Override
 982    public void onTransportSetupFailed() {
 983        final var transport = this.transport;
 984        if (transport == null) {
 985            // this can happen on IQ timeouts
 986            if (isTerminated()) {
 987                return;
 988            }
 989            sendSessionTerminate(Reason.FAILED_APPLICATION, null);
 990            return;
 991        }
 992        Log.d(Config.LOGTAG, "onTransportSetupFailed");
 993        final var isTransportInBand = transport instanceof InbandBytestreamsTransport;
 994        if (isTransportInBand) {
 995            terminateTransport();
 996            sendSessionTerminate(Reason.CONNECTIVITY_ERROR, "Failed to setup IBB transport");
 997            return;
 998        }
 999        // terminate the current transport
1000        transport.terminate();
1001        if (isInitiator()) {
1002            this.transport = setupLastResortTransport();
1003            Log.d(
1004                    Config.LOGTAG,
1005                    "replacing transport with " + this.transport.getClass().getSimpleName());
1006            this.transport.setTransportCallback(this);
1007            final var transportInfoFuture = this.transport.asTransportInfo();
1008            Futures.addCallback(
1009                    transportInfoFuture,
1010                    new FutureCallback<>() {
1011                        @Override
1012                        public void onSuccess(final Transport.TransportInfo transportWrapper) {
1013                            final FileTransferContentMap contentMap = getLocalContentMap();
1014                            sendTransportReplace(contentMap.withTransport(transportWrapper));
1015                        }
1016
1017                        @Override
1018                        public void onFailure(@NonNull Throwable throwable) {
1019                            // TODO send application failure;
1020                        }
1021                    },
1022                    MoreExecutors.directExecutor());
1023
1024        } else {
1025            Log.d(Config.LOGTAG, "transport setup failed. waiting for initiator to replace");
1026        }
1027    }
1028
1029    private void sendTransportReplace(final FileTransferContentMap contentMap) {
1030        setLocalContentMap(contentMap);
1031        final var jinglePacket =
1032                contentMap
1033                        .transportInfo()
1034                        .toJinglePacket(JinglePacket.Action.TRANSPORT_REPLACE, id.sessionId);
1035        send(jinglePacket);
1036    }
1037
1038    @Override
1039    public void onAdditionalCandidate(
1040            final String contentName, final Transport.Candidate candidate) {
1041        if (candidate instanceof IceUdpTransportInfo.Candidate iceCandidate) {
1042            sendTransportInfo(contentName, iceCandidate);
1043        }
1044    }
1045
1046    public void sendTransportInfo(
1047            final String contentName, final IceUdpTransportInfo.Candidate candidate) {
1048        final FileTransferContentMap transportInfo;
1049        try {
1050            final FileTransferContentMap rtpContentMap = getLocalContentMap();
1051            transportInfo = rtpContentMap.transportInfo(contentName, candidate);
1052        } catch (final Exception e) {
1053            Log.d(
1054                    Config.LOGTAG,
1055                    id.account.getJid().asBareJid()
1056                            + ": unable to prepare transport-info from candidate for content="
1057                            + contentName);
1058            return;
1059        }
1060        final JinglePacket jinglePacket =
1061                transportInfo.toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
1062        send(jinglePacket);
1063    }
1064
1065    @Override
1066    public void onCandidateUsed(
1067            final String streamId, final SocksByteStreamsTransport.Candidate candidate) {
1068        final FileTransferContentMap contentMap = getLocalContentMap();
1069        if (contentMap == null) {
1070            Log.e(Config.LOGTAG, "local content map is null on candidate used");
1071            return;
1072        }
1073        final var jinglePacket =
1074                contentMap
1075                        .candidateUsed(streamId, candidate.cid)
1076                        .toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
1077        Log.d(Config.LOGTAG, "sending candidate used " + jinglePacket);
1078        send(jinglePacket);
1079    }
1080
1081    @Override
1082    public void onCandidateError(final String streamId) {
1083        final FileTransferContentMap contentMap = getLocalContentMap();
1084        if (contentMap == null) {
1085            Log.e(Config.LOGTAG, "local content map is null on candidate used");
1086            return;
1087        }
1088        final var jinglePacket =
1089                contentMap
1090                        .candidateError(streamId)
1091                        .toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
1092        Log.d(Config.LOGTAG, "sending candidate error " + jinglePacket);
1093        send(jinglePacket);
1094    }
1095
1096    @Override
1097    public void onProxyActivated(String streamId, SocksByteStreamsTransport.Candidate candidate) {
1098        final FileTransferContentMap contentMap = getLocalContentMap();
1099        if (contentMap == null) {
1100            Log.e(Config.LOGTAG, "local content map is null on candidate used");
1101            return;
1102        }
1103        final var jinglePacket =
1104                contentMap
1105                        .proxyActivated(streamId, candidate.cid)
1106                        .toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
1107        send(jinglePacket);
1108    }
1109
1110    @Override
1111    protected boolean transition(final State target, final Runnable runnable) {
1112        final boolean transitioned = super.transition(target, runnable);
1113        if (transitioned && isInitiator()) {
1114            Log.d(Config.LOGTAG, "running mark message hooks");
1115            if (target == State.SESSION_ACCEPTED) {
1116                xmppConnectionService.markMessage(message, Message.STATUS_UNSEND);
1117            } else if (target == State.TERMINATED_SUCCESS) {
1118                xmppConnectionService.markMessage(message, Message.STATUS_SEND_RECEIVED);
1119            } else if (TERMINATED.contains(target)) {
1120                xmppConnectionService.markMessage(
1121                        message, Message.STATUS_SEND_FAILED, message.getErrorMessage());
1122            } else {
1123                xmppConnectionService.updateConversationUi();
1124            }
1125        } else {
1126            if (Arrays.asList(State.TERMINATED_CANCEL_OR_TIMEOUT, State.TERMINATED_DECLINED_OR_BUSY)
1127                    .contains(target)) {
1128                this.message.setTransferable(
1129                        new TransferablePlaceholder(Transferable.STATUS_CANCELLED));
1130            } else if (target != State.TERMINATED_SUCCESS && TERMINATED.contains(target)) {
1131                this.message.setTransferable(
1132                        new TransferablePlaceholder(Transferable.STATUS_FAILED));
1133            }
1134            xmppConnectionService.updateConversationUi();
1135        }
1136        return transitioned;
1137    }
1138
1139    @Override
1140    protected void finish() {
1141        if (transport != null) {
1142            throw new AssertionError(
1143                    "finish MUST not be called without terminating the transport first");
1144        }
1145        // we don't want to remove TransferablePlaceholder
1146        if (message.getTransferable() instanceof JingleFileTransferConnection) {
1147            Log.d(Config.LOGTAG, "nulling transferable on message");
1148            this.message.setTransferable(null);
1149        }
1150        super.finish();
1151    }
1152
1153    private int getTransferableStatus() {
1154        // status in file transfer is a bit weird. for sending it is mostly handled via
1155        // Message.STATUS_* (offered, unsend (sic) send_received) the transferable status is just
1156        // uploading
1157        // for receiving the message status remains at 'received' but Transferable goes through
1158        // various status
1159        if (isInitiator()) {
1160            return Transferable.STATUS_UPLOADING;
1161        }
1162        final var state = getState();
1163        return switch (state) {
1164            case NULL, SESSION_INITIALIZED, SESSION_INITIALIZED_PRE_APPROVED -> Transferable
1165                    .STATUS_OFFER;
1166            case TERMINATED_APPLICATION_FAILURE,
1167                    TERMINATED_CONNECTIVITY_ERROR,
1168                    TERMINATED_DECLINED_OR_BUSY,
1169                    TERMINATED_SECURITY_ERROR -> Transferable.STATUS_FAILED;
1170            case TERMINATED_CANCEL_OR_TIMEOUT -> Transferable.STATUS_CANCELLED;
1171            case SESSION_ACCEPTED -> Transferable.STATUS_DOWNLOADING;
1172            default -> Transferable.STATUS_UNKNOWN;
1173        };
1174    }
1175
1176    // these methods are for interacting with 'Transferable' - we might want to remove the concept
1177    // at some point
1178
1179    @Override
1180    public boolean start() {
1181        Log.d(Config.LOGTAG, "user pressed start()");
1182        // TODO there is a 'connected' check apparently?
1183        if (isInState(State.SESSION_INITIALIZED)) {
1184            sendSessionAccept();
1185        }
1186        return true;
1187    }
1188
1189    @Override
1190    public int getStatus() {
1191        return getTransferableStatus();
1192    }
1193
1194    @Override
1195    public Long getFileSize() {
1196        final var transceiver = this.fileTransceiver;
1197        if (transceiver != null) {
1198            return transceiver.total;
1199        }
1200        final var contentMap = this.initiatorFileTransferContentMap;
1201        if (contentMap != null) {
1202            return contentMap.requireOnlyFile().size;
1203        }
1204        return null;
1205    }
1206
1207    @Override
1208    public int getProgress() {
1209        final var transceiver = this.fileTransceiver;
1210        return transceiver != null ? transceiver.getProgress() : 0;
1211    }
1212
1213    @Override
1214    public void cancel() {
1215        if (stopFileTransfer()) {
1216            Log.d(Config.LOGTAG, "user has stopped file transfer");
1217        } else {
1218            Log.d(Config.LOGTAG, "user pressed cancel but file transfer was already terminated?");
1219        }
1220    }
1221
1222    private boolean stopFileTransfer() {
1223        if (isInitiator()) {
1224            return stopFileTransfer(Reason.CANCEL);
1225        } else {
1226            return stopFileTransfer(Reason.DECLINE);
1227        }
1228    }
1229
1230    private boolean stopFileTransfer(final Reason reason) {
1231        final State target = reasonToState(reason);
1232        if (transition(target)) {
1233            // we change state before terminating transport so we don't consume the following
1234            // IOException and turn it into a connectivity error
1235            terminateTransport();
1236            final JinglePacket jinglePacket =
1237                    new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId);
1238            jinglePacket.setReason(reason, "User requested to stop file transfer");
1239            send(jinglePacket);
1240            finish();
1241            return true;
1242        } else {
1243            return false;
1244        }
1245    }
1246
1247    private abstract static class AbstractFileTransceiver implements Runnable {
1248
1249        protected final SettableFuture<List<FileTransferDescription.Hash>> complete =
1250                SettableFuture.create();
1251
1252        protected final File file;
1253        protected final TransportSecurity transportSecurity;
1254
1255        protected final CountDownLatch transportTerminationLatch;
1256        protected final long total;
1257        protected long transmitted = 0;
1258        private int progress = Integer.MIN_VALUE;
1259        private final Runnable updateRunnable;
1260
1261        private AbstractFileTransceiver(
1262                final File file,
1263                final TransportSecurity transportSecurity,
1264                final CountDownLatch transportTerminationLatch,
1265                final long total,
1266                final Runnable updateRunnable) {
1267            this.file = file;
1268            this.transportSecurity = transportSecurity;
1269            this.transportTerminationLatch = transportTerminationLatch;
1270            this.total = transportSecurity == null ? total : (total + 16);
1271            this.updateRunnable = updateRunnable;
1272        }
1273
1274        static void closeTransport(final Closeable stream) {
1275            try {
1276                stream.close();
1277            } catch (final IOException e) {
1278                Log.d(Config.LOGTAG, "transport has already been closed. good");
1279            }
1280        }
1281
1282        public int getProgress() {
1283            return Ints.saturatedCast(Math.round((1.0 * transmitted / total) * 100));
1284        }
1285
1286        public void updateProgress() {
1287            final int current = getProgress();
1288            final boolean update;
1289            synchronized (this) {
1290                if (this.progress != current) {
1291                    this.progress = current;
1292                    update = true;
1293                } else {
1294                    update = false;
1295                }
1296                if (update) {
1297                    this.updateRunnable.run();
1298                }
1299            }
1300        }
1301
1302        protected void awaitTransportTermination() {
1303            try {
1304                this.transportTerminationLatch.await();
1305            } catch (final InterruptedException ignored) {
1306                return;
1307            }
1308            Log.d(Config.LOGTAG, getClass().getSimpleName() + " says Goodbye!");
1309        }
1310    }
1311
1312    private static class FileTransmitter extends AbstractFileTransceiver {
1313
1314        private final OutputStream outputStream;
1315
1316        private FileTransmitter(
1317                final File file,
1318                final TransportSecurity transportSecurity,
1319                final OutputStream outputStream,
1320                final CountDownLatch transportTerminationLatch,
1321                final long total,
1322                final Runnable updateRunnable) {
1323            super(file, transportSecurity, transportTerminationLatch, total, updateRunnable);
1324            this.outputStream = outputStream;
1325        }
1326
1327        private InputStream openFileInputStream() throws FileNotFoundException {
1328            final var fileInputStream = new FileInputStream(this.file);
1329            if (this.transportSecurity == null) {
1330                return fileInputStream;
1331            } else {
1332                final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
1333                cipher.init(
1334                        true,
1335                        new AEADParameters(
1336                                new KeyParameter(transportSecurity.key),
1337                                128,
1338                                transportSecurity.iv));
1339                Log.d(Config.LOGTAG, "setting up CipherInputStream");
1340                return new CipherInputStream(fileInputStream, cipher);
1341            }
1342        }
1343
1344        @Override
1345        public void run() {
1346            Log.d(Config.LOGTAG, "file transmitter attempting to send " + total + " bytes");
1347            final var sha1Hasher = Hashing.sha1().newHasher();
1348            final var sha256Hasher = Hashing.sha256().newHasher();
1349            try (final var fileInputStream = openFileInputStream()) {
1350                final var buffer = new byte[4096];
1351                while (total - transmitted > 0) {
1352                    final int count = fileInputStream.read(buffer);
1353                    if (count == -1) {
1354                        throw new EOFException(
1355                                String.format("reached EOF after %d/%d", transmitted, total));
1356                    }
1357                    outputStream.write(buffer, 0, count);
1358                    sha1Hasher.putBytes(buffer, 0, count);
1359                    sha256Hasher.putBytes(buffer, 0, count);
1360                    transmitted += count;
1361                    updateProgress();
1362                }
1363                outputStream.flush();
1364                Log.d(
1365                        Config.LOGTAG,
1366                        "transmitted " + transmitted + " bytes from " + file.getAbsolutePath());
1367                final List<FileTransferDescription.Hash> hashes =
1368                        ImmutableList.of(
1369                                new FileTransferDescription.Hash(
1370                                        sha1Hasher.hash().asBytes(),
1371                                        FileTransferDescription.Algorithm.SHA_1),
1372                                new FileTransferDescription.Hash(
1373                                        sha256Hasher.hash().asBytes(),
1374                                        FileTransferDescription.Algorithm.SHA_256));
1375                complete.set(hashes);
1376            } catch (final Exception e) {
1377                complete.setException(e);
1378            }
1379            // the transport implementations backed by PipedOutputStreams do not like it when
1380            // the writing Thread (this thread) goes away. so we just wait until the other peer
1381            // has received our file and we are shutting down the transport
1382            Log.d(Config.LOGTAG, "waiting for transport to terminate before stopping thread");
1383            awaitTransportTermination();
1384            closeTransport(outputStream);
1385        }
1386    }
1387
1388    private static class FileReceiver extends AbstractFileTransceiver {
1389
1390        private final InputStream inputStream;
1391
1392        private FileReceiver(
1393                final File file,
1394                final TransportSecurity transportSecurity,
1395                final InputStream inputStream,
1396                final CountDownLatch transportTerminationLatch,
1397                final long total,
1398                final Runnable updateRunnable) {
1399            super(file, transportSecurity, transportTerminationLatch, total, updateRunnable);
1400            this.inputStream = inputStream;
1401        }
1402
1403        private OutputStream openFileOutputStream() throws FileNotFoundException {
1404            final var directory = this.file.getParentFile();
1405            if (directory != null && directory.mkdirs()) {
1406                Log.d(Config.LOGTAG, "created directory " + directory.getAbsolutePath());
1407            }
1408            final var fileOutputStream = new FileOutputStream(this.file);
1409            if (this.transportSecurity == null) {
1410                return fileOutputStream;
1411            } else {
1412                final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
1413                cipher.init(
1414                        false,
1415                        new AEADParameters(
1416                                new KeyParameter(transportSecurity.key),
1417                                128,
1418                                transportSecurity.iv));
1419                Log.d(Config.LOGTAG, "setting up CipherOutputStream");
1420                return new CipherOutputStream(fileOutputStream, cipher);
1421            }
1422        }
1423
1424        @Override
1425        public void run() {
1426            Log.d(Config.LOGTAG, "file receiver attempting to receive " + total + " bytes");
1427            final var sha1Hasher = Hashing.sha1().newHasher();
1428            final var sha256Hasher = Hashing.sha256().newHasher();
1429            try (final var fileOutputStream = openFileOutputStream()) {
1430                final var buffer = new byte[4096];
1431                while (total - transmitted > 0) {
1432                    final int count = inputStream.read(buffer);
1433                    if (count == -1) {
1434                        throw new EOFException(
1435                                String.format("reached EOF after %d/%d", transmitted, total));
1436                    }
1437                    fileOutputStream.write(buffer, 0, count);
1438                    sha1Hasher.putBytes(buffer, 0, count);
1439                    sha256Hasher.putBytes(buffer, 0, count);
1440                    transmitted += count;
1441                    updateProgress();
1442                }
1443                Log.d(
1444                        Config.LOGTAG,
1445                        "written " + transmitted + " bytes to " + file.getAbsolutePath());
1446                final List<FileTransferDescription.Hash> hashes =
1447                        ImmutableList.of(
1448                                new FileTransferDescription.Hash(
1449                                        sha1Hasher.hash().asBytes(),
1450                                        FileTransferDescription.Algorithm.SHA_1),
1451                                new FileTransferDescription.Hash(
1452                                        sha256Hasher.hash().asBytes(),
1453                                        FileTransferDescription.Algorithm.SHA_256));
1454                complete.set(hashes);
1455            } catch (final Exception e) {
1456                complete.setException(e);
1457            }
1458            Log.d(Config.LOGTAG, "waiting for transport to terminate before stopping thread");
1459            awaitTransportTermination();
1460            closeTransport(inputStream);
1461        }
1462    }
1463
1464    private static final class TransportSecurity {
1465        final byte[] key;
1466        final byte[] iv;
1467
1468        private TransportSecurity(byte[] key, byte[] iv) {
1469            this.key = key;
1470            this.iv = iv;
1471        }
1472    }
1473}