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