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}