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