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