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