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