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                },
 186                MoreExecutors.directExecutor());
 187    }
 188
 189    private Conversation requireConversation() {
 190        final var conversational = message.getConversation();
 191        if (conversational instanceof Conversation c) {
 192            return c;
 193        } else {
 194            throw new IllegalStateException("Message had no proper conversation attached");
 195        }
 196    }
 197
 198    private void sendSessionInitialize(
 199            final XmppAxolotlMessage xmppAxolotlMessage, final FileTransferContentMap contentMap) {
 200        if (transition(
 201                State.SESSION_INITIALIZED,
 202                () -> this.initiatorFileTransferContentMap = contentMap)) {
 203            final var iq = contentMap.toJinglePacket(Jingle.Action.SESSION_INITIATE, id.sessionId);
 204            final var jingle = iq.getExtension(Jingle.class);
 205            if (xmppAxolotlMessage != null) {
 206                this.transportSecurity =
 207                        new TransportSecurity(
 208                                xmppAxolotlMessage.getInnerKey(), xmppAxolotlMessage.getIV());
 209                final var contents = jingle.getJingleContents();
 210                final var rawContent =
 211                        contents.get(Iterables.getOnlyElement(contentMap.contents.keySet()));
 212                if (rawContent != null) {
 213                    rawContent.setSecurity(xmppAxolotlMessage);
 214                }
 215            }
 216            iq.setTo(id.with);
 217            xmppConnectionService.sendIqPacket(
 218                    id.account,
 219                    iq,
 220                    (response) -> {
 221                        if (response.getType() == Iq.Type.RESULT) {
 222                            xmppConnectionService.markMessage(message, Message.STATUS_OFFERED);
 223                            return;
 224                        }
 225                        if (response.getType() == Iq.Type.ERROR) {
 226                            handleIqErrorResponse(response);
 227                            return;
 228                        }
 229                        if (response.getType() == Iq.Type.TIMEOUT) {
 230                            handleIqTimeoutResponse(response);
 231                        }
 232                    });
 233            this.transport.readyToSentAdditionalCandidates();
 234        }
 235    }
 236
 237    private void receiveSessionAccept(final Iq jinglePacket, final Jingle jingle) {
 238        Log.d(Config.LOGTAG, "receive file transfer session accept");
 239        if (isResponder()) {
 240            receiveOutOfOrderAction(jinglePacket, Jingle.Action.SESSION_ACCEPT);
 241            return;
 242        }
 243        final FileTransferContentMap contentMap;
 244        try {
 245            contentMap = FileTransferContentMap.of(jingle);
 246            contentMap.requireOnlyFileTransferDescription();
 247        } catch (final RuntimeException e) {
 248            Log.d(
 249                    Config.LOGTAG,
 250                    id.account.getJid().asBareJid() + ": improperly formatted contents",
 251                    Throwables.getRootCause(e));
 252            respondOk(jinglePacket);
 253            terminateTransport();
 254            sendSessionTerminate(Reason.of(e), e.getMessage());
 255            return;
 256        }
 257        receiveSessionAccept(jinglePacket, contentMap);
 258    }
 259
 260    private void receiveSessionAccept(
 261            final Iq jinglePacket, final FileTransferContentMap contentMap) {
 262        if (transition(State.SESSION_ACCEPTED, () -> setRemoteContentMap(contentMap))) {
 263            respondOk(jinglePacket);
 264            final var transport = this.transport;
 265            if (configureTransportWithPeerInfo(transport, contentMap)) {
 266                transport.connect();
 267            } else {
 268                Log.e(
 269                        Config.LOGTAG,
 270                        "Transport in session accept did not match our session-initialize");
 271                terminateTransport();
 272                sendSessionTerminate(
 273                        Reason.FAILED_APPLICATION,
 274                        "Transport in session accept did not match our session-initialize");
 275            }
 276        } else {
 277            Log.d(
 278                    Config.LOGTAG,
 279                    id.account.getJid().asBareJid() + ": receive out of order session-accept");
 280            receiveOutOfOrderAction(jinglePacket, Jingle.Action.SESSION_ACCEPT);
 281        }
 282    }
 283
 284    private static boolean configureTransportWithPeerInfo(
 285            final Transport transport, final FileTransferContentMap contentMap) {
 286        final GenericTransportInfo transportInfo = contentMap.requireOnlyTransportInfo();
 287        if (transport instanceof WebRTCDataChannelTransport webRTCDataChannelTransport
 288                && transportInfo instanceof WebRTCDataChannelTransportInfo) {
 289            webRTCDataChannelTransport.setResponderDescription(SessionDescription.of(contentMap));
 290            return true;
 291        } else if (transport instanceof SocksByteStreamsTransport socksBytestreamsTransport
 292                && transportInfo
 293                        instanceof SocksByteStreamsTransportInfo socksBytestreamsTransportInfo) {
 294            socksBytestreamsTransport.setTheirCandidates(
 295                    socksBytestreamsTransportInfo.getCandidates());
 296            return true;
 297        } else if (transport instanceof InbandBytestreamsTransport inbandBytestreamsTransport
 298                && transportInfo instanceof IbbTransportInfo ibbTransportInfo) {
 299            final var peerBlockSize = ibbTransportInfo.getBlockSize();
 300            if (peerBlockSize != null) {
 301                inbandBytestreamsTransport.setPeerBlockSize(peerBlockSize);
 302            }
 303            return true;
 304        } else {
 305            return false;
 306        }
 307    }
 308
 309    private void receiveSessionInitiate(final Iq jinglePacket, final Jingle jingle) {
 310        if (isInitiator()) {
 311            receiveOutOfOrderAction(jinglePacket, Jingle.Action.SESSION_INITIATE);
 312            return;
 313        }
 314        Log.d(Config.LOGTAG, "receive session initiate " + jinglePacket);
 315        final FileTransferContentMap contentMap;
 316        final FileTransferDescription.File file;
 317        try {
 318            contentMap = FileTransferContentMap.of(jingle);
 319            contentMap.requireContentDescriptions();
 320            file = contentMap.requireOnlyFile();
 321            // TODO check is offer
 322        } catch (final RuntimeException e) {
 323            Log.d(
 324                    Config.LOGTAG,
 325                    id.account.getJid().asBareJid() + ": improperly formatted contents",
 326                    Throwables.getRootCause(e));
 327            respondOk(jinglePacket);
 328            sendSessionTerminate(Reason.of(e), e.getMessage());
 329            return;
 330        }
 331        final XmppAxolotlMessage.XmppAxolotlKeyTransportMessage keyTransportMessage;
 332        final var contents = jingle.getJingleContents();
 333        final var rawContent = contents.get(Iterables.getOnlyElement(contentMap.contents.keySet()));
 334        final var security =
 335                rawContent == null ? null : rawContent.getSecurity(jinglePacket.getFrom());
 336        if (security != null) {
 337            Log.d(Config.LOGTAG, "found security element!");
 338            keyTransportMessage =
 339                    id.account
 340                            .getAxolotlService()
 341                            .processReceivingKeyTransportMessage(security, false);
 342        } else {
 343            keyTransportMessage = null;
 344        }
 345        receiveSessionInitiate(jinglePacket, contentMap, file, keyTransportMessage);
 346    }
 347
 348    private void receiveSessionInitiate(
 349            final Iq jinglePacket,
 350            final FileTransferContentMap contentMap,
 351            final FileTransferDescription.File file,
 352            final XmppAxolotlMessage.XmppAxolotlKeyTransportMessage keyTransportMessage) {
 353
 354        if (transition(State.SESSION_INITIALIZED, () -> setRemoteContentMap(contentMap))) {
 355            respondOk(jinglePacket);
 356            Log.d(
 357                    Config.LOGTAG,
 358                    "got file offer " + file + " jet=" + Objects.nonNull(keyTransportMessage));
 359            // TODO store hashes if there are any
 360            setFileOffer(file);
 361            if (keyTransportMessage != null) {
 362                this.transportSecurity =
 363                        new TransportSecurity(
 364                                keyTransportMessage.getKey(), keyTransportMessage.getIv());
 365                this.message.setFingerprint(keyTransportMessage.getFingerprint());
 366                this.message.setEncryption(Message.ENCRYPTION_AXOLOTL);
 367            } else {
 368                this.transportSecurity = null;
 369                this.message.setFingerprint(null);
 370            }
 371            final var conversation = (Conversation) message.getConversation();
 372            conversation.add(message);
 373
 374            // make auto accept decision
 375            if (id.account.getRoster().getContact(id.with).showInContactList()
 376                    && jingleConnectionManager.hasStoragePermission()
 377                    && file.size <= this.jingleConnectionManager.getAutoAcceptFileSize()
 378                    && xmppConnectionService.isDataSaverDisabled()) {
 379                Log.d(Config.LOGTAG, "auto accepting file from " + id.with);
 380                this.acceptedAutomatically = true;
 381                this.sendSessionAccept();
 382            } else {
 383                Log.d(
 384                        Config.LOGTAG,
 385                        "not auto accepting new file offer with size: "
 386                                + file.size
 387                                + " allowed size:"
 388                                + this.jingleConnectionManager.getAutoAcceptFileSize());
 389                message.markUnread();
 390                this.xmppConnectionService.updateConversationUi();
 391                this.xmppConnectionService.getNotificationService().push(message);
 392            }
 393        } else {
 394            Log.d(
 395                    Config.LOGTAG,
 396                    id.account.getJid().asBareJid() + ": receive out of order session-initiate");
 397            receiveOutOfOrderAction(jinglePacket, Jingle.Action.SESSION_INITIATE);
 398        }
 399    }
 400
 401    private void setFileOffer(final FileTransferDescription.File file) {
 402        final AbstractConnectionManager.Extension extension =
 403                AbstractConnectionManager.Extension.of(file.name);
 404        if (VALID_CRYPTO_EXTENSIONS.contains(extension.main)) {
 405            this.message.setEncryption(Message.ENCRYPTION_PGP);
 406        } else {
 407            this.message.setEncryption(Message.ENCRYPTION_NONE);
 408        }
 409        final String ext = extension.getExtension();
 410        final String filename =
 411                Strings.isNullOrEmpty(ext)
 412                        ? message.getUuid()
 413                        : String.format("%s.%s", message.getUuid(), ext);
 414        xmppConnectionService.getFileBackend().setupRelativeFilePath(message, filename);
 415    }
 416
 417    public void sendSessionAccept() {
 418        final FileTransferContentMap contentMap = this.initiatorFileTransferContentMap;
 419        final Transport transport;
 420        try {
 421            transport = setupTransport(contentMap.requireOnlyTransportInfo());
 422        } catch (final RuntimeException e) {
 423            sendSessionTerminate(Reason.of(e), e.getMessage());
 424            return;
 425        }
 426        transitionOrThrow(State.SESSION_ACCEPTED);
 427        this.transport = transport;
 428        this.transport.setTransportCallback(this);
 429        if (this.transport instanceof WebRTCDataChannelTransport webRTCDataChannelTransport) {
 430            final var sessionDescription = SessionDescription.of(contentMap);
 431            webRTCDataChannelTransport.setInitiatorDescription(sessionDescription);
 432        }
 433        final var transportInfoFuture = transport.asTransportInfo();
 434        Futures.addCallback(
 435                transportInfoFuture,
 436                new FutureCallback<>() {
 437                    @Override
 438                    public void onSuccess(final Transport.TransportInfo transportInfo) {
 439                        final FileTransferContentMap responderContentMap =
 440                                contentMap.withTransport(transportInfo);
 441                        sendSessionAccept(responderContentMap);
 442                    }
 443
 444                    @Override
 445                    public void onFailure(@NonNull Throwable throwable) {
 446                        failureToAcceptSession(throwable);
 447                    }
 448                },
 449                MoreExecutors.directExecutor());
 450    }
 451
 452    private void sendSessionAccept(final FileTransferContentMap contentMap) {
 453        setLocalContentMap(contentMap);
 454        final var iq = contentMap.toJinglePacket(Jingle.Action.SESSION_ACCEPT, id.sessionId);
 455        send(iq);
 456        // this needs to come after session-accept or else our candidate-error might arrive first
 457        this.transport.connect();
 458        this.transport.readyToSentAdditionalCandidates();
 459        if (this.transport instanceof WebRTCDataChannelTransport webRTCDataChannelTransport) {
 460            drainPendingIncomingIceCandidates(webRTCDataChannelTransport);
 461        }
 462    }
 463
 464    private void drainPendingIncomingIceCandidates(
 465            final WebRTCDataChannelTransport webRTCDataChannelTransport) {
 466        while (this.pendingIncomingIceCandidates.peek() != null) {
 467            final var candidate = this.pendingIncomingIceCandidates.poll();
 468            if (candidate == null) {
 469                continue;
 470            }
 471            webRTCDataChannelTransport.addIceCandidates(ImmutableList.of(candidate));
 472        }
 473    }
 474
 475    private Transport setupTransport(final GenericTransportInfo transportInfo) {
 476        final XmppConnection xmppConnection = id.account.getXmppConnection();
 477        final boolean useTor = id.account.isOnion() || xmppConnectionService.useTorToConnect();
 478        if (transportInfo instanceof IbbTransportInfo ibbTransportInfo) {
 479            final String streamId = ibbTransportInfo.getTransportId();
 480            final Long blockSize = ibbTransportInfo.getBlockSize();
 481            if (streamId == null || blockSize == null) {
 482                throw new IllegalStateException("ibb transport is missing sid and/or block-size");
 483            }
 484            return new InbandBytestreamsTransport(
 485                    xmppConnection,
 486                    id.with,
 487                    isInitiator(),
 488                    streamId,
 489                    Ints.saturatedCast(blockSize));
 490        } else if (transportInfo
 491                instanceof SocksByteStreamsTransportInfo socksBytestreamsTransportInfo) {
 492            final String streamId = socksBytestreamsTransportInfo.getTransportId();
 493            final String destination = socksBytestreamsTransportInfo.getDestinationAddress();
 494            final List<SocksByteStreamsTransport.Candidate> candidates =
 495                    socksBytestreamsTransportInfo.getCandidates();
 496            Log.d(Config.LOGTAG, "received socks candidates " + candidates);
 497            return new SocksByteStreamsTransport(
 498                    xmppConnection, id, isInitiator(), useTor, streamId, candidates);
 499        } else if (!useTor && transportInfo instanceof WebRTCDataChannelTransportInfo) {
 500            return new WebRTCDataChannelTransport(
 501                    xmppConnectionService.getApplicationContext(),
 502                    xmppConnection,
 503                    id.account,
 504                    isInitiator());
 505        } else {
 506            throw new IllegalArgumentException("Do not know how to create transport");
 507        }
 508    }
 509
 510    private Transport setupTransport() {
 511        final XmppConnection xmppConnection = id.account.getXmppConnection();
 512        final boolean useTor = id.account.isOnion() || xmppConnectionService.useTorToConnect();
 513        if (!useTor && remoteHasFeature(Namespace.JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL)) {
 514            return new WebRTCDataChannelTransport(
 515                    xmppConnectionService.getApplicationContext(),
 516                    xmppConnection,
 517                    id.account,
 518                    isInitiator());
 519        }
 520        if (remoteHasFeature(Namespace.JINGLE_TRANSPORTS_S5B)) {
 521            return new SocksByteStreamsTransport(xmppConnection, id, isInitiator(), useTor);
 522        }
 523        return setupLastResortTransport();
 524    }
 525
 526    private Transport setupLastResortTransport() {
 527        final XmppConnection xmppConnection = id.account.getXmppConnection();
 528        return new InbandBytestreamsTransport(xmppConnection, id.with, isInitiator());
 529    }
 530
 531    private void failureToAcceptSession(final Throwable throwable) {
 532        if (isTerminated()) {
 533            return;
 534        }
 535        terminateTransport();
 536        final Throwable rootCause = Throwables.getRootCause(throwable);
 537        Log.d(Config.LOGTAG, "unable to send session accept", rootCause);
 538        sendSessionTerminate(Reason.ofThrowable(rootCause), rootCause.getMessage());
 539    }
 540
 541    private void receiveSessionInfo(final Iq jinglePacket, final Jingle jingle) {
 542        respondOk(jinglePacket);
 543        final var sessionInfo = FileTransferDescription.getSessionInfo(jingle);
 544        if (sessionInfo instanceof FileTransferDescription.Checksum checksum) {
 545            receiveSessionInfoChecksum(checksum);
 546        } else if (sessionInfo instanceof FileTransferDescription.Received received) {
 547            receiveSessionInfoReceived(received);
 548        }
 549    }
 550
 551    private void receiveSessionInfoChecksum(final FileTransferDescription.Checksum checksum) {
 552        Log.d(Config.LOGTAG, "received checksum " + checksum);
 553        // TODO check that we are receiver
 554        // TODO store hashes
 555    }
 556
 557    private void receiveSessionInfoReceived(final FileTransferDescription.Received received) {
 558        Log.d(Config.LOGTAG, "peer confirmed received " + received);
 559        // TODO check that we are sender
 560    }
 561
 562    private synchronized void receiveSessionTerminate(final Iq jinglePacket, final Jingle jingle) {
 563        respondOk(jinglePacket);
 564        final Jingle.ReasonWrapper wrapper = jingle.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 Iq jinglePacket, final Jingle jingle) {
 594        if (isResponder()) {
 595            receiveOutOfOrderAction(jinglePacket, Jingle.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(jingle).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 = jingle.getGroup();
 614            receiveTransportAccept(jinglePacket, new Transport.TransportInfo(transportInfo, group));
 615        } else {
 616            receiveOutOfOrderAction(jinglePacket, Jingle.Action.TRANSPORT_ACCEPT);
 617        }
 618    }
 619
 620    private void receiveTransportAccept(
 621            final Iq 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 Iq jinglePacket, final Jingle jingle) {
 641        final FileTransferContentMap contentMap;
 642        final GenericTransportInfo transportInfo;
 643        try {
 644            contentMap = FileTransferContentMap.of(jingle);
 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 Iq jinglePacket, final Jingle jingle) {
 729        if (isInitiator()) {
 730            receiveOutOfOrderAction(jinglePacket, Jingle.Action.TRANSPORT_REPLACE);
 731            return;
 732        }
 733        final GenericTransportInfo transportInfo;
 734        try {
 735            transportInfo = FileTransferContentMap.of(jingle).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, Jingle.Action.TRANSPORT_REPLACE);
 750        }
 751    }
 752
 753    private void receiveTransportReplace(
 754            final Iq 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 iq =
 800                contentMap
 801                        .transportInfo()
 802                        .toJinglePacket(Jingle.Action.TRANSPORT_ACCEPT, id.sessionId);
 803        send(iq);
 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, "transport established");
 865        final AbstractFileTransceiver fileTransceiver;
 866        try {
 867            fileTransceiver = setupTransceiver(isResponder());
 868        } catch (final Exception e) {
 869            terminateTransport();
 870            if (isTerminated()) {
 871                Log.d(
 872                        Config.LOGTAG,
 873                        "failed to set up file transceiver but session has already been"
 874                                + " terminated");
 875            } else {
 876                Log.d(Config.LOGTAG, "failed to set up file transceiver", e);
 877                sendSessionTerminate(Reason.ofThrowable(e), e.getMessage());
 878            }
 879            return;
 880        }
 881        this.fileTransceiver = fileTransceiver;
 882        final var fileTransceiverThread = new Thread(fileTransceiver);
 883        fileTransceiverThread.start();
 884        Futures.addCallback(
 885                fileTransceiver.complete,
 886                new FutureCallback<>() {
 887                    @Override
 888                    public void onSuccess(final List<FileTransferDescription.Hash> hashes) {
 889                        onFileTransmissionComplete(hashes);
 890                    }
 891
 892                    @Override
 893                    public void onFailure(@NonNull final Throwable throwable) {
 894                        // The state transition in here should be synchronized to not race with the
 895                        // state transition in receiveSessionTerminate
 896                        synchronized (JingleFileTransferConnection.this) {
 897                            onFileTransmissionFailed(throwable);
 898                        }
 899                    }
 900                },
 901                MoreExecutors.directExecutor());
 902    }
 903
 904    private void onFileTransmissionComplete(final List<FileTransferDescription.Hash> hashes) {
 905        // TODO if we ever support receiving files this should become isSending(); isReceiving()
 906        if (isInitiator()) {
 907            sendSessionInfoChecksum(hashes);
 908        } else {
 909            Log.d(Config.LOGTAG, "file transfer complete " + hashes);
 910            // TODO compare with stored file hashes
 911            sendFileSessionInfoReceived();
 912            terminateTransport();
 913            messageReceivedSuccess();
 914            sendSessionTerminate(Reason.SUCCESS, null);
 915        }
 916    }
 917
 918    private void messageReceivedSuccess() {
 919        this.message.setTransferable(null);
 920        xmppConnectionService.getFileBackend().updateFileParams(message);
 921        xmppConnectionService.databaseBackend.createMessage(message);
 922        final File file = xmppConnectionService.getFileBackend().getFile(message);
 923        if (acceptedAutomatically) {
 924            message.markUnread();
 925            if (message.getEncryption() == Message.ENCRYPTION_PGP) {
 926                id.account.getPgpDecryptionService().decrypt(message, true);
 927            } else {
 928                xmppConnectionService
 929                        .getFileBackend()
 930                        .updateMediaScanner(
 931                                file,
 932                                () ->
 933                                        JingleFileTransferConnection.this
 934                                                .xmppConnectionService
 935                                                .getNotificationService()
 936                                                .push(message));
 937            }
 938        } else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
 939            id.account.getPgpDecryptionService().decrypt(message, false);
 940        } else {
 941            xmppConnectionService.getFileBackend().updateMediaScanner(file);
 942        }
 943    }
 944
 945    private void onFileTransmissionFailed(final Throwable throwable) {
 946        if (isTerminated()) {
 947            Log.d(
 948                    Config.LOGTAG,
 949                    "file transfer failed but session is already terminated",
 950                    throwable);
 951        } else {
 952            terminateTransport();
 953            Log.d(Config.LOGTAG, "on file transmission failed", throwable);
 954            sendSessionTerminate(Reason.CONNECTIVITY_ERROR, null);
 955        }
 956    }
 957
 958    private AbstractFileTransceiver setupTransceiver(final boolean receiving) throws IOException {
 959        final var transport = this.transport;
 960        if (transport == null) {
 961            throw new IOException("No transport configured");
 962        }
 963        final var fileDescription = getLocalContentMap().requireOnlyFile();
 964        final File file = xmppConnectionService.getFileBackend().getFile(message);
 965        final Runnable updateRunnable = () -> jingleConnectionManager.updateConversationUi(false);
 966        if (receiving) {
 967            return new FileReceiver(
 968                    file,
 969                    this.transportSecurity,
 970                    transport.getInputStream(),
 971                    transport.getTerminationLatch(),
 972                    fileDescription.size,
 973                    updateRunnable);
 974        } else {
 975            return new FileTransmitter(
 976                    file,
 977                    this.transportSecurity,
 978                    transport.getOutputStream(),
 979                    transport.getTerminationLatch(),
 980                    fileDescription.size,
 981                    updateRunnable);
 982        }
 983    }
 984
 985    private void sendFileSessionInfoReceived() {
 986        final var contentMap = getLocalContentMap();
 987        final String name = Iterables.getOnlyElement(contentMap.contents.keySet());
 988        sendSessionInfo(new FileTransferDescription.Received(name));
 989    }
 990
 991    private void sendSessionInfoChecksum(List<FileTransferDescription.Hash> hashes) {
 992        final var contentMap = getLocalContentMap();
 993        final String name = Iterables.getOnlyElement(contentMap.contents.keySet());
 994        sendSessionInfo(new FileTransferDescription.Checksum(name, hashes));
 995    }
 996
 997    private void sendSessionInfo(final FileTransferDescription.SessionInfo sessionInfo) {
 998        final var iq = new Iq(Iq.Type.SET);
 999        final var jinglePacket =
1000                iq.addExtension(new Jingle(Jingle.Action.SESSION_INFO, this.id.sessionId));
1001        jinglePacket.addChild(sessionInfo.asElement());
1002        send(iq);
1003    }
1004
1005    @Override
1006    public void onTransportSetupFailed() {
1007        final var transport = this.transport;
1008        if (transport == null) {
1009            synchronized (this) {
1010                // this can happen on IQ timeouts
1011                if (isTerminated()) {
1012                    return;
1013                }
1014                sendSessionTerminate(Reason.FAILED_APPLICATION, null);
1015            }
1016            return;
1017        }
1018        Log.d(Config.LOGTAG, "onTransportSetupFailed");
1019        final var isTransportInBand = transport instanceof InbandBytestreamsTransport;
1020        if (isTransportInBand) {
1021            terminateTransport();
1022            sendSessionTerminate(Reason.CONNECTIVITY_ERROR, "Failed to setup IBB transport");
1023            return;
1024        }
1025        // terminate the current transport
1026        transport.terminate();
1027        if (isInitiator()) {
1028            this.transport = setupLastResortTransport();
1029            Log.d(
1030                    Config.LOGTAG,
1031                    "replacing transport with " + this.transport.getClass().getSimpleName());
1032            this.transport.setTransportCallback(this);
1033            final var transportInfoFuture = this.transport.asTransportInfo();
1034            Futures.addCallback(
1035                    transportInfoFuture,
1036                    new FutureCallback<>() {
1037                        @Override
1038                        public void onSuccess(final Transport.TransportInfo transportWrapper) {
1039                            final FileTransferContentMap contentMap = getLocalContentMap();
1040                            sendTransportReplace(contentMap.withTransport(transportWrapper));
1041                        }
1042
1043                        @Override
1044                        public void onFailure(@NonNull Throwable throwable) {
1045                            // TODO send application failure;
1046                        }
1047                    },
1048                    MoreExecutors.directExecutor());
1049
1050        } else {
1051            Log.d(Config.LOGTAG, "transport setup failed. waiting for initiator to replace");
1052        }
1053    }
1054
1055    private void sendTransportReplace(final FileTransferContentMap contentMap) {
1056        setLocalContentMap(contentMap);
1057        final var iq =
1058                contentMap
1059                        .transportInfo()
1060                        .toJinglePacket(Jingle.Action.TRANSPORT_REPLACE, id.sessionId);
1061        send(iq);
1062    }
1063
1064    @Override
1065    public void onAdditionalCandidate(
1066            final String contentName, final Transport.Candidate candidate) {
1067        if (candidate instanceof IceUdpTransportInfo.Candidate iceCandidate) {
1068            sendTransportInfo(contentName, iceCandidate);
1069        }
1070    }
1071
1072    public void sendTransportInfo(
1073            final String contentName, final IceUdpTransportInfo.Candidate candidate) {
1074        final FileTransferContentMap transportInfo;
1075        try {
1076            final FileTransferContentMap rtpContentMap = getLocalContentMap();
1077            transportInfo = rtpContentMap.transportInfo(contentName, candidate);
1078        } catch (final Exception e) {
1079            Log.d(
1080                    Config.LOGTAG,
1081                    id.account.getJid().asBareJid()
1082                            + ": unable to prepare transport-info from candidate for content="
1083                            + contentName);
1084            return;
1085        }
1086        final Iq iq = transportInfo.toJinglePacket(Jingle.Action.TRANSPORT_INFO, id.sessionId);
1087        send(iq);
1088    }
1089
1090    @Override
1091    public void onCandidateUsed(
1092            final String streamId, final SocksByteStreamsTransport.Candidate candidate) {
1093        final FileTransferContentMap contentMap = getLocalContentMap();
1094        if (contentMap == null) {
1095            Log.e(Config.LOGTAG, "local content map is null on candidate used");
1096            return;
1097        }
1098        final var iq =
1099                contentMap
1100                        .candidateUsed(streamId, candidate.cid)
1101                        .toJinglePacket(Jingle.Action.TRANSPORT_INFO, id.sessionId);
1102        Log.d(Config.LOGTAG, "sending candidate used " + iq);
1103        send(iq);
1104    }
1105
1106    @Override
1107    public void onCandidateError(final String streamId) {
1108        final FileTransferContentMap contentMap = getLocalContentMap();
1109        if (contentMap == null) {
1110            Log.e(Config.LOGTAG, "local content map is null on candidate used");
1111            return;
1112        }
1113        final var iq =
1114                contentMap
1115                        .candidateError(streamId)
1116                        .toJinglePacket(Jingle.Action.TRANSPORT_INFO, id.sessionId);
1117        Log.d(Config.LOGTAG, "sending candidate error " + iq);
1118        send(iq);
1119    }
1120
1121    @Override
1122    public void onProxyActivated(String streamId, SocksByteStreamsTransport.Candidate candidate) {
1123        final FileTransferContentMap contentMap = getLocalContentMap();
1124        if (contentMap == null) {
1125            Log.e(Config.LOGTAG, "local content map is null on candidate used");
1126            return;
1127        }
1128        final var iq =
1129                contentMap
1130                        .proxyActivated(streamId, candidate.cid)
1131                        .toJinglePacket(Jingle.Action.TRANSPORT_INFO, id.sessionId);
1132        send(iq);
1133    }
1134
1135    @Override
1136    protected boolean transition(final State target, final Runnable runnable) {
1137        final boolean transitioned = super.transition(target, runnable);
1138        if (transitioned && isInitiator()) {
1139            Log.d(Config.LOGTAG, "running mark message hooks");
1140            if (target == State.SESSION_ACCEPTED) {
1141                xmppConnectionService.markMessage(message, Message.STATUS_UNSEND);
1142            } else if (target == State.TERMINATED_SUCCESS) {
1143                xmppConnectionService.markMessage(message, Message.STATUS_SEND_RECEIVED);
1144            } else if (TERMINATED.contains(target)) {
1145                xmppConnectionService.markMessage(
1146                        message, Message.STATUS_SEND_FAILED, message.getErrorMessage());
1147            } else {
1148                xmppConnectionService.updateConversationUi();
1149            }
1150        } else {
1151            if (Arrays.asList(State.TERMINATED_CANCEL_OR_TIMEOUT, State.TERMINATED_DECLINED_OR_BUSY)
1152                    .contains(target)) {
1153                this.message.setTransferable(
1154                        new TransferablePlaceholder(Transferable.STATUS_CANCELLED));
1155            } else if (target != State.TERMINATED_SUCCESS && TERMINATED.contains(target)) {
1156                this.message.setTransferable(
1157                        new TransferablePlaceholder(Transferable.STATUS_FAILED));
1158            }
1159            xmppConnectionService.updateConversationUi();
1160        }
1161        return transitioned;
1162    }
1163
1164    @Override
1165    protected void finish() {
1166        if (transport != null) {
1167            throw new AssertionError(
1168                    "finish MUST not be called without terminating the transport first");
1169        }
1170        // we don't want to remove TransferablePlaceholder
1171        if (message.getTransferable() instanceof JingleFileTransferConnection) {
1172            Log.d(Config.LOGTAG, "nulling transferable on message");
1173            this.message.setTransferable(null);
1174        }
1175        super.finish();
1176    }
1177
1178    private int getTransferableStatus() {
1179        // status in file transfer is a bit weird. for sending it is mostly handled via
1180        // Message.STATUS_* (offered, unsend (sic) send_received) the transferable status is just
1181        // uploading
1182        // for receiving the message status remains at 'received' but Transferable goes through
1183        // various status
1184        if (isInitiator()) {
1185            return Transferable.STATUS_UPLOADING;
1186        }
1187        final var state = getState();
1188        return switch (state) {
1189            case NULL, SESSION_INITIALIZED, SESSION_INITIALIZED_PRE_APPROVED ->
1190                    Transferable.STATUS_OFFER;
1191            case TERMINATED_APPLICATION_FAILURE,
1192                            TERMINATED_CONNECTIVITY_ERROR,
1193                            TERMINATED_DECLINED_OR_BUSY,
1194                            TERMINATED_SECURITY_ERROR ->
1195                    Transferable.STATUS_FAILED;
1196            case TERMINATED_CANCEL_OR_TIMEOUT -> Transferable.STATUS_CANCELLED;
1197            case SESSION_ACCEPTED -> Transferable.STATUS_DOWNLOADING;
1198            default -> Transferable.STATUS_UNKNOWN;
1199        };
1200    }
1201
1202    // these methods are for interacting with 'Transferable' - we might want to remove the concept
1203    // at some point
1204
1205    @Override
1206    public boolean start() {
1207        Log.d(Config.LOGTAG, "user pressed start()");
1208        // TODO there is a 'connected' check apparently?
1209        if (isInState(State.SESSION_INITIALIZED)) {
1210            sendSessionAccept();
1211        }
1212        return true;
1213    }
1214
1215    @Override
1216    public int getStatus() {
1217        return getTransferableStatus();
1218    }
1219
1220    @Override
1221    public Long getFileSize() {
1222        final var transceiver = this.fileTransceiver;
1223        if (transceiver != null) {
1224            return transceiver.total;
1225        }
1226        final var contentMap = this.initiatorFileTransferContentMap;
1227        if (contentMap != null) {
1228            return contentMap.requireOnlyFile().size;
1229        }
1230        return null;
1231    }
1232
1233    @Override
1234    public int getProgress() {
1235        final var transceiver = this.fileTransceiver;
1236        return transceiver != null ? transceiver.getProgress() : 0;
1237    }
1238
1239    @Override
1240    public void cancel() {
1241        if (stopFileTransfer()) {
1242            Log.d(Config.LOGTAG, "user has stopped file transfer");
1243        } else {
1244            Log.d(Config.LOGTAG, "user pressed cancel but file transfer was already terminated?");
1245        }
1246    }
1247
1248    private boolean stopFileTransfer() {
1249        if (isInitiator()) {
1250            return stopFileTransfer(Reason.CANCEL);
1251        } else {
1252            return stopFileTransfer(Reason.DECLINE);
1253        }
1254    }
1255
1256    private boolean stopFileTransfer(final Reason reason) {
1257        final State target = reasonToState(reason);
1258        if (transition(target)) {
1259            // we change state before terminating transport so we don't consume the following
1260            // IOException and turn it into a connectivity error
1261
1262            if (isInitiator() && reason == Reason.CANCEL) {
1263                // message hooks have already run so we need to mark to persist the 'cancelled'
1264                // status
1265                xmppConnectionService.markMessage(
1266                        message, Message.STATUS_SEND_FAILED, Message.ERROR_MESSAGE_CANCELLED);
1267            }
1268            terminateTransport();
1269            final Iq iq = new Iq(Iq.Type.SET);
1270            final var jingle =
1271                    iq.addExtension(new Jingle(Jingle.Action.SESSION_TERMINATE, id.sessionId));
1272            jingle.setReason(reason, "User requested to stop file transfer");
1273            send(iq);
1274            finish();
1275            return true;
1276        } else {
1277            return false;
1278        }
1279    }
1280
1281    private abstract static class AbstractFileTransceiver implements Runnable {
1282
1283        protected final SettableFuture<List<FileTransferDescription.Hash>> complete =
1284                SettableFuture.create();
1285
1286        protected final File file;
1287        protected final TransportSecurity transportSecurity;
1288
1289        protected final CountDownLatch transportTerminationLatch;
1290        protected final long total;
1291        protected long transmitted = 0;
1292        private int progress = Integer.MIN_VALUE;
1293        private final Runnable updateRunnable;
1294
1295        private AbstractFileTransceiver(
1296                final File file,
1297                final TransportSecurity transportSecurity,
1298                final CountDownLatch transportTerminationLatch,
1299                final long total,
1300                final Runnable updateRunnable) {
1301            this.file = file;
1302            this.transportSecurity = transportSecurity;
1303            this.transportTerminationLatch = transportTerminationLatch;
1304            this.total = transportSecurity == null ? total : (total + 16);
1305            this.updateRunnable = updateRunnable;
1306        }
1307
1308        static void closeTransport(final Closeable stream) {
1309            try {
1310                stream.close();
1311            } catch (final IOException e) {
1312                Log.d(Config.LOGTAG, "transport has already been closed. good");
1313            }
1314        }
1315
1316        public int getProgress() {
1317            return Ints.saturatedCast(Math.round((1.0 * transmitted / total) * 100));
1318        }
1319
1320        public void updateProgress() {
1321            final int current = getProgress();
1322            final boolean update;
1323            synchronized (this) {
1324                if (this.progress != current) {
1325                    this.progress = current;
1326                    update = true;
1327                } else {
1328                    update = false;
1329                }
1330                if (update) {
1331                    this.updateRunnable.run();
1332                }
1333            }
1334        }
1335
1336        protected void awaitTransportTermination() {
1337            try {
1338                this.transportTerminationLatch.await();
1339            } catch (final InterruptedException ignored) {
1340                return;
1341            }
1342            Log.d(Config.LOGTAG, getClass().getSimpleName() + " says Goodbye!");
1343        }
1344    }
1345
1346    private static class FileTransmitter extends AbstractFileTransceiver {
1347
1348        private final OutputStream outputStream;
1349
1350        private FileTransmitter(
1351                final File file,
1352                final TransportSecurity transportSecurity,
1353                final OutputStream outputStream,
1354                final CountDownLatch transportTerminationLatch,
1355                final long total,
1356                final Runnable updateRunnable) {
1357            super(file, transportSecurity, transportTerminationLatch, total, updateRunnable);
1358            this.outputStream = outputStream;
1359        }
1360
1361        private InputStream openFileInputStream() throws FileNotFoundException {
1362            final var fileInputStream = new FileInputStream(this.file);
1363            if (this.transportSecurity == null) {
1364                return fileInputStream;
1365            } else {
1366                final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
1367                cipher.init(
1368                        true,
1369                        new AEADParameters(
1370                                new KeyParameter(transportSecurity.key),
1371                                128,
1372                                transportSecurity.iv));
1373                Log.d(Config.LOGTAG, "setting up CipherInputStream");
1374                return new CipherInputStream(fileInputStream, cipher);
1375            }
1376        }
1377
1378        @Override
1379        public void run() {
1380            Log.d(Config.LOGTAG, "file transmitter attempting to send " + total + " bytes");
1381            final var sha1Hasher = Hashing.sha1().newHasher();
1382            final var sha256Hasher = Hashing.sha256().newHasher();
1383            try (final var fileInputStream = openFileInputStream()) {
1384                final var buffer = new byte[4096];
1385                while (total - transmitted > 0) {
1386                    final int count = fileInputStream.read(buffer);
1387                    if (count == -1) {
1388                        throw new EOFException(
1389                                String.format("reached EOF after %d/%d", transmitted, total));
1390                    }
1391                    outputStream.write(buffer, 0, count);
1392                    sha1Hasher.putBytes(buffer, 0, count);
1393                    sha256Hasher.putBytes(buffer, 0, count);
1394                    transmitted += count;
1395                    updateProgress();
1396                }
1397                outputStream.flush();
1398                Log.d(
1399                        Config.LOGTAG,
1400                        "transmitted " + transmitted + " bytes from " + file.getAbsolutePath());
1401                final List<FileTransferDescription.Hash> hashes =
1402                        ImmutableList.of(
1403                                new FileTransferDescription.Hash(
1404                                        sha1Hasher.hash().asBytes(),
1405                                        FileTransferDescription.Algorithm.SHA_1),
1406                                new FileTransferDescription.Hash(
1407                                        sha256Hasher.hash().asBytes(),
1408                                        FileTransferDescription.Algorithm.SHA_256));
1409                complete.set(hashes);
1410            } catch (final Exception e) {
1411                complete.setException(e);
1412            }
1413            // the transport implementations backed by PipedOutputStreams do not like it when
1414            // the writing Thread (this thread) goes away. so we just wait until the other peer
1415            // has received our file and we are shutting down the transport
1416            Log.d(Config.LOGTAG, "waiting for transport to terminate before stopping thread");
1417            awaitTransportTermination();
1418            closeTransport(outputStream);
1419        }
1420    }
1421
1422    private static class FileReceiver extends AbstractFileTransceiver {
1423
1424        private final InputStream inputStream;
1425
1426        private FileReceiver(
1427                final File file,
1428                final TransportSecurity transportSecurity,
1429                final InputStream inputStream,
1430                final CountDownLatch transportTerminationLatch,
1431                final long total,
1432                final Runnable updateRunnable) {
1433            super(file, transportSecurity, transportTerminationLatch, total, updateRunnable);
1434            this.inputStream = inputStream;
1435        }
1436
1437        private OutputStream openFileOutputStream() throws FileNotFoundException {
1438            final var directory = this.file.getParentFile();
1439            if (directory != null && directory.mkdirs()) {
1440                Log.d(Config.LOGTAG, "created directory " + directory.getAbsolutePath());
1441            }
1442            final var fileOutputStream = new FileOutputStream(this.file);
1443            if (this.transportSecurity == null) {
1444                return fileOutputStream;
1445            } else {
1446                final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
1447                cipher.init(
1448                        false,
1449                        new AEADParameters(
1450                                new KeyParameter(transportSecurity.key),
1451                                128,
1452                                transportSecurity.iv));
1453                Log.d(Config.LOGTAG, "setting up CipherOutputStream");
1454                return new CipherOutputStream(fileOutputStream, cipher);
1455            }
1456        }
1457
1458        @Override
1459        public void run() {
1460            Log.d(Config.LOGTAG, "file receiver attempting to receive " + total + " bytes");
1461            final var sha1Hasher = Hashing.sha1().newHasher();
1462            final var sha256Hasher = Hashing.sha256().newHasher();
1463            try (final var fileOutputStream = openFileOutputStream()) {
1464                final var buffer = new byte[4096];
1465                while (total - transmitted > 0) {
1466                    final int count = inputStream.read(buffer);
1467                    if (count == -1) {
1468                        throw new EOFException(
1469                                String.format("reached EOF after %d/%d", transmitted, total));
1470                    }
1471                    fileOutputStream.write(buffer, 0, count);
1472                    sha1Hasher.putBytes(buffer, 0, count);
1473                    sha256Hasher.putBytes(buffer, 0, count);
1474                    transmitted += count;
1475                    updateProgress();
1476                }
1477                Log.d(
1478                        Config.LOGTAG,
1479                        "written " + transmitted + " bytes to " + file.getAbsolutePath());
1480                final List<FileTransferDescription.Hash> hashes =
1481                        ImmutableList.of(
1482                                new FileTransferDescription.Hash(
1483                                        sha1Hasher.hash().asBytes(),
1484                                        FileTransferDescription.Algorithm.SHA_1),
1485                                new FileTransferDescription.Hash(
1486                                        sha256Hasher.hash().asBytes(),
1487                                        FileTransferDescription.Algorithm.SHA_256));
1488                complete.set(hashes);
1489            } catch (final Exception e) {
1490                complete.setException(e);
1491            }
1492            Log.d(Config.LOGTAG, "waiting for transport to terminate before stopping thread");
1493            awaitTransportTermination();
1494            closeTransport(inputStream);
1495        }
1496    }
1497
1498    private static final class TransportSecurity {
1499        final byte[] key;
1500        final byte[] iv;
1501
1502        private TransportSecurity(byte[] key, byte[] iv) {
1503            this.key = key;
1504            this.iv = iv;
1505        }
1506    }
1507}