JingleFileTransferConnection.java

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