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