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        final JinglePacket.ReasonWrapper wrapper = jinglePacket.getReason();
 560        final State previous = this.state;
 561        Log.d(
 562                Config.LOGTAG,
 563                id.account.getJid().asBareJid()
 564                        + ": received session terminate reason="
 565                        + wrapper.reason
 566                        + "("
 567                        + Strings.nullToEmpty(wrapper.text)
 568                        + ") while in state "
 569                        + previous);
 570        if (TERMINATED.contains(previous)) {
 571            Log.d(
 572                    Config.LOGTAG,
 573                    id.account.getJid().asBareJid()
 574                            + ": ignoring session terminate because already in "
 575                            + previous);
 576            return;
 577        }
 578        if (isInitiator()) {
 579            this.message.setErrorMessage(
 580                    Strings.isNullOrEmpty(wrapper.text) ? wrapper.reason.toString() : wrapper.text);
 581        }
 582        terminateTransport();
 583        final State target = reasonToState(wrapper.reason);
 584        transitionOrThrow(target);
 585        finish();
 586    }
 587
 588    private void receiveTransportAccept(final JinglePacket jinglePacket) {
 589        if (isResponder()) {
 590            receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.TRANSPORT_ACCEPT);
 591            return;
 592        }
 593        Log.d(Config.LOGTAG, "receive transport accept " + jinglePacket);
 594        final GenericTransportInfo transportInfo;
 595        try {
 596            transportInfo = FileTransferContentMap.of(jinglePacket).requireOnlyTransportInfo();
 597        } catch (final RuntimeException e) {
 598            Log.d(
 599                    Config.LOGTAG,
 600                    id.account.getJid().asBareJid() + ": improperly formatted contents",
 601                    Throwables.getRootCause(e));
 602            respondOk(jinglePacket);
 603            sendSessionTerminate(Reason.of(e), e.getMessage());
 604            return;
 605        }
 606        if (isInState(State.SESSION_ACCEPTED)) {
 607            final var group = jinglePacket.getGroup();
 608            receiveTransportAccept(jinglePacket, new Transport.TransportInfo(transportInfo, group));
 609        } else {
 610            receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.TRANSPORT_ACCEPT);
 611        }
 612    }
 613
 614    private void receiveTransportAccept(
 615            final JinglePacket jinglePacket, final Transport.TransportInfo transportInfo) {
 616        final FileTransferContentMap remoteContentMap =
 617                getRemoteContentMap().withTransport(transportInfo);
 618        setRemoteContentMap(remoteContentMap);
 619        respondOk(jinglePacket);
 620        final var transport = this.transport;
 621        if (configureTransportWithPeerInfo(transport, remoteContentMap)) {
 622            transport.connect();
 623        } else {
 624            Log.e(
 625                    Config.LOGTAG,
 626                    "Transport in transport-accept did not match our transport-replace");
 627            terminateTransport();
 628            sendSessionTerminate(
 629                    Reason.FAILED_APPLICATION,
 630                    "Transport in transport-accept did not match our transport-replace");
 631        }
 632    }
 633
 634    private void receiveTransportInfo(final JinglePacket jinglePacket) {
 635        final FileTransferContentMap contentMap;
 636        final GenericTransportInfo transportInfo;
 637        try {
 638            contentMap = FileTransferContentMap.of(jinglePacket);
 639            transportInfo = contentMap.requireOnlyTransportInfo();
 640        } catch (final RuntimeException e) {
 641            Log.d(
 642                    Config.LOGTAG,
 643                    id.account.getJid().asBareJid() + ": improperly formatted contents",
 644                    Throwables.getRootCause(e));
 645            respondOk(jinglePacket);
 646            sendSessionTerminate(Reason.of(e), e.getMessage());
 647            return;
 648        }
 649        respondOk(jinglePacket);
 650        final var transport = this.transport;
 651        if (transport instanceof SocksByteStreamsTransport socksBytestreamsTransport
 652                && transportInfo
 653                        instanceof SocksByteStreamsTransportInfo socksBytestreamsTransportInfo) {
 654            receiveTransportInfo(socksBytestreamsTransport, socksBytestreamsTransportInfo);
 655        } else if (transport instanceof WebRTCDataChannelTransport webRTCDataChannelTransport
 656                && transportInfo
 657                        instanceof WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) {
 658            receiveTransportInfo(
 659                    Iterables.getOnlyElement(contentMap.contents.keySet()),
 660                    webRTCDataChannelTransport,
 661                    webRTCDataChannelTransportInfo);
 662        } else if (transportInfo
 663                instanceof WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) {
 664            receiveTransportInfo(
 665                    Iterables.getOnlyElement(contentMap.contents.keySet()),
 666                    webRTCDataChannelTransportInfo);
 667        } else {
 668            Log.d(Config.LOGTAG, "could not deliver transport-info to transport");
 669        }
 670    }
 671
 672    private void receiveTransportInfo(
 673            final String contentName,
 674            final WebRTCDataChannelTransport webRTCDataChannelTransport,
 675            final WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) {
 676        final var credentials = webRTCDataChannelTransportInfo.getCredentials();
 677        final var iceCandidates =
 678                WebRTCDataChannelTransport.iceCandidatesOf(
 679                        contentName, credentials, webRTCDataChannelTransportInfo.getCandidates());
 680        final var localContentMap = getLocalContentMap();
 681        if (localContentMap == null) {
 682            Log.d(Config.LOGTAG, "transport not ready. add pending ice candidate");
 683            this.pendingIncomingIceCandidates.addAll(iceCandidates);
 684        } else {
 685            webRTCDataChannelTransport.addIceCandidates(iceCandidates);
 686        }
 687    }
 688
 689    private void receiveTransportInfo(
 690            final String contentName,
 691            final WebRTCDataChannelTransportInfo webRTCDataChannelTransportInfo) {
 692        final var credentials = webRTCDataChannelTransportInfo.getCredentials();
 693        final var iceCandidates =
 694                WebRTCDataChannelTransport.iceCandidatesOf(
 695                        contentName, credentials, webRTCDataChannelTransportInfo.getCandidates());
 696        this.pendingIncomingIceCandidates.addAll(iceCandidates);
 697    }
 698
 699    private void receiveTransportInfo(
 700            final SocksByteStreamsTransport socksBytestreamsTransport,
 701            final SocksByteStreamsTransportInfo socksBytestreamsTransportInfo) {
 702        final var transportInfo = socksBytestreamsTransportInfo.getTransportInfo();
 703        if (transportInfo instanceof SocksByteStreamsTransportInfo.CandidateError) {
 704            socksBytestreamsTransport.setCandidateError();
 705        } else if (transportInfo
 706                instanceof SocksByteStreamsTransportInfo.CandidateUsed candidateUsed) {
 707            if (!socksBytestreamsTransport.setCandidateUsed(candidateUsed.cid)) {
 708                sendSessionTerminate(
 709                        Reason.FAILED_TRANSPORT,
 710                        String.format(
 711                                "Peer is not connected to our candidate %s", candidateUsed.cid));
 712            }
 713        } else if (transportInfo instanceof SocksByteStreamsTransportInfo.Activated activated) {
 714            socksBytestreamsTransport.setProxyActivated(activated.cid);
 715        } else if (transportInfo instanceof SocksByteStreamsTransportInfo.ProxyError) {
 716            socksBytestreamsTransport.setProxyError();
 717        }
 718    }
 719
 720    private void receiveTransportReplace(final JinglePacket jinglePacket) {
 721        if (isInitiator()) {
 722            receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.TRANSPORT_REPLACE);
 723            return;
 724        }
 725        Log.d(Config.LOGTAG, "receive transport replace " + jinglePacket);
 726        final GenericTransportInfo transportInfo;
 727        try {
 728            transportInfo = FileTransferContentMap.of(jinglePacket).requireOnlyTransportInfo();
 729        } catch (final RuntimeException e) {
 730            Log.d(
 731                    Config.LOGTAG,
 732                    id.account.getJid().asBareJid() + ": improperly formatted contents",
 733                    Throwables.getRootCause(e));
 734            respondOk(jinglePacket);
 735            sendSessionTerminate(Reason.of(e), e.getMessage());
 736            return;
 737        }
 738        if (isInState(State.SESSION_ACCEPTED)) {
 739            receiveTransportReplace(jinglePacket, transportInfo);
 740        } else {
 741            receiveOutOfOrderAction(jinglePacket, JinglePacket.Action.TRANSPORT_REPLACE);
 742        }
 743    }
 744
 745    private void receiveTransportReplace(
 746            final JinglePacket jinglePacket, final GenericTransportInfo transportInfo) {
 747        respondOk(jinglePacket);
 748        final Transport transport;
 749        try {
 750            transport = setupTransport(transportInfo);
 751        } catch (final RuntimeException e) {
 752            sendSessionTerminate(Reason.of(e), e.getMessage());
 753            return;
 754        }
 755        this.transport = transport;
 756        this.transport.setTransportCallback(this);
 757        final var transportInfoFuture = transport.asTransportInfo();
 758        Futures.addCallback(
 759                transportInfoFuture,
 760                new FutureCallback<>() {
 761                    @Override
 762                    public void onSuccess(final Transport.TransportInfo transportWrapper) {
 763                        final FileTransferContentMap contentMap =
 764                                getLocalContentMap().withTransport(transportWrapper);
 765                        sendTransportAccept(contentMap);
 766                    }
 767
 768                    @Override
 769                    public void onFailure(@NonNull Throwable throwable) {
 770                        // transition into application failed (analogues to failureToAccept
 771                    }
 772                },
 773                MoreExecutors.directExecutor());
 774    }
 775
 776    private void sendTransportAccept(final FileTransferContentMap contentMap) {
 777        setLocalContentMap(contentMap);
 778        final var jinglePacket =
 779                contentMap
 780                        .transportInfo()
 781                        .toJinglePacket(JinglePacket.Action.TRANSPORT_ACCEPT, id.sessionId);
 782        Log.d(Config.LOGTAG, "sending transport accept " + jinglePacket);
 783        send(jinglePacket);
 784        transport.connect();
 785    }
 786
 787    protected void sendSessionTerminate(final Reason reason, final String text) {
 788        if (isInitiator()) {
 789            this.message.setErrorMessage(Strings.isNullOrEmpty(text) ? reason.toString() : text);
 790        }
 791        sendSessionTerminate(reason, text, null);
 792    }
 793
 794    private FileTransferContentMap getLocalContentMap() {
 795        return isInitiator()
 796                ? this.initiatorFileTransferContentMap
 797                : this.responderFileTransferContentMap;
 798    }
 799
 800    private FileTransferContentMap getRemoteContentMap() {
 801        return isInitiator()
 802                ? this.responderFileTransferContentMap
 803                : this.initiatorFileTransferContentMap;
 804    }
 805
 806    private void setLocalContentMap(final FileTransferContentMap contentMap) {
 807        if (isInitiator()) {
 808            this.initiatorFileTransferContentMap = contentMap;
 809        } else {
 810            this.responderFileTransferContentMap = contentMap;
 811        }
 812    }
 813
 814    private void setRemoteContentMap(final FileTransferContentMap contentMap) {
 815        if (isInitiator()) {
 816            this.responderFileTransferContentMap = contentMap;
 817        } else {
 818            this.initiatorFileTransferContentMap = contentMap;
 819        }
 820    }
 821
 822    public Transport getTransport() {
 823        return this.transport;
 824    }
 825
 826    @Override
 827    protected void terminateTransport() {
 828        final var transport = this.transport;
 829        if (transport == null) {
 830            return;
 831        }
 832        transport.terminate();
 833        this.transport = null;
 834    }
 835
 836    @Override
 837    void notifyRebound() {}
 838
 839    @Override
 840    public void onTransportEstablished() {
 841        Log.d(Config.LOGTAG, "on transport established");
 842        final AbstractFileTransceiver fileTransceiver;
 843        try {
 844            fileTransceiver = setupTransceiver(isResponder());
 845        } catch (final Exception e) {
 846            Log.d(Config.LOGTAG, "failed to set up file transceiver", e);
 847            sendSessionTerminate(Reason.ofThrowable(e), e.getMessage());
 848            return;
 849        }
 850        this.fileTransceiver = fileTransceiver;
 851        final var fileTransceiverThread = new Thread(fileTransceiver);
 852        fileTransceiverThread.start();
 853        Futures.addCallback(
 854                fileTransceiver.complete,
 855                new FutureCallback<>() {
 856                    @Override
 857                    public void onSuccess(final List<FileTransferDescription.Hash> hashes) {
 858                        onFileTransmissionComplete(hashes);
 859                    }
 860
 861                    @Override
 862                    public void onFailure(@NonNull Throwable throwable) {
 863                        onFileTransmissionFailed(throwable);
 864                    }
 865                },
 866                MoreExecutors.directExecutor());
 867    }
 868
 869    private void onFileTransmissionComplete(final List<FileTransferDescription.Hash> hashes) {
 870        // TODO if we ever support receiving files this should become isSending(); isReceiving()
 871        if (isInitiator()) {
 872            sendSessionInfoChecksum(hashes);
 873        } else {
 874            Log.d(Config.LOGTAG, "file transfer complete " + hashes);
 875            sendFileSessionInfoReceived();
 876            terminateTransport();
 877            messageReceivedSuccess();
 878            sendSessionTerminate(Reason.SUCCESS, null);
 879        }
 880    }
 881
 882    private void messageReceivedSuccess() {
 883        this.message.setTransferable(null);
 884        xmppConnectionService.getFileBackend().updateFileParams(message);
 885        xmppConnectionService.databaseBackend.createMessage(message);
 886        final File file = xmppConnectionService.getFileBackend().getFile(message);
 887        if (acceptedAutomatically) {
 888            message.markUnread();
 889            if (message.getEncryption() == Message.ENCRYPTION_PGP) {
 890                id.account.getPgpDecryptionService().decrypt(message, true);
 891            } else {
 892                xmppConnectionService
 893                        .getFileBackend()
 894                        .updateMediaScanner(
 895                                file,
 896                                () ->
 897                                        JingleFileTransferConnection.this
 898                                                .xmppConnectionService
 899                                                .getNotificationService()
 900                                                .push(message));
 901            }
 902        } else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
 903            id.account.getPgpDecryptionService().decrypt(message, false);
 904        } else {
 905            xmppConnectionService.getFileBackend().updateMediaScanner(file);
 906        }
 907    }
 908
 909    private void onFileTransmissionFailed(final Throwable throwable) {
 910        if (isTerminated()) {
 911            Log.d(
 912                    Config.LOGTAG,
 913                    "file transfer failed but session is already terminated",
 914                    throwable);
 915        } else {
 916            terminateTransport();
 917            Log.d(Config.LOGTAG, "on file transmission failed", throwable);
 918            sendSessionTerminate(Reason.CONNECTIVITY_ERROR, null);
 919        }
 920    }
 921
 922    private AbstractFileTransceiver setupTransceiver(final boolean receiving) throws IOException {
 923        final var fileDescription = getLocalContentMap().requireOnlyFile();
 924        final File file = xmppConnectionService.getFileBackend().getFile(message);
 925        final Runnable updateRunnable = () -> jingleConnectionManager.updateConversationUi(false);
 926        if (receiving) {
 927            return new FileReceiver(
 928                    file,
 929                    this.transportSecurity,
 930                    transport.getInputStream(),
 931                    transport.getTerminationLatch(),
 932                    fileDescription.size,
 933                    updateRunnable);
 934        } else {
 935            return new FileTransmitter(
 936                    file,
 937                    this.transportSecurity,
 938                    transport.getOutputStream(),
 939                    transport.getTerminationLatch(),
 940                    fileDescription.size,
 941                    updateRunnable);
 942        }
 943    }
 944
 945    private void sendFileSessionInfoReceived() {
 946        final var contentMap = getLocalContentMap();
 947        final String name = Iterables.getOnlyElement(contentMap.contents.keySet());
 948        sendSessionInfo(new FileTransferDescription.Received(name));
 949    }
 950
 951    private void sendSessionInfoChecksum(List<FileTransferDescription.Hash> hashes) {
 952        final var contentMap = getLocalContentMap();
 953        final String name = Iterables.getOnlyElement(contentMap.contents.keySet());
 954        sendSessionInfo(new FileTransferDescription.Checksum(name, hashes));
 955    }
 956
 957    private void sendSessionInfo(final FileTransferDescription.SessionInfo sessionInfo) {
 958        final var jinglePacket =
 959                new JinglePacket(JinglePacket.Action.SESSION_INFO, this.id.sessionId);
 960        jinglePacket.addJingleChild(sessionInfo.asElement());
 961        jinglePacket.setTo(this.id.with);
 962        Log.d(Config.LOGTAG, "--> " + jinglePacket);
 963        send(jinglePacket);
 964    }
 965
 966    @Override
 967    public void onTransportSetupFailed() {
 968        final var transport = this.transport;
 969        if (transport == null) {
 970            // this really is not supposed to happen
 971            sendSessionTerminate(Reason.FAILED_APPLICATION, null);
 972            return;
 973        }
 974        Log.d(Config.LOGTAG, "onTransportSetupFailed");
 975        final var isTransportInBand = transport instanceof InbandBytestreamsTransport;
 976        if (isTransportInBand) {
 977            terminateTransport();
 978            sendSessionTerminate(Reason.CONNECTIVITY_ERROR, "Failed to setup IBB transport");
 979            return;
 980        }
 981        // terminate the current transport
 982        transport.terminate();
 983        if (isInitiator()) {
 984            this.transport = setupLastResortTransport();
 985            this.transport.setTransportCallback(this);
 986            final var transportInfoFuture = this.transport.asTransportInfo();
 987            Futures.addCallback(
 988                    transportInfoFuture,
 989                    new FutureCallback<>() {
 990                        @Override
 991                        public void onSuccess(final Transport.TransportInfo transportWrapper) {
 992                            final FileTransferContentMap contentMap = getLocalContentMap();
 993                            sendTransportReplace(contentMap.withTransport(transportWrapper));
 994                        }
 995
 996                        @Override
 997                        public void onFailure(@NonNull Throwable throwable) {
 998                            // TODO send application failure;
 999                        }
1000                    },
1001                    MoreExecutors.directExecutor());
1002
1003        } else {
1004            Log.d(Config.LOGTAG, "transport setup failed. waiting for initiator to replace");
1005        }
1006    }
1007
1008    private void sendTransportReplace(final FileTransferContentMap contentMap) {
1009        setLocalContentMap(contentMap);
1010        final var jinglePacket =
1011                contentMap
1012                        .transportInfo()
1013                        .toJinglePacket(JinglePacket.Action.TRANSPORT_REPLACE, id.sessionId);
1014        Log.d(Config.LOGTAG, "sending transport replace " + jinglePacket);
1015        send(jinglePacket);
1016    }
1017
1018    @Override
1019    public void onAdditionalCandidate(
1020            final String contentName, final Transport.Candidate candidate) {
1021        if (candidate instanceof IceUdpTransportInfo.Candidate iceCandidate) {
1022            sendTransportInfo(contentName, iceCandidate);
1023        }
1024    }
1025
1026    public void sendTransportInfo(
1027            final String contentName, final IceUdpTransportInfo.Candidate candidate) {
1028        final FileTransferContentMap transportInfo;
1029        try {
1030            final FileTransferContentMap rtpContentMap = getLocalContentMap();
1031            transportInfo = rtpContentMap.transportInfo(contentName, candidate);
1032        } catch (final Exception e) {
1033            Log.d(
1034                    Config.LOGTAG,
1035                    id.account.getJid().asBareJid()
1036                            + ": unable to prepare transport-info from candidate for content="
1037                            + contentName);
1038            return;
1039        }
1040        final JinglePacket jinglePacket =
1041                transportInfo.toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
1042        Log.d(Config.LOGTAG, "--> " + jinglePacket);
1043        send(jinglePacket);
1044    }
1045
1046    @Override
1047    public void onCandidateUsed(
1048            final String streamId, final SocksByteStreamsTransport.Candidate candidate) {
1049        final FileTransferContentMap contentMap = getLocalContentMap();
1050        if (contentMap == null) {
1051            Log.e(Config.LOGTAG, "local content map is null on candidate used");
1052            return;
1053        }
1054        final var jinglePacket =
1055                contentMap
1056                        .candidateUsed(streamId, candidate.cid)
1057                        .toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
1058        Log.d(Config.LOGTAG, "sending candidate used " + jinglePacket);
1059        send(jinglePacket);
1060    }
1061
1062    @Override
1063    public void onCandidateError(final String streamId) {
1064        final FileTransferContentMap contentMap = getLocalContentMap();
1065        if (contentMap == null) {
1066            Log.e(Config.LOGTAG, "local content map is null on candidate used");
1067            return;
1068        }
1069        final var jinglePacket =
1070                contentMap
1071                        .candidateError(streamId)
1072                        .toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
1073        Log.d(Config.LOGTAG, "sending candidate error " + jinglePacket);
1074        send(jinglePacket);
1075    }
1076
1077    @Override
1078    public void onProxyActivated(String streamId, SocksByteStreamsTransport.Candidate candidate) {
1079        final FileTransferContentMap contentMap = getLocalContentMap();
1080        if (contentMap == null) {
1081            Log.e(Config.LOGTAG, "local content map is null on candidate used");
1082            return;
1083        }
1084        final var jinglePacket =
1085                contentMap
1086                        .proxyActivated(streamId, candidate.cid)
1087                        .toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
1088        send(jinglePacket);
1089    }
1090
1091    @Override
1092    protected boolean transition(final State target, final Runnable runnable) {
1093        final boolean transitioned = super.transition(target, runnable);
1094        if (transitioned && isInitiator()) {
1095            Log.d(Config.LOGTAG, "running mark message hooks");
1096            if (target == State.SESSION_ACCEPTED) {
1097                xmppConnectionService.markMessage(message, Message.STATUS_UNSEND);
1098            } else if (target == State.TERMINATED_SUCCESS) {
1099                xmppConnectionService.markMessage(message, Message.STATUS_SEND_RECEIVED);
1100            } else if (TERMINATED.contains(target)) {
1101                xmppConnectionService.markMessage(
1102                        message, Message.STATUS_SEND_FAILED, message.getErrorMessage());
1103            } else {
1104                xmppConnectionService.updateConversationUi();
1105            }
1106        } else {
1107            if (Arrays.asList(State.TERMINATED_CANCEL_OR_TIMEOUT, State.TERMINATED_DECLINED_OR_BUSY)
1108                    .contains(target)) {
1109                this.message.setTransferable(
1110                        new TransferablePlaceholder(Transferable.STATUS_CANCELLED));
1111            } else if (target != State.TERMINATED_SUCCESS && TERMINATED.contains(target)) {
1112                this.message.setTransferable(
1113                        new TransferablePlaceholder(Transferable.STATUS_FAILED));
1114            }
1115            xmppConnectionService.updateConversationUi();
1116        }
1117        return transitioned;
1118    }
1119
1120    @Override
1121    protected void finish() {
1122        if (transport != null) {
1123            throw new AssertionError(
1124                    "finish MUST not be called without terminating the transport first");
1125        }
1126        // we don't want to remove TransferablePlaceholder
1127        if (message.getTransferable() instanceof JingleFileTransferConnection) {
1128            Log.d(Config.LOGTAG, "nulling transferable on message");
1129            this.message.setTransferable(null);
1130        }
1131        super.finish();
1132    }
1133
1134    private int getTransferableStatus() {
1135        // status in file transfer is a bit weird. for sending it is mostly handled via
1136        // Message.STATUS_* (offered, unsend (sic) send_received) the transferable status is just
1137        // uploading
1138        // for receiving the message status remains at 'received' but Transferable goes through
1139        // various status
1140        if (isInitiator()) {
1141            return Transferable.STATUS_UPLOADING;
1142        }
1143        final var state = getState();
1144        return switch (state) {
1145            case NULL, SESSION_INITIALIZED, SESSION_INITIALIZED_PRE_APPROVED -> Transferable
1146                    .STATUS_OFFER;
1147            case TERMINATED_APPLICATION_FAILURE,
1148                    TERMINATED_CONNECTIVITY_ERROR,
1149                    TERMINATED_DECLINED_OR_BUSY,
1150                    TERMINATED_SECURITY_ERROR -> Transferable.STATUS_FAILED;
1151            case TERMINATED_CANCEL_OR_TIMEOUT -> Transferable.STATUS_CANCELLED;
1152            case SESSION_ACCEPTED -> Transferable.STATUS_DOWNLOADING;
1153            default -> Transferable.STATUS_UNKNOWN;
1154        };
1155    }
1156
1157    // these methods are for interacting with 'Transferable' - we might want to remove the concept
1158    // at some point
1159
1160    @Override
1161    public boolean start() {
1162        Log.d(Config.LOGTAG, "user pressed start()");
1163        // TODO there is a 'connected' check apparently?
1164        if (isInState(State.SESSION_INITIALIZED)) {
1165            sendSessionAccept();
1166        }
1167        return true;
1168    }
1169
1170    @Override
1171    public int getStatus() {
1172        return getTransferableStatus();
1173    }
1174
1175    @Override
1176    public Long getFileSize() {
1177        final var transceiver = this.fileTransceiver;
1178        if (transceiver != null) {
1179            return transceiver.total;
1180        }
1181        final var contentMap = this.initiatorFileTransferContentMap;
1182        if (contentMap != null) {
1183            return contentMap.requireOnlyFile().size;
1184        }
1185        return null;
1186    }
1187
1188    @Override
1189    public int getProgress() {
1190        final var transceiver = this.fileTransceiver;
1191        return transceiver != null ? transceiver.getProgress() : 0;
1192    }
1193
1194    @Override
1195    public void cancel() {
1196        if (stopFileTransfer()) {
1197            Log.d(Config.LOGTAG, "user has stopped file transfer");
1198        } else {
1199            Log.d(Config.LOGTAG, "user pressed cancel but file transfer was already terminated?");
1200        }
1201    }
1202
1203    private boolean stopFileTransfer() {
1204        if (isInitiator()) {
1205            return stopFileTransfer(Reason.CANCEL);
1206        } else {
1207            return stopFileTransfer(Reason.DECLINE);
1208        }
1209    }
1210
1211    private boolean stopFileTransfer(final Reason reason) {
1212        final State target = reasonToState(reason);
1213        if (transition(target)) {
1214            // we change state before terminating transport so we don't consume the following
1215            // IOException and turn it into a connectivity error
1216            terminateTransport();
1217            final JinglePacket jinglePacket =
1218                    new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId);
1219            jinglePacket.setReason(reason, "User requested to stop file transfer");
1220            send(jinglePacket);
1221            finish();
1222            return true;
1223        } else {
1224            return false;
1225        }
1226    }
1227
1228    private abstract static class AbstractFileTransceiver implements Runnable {
1229
1230        protected final SettableFuture<List<FileTransferDescription.Hash>> complete =
1231                SettableFuture.create();
1232
1233        protected final File file;
1234        protected final TransportSecurity transportSecurity;
1235
1236        protected final CountDownLatch transportTerminationLatch;
1237        protected final long total;
1238        protected long transmitted = 0;
1239        private int progress = Integer.MIN_VALUE;
1240        private final Runnable updateRunnable;
1241
1242        private AbstractFileTransceiver(
1243                final File file,
1244                final TransportSecurity transportSecurity,
1245                final CountDownLatch transportTerminationLatch,
1246                final long total,
1247                final Runnable updateRunnable) {
1248            this.file = file;
1249            this.transportSecurity = transportSecurity;
1250            this.transportTerminationLatch = transportTerminationLatch;
1251            this.total = transportSecurity == null ? total : (total + 16);
1252            this.updateRunnable = updateRunnable;
1253        }
1254
1255        static void closeTransport(final Closeable stream) {
1256            try {
1257                stream.close();
1258            } catch (final IOException e) {
1259                Log.d(Config.LOGTAG, "transport has already been closed. good");
1260            }
1261        }
1262
1263        public int getProgress() {
1264            return Ints.saturatedCast(Math.round((1.0 * transmitted / total) * 100));
1265        }
1266
1267        public void updateProgress() {
1268            final int current = getProgress();
1269            final boolean update;
1270            synchronized (this) {
1271                if (this.progress != current) {
1272                    this.progress = current;
1273                    update = true;
1274                } else {
1275                    update = false;
1276                }
1277                if (update) {
1278                    this.updateRunnable.run();
1279                }
1280            }
1281        }
1282
1283        protected void awaitTransportTermination() {
1284            try {
1285                this.transportTerminationLatch.await();
1286            } catch (final InterruptedException ignored) {
1287                return;
1288            }
1289            Log.d(Config.LOGTAG, getClass().getSimpleName() + " says Goodbye!");
1290        }
1291    }
1292
1293    private static class FileTransmitter extends AbstractFileTransceiver {
1294
1295        private final OutputStream outputStream;
1296
1297        private FileTransmitter(
1298                final File file,
1299                final TransportSecurity transportSecurity,
1300                final OutputStream outputStream,
1301                final CountDownLatch transportTerminationLatch,
1302                final long total,
1303                final Runnable updateRunnable) {
1304            super(file, transportSecurity, transportTerminationLatch, total, updateRunnable);
1305            this.outputStream = outputStream;
1306        }
1307
1308        private InputStream openFileInputStream() throws FileNotFoundException {
1309            final var fileInputStream = new FileInputStream(this.file);
1310            if (this.transportSecurity == null) {
1311                return fileInputStream;
1312            } else {
1313                final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
1314                cipher.init(
1315                        true,
1316                        new AEADParameters(
1317                                new KeyParameter(transportSecurity.key),
1318                                128,
1319                                transportSecurity.iv));
1320                Log.d(Config.LOGTAG, "setting up CipherInputStream");
1321                return new CipherInputStream(fileInputStream, cipher);
1322            }
1323        }
1324
1325        @Override
1326        public void run() {
1327            Log.d(Config.LOGTAG, "file transmitter attempting to send " + total + " bytes");
1328            final var sha1Hasher = Hashing.sha1().newHasher();
1329            final var sha256Hasher = Hashing.sha256().newHasher();
1330            try (final var fileInputStream = openFileInputStream()) {
1331                final var buffer = new byte[4096];
1332                while (total - transmitted > 0) {
1333                    final int count = fileInputStream.read(buffer);
1334                    if (count == -1) {
1335                        throw new EOFException(
1336                                String.format("reached EOF after %d/%d", transmitted, total));
1337                    }
1338                    outputStream.write(buffer, 0, count);
1339                    sha1Hasher.putBytes(buffer, 0, count);
1340                    sha256Hasher.putBytes(buffer, 0, count);
1341                    transmitted += count;
1342                    updateProgress();
1343                }
1344                outputStream.flush();
1345                Log.d(
1346                        Config.LOGTAG,
1347                        "transmitted " + transmitted + " bytes from " + file.getAbsolutePath());
1348                final List<FileTransferDescription.Hash> hashes =
1349                        ImmutableList.of(
1350                                new FileTransferDescription.Hash(
1351                                        sha1Hasher.hash().asBytes(),
1352                                        FileTransferDescription.Algorithm.SHA_1),
1353                                new FileTransferDescription.Hash(
1354                                        sha256Hasher.hash().asBytes(),
1355                                        FileTransferDescription.Algorithm.SHA_256));
1356                complete.set(hashes);
1357            } catch (final Exception e) {
1358                complete.setException(e);
1359            }
1360            // the transport implementations backed by PipedOutputStreams do not like it when
1361            // the writing Thread (this thread) goes away. so we just wait until the other peer
1362            // has received our file and we are shutting down the transport
1363            Log.d(Config.LOGTAG, "waiting for transport to terminate before stopping thread");
1364            awaitTransportTermination();
1365            closeTransport(outputStream);
1366        }
1367    }
1368
1369    private static class FileReceiver extends AbstractFileTransceiver {
1370
1371        private final InputStream inputStream;
1372
1373        private FileReceiver(
1374                final File file,
1375                final TransportSecurity transportSecurity,
1376                final InputStream inputStream,
1377                final CountDownLatch transportTerminationLatch,
1378                final long total,
1379                final Runnable updateRunnable) {
1380            super(file, transportSecurity, transportTerminationLatch, total, updateRunnable);
1381            this.inputStream = inputStream;
1382        }
1383
1384        private OutputStream openFileOutputStream() throws FileNotFoundException {
1385            final var directory = this.file.getParentFile();
1386            if (directory != null && directory.mkdirs()) {
1387                Log.d(Config.LOGTAG, "created directory " + directory.getAbsolutePath());
1388            }
1389            final var fileOutputStream = new FileOutputStream(this.file);
1390            if (this.transportSecurity == null) {
1391                return fileOutputStream;
1392            } else {
1393                final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
1394                cipher.init(
1395                        false,
1396                        new AEADParameters(
1397                                new KeyParameter(transportSecurity.key),
1398                                128,
1399                                transportSecurity.iv));
1400                Log.d(Config.LOGTAG, "setting up CipherOutputStream");
1401                return new CipherOutputStream(fileOutputStream, cipher);
1402            }
1403        }
1404
1405        @Override
1406        public void run() {
1407            Log.d(Config.LOGTAG, "file receiver attempting to receive " + total + " bytes");
1408            final var sha1Hasher = Hashing.sha1().newHasher();
1409            final var sha256Hasher = Hashing.sha256().newHasher();
1410            try (final var fileOutputStream = openFileOutputStream()) {
1411                final var buffer = new byte[4096];
1412                while (total - transmitted > 0) {
1413                    final int count = inputStream.read(buffer);
1414                    if (count == -1) {
1415                        throw new EOFException(
1416                                String.format("reached EOF after %d/%d", transmitted, total));
1417                    }
1418                    fileOutputStream.write(buffer, 0, count);
1419                    sha1Hasher.putBytes(buffer, 0, count);
1420                    sha256Hasher.putBytes(buffer, 0, count);
1421                    transmitted += count;
1422                    updateProgress();
1423                }
1424                Log.d(
1425                        Config.LOGTAG,
1426                        "written " + transmitted + " bytes to " + file.getAbsolutePath());
1427                final List<FileTransferDescription.Hash> hashes =
1428                        ImmutableList.of(
1429                                new FileTransferDescription.Hash(
1430                                        sha1Hasher.hash().asBytes(),
1431                                        FileTransferDescription.Algorithm.SHA_1),
1432                                new FileTransferDescription.Hash(
1433                                        sha256Hasher.hash().asBytes(),
1434                                        FileTransferDescription.Algorithm.SHA_256));
1435                complete.set(hashes);
1436            } catch (final Exception e) {
1437                complete.setException(e);
1438            }
1439            Log.d(Config.LOGTAG, "waiting for transport to terminate before stopping thread");
1440            awaitTransportTermination();
1441            closeTransport(inputStream);
1442        }
1443    }
1444
1445    private static final class TransportSecurity {
1446        final byte[] key;
1447        final byte[] iv;
1448
1449        private TransportSecurity(byte[] key, byte[] iv) {
1450            this.key = key;
1451            this.iv = iv;
1452        }
1453    }
1454}