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