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