JingleFileTransferConnection.java

   1package eu.siacs.conversations.xmpp.jingle;
   2
   3import android.util.Log;
   4import androidx.annotation.NonNull;
   5import com.google.common.base.Preconditions;
   6import com.google.common.base.Strings;
   7import com.google.common.base.Throwables;
   8import com.google.common.collect.ImmutableList;
   9import com.google.common.collect.Iterables;
  10import com.google.common.hash.Hashing;
  11import com.google.common.primitives.Ints;
  12import com.google.common.util.concurrent.FutureCallback;
  13import com.google.common.util.concurrent.Futures;
  14import com.google.common.util.concurrent.ListenableFuture;
  15import com.google.common.util.concurrent.MoreExecutors;
  16import com.google.common.util.concurrent.SettableFuture;
  17import eu.siacs.conversations.Config;
  18import eu.siacs.conversations.crypto.axolotl.XmppAxolotlMessage;
  19import eu.siacs.conversations.entities.Conversation;
  20import eu.siacs.conversations.entities.Message;
  21import eu.siacs.conversations.entities.Transferable;
  22import eu.siacs.conversations.entities.TransferablePlaceholder;
  23import eu.siacs.conversations.services.AbstractConnectionManager;
  24import eu.siacs.conversations.xml.Namespace;
  25import eu.siacs.conversations.xmpp.Jid;
  26import eu.siacs.conversations.xmpp.XmppConnection;
  27import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription;
  28import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo;
  29import eu.siacs.conversations.xmpp.jingle.stanzas.IbbTransportInfo;
  30import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
  31import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
  32import eu.siacs.conversations.xmpp.jingle.stanzas.SocksByteStreamsTransportInfo;
  33import eu.siacs.conversations.xmpp.jingle.stanzas.WebRTCDataChannelTransportInfo;
  34import eu.siacs.conversations.xmpp.jingle.transports.InbandBytestreamsTransport;
  35import eu.siacs.conversations.xmpp.jingle.transports.SocksByteStreamsTransport;
  36import eu.siacs.conversations.xmpp.jingle.transports.Transport;
  37import eu.siacs.conversations.xmpp.jingle.transports.WebRTCDataChannelTransport;
  38import im.conversations.android.xmpp.model.jingle.Jingle;
  39import im.conversations.android.xmpp.model.stanza.Iq;
  40import java.io.Closeable;
  41import java.io.EOFException;
  42import java.io.File;
  43import java.io.FileInputStream;
  44import java.io.FileNotFoundException;
  45import java.io.FileOutputStream;
  46import java.io.IOException;
  47import java.io.InputStream;
  48import java.io.OutputStream;
  49import java.util.Arrays;
  50import java.util.Collections;
  51import java.util.LinkedList;
  52import java.util.List;
  53import java.util.Objects;
  54import java.util.Optional;
  55import java.util.Queue;
  56import java.util.concurrent.CountDownLatch;
  57import org.bouncycastle.crypto.engines.AESEngine;
  58import org.bouncycastle.crypto.io.CipherInputStream;
  59import org.bouncycastle.crypto.io.CipherOutputStream;
  60import org.bouncycastle.crypto.modes.AEADBlockCipher;
  61import org.bouncycastle.crypto.modes.GCMBlockCipher;
  62import org.bouncycastle.crypto.params.AEADParameters;
  63import org.bouncycastle.crypto.params.KeyParameter;
  64import org.webrtc.IceCandidate;
  65
  66public class JingleFileTransferConnection extends AbstractJingleConnection
  67        implements Transport.Callback, Transferable {
  68
  69    private final Message message;
  70
  71    private FileTransferContentMap initiatorFileTransferContentMap;
  72    private FileTransferContentMap responderFileTransferContentMap;
  73
  74    private Transport transport;
  75    private TransportSecurity transportSecurity;
  76    private AbstractFileTransceiver fileTransceiver;
  77
  78    private final Queue<IceCandidate> pendingIncomingIceCandidates = new LinkedList<>();
  79    private boolean acceptedAutomatically = false;
  80
  81    public JingleFileTransferConnection(
  82            final JingleConnectionManager jingleConnectionManager, final Message message) {
  83        super(
  84                jingleConnectionManager,
  85                AbstractJingleConnection.Id.of(message),
  86                message.getConversation().getAccount().getJid());
  87        Preconditions.checkArgument(
  88                message.isFileOrImage(),
  89                "only file or images messages can be transported via jingle");
  90        this.message = message;
  91        this.message.setTransferable(this);
  92        xmppConnectionService.markMessage(message, Message.STATUS_WAITING);
  93    }
  94
  95    public JingleFileTransferConnection(
  96            final JingleConnectionManager jingleConnectionManager,
  97            final Id id,
  98            final Jid initiator) {
  99        super(jingleConnectionManager, id, initiator);
 100        final Conversation conversation =
 101                this.xmppConnectionService.findOrCreateConversation(
 102                        id.account, id.with.asBareJid(), false, false);
 103        this.message = new Message(conversation, "", Message.ENCRYPTION_NONE);
 104        this.message.setRemoteMsgId(id.sessionId);
 105        this.message.setStatus(Message.STATUS_RECEIVED);
 106        this.message.setErrorMessage(null);
 107        this.message.setTransferable(this);
 108    }
 109
 110    @Override
 111    void deliverPacket(final Iq iq) {
 112        final var jingle = iq.getExtension(Jingle.class);
 113        switch (jingle.getAction()) {
 114            case SESSION_ACCEPT -> receiveSessionAccept(iq, jingle);
 115            case SESSION_INITIATE -> receiveSessionInitiate(iq, jingle);
 116            case SESSION_INFO -> receiveSessionInfo(iq, jingle);
 117            case SESSION_TERMINATE -> receiveSessionTerminate(iq, jingle);
 118            case TRANSPORT_ACCEPT -> receiveTransportAccept(iq, jingle);
 119            case TRANSPORT_INFO -> receiveTransportInfo(iq, jingle);
 120            case TRANSPORT_REPLACE -> receiveTransportReplace(iq, jingle);
 121            default -> {
 122                respondOk(iq);
 123                Log.d(
 124                        Config.LOGTAG,
 125                        String.format(
 126                                "%s: received unhandled jingle action %s",
 127                                id.account.getJid().asBareJid(), jingle.getAction()));
 128            }
 129        }
 130    }
 131
 132    public void sendSessionInitialize() {
 133        final ListenableFuture<Optional<XmppAxolotlMessage>> keyTransportMessage;
 134        if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL) {
 135            keyTransportMessage =
 136                    Futures.transform(
 137                            id.account
 138                                    .getAxolotlService()
 139                                    .prepareKeyTransportMessage(requireConversation()),
 140                            Optional::of,
 141                            MoreExecutors.directExecutor());
 142        } else {
 143            keyTransportMessage = Futures.immediateFuture(Optional.empty());
 144        }
 145        Futures.addCallback(
 146                keyTransportMessage,
 147                new FutureCallback<>() {
 148                    @Override
 149                    public void onSuccess(final Optional<XmppAxolotlMessage> xmppAxolotlMessage) {
 150                        sendSessionInitialize(xmppAxolotlMessage.orElse(null));
 151                    }
 152
 153                    @Override
 154                    public void onFailure(@NonNull Throwable throwable) {
 155                        Log.d(Config.LOGTAG, "can not send message");
 156                    }
 157                },
 158                MoreExecutors.directExecutor());
 159    }
 160
 161    private void sendSessionInitialize(final XmppAxolotlMessage xmppAxolotlMessage) {
 162        this.transport = setupTransport();
 163        this.transport.setTransportCallback(this);
 164        final File file = xmppConnectionService.getFileBackend().getFile(message);
 165        final var fileDescription =
 166                new FileTransferDescription.File(
 167                        file.length(),
 168                        file.getName(),
 169                        message.getMimeType(),
 170                        Collections.emptyList());
 171        final var transportInfoFuture = this.transport.asInitialTransportInfo();
 172        Futures.addCallback(
 173                transportInfoFuture,
 174                new FutureCallback<>() {
 175                    @Override
 176                    public void onSuccess(
 177                            final Transport.InitialTransportInfo initialTransportInfo) {
 178                        final FileTransferContentMap contentMap =
 179                                FileTransferContentMap.of(fileDescription, initialTransportInfo);
 180                        sendSessionInitialize(xmppAxolotlMessage, contentMap);
 181                    }
 182
 183                    @Override
 184                    public void onFailure(@NonNull Throwable throwable) {
 185                        Log.d(Config.LOGTAG, "could not prepare transport info", throwable);
 186                    }
 187                },
 188                MoreExecutors.directExecutor());
 189    }
 190
 191    private Conversation requireConversation() {
 192        final var conversational = message.getConversation();
 193        if (conversational instanceof Conversation c) {
 194            return c;
 195        } else {
 196            throw new IllegalStateException("Message had no proper conversation attached");
 197        }
 198    }
 199
 200    private void sendSessionInitialize(
 201            final XmppAxolotlMessage xmppAxolotlMessage, final FileTransferContentMap contentMap) {
 202        if (transition(
 203                State.SESSION_INITIALIZED,
 204                () -> this.initiatorFileTransferContentMap = contentMap)) {
 205            final var iq = contentMap.toJinglePacket(Jingle.Action.SESSION_INITIATE, id.sessionId);
 206            final var jingle = iq.getExtension(Jingle.class);
 207            if (xmppAxolotlMessage != null) {
 208                this.transportSecurity =
 209                        new TransportSecurity(
 210                                xmppAxolotlMessage.getInnerKey(), xmppAxolotlMessage.getIV());
 211                final var contents = jingle.getJingleContents();
 212                final var rawContent =
 213                        contents.get(Iterables.getOnlyElement(contentMap.contents.keySet()));
 214                if (rawContent != null) {
 215                    rawContent.setSecurity(xmppAxolotlMessage);
 216                }
 217            }
 218            iq.setTo(id.with);
 219            xmppConnectionService.sendIqPacket(
 220                    id.account,
 221                    iq,
 222                    (response) -> {
 223                        if (response.getType() == Iq.Type.RESULT) {
 224                            xmppConnectionService.markMessage(message, Message.STATUS_OFFERED);
 225                            return;
 226                        }
 227                        if (response.getType() == Iq.Type.ERROR) {
 228                            handleIqErrorResponse(response);
 229                            return;
 230                        }
 231                        if (response.getType() == Iq.Type.TIMEOUT) {
 232                            handleIqTimeoutResponse(response);
 233                        }
 234                    });
 235            this.transport.readyToSentAdditionalCandidates();
 236        }
 237    }
 238
 239    private void receiveSessionAccept(final Iq jinglePacket, final Jingle jingle) {
 240        Log.d(Config.LOGTAG, "receive file transfer session accept");
 241        if (isResponder()) {
 242            receiveOutOfOrderAction(jinglePacket, Jingle.Action.SESSION_ACCEPT);
 243            return;
 244        }
 245        final FileTransferContentMap contentMap;
 246        try {
 247            contentMap = FileTransferContentMap.of(jingle);
 248            contentMap.requireOnlyFileTransferDescription();
 249        } catch (final RuntimeException e) {
 250            Log.d(
 251                    Config.LOGTAG,
 252                    id.account.getJid().asBareJid() + ": improperly formatted contents",
 253                    Throwables.getRootCause(e));
 254            respondOk(jinglePacket);
 255            terminateTransport();
 256            sendSessionTerminate(Reason.of(e), e.getMessage());
 257            return;
 258        }
 259        receiveSessionAccept(jinglePacket, contentMap);
 260    }
 261
 262    private void receiveSessionAccept(
 263            final Iq 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, Jingle.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 Iq jinglePacket, final Jingle jingle) {
 312        if (isInitiator()) {
 313            receiveOutOfOrderAction(jinglePacket, Jingle.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(jingle);
 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 = jingle.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 Iq 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            // TODO store hashes if there are any
 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, Jingle.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 iq = contentMap.toJinglePacket(Jingle.Action.SESSION_ACCEPT, id.sessionId);
 457        send(iq);
 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        terminateTransport();
 538        final Throwable rootCause = Throwables.getRootCause(throwable);
 539        Log.d(Config.LOGTAG, "unable to send session accept", rootCause);
 540        sendSessionTerminate(Reason.ofThrowable(rootCause), rootCause.getMessage());
 541    }
 542
 543    private void receiveSessionInfo(final Iq jinglePacket, final Jingle jingle) {
 544        respondOk(jinglePacket);
 545        final var sessionInfo = FileTransferDescription.getSessionInfo(jingle);
 546        if (sessionInfo instanceof FileTransferDescription.Checksum checksum) {
 547            receiveSessionInfoChecksum(checksum);
 548        } else if (sessionInfo instanceof FileTransferDescription.Received received) {
 549            receiveSessionInfoReceived(received);
 550        }
 551    }
 552
 553    private void receiveSessionInfoChecksum(final FileTransferDescription.Checksum checksum) {
 554        Log.d(Config.LOGTAG, "received checksum " + checksum);
 555        // TODO check that we are receiver
 556        // TODO store hashes
 557    }
 558
 559    private void receiveSessionInfoReceived(final FileTransferDescription.Received received) {
 560        Log.d(Config.LOGTAG, "peer confirmed received " + received);
 561        // TODO check that we are sender
 562    }
 563
 564    private synchronized void receiveSessionTerminate(final Iq jinglePacket, final Jingle jingle) {
 565        respondOk(jinglePacket);
 566        final Jingle.ReasonWrapper wrapper = jingle.getReason();
 567        final State previous = this.state;
 568        Log.d(
 569                Config.LOGTAG,
 570                id.account.getJid().asBareJid()
 571                        + ": received session terminate reason="
 572                        + wrapper.reason
 573                        + "("
 574                        + Strings.nullToEmpty(wrapper.text)
 575                        + ") while in state "
 576                        + previous);
 577        if (TERMINATED.contains(previous)) {
 578            Log.d(
 579                    Config.LOGTAG,
 580                    id.account.getJid().asBareJid()
 581                            + ": ignoring session terminate because already in "
 582                            + previous);
 583            return;
 584        }
 585        if (isInitiator()) {
 586            this.message.setErrorMessage(
 587                    Strings.isNullOrEmpty(wrapper.text) ? wrapper.reason.toString() : wrapper.text);
 588        }
 589        terminateTransport();
 590        final State target = reasonToState(wrapper.reason);
 591        transitionOrThrow(target);
 592        finish();
 593    }
 594
 595    private void receiveTransportAccept(final Iq jinglePacket, final Jingle jingle) {
 596        if (isResponder()) {
 597            receiveOutOfOrderAction(jinglePacket, Jingle.Action.TRANSPORT_ACCEPT);
 598            return;
 599        }
 600        Log.d(Config.LOGTAG, "receive transport accept " + jinglePacket);
 601        final GenericTransportInfo transportInfo;
 602        try {
 603            transportInfo = FileTransferContentMap.of(jingle).requireOnlyTransportInfo();
 604        } catch (final RuntimeException e) {
 605            Log.d(
 606                    Config.LOGTAG,
 607                    id.account.getJid().asBareJid() + ": improperly formatted contents",
 608                    Throwables.getRootCause(e));
 609            respondOk(jinglePacket);
 610            terminateTransport();
 611            sendSessionTerminate(Reason.of(e), e.getMessage());
 612            return;
 613        }
 614        if (isInState(State.SESSION_ACCEPTED)) {
 615            final var group = jingle.getGroup();
 616            receiveTransportAccept(jinglePacket, new Transport.TransportInfo(transportInfo, group));
 617        } else {
 618            receiveOutOfOrderAction(jinglePacket, Jingle.Action.TRANSPORT_ACCEPT);
 619        }
 620    }
 621
 622    private void receiveTransportAccept(
 623            final Iq jinglePacket, final Transport.TransportInfo transportInfo) {
 624        final FileTransferContentMap remoteContentMap =
 625                getRemoteContentMap().withTransport(transportInfo);
 626        setRemoteContentMap(remoteContentMap);
 627        respondOk(jinglePacket);
 628        final var transport = this.transport;
 629        if (configureTransportWithPeerInfo(transport, remoteContentMap)) {
 630            transport.connect();
 631        } else {
 632            Log.e(
 633                    Config.LOGTAG,
 634                    "Transport in transport-accept did not match our transport-replace");
 635            terminateTransport();
 636            sendSessionTerminate(
 637                    Reason.FAILED_APPLICATION,
 638                    "Transport in transport-accept did not match our transport-replace");
 639        }
 640    }
 641
 642    private void receiveTransportInfo(final Iq jinglePacket, final Jingle jingle) {
 643        final FileTransferContentMap contentMap;
 644        final GenericTransportInfo transportInfo;
 645        try {
 646            contentMap = FileTransferContentMap.of(jingle);
 647            transportInfo = contentMap.requireOnlyTransportInfo();
 648        } catch (final RuntimeException e) {
 649            Log.d(
 650                    Config.LOGTAG,
 651                    id.account.getJid().asBareJid() + ": improperly formatted contents",
 652                    Throwables.getRootCause(e));
 653            respondOk(jinglePacket);
 654            terminateTransport();
 655            sendSessionTerminate(Reason.of(e), e.getMessage());
 656            return;
 657        }
 658        respondOk(jinglePacket);
 659        final var transport = this.transport;
 660        if (transport instanceof SocksByteStreamsTransport socksBytestreamsTransport
 661                && transportInfo
 662                        instanceof SocksByteStreamsTransportInfo socksBytestreamsTransportInfo) {
 663            receiveTransportInfo(socksBytestreamsTransport, socksBytestreamsTransportInfo);
 664        } else if (transport instanceof WebRTCDataChannelTransport webRTCDataChannelTransport
 665                && transportInfo
 666                        instanceof WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) {
 667            receiveTransportInfo(
 668                    Iterables.getOnlyElement(contentMap.contents.keySet()),
 669                    webRTCDataChannelTransport,
 670                    webRTCDataChannelTransportInfo);
 671        } else if (transportInfo
 672                instanceof WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) {
 673            receiveTransportInfo(
 674                    Iterables.getOnlyElement(contentMap.contents.keySet()),
 675                    webRTCDataChannelTransportInfo);
 676        } else {
 677            Log.d(Config.LOGTAG, "could not deliver transport-info to transport");
 678        }
 679    }
 680
 681    private void receiveTransportInfo(
 682            final String contentName,
 683            final WebRTCDataChannelTransport webRTCDataChannelTransport,
 684            final WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) {
 685        final var credentials = webRTCDataChannelTransportInfo.getCredentials();
 686        final var iceCandidates =
 687                WebRTCDataChannelTransport.iceCandidatesOf(
 688                        contentName, credentials, webRTCDataChannelTransportInfo.getCandidates());
 689        final var localContentMap = getLocalContentMap();
 690        if (localContentMap == null) {
 691            Log.d(Config.LOGTAG, "transport not ready. add pending ice candidate");
 692            this.pendingIncomingIceCandidates.addAll(iceCandidates);
 693        } else {
 694            webRTCDataChannelTransport.addIceCandidates(iceCandidates);
 695        }
 696    }
 697
 698    private void receiveTransportInfo(
 699            final String contentName,
 700            final WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) {
 701        final var credentials = webRTCDataChannelTransportInfo.getCredentials();
 702        final var iceCandidates =
 703                WebRTCDataChannelTransport.iceCandidatesOf(
 704                        contentName, credentials, webRTCDataChannelTransportInfo.getCandidates());
 705        this.pendingIncomingIceCandidates.addAll(iceCandidates);
 706    }
 707
 708    private void receiveTransportInfo(
 709            final SocksByteStreamsTransport socksBytestreamsTransport,
 710            final SocksByteStreamsTransportInfo socksBytestreamsTransportInfo) {
 711        final var transportInfo = socksBytestreamsTransportInfo.getTransportInfo();
 712        if (transportInfo instanceof SocksByteStreamsTransportInfo.CandidateError) {
 713            socksBytestreamsTransport.setCandidateError();
 714        } else if (transportInfo
 715                instanceof SocksByteStreamsTransportInfo.CandidateUsed candidateUsed) {
 716            if (!socksBytestreamsTransport.setCandidateUsed(candidateUsed.cid)) {
 717                terminateTransport();
 718                sendSessionTerminate(
 719                        Reason.FAILED_TRANSPORT,
 720                        String.format(
 721                                "Peer is not connected to our candidate %s", candidateUsed.cid));
 722            }
 723        } else if (transportInfo instanceof SocksByteStreamsTransportInfo.Activated activated) {
 724            socksBytestreamsTransport.setProxyActivated(activated.cid);
 725        } else if (transportInfo instanceof SocksByteStreamsTransportInfo.ProxyError) {
 726            socksBytestreamsTransport.setProxyError();
 727        }
 728    }
 729
 730    private void receiveTransportReplace(final Iq jinglePacket, final Jingle jingle) {
 731        if (isInitiator()) {
 732            receiveOutOfOrderAction(jinglePacket, Jingle.Action.TRANSPORT_REPLACE);
 733            return;
 734        }
 735        final GenericTransportInfo transportInfo;
 736        try {
 737            transportInfo = FileTransferContentMap.of(jingle).requireOnlyTransportInfo();
 738        } catch (final RuntimeException e) {
 739            Log.d(
 740                    Config.LOGTAG,
 741                    id.account.getJid().asBareJid() + ": improperly formatted contents",
 742                    Throwables.getRootCause(e));
 743            respondOk(jinglePacket);
 744            terminateTransport();
 745            sendSessionTerminate(Reason.of(e), e.getMessage());
 746            return;
 747        }
 748        if (isInState(State.SESSION_ACCEPTED)) {
 749            receiveTransportReplace(jinglePacket, transportInfo);
 750        } else {
 751            receiveOutOfOrderAction(jinglePacket, Jingle.Action.TRANSPORT_REPLACE);
 752        }
 753    }
 754
 755    private void receiveTransportReplace(
 756            final Iq jinglePacket, final GenericTransportInfo transportInfo) {
 757        respondOk(jinglePacket);
 758        final Transport currentTransport = this.transport;
 759        if (currentTransport != null) {
 760            Log.d(
 761                    Config.LOGTAG,
 762                    "terminating "
 763                            + currentTransport.getClass().getSimpleName()
 764                            + " upon receiving transport-replace");
 765            currentTransport.setTransportCallback(null);
 766            currentTransport.terminate();
 767        }
 768        final Transport nextTransport;
 769        try {
 770            nextTransport = setupTransport(transportInfo);
 771        } catch (final RuntimeException e) {
 772            sendSessionTerminate(Reason.of(e), e.getMessage());
 773            return;
 774        }
 775        this.transport = nextTransport;
 776        Log.d(
 777                Config.LOGTAG,
 778                "replacing transport with " + nextTransport.getClass().getSimpleName());
 779        this.transport.setTransportCallback(this);
 780        final var transportInfoFuture = nextTransport.asTransportInfo();
 781        Futures.addCallback(
 782                transportInfoFuture,
 783                new FutureCallback<>() {
 784                    @Override
 785                    public void onSuccess(final Transport.TransportInfo transportWrapper) {
 786                        final FileTransferContentMap contentMap =
 787                                getLocalContentMap().withTransport(transportWrapper);
 788                        sendTransportAccept(contentMap);
 789                    }
 790
 791                    @Override
 792                    public void onFailure(@NonNull Throwable throwable) {
 793                        // transition into application failed (analogues to failureToAccept
 794                    }
 795                },
 796                MoreExecutors.directExecutor());
 797    }
 798
 799    private void sendTransportAccept(final FileTransferContentMap contentMap) {
 800        setLocalContentMap(contentMap);
 801        final var iq =
 802                contentMap
 803                        .transportInfo()
 804                        .toJinglePacket(Jingle.Action.TRANSPORT_ACCEPT, id.sessionId);
 805        send(iq);
 806        transport.connect();
 807    }
 808
 809    protected void sendSessionTerminate(final Reason reason, final String text) {
 810        if (isInitiator()) {
 811            this.message.setErrorMessage(Strings.isNullOrEmpty(text) ? reason.toString() : text);
 812        }
 813        sendSessionTerminate(reason, text, null);
 814    }
 815
 816    private FileTransferContentMap getLocalContentMap() {
 817        return isInitiator()
 818                ? this.initiatorFileTransferContentMap
 819                : this.responderFileTransferContentMap;
 820    }
 821
 822    private FileTransferContentMap getRemoteContentMap() {
 823        return isInitiator()
 824                ? this.responderFileTransferContentMap
 825                : this.initiatorFileTransferContentMap;
 826    }
 827
 828    private void setLocalContentMap(final FileTransferContentMap contentMap) {
 829        if (isInitiator()) {
 830            this.initiatorFileTransferContentMap = contentMap;
 831        } else {
 832            this.responderFileTransferContentMap = contentMap;
 833        }
 834    }
 835
 836    private void setRemoteContentMap(final FileTransferContentMap contentMap) {
 837        if (isInitiator()) {
 838            this.responderFileTransferContentMap = contentMap;
 839        } else {
 840            this.initiatorFileTransferContentMap = contentMap;
 841        }
 842    }
 843
 844    public Transport getTransport() {
 845        return this.transport;
 846    }
 847
 848    @Override
 849    protected void terminateTransport() {
 850        final var transport = this.transport;
 851        if (transport == null) {
 852            return;
 853        }
 854        // TODO consider setting transport callback to null. requires transport to handle null
 855        // callback
 856        // transport.setTransportCallback(null);
 857        transport.terminate();
 858        this.transport = null;
 859    }
 860
 861    @Override
 862    void notifyRebound() {}
 863
 864    @Override
 865    public void onTransportEstablished() {
 866        Log.d(Config.LOGTAG, "transport established");
 867        final AbstractFileTransceiver fileTransceiver;
 868        try {
 869            fileTransceiver = setupTransceiver(isResponder());
 870        } catch (final Exception e) {
 871            terminateTransport();
 872            if (isTerminated()) {
 873                Log.d(
 874                        Config.LOGTAG,
 875                        "failed to set up file transceiver but session has already been"
 876                                + " terminated");
 877            } else {
 878                Log.d(Config.LOGTAG, "failed to set up file transceiver", e);
 879                sendSessionTerminate(Reason.ofThrowable(e), e.getMessage());
 880            }
 881            return;
 882        }
 883        this.fileTransceiver = fileTransceiver;
 884        final var fileTransceiverThread = new Thread(fileTransceiver);
 885        fileTransceiverThread.start();
 886        Futures.addCallback(
 887                fileTransceiver.complete,
 888                new FutureCallback<>() {
 889                    @Override
 890                    public void onSuccess(final List<FileTransferDescription.Hash> hashes) {
 891                        onFileTransmissionComplete(hashes);
 892                    }
 893
 894                    @Override
 895                    public void onFailure(@NonNull final Throwable throwable) {
 896                        // The state transition in here should be synchronized to not race with the
 897                        // state transition in receiveSessionTerminate
 898                        synchronized (JingleFileTransferConnection.this) {
 899                            onFileTransmissionFailed(throwable);
 900                        }
 901                    }
 902                },
 903                MoreExecutors.directExecutor());
 904    }
 905
 906    private void onFileTransmissionComplete(final List<FileTransferDescription.Hash> hashes) {
 907        // TODO if we ever support receiving files this should become isSending(); isReceiving()
 908        if (isInitiator()) {
 909            sendSessionInfoChecksum(hashes);
 910        } else {
 911            Log.d(Config.LOGTAG, "file transfer complete " + hashes);
 912            // TODO compare with stored file hashes
 913            sendFileSessionInfoReceived();
 914            terminateTransport();
 915            messageReceivedSuccess();
 916            sendSessionTerminate(Reason.SUCCESS, null);
 917        }
 918    }
 919
 920    private void messageReceivedSuccess() {
 921        this.message.setTransferable(null);
 922        xmppConnectionService.getFileBackend().updateFileParams(message);
 923        xmppConnectionService.databaseBackend.createMessage(message);
 924        final File file = xmppConnectionService.getFileBackend().getFile(message);
 925        if (acceptedAutomatically) {
 926            message.markUnread();
 927            if (message.getEncryption() == Message.ENCRYPTION_PGP) {
 928                id.account.getPgpDecryptionService().decrypt(message, true);
 929            } else {
 930                xmppConnectionService
 931                        .getFileBackend()
 932                        .updateMediaScanner(
 933                                file,
 934                                () ->
 935                                        JingleFileTransferConnection.this
 936                                                .xmppConnectionService
 937                                                .getNotificationService()
 938                                                .push(message));
 939            }
 940        } else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
 941            id.account.getPgpDecryptionService().decrypt(message, false);
 942        } else {
 943            xmppConnectionService.getFileBackend().updateMediaScanner(file);
 944        }
 945    }
 946
 947    private void onFileTransmissionFailed(final Throwable throwable) {
 948        if (isTerminated()) {
 949            Log.d(
 950                    Config.LOGTAG,
 951                    "file transfer failed but session is already terminated",
 952                    throwable);
 953        } else {
 954            terminateTransport();
 955            Log.d(Config.LOGTAG, "on file transmission failed", throwable);
 956            sendSessionTerminate(Reason.CONNECTIVITY_ERROR, null);
 957        }
 958    }
 959
 960    private AbstractFileTransceiver setupTransceiver(final boolean receiving) throws IOException {
 961        final var transport = this.transport;
 962        if (transport == null) {
 963            throw new IOException("No transport configured");
 964        }
 965        final var fileDescription = getLocalContentMap().requireOnlyFile();
 966        final File file = xmppConnectionService.getFileBackend().getFile(message);
 967        final Runnable updateRunnable = () -> jingleConnectionManager.updateConversationUi(false);
 968        if (receiving) {
 969            return new FileReceiver(
 970                    file,
 971                    this.transportSecurity,
 972                    transport.getInputStream(),
 973                    transport.getTerminationLatch(),
 974                    fileDescription.size,
 975                    updateRunnable);
 976        } else {
 977            return new FileTransmitter(
 978                    file,
 979                    this.transportSecurity,
 980                    transport.getOutputStream(),
 981                    transport.getTerminationLatch(),
 982                    fileDescription.size,
 983                    updateRunnable);
 984        }
 985    }
 986
 987    private void sendFileSessionInfoReceived() {
 988        final var contentMap = getLocalContentMap();
 989        final String name = Iterables.getOnlyElement(contentMap.contents.keySet());
 990        sendSessionInfo(new FileTransferDescription.Received(name));
 991    }
 992
 993    private void sendSessionInfoChecksum(List<FileTransferDescription.Hash> hashes) {
 994        final var contentMap = getLocalContentMap();
 995        final String name = Iterables.getOnlyElement(contentMap.contents.keySet());
 996        sendSessionInfo(new FileTransferDescription.Checksum(name, hashes));
 997    }
 998
 999    private void sendSessionInfo(final FileTransferDescription.SessionInfo sessionInfo) {
1000        final var iq = new Iq(Iq.Type.SET);
1001        final var jinglePacket =
1002                iq.addExtension(new Jingle(Jingle.Action.SESSION_INFO, this.id.sessionId));
1003        jinglePacket.addChild(sessionInfo.asElement());
1004        send(iq);
1005    }
1006
1007    @Override
1008    public void onTransportSetupFailed() {
1009        final var transport = this.transport;
1010        if (transport == null) {
1011            synchronized (this) {
1012                // this can happen on IQ timeouts
1013                if (isTerminated()) {
1014                    return;
1015                }
1016                sendSessionTerminate(Reason.FAILED_APPLICATION, null);
1017            }
1018            return;
1019        }
1020        Log.d(Config.LOGTAG, "onTransportSetupFailed");
1021        final var isTransportInBand = transport instanceof InbandBytestreamsTransport;
1022        if (isTransportInBand) {
1023            terminateTransport();
1024            sendSessionTerminate(Reason.CONNECTIVITY_ERROR, "Failed to setup IBB transport");
1025            return;
1026        }
1027        // terminate the current transport
1028        transport.terminate();
1029        if (isInitiator()) {
1030            this.transport = setupLastResortTransport();
1031            Log.d(
1032                    Config.LOGTAG,
1033                    "replacing transport with " + this.transport.getClass().getSimpleName());
1034            this.transport.setTransportCallback(this);
1035            final var transportInfoFuture = this.transport.asTransportInfo();
1036            Futures.addCallback(
1037                    transportInfoFuture,
1038                    new FutureCallback<>() {
1039                        @Override
1040                        public void onSuccess(final Transport.TransportInfo transportWrapper) {
1041                            final FileTransferContentMap contentMap = getLocalContentMap();
1042                            sendTransportReplace(contentMap.withTransport(transportWrapper));
1043                        }
1044
1045                        @Override
1046                        public void onFailure(@NonNull Throwable throwable) {
1047                            // TODO send application failure;
1048                        }
1049                    },
1050                    MoreExecutors.directExecutor());
1051
1052        } else {
1053            Log.d(Config.LOGTAG, "transport setup failed. waiting for initiator to replace");
1054        }
1055    }
1056
1057    private void sendTransportReplace(final FileTransferContentMap contentMap) {
1058        setLocalContentMap(contentMap);
1059        final var iq =
1060                contentMap
1061                        .transportInfo()
1062                        .toJinglePacket(Jingle.Action.TRANSPORT_REPLACE, id.sessionId);
1063        send(iq);
1064    }
1065
1066    @Override
1067    public void onAdditionalCandidate(
1068            final String contentName, final Transport.Candidate candidate) {
1069        if (candidate instanceof IceUdpTransportInfo.Candidate iceCandidate) {
1070            sendTransportInfo(contentName, iceCandidate);
1071        }
1072    }
1073
1074    public void sendTransportInfo(
1075            final String contentName, final IceUdpTransportInfo.Candidate candidate) {
1076        final FileTransferContentMap transportInfo;
1077        try {
1078            final FileTransferContentMap rtpContentMap = getLocalContentMap();
1079            transportInfo = rtpContentMap.transportInfo(contentName, candidate);
1080        } catch (final Exception e) {
1081            Log.d(
1082                    Config.LOGTAG,
1083                    id.account.getJid().asBareJid()
1084                            + ": unable to prepare transport-info from candidate for content="
1085                            + contentName);
1086            return;
1087        }
1088        final Iq iq = transportInfo.toJinglePacket(Jingle.Action.TRANSPORT_INFO, id.sessionId);
1089        send(iq);
1090    }
1091
1092    @Override
1093    public void onCandidateUsed(
1094            final String streamId, final SocksByteStreamsTransport.Candidate candidate) {
1095        final FileTransferContentMap contentMap = getLocalContentMap();
1096        if (contentMap == null) {
1097            Log.e(Config.LOGTAG, "local content map is null on candidate used");
1098            return;
1099        }
1100        final var iq =
1101                contentMap
1102                        .candidateUsed(streamId, candidate.cid)
1103                        .toJinglePacket(Jingle.Action.TRANSPORT_INFO, id.sessionId);
1104        Log.d(Config.LOGTAG, "sending candidate used " + iq);
1105        send(iq);
1106    }
1107
1108    @Override
1109    public void onCandidateError(final String streamId) {
1110        final FileTransferContentMap contentMap = getLocalContentMap();
1111        if (contentMap == null) {
1112            Log.e(Config.LOGTAG, "local content map is null on candidate used");
1113            return;
1114        }
1115        final var iq =
1116                contentMap
1117                        .candidateError(streamId)
1118                        .toJinglePacket(Jingle.Action.TRANSPORT_INFO, id.sessionId);
1119        Log.d(Config.LOGTAG, "sending candidate error " + iq);
1120        send(iq);
1121    }
1122
1123    @Override
1124    public void onProxyActivated(String streamId, SocksByteStreamsTransport.Candidate candidate) {
1125        final FileTransferContentMap contentMap = getLocalContentMap();
1126        if (contentMap == null) {
1127            Log.e(Config.LOGTAG, "local content map is null on candidate used");
1128            return;
1129        }
1130        final var iq =
1131                contentMap
1132                        .proxyActivated(streamId, candidate.cid)
1133                        .toJinglePacket(Jingle.Action.TRANSPORT_INFO, id.sessionId);
1134        send(iq);
1135    }
1136
1137    @Override
1138    protected boolean transition(final State target, final Runnable runnable) {
1139        final boolean transitioned = super.transition(target, runnable);
1140        if (transitioned && isInitiator()) {
1141            Log.d(Config.LOGTAG, "running mark message hooks");
1142            if (target == State.SESSION_ACCEPTED) {
1143                xmppConnectionService.markMessage(message, Message.STATUS_UNSEND);
1144            } else if (target == State.TERMINATED_SUCCESS) {
1145                xmppConnectionService.markMessage(message, Message.STATUS_SEND_RECEIVED);
1146            } else if (TERMINATED.contains(target)) {
1147                xmppConnectionService.markMessage(
1148                        message, Message.STATUS_SEND_FAILED, message.getErrorMessage());
1149            } else {
1150                xmppConnectionService.updateConversationUi();
1151            }
1152        } else {
1153            if (Arrays.asList(State.TERMINATED_CANCEL_OR_TIMEOUT, State.TERMINATED_DECLINED_OR_BUSY)
1154                    .contains(target)) {
1155                this.message.setTransferable(
1156                        new TransferablePlaceholder(Transferable.STATUS_CANCELLED));
1157            } else if (target != State.TERMINATED_SUCCESS && TERMINATED.contains(target)) {
1158                this.message.setTransferable(
1159                        new TransferablePlaceholder(Transferable.STATUS_FAILED));
1160            }
1161            xmppConnectionService.updateConversationUi();
1162        }
1163        return transitioned;
1164    }
1165
1166    @Override
1167    protected void finish() {
1168        if (transport != null) {
1169            throw new AssertionError(
1170                    "finish MUST not be called without terminating the transport first");
1171        }
1172        // we don't want to remove TransferablePlaceholder
1173        if (message.getTransferable() instanceof JingleFileTransferConnection) {
1174            Log.d(Config.LOGTAG, "nulling transferable on message");
1175            this.message.setTransferable(null);
1176        }
1177        super.finish();
1178    }
1179
1180    private int getTransferableStatus() {
1181        // status in file transfer is a bit weird. for sending it is mostly handled via
1182        // Message.STATUS_* (offered, unsend (sic) send_received) the transferable status is just
1183        // uploading
1184        // for receiving the message status remains at 'received' but Transferable goes through
1185        // various status
1186        if (isInitiator()) {
1187            return Transferable.STATUS_UPLOADING;
1188        }
1189        final var state = getState();
1190        return switch (state) {
1191            case NULL, SESSION_INITIALIZED, SESSION_INITIALIZED_PRE_APPROVED ->
1192                    Transferable.STATUS_OFFER;
1193            case TERMINATED_APPLICATION_FAILURE,
1194                            TERMINATED_CONNECTIVITY_ERROR,
1195                            TERMINATED_DECLINED_OR_BUSY,
1196                            TERMINATED_SECURITY_ERROR ->
1197                    Transferable.STATUS_FAILED;
1198            case TERMINATED_CANCEL_OR_TIMEOUT -> Transferable.STATUS_CANCELLED;
1199            case SESSION_ACCEPTED -> Transferable.STATUS_DOWNLOADING;
1200            default -> Transferable.STATUS_UNKNOWN;
1201        };
1202    }
1203
1204    // these methods are for interacting with 'Transferable' - we might want to remove the concept
1205    // at some point
1206
1207    @Override
1208    public boolean start() {
1209        Log.d(Config.LOGTAG, "user pressed start()");
1210        // TODO there is a 'connected' check apparently?
1211        if (isInState(State.SESSION_INITIALIZED)) {
1212            sendSessionAccept();
1213        }
1214        return true;
1215    }
1216
1217    @Override
1218    public int getStatus() {
1219        return getTransferableStatus();
1220    }
1221
1222    @Override
1223    public Long getFileSize() {
1224        final var transceiver = this.fileTransceiver;
1225        if (transceiver != null) {
1226            return transceiver.total;
1227        }
1228        final var contentMap = this.initiatorFileTransferContentMap;
1229        if (contentMap != null) {
1230            return contentMap.requireOnlyFile().size;
1231        }
1232        return null;
1233    }
1234
1235    @Override
1236    public int getProgress() {
1237        final var transceiver = this.fileTransceiver;
1238        return transceiver != null ? transceiver.getProgress() : 0;
1239    }
1240
1241    @Override
1242    public void cancel() {
1243        if (stopFileTransfer()) {
1244            Log.d(Config.LOGTAG, "user has stopped file transfer");
1245        } else {
1246            Log.d(Config.LOGTAG, "user pressed cancel but file transfer was already terminated?");
1247        }
1248    }
1249
1250    private boolean stopFileTransfer() {
1251        if (isInitiator()) {
1252            return stopFileTransfer(Reason.CANCEL);
1253        } else {
1254            return stopFileTransfer(Reason.DECLINE);
1255        }
1256    }
1257
1258    private boolean stopFileTransfer(final Reason reason) {
1259        final State target = reasonToState(reason);
1260        if (transition(target)) {
1261            // we change state before terminating transport so we don't consume the following
1262            // IOException and turn it into a connectivity error
1263
1264            if (isInitiator() && reason == Reason.CANCEL) {
1265                // message hooks have already run so we need to mark to persist the 'cancelled'
1266                // status
1267                xmppConnectionService.markMessage(
1268                        message, Message.STATUS_SEND_FAILED, Message.ERROR_MESSAGE_CANCELLED);
1269            }
1270            terminateTransport();
1271            final Iq iq = new Iq(Iq.Type.SET);
1272            final var jingle =
1273                    iq.addExtension(new Jingle(Jingle.Action.SESSION_TERMINATE, id.sessionId));
1274            jingle.setReason(reason, "User requested to stop file transfer");
1275            send(iq);
1276            finish();
1277            return true;
1278        } else {
1279            return false;
1280        }
1281    }
1282
1283    private abstract static class AbstractFileTransceiver implements Runnable {
1284
1285        protected final SettableFuture<List<FileTransferDescription.Hash>> complete =
1286                SettableFuture.create();
1287
1288        protected final File file;
1289        protected final TransportSecurity transportSecurity;
1290
1291        protected final CountDownLatch transportTerminationLatch;
1292        protected final long total;
1293        protected long transmitted = 0;
1294        private int progress = Integer.MIN_VALUE;
1295        private final Runnable updateRunnable;
1296
1297        private AbstractFileTransceiver(
1298                final File file,
1299                final TransportSecurity transportSecurity,
1300                final CountDownLatch transportTerminationLatch,
1301                final long total,
1302                final Runnable updateRunnable) {
1303            this.file = file;
1304            this.transportSecurity = transportSecurity;
1305            this.transportTerminationLatch = transportTerminationLatch;
1306            this.total =
1307                    transportSecurity == null ? total : (total + GCM_AUTHENTICATION_TAG_LENGTH);
1308            this.updateRunnable = updateRunnable;
1309        }
1310
1311        static void closeTransport(final Closeable stream) {
1312            try {
1313                stream.close();
1314            } catch (final IOException e) {
1315                Log.d(Config.LOGTAG, "transport has already been closed. good");
1316            }
1317        }
1318
1319        public int getProgress() {
1320            return Ints.saturatedCast(Math.round((1.0 * transmitted / total) * 100));
1321        }
1322
1323        public void updateProgress() {
1324            final int current = getProgress();
1325            final boolean update;
1326            synchronized (this) {
1327                if (this.progress != current) {
1328                    this.progress = current;
1329                    update = true;
1330                } else {
1331                    update = false;
1332                }
1333                if (update) {
1334                    this.updateRunnable.run();
1335                }
1336            }
1337        }
1338
1339        protected void awaitTransportTermination() {
1340            try {
1341                this.transportTerminationLatch.await();
1342            } catch (final InterruptedException ignored) {
1343                return;
1344            }
1345            Log.d(Config.LOGTAG, getClass().getSimpleName() + " says Goodbye!");
1346        }
1347    }
1348
1349    private static class FileTransmitter extends AbstractFileTransceiver {
1350
1351        private final OutputStream outputStream;
1352
1353        private FileTransmitter(
1354                final File file,
1355                final TransportSecurity transportSecurity,
1356                final OutputStream outputStream,
1357                final CountDownLatch transportTerminationLatch,
1358                final long total,
1359                final Runnable updateRunnable) {
1360            super(file, transportSecurity, transportTerminationLatch, total, updateRunnable);
1361            this.outputStream = outputStream;
1362        }
1363
1364        private InputStream openFileInputStream() throws FileNotFoundException {
1365            final var fileInputStream = new FileInputStream(this.file);
1366            if (this.transportSecurity == null) {
1367                return fileInputStream;
1368            } else {
1369                final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
1370                cipher.init(
1371                        true,
1372                        new AEADParameters(
1373                                new KeyParameter(transportSecurity.key),
1374                                128,
1375                                transportSecurity.iv));
1376                Log.d(Config.LOGTAG, "setting up CipherInputStream");
1377                return new CipherInputStream(fileInputStream, cipher);
1378            }
1379        }
1380
1381        @Override
1382        public void run() {
1383            Log.d(Config.LOGTAG, "file transmitter attempting to send " + total + " bytes");
1384            final var sha1Hasher = Hashing.sha1().newHasher();
1385            final var sha256Hasher = Hashing.sha256().newHasher();
1386            try (final var fileInputStream = openFileInputStream()) {
1387                final var buffer = new byte[4096];
1388                while (total - transmitted > 0) {
1389                    final int count = fileInputStream.read(buffer);
1390                    if (count == -1) {
1391                        throw new EOFException(
1392                                String.format("reached EOF after %d/%d", transmitted, total));
1393                    }
1394                    outputStream.write(buffer, 0, count);
1395                    sha1Hasher.putBytes(buffer, 0, count);
1396                    sha256Hasher.putBytes(buffer, 0, count);
1397                    transmitted += count;
1398                    updateProgress();
1399                }
1400                outputStream.flush();
1401                Log.d(
1402                        Config.LOGTAG,
1403                        "transmitted " + transmitted + " bytes from " + file.getAbsolutePath());
1404                final List<FileTransferDescription.Hash> hashes =
1405                        ImmutableList.of(
1406                                new FileTransferDescription.Hash(
1407                                        sha1Hasher.hash().asBytes(),
1408                                        FileTransferDescription.Algorithm.SHA_1),
1409                                new FileTransferDescription.Hash(
1410                                        sha256Hasher.hash().asBytes(),
1411                                        FileTransferDescription.Algorithm.SHA_256));
1412                complete.set(hashes);
1413            } catch (final Exception e) {
1414                complete.setException(e);
1415            }
1416            // the transport implementations backed by PipedOutputStreams do not like it when
1417            // the writing Thread (this thread) goes away. so we just wait until the other peer
1418            // has received our file and we are shutting down the transport
1419            Log.d(Config.LOGTAG, "waiting for transport to terminate before stopping thread");
1420            awaitTransportTermination();
1421            closeTransport(outputStream);
1422        }
1423    }
1424
1425    private static class FileReceiver extends AbstractFileTransceiver {
1426
1427        private final InputStream inputStream;
1428
1429        private FileReceiver(
1430                final File file,
1431                final TransportSecurity transportSecurity,
1432                final InputStream inputStream,
1433                final CountDownLatch transportTerminationLatch,
1434                final long total,
1435                final Runnable updateRunnable) {
1436            super(file, transportSecurity, transportTerminationLatch, total, updateRunnable);
1437            this.inputStream = inputStream;
1438        }
1439
1440        private OutputStream openFileOutputStream() throws FileNotFoundException {
1441            final var directory = this.file.getParentFile();
1442            if (directory != null && directory.mkdirs()) {
1443                Log.d(Config.LOGTAG, "created directory " + directory.getAbsolutePath());
1444            }
1445            final var fileOutputStream = new FileOutputStream(this.file);
1446            if (this.transportSecurity == null) {
1447                return fileOutputStream;
1448            } else {
1449                final var cipher = GCMBlockCipher.newInstance(AESEngine.newInstance());
1450                cipher.init(
1451                        false,
1452                        new AEADParameters(
1453                                new KeyParameter(transportSecurity.key),
1454                                128,
1455                                transportSecurity.iv));
1456                Log.d(Config.LOGTAG, "setting up CipherOutputStream");
1457                return new CipherOutputStream(fileOutputStream, cipher);
1458            }
1459        }
1460
1461        @Override
1462        public void run() {
1463            Log.d(Config.LOGTAG, "file receiver attempting to receive " + total + " bytes");
1464            final var sha1Hasher = Hashing.sha1().newHasher();
1465            final var sha256Hasher = Hashing.sha256().newHasher();
1466            try (final var fileOutputStream = openFileOutputStream()) {
1467                final var buffer = new byte[4096];
1468                while (total - transmitted > 0) {
1469                    final int count = inputStream.read(buffer);
1470                    if (count == -1) {
1471                        throw new EOFException(
1472                                String.format("reached EOF after %d/%d", transmitted, total));
1473                    }
1474                    fileOutputStream.write(buffer, 0, count);
1475                    sha1Hasher.putBytes(buffer, 0, count);
1476                    sha256Hasher.putBytes(buffer, 0, count);
1477                    transmitted += count;
1478                    updateProgress();
1479                }
1480                Log.d(
1481                        Config.LOGTAG,
1482                        "written " + transmitted + " bytes to " + file.getAbsolutePath());
1483                final List<FileTransferDescription.Hash> hashes =
1484                        ImmutableList.of(
1485                                new FileTransferDescription.Hash(
1486                                        sha1Hasher.hash().asBytes(),
1487                                        FileTransferDescription.Algorithm.SHA_1),
1488                                new FileTransferDescription.Hash(
1489                                        sha256Hasher.hash().asBytes(),
1490                                        FileTransferDescription.Algorithm.SHA_256));
1491                complete.set(hashes);
1492            } catch (final Exception e) {
1493                complete.setException(e);
1494            }
1495            Log.d(Config.LOGTAG, "waiting for transport to terminate before stopping thread");
1496            awaitTransportTermination();
1497            closeTransport(inputStream);
1498        }
1499    }
1500
1501    private static final class TransportSecurity {
1502        final byte[] key;
1503        final byte[] iv;
1504
1505        private TransportSecurity(byte[] key, byte[] iv) {
1506            this.key = key;
1507            this.iv = iv;
1508        }
1509    }
1510}