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