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