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 transport.terminate();
847 this.transport = null;
848 }
849
850 @Override
851 void notifyRebound() {}
852
853 @Override
854 public void onTransportEstablished() {
855 Log.d(Config.LOGTAG, "on transport established");
856 final AbstractFileTransceiver fileTransceiver;
857 try {
858 fileTransceiver = setupTransceiver(isResponder());
859 } catch (final Exception e) {
860 Log.d(Config.LOGTAG, "failed to set up file transceiver", e);
861 sendSessionTerminate(Reason.ofThrowable(e), e.getMessage());
862 return;
863 }
864 this.fileTransceiver = fileTransceiver;
865 final var fileTransceiverThread = new Thread(fileTransceiver);
866 fileTransceiverThread.start();
867 Futures.addCallback(
868 fileTransceiver.complete,
869 new FutureCallback<>() {
870 @Override
871 public void onSuccess(final List<FileTransferDescription.Hash> hashes) {
872 onFileTransmissionComplete(hashes);
873 }
874
875 @Override
876 public void onFailure(@NonNull Throwable throwable) {
877 onFileTransmissionFailed(throwable);
878 }
879 },
880 MoreExecutors.directExecutor());
881 }
882
883 private void onFileTransmissionComplete(final List<FileTransferDescription.Hash> hashes) {
884 // TODO if we ever support receiving files this should become isSending(); isReceiving()
885 if (isInitiator()) {
886 sendSessionInfoChecksum(hashes);
887 } else {
888 Log.d(Config.LOGTAG, "file transfer complete " + hashes);
889 sendFileSessionInfoReceived();
890 terminateTransport();
891 messageReceivedSuccess();
892 sendSessionTerminate(Reason.SUCCESS, null);
893 }
894 }
895
896 private void messageReceivedSuccess() {
897 this.message.setTransferable(null);
898 xmppConnectionService.getFileBackend().updateFileParams(message);
899 xmppConnectionService.databaseBackend.createMessage(message);
900 final File file = xmppConnectionService.getFileBackend().getFile(message);
901 if (acceptedAutomatically) {
902 message.markUnread();
903 if (message.getEncryption() == Message.ENCRYPTION_PGP) {
904 id.account.getPgpDecryptionService().decrypt(message, true);
905 } else {
906 xmppConnectionService
907 .getFileBackend()
908 .updateMediaScanner(
909 file,
910 () ->
911 JingleFileTransferConnection.this
912 .xmppConnectionService
913 .getNotificationService()
914 .push(message));
915 }
916 } else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
917 id.account.getPgpDecryptionService().decrypt(message, false);
918 } else {
919 xmppConnectionService.getFileBackend().updateMediaScanner(file);
920 }
921 }
922
923 private void onFileTransmissionFailed(final Throwable throwable) {
924 if (isTerminated()) {
925 Log.d(
926 Config.LOGTAG,
927 "file transfer failed but session is already terminated",
928 throwable);
929 } else {
930 terminateTransport();
931 Log.d(Config.LOGTAG, "on file transmission failed", throwable);
932 sendSessionTerminate(Reason.CONNECTIVITY_ERROR, null);
933 }
934 }
935
936 private AbstractFileTransceiver setupTransceiver(final boolean receiving) throws IOException {
937 final var fileDescription = getLocalContentMap().requireOnlyFile();
938 final File file = xmppConnectionService.getFileBackend().getFile(message);
939 final Runnable updateRunnable = () -> jingleConnectionManager.updateConversationUi(false);
940 if (receiving) {
941 return new FileReceiver(
942 file,
943 this.transportSecurity,
944 transport.getInputStream(),
945 transport.getTerminationLatch(),
946 fileDescription.size,
947 updateRunnable);
948 } else {
949 return new FileTransmitter(
950 file,
951 this.transportSecurity,
952 transport.getOutputStream(),
953 transport.getTerminationLatch(),
954 fileDescription.size,
955 updateRunnable);
956 }
957 }
958
959 private void sendFileSessionInfoReceived() {
960 final var contentMap = getLocalContentMap();
961 final String name = Iterables.getOnlyElement(contentMap.contents.keySet());
962 sendSessionInfo(new FileTransferDescription.Received(name));
963 }
964
965 private void sendSessionInfoChecksum(List<FileTransferDescription.Hash> hashes) {
966 final var contentMap = getLocalContentMap();
967 final String name = Iterables.getOnlyElement(contentMap.contents.keySet());
968 sendSessionInfo(new FileTransferDescription.Checksum(name, hashes));
969 }
970
971 private void sendSessionInfo(final FileTransferDescription.SessionInfo sessionInfo) {
972 final var jinglePacket =
973 new JinglePacket(JinglePacket.Action.SESSION_INFO, this.id.sessionId);
974 jinglePacket.addJingleChild(sessionInfo.asElement());
975 jinglePacket.setTo(this.id.with);
976 send(jinglePacket);
977 }
978
979 @Override
980 public void onTransportSetupFailed() {
981 final var transport = this.transport;
982 if (transport == null) {
983 // this really is not supposed to happen
984 sendSessionTerminate(Reason.FAILED_APPLICATION, null);
985 return;
986 }
987 Log.d(Config.LOGTAG, "onTransportSetupFailed");
988 final var isTransportInBand = transport instanceof InbandBytestreamsTransport;
989 if (isTransportInBand) {
990 terminateTransport();
991 sendSessionTerminate(Reason.CONNECTIVITY_ERROR, "Failed to setup IBB transport");
992 return;
993 }
994 // terminate the current transport
995 transport.terminate();
996 if (isInitiator()) {
997 this.transport = setupLastResortTransport();
998 Log.d(
999 Config.LOGTAG,
1000 "replacing transport with " + this.transport.getClass().getSimpleName());
1001 this.transport.setTransportCallback(this);
1002 final var transportInfoFuture = this.transport.asTransportInfo();
1003 Futures.addCallback(
1004 transportInfoFuture,
1005 new FutureCallback<>() {
1006 @Override
1007 public void onSuccess(final Transport.TransportInfo transportWrapper) {
1008 final FileTransferContentMap contentMap = getLocalContentMap();
1009 sendTransportReplace(contentMap.withTransport(transportWrapper));
1010 }
1011
1012 @Override
1013 public void onFailure(@NonNull Throwable throwable) {
1014 // TODO send application failure;
1015 }
1016 },
1017 MoreExecutors.directExecutor());
1018
1019 } else {
1020 Log.d(Config.LOGTAG, "transport setup failed. waiting for initiator to replace");
1021 }
1022 }
1023
1024 private void sendTransportReplace(final FileTransferContentMap contentMap) {
1025 setLocalContentMap(contentMap);
1026 final var jinglePacket =
1027 contentMap
1028 .transportInfo()
1029 .toJinglePacket(JinglePacket.Action.TRANSPORT_REPLACE, id.sessionId);
1030 send(jinglePacket);
1031 }
1032
1033 @Override
1034 public void onAdditionalCandidate(
1035 final String contentName, final Transport.Candidate candidate) {
1036 if (candidate instanceof IceUdpTransportInfo.Candidate iceCandidate) {
1037 sendTransportInfo(contentName, iceCandidate);
1038 }
1039 }
1040
1041 public void sendTransportInfo(
1042 final String contentName, final IceUdpTransportInfo.Candidate candidate) {
1043 final FileTransferContentMap transportInfo;
1044 try {
1045 final FileTransferContentMap rtpContentMap = getLocalContentMap();
1046 transportInfo = rtpContentMap.transportInfo(contentName, candidate);
1047 } catch (final Exception e) {
1048 Log.d(
1049 Config.LOGTAG,
1050 id.account.getJid().asBareJid()
1051 + ": unable to prepare transport-info from candidate for content="
1052 + contentName);
1053 return;
1054 }
1055 final JinglePacket jinglePacket =
1056 transportInfo.toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
1057 send(jinglePacket);
1058 }
1059
1060 @Override
1061 public void onCandidateUsed(
1062 final String streamId, final SocksByteStreamsTransport.Candidate candidate) {
1063 final FileTransferContentMap contentMap = getLocalContentMap();
1064 if (contentMap == null) {
1065 Log.e(Config.LOGTAG, "local content map is null on candidate used");
1066 return;
1067 }
1068 final var jinglePacket =
1069 contentMap
1070 .candidateUsed(streamId, candidate.cid)
1071 .toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
1072 Log.d(Config.LOGTAG, "sending candidate used " + jinglePacket);
1073 send(jinglePacket);
1074 }
1075
1076 @Override
1077 public void onCandidateError(final String streamId) {
1078 final FileTransferContentMap contentMap = getLocalContentMap();
1079 if (contentMap == null) {
1080 Log.e(Config.LOGTAG, "local content map is null on candidate used");
1081 return;
1082 }
1083 final var jinglePacket =
1084 contentMap
1085 .candidateError(streamId)
1086 .toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
1087 Log.d(Config.LOGTAG, "sending candidate error " + jinglePacket);
1088 send(jinglePacket);
1089 }
1090
1091 @Override
1092 public void onProxyActivated(String streamId, SocksByteStreamsTransport.Candidate candidate) {
1093 final FileTransferContentMap contentMap = getLocalContentMap();
1094 if (contentMap == null) {
1095 Log.e(Config.LOGTAG, "local content map is null on candidate used");
1096 return;
1097 }
1098 final var jinglePacket =
1099 contentMap
1100 .proxyActivated(streamId, candidate.cid)
1101 .toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
1102 send(jinglePacket);
1103 }
1104
1105 @Override
1106 protected boolean transition(final State target, final Runnable runnable) {
1107 final boolean transitioned = super.transition(target, runnable);
1108 if (transitioned && isInitiator()) {
1109 Log.d(Config.LOGTAG, "running mark message hooks");
1110 if (target == State.SESSION_ACCEPTED) {
1111 xmppConnectionService.markMessage(message, Message.STATUS_UNSEND);
1112 } else if (target == State.TERMINATED_SUCCESS) {
1113 xmppConnectionService.markMessage(message, Message.STATUS_SEND_RECEIVED);
1114 } else if (TERMINATED.contains(target)) {
1115 xmppConnectionService.markMessage(
1116 message, Message.STATUS_SEND_FAILED, message.getErrorMessage());
1117 } else {
1118 xmppConnectionService.updateConversationUi();
1119 }
1120 } else {
1121 if (Arrays.asList(State.TERMINATED_CANCEL_OR_TIMEOUT, State.TERMINATED_DECLINED_OR_BUSY)
1122 .contains(target)) {
1123 this.message.setTransferable(
1124 new TransferablePlaceholder(Transferable.STATUS_CANCELLED));
1125 } else if (target != State.TERMINATED_SUCCESS && TERMINATED.contains(target)) {
1126 this.message.setTransferable(
1127 new TransferablePlaceholder(Transferable.STATUS_FAILED));
1128 }
1129 xmppConnectionService.updateConversationUi();
1130 }
1131 return transitioned;
1132 }
1133
1134 @Override
1135 protected void finish() {
1136 if (transport != null) {
1137 throw new AssertionError(
1138 "finish MUST not be called without terminating the transport first");
1139 }
1140 // we don't want to remove TransferablePlaceholder
1141 if (message.getTransferable() instanceof JingleFileTransferConnection) {
1142 Log.d(Config.LOGTAG, "nulling transferable on message");
1143 this.message.setTransferable(null);
1144 }
1145 super.finish();
1146 }
1147
1148 private int getTransferableStatus() {
1149 // status in file transfer is a bit weird. for sending it is mostly handled via
1150 // Message.STATUS_* (offered, unsend (sic) send_received) the transferable status is just
1151 // uploading
1152 // for receiving the message status remains at 'received' but Transferable goes through
1153 // various status
1154 if (isInitiator()) {
1155 return Transferable.STATUS_UPLOADING;
1156 }
1157 final var state = getState();
1158 return switch (state) {
1159 case NULL, SESSION_INITIALIZED, SESSION_INITIALIZED_PRE_APPROVED -> Transferable
1160 .STATUS_OFFER;
1161 case TERMINATED_APPLICATION_FAILURE,
1162 TERMINATED_CONNECTIVITY_ERROR,
1163 TERMINATED_DECLINED_OR_BUSY,
1164 TERMINATED_SECURITY_ERROR -> Transferable.STATUS_FAILED;
1165 case TERMINATED_CANCEL_OR_TIMEOUT -> Transferable.STATUS_CANCELLED;
1166 case SESSION_ACCEPTED -> Transferable.STATUS_DOWNLOADING;
1167 default -> Transferable.STATUS_UNKNOWN;
1168 };
1169 }
1170
1171 // these methods are for interacting with 'Transferable' - we might want to remove the concept
1172 // at some point
1173
1174 @Override
1175 public boolean start() {
1176 Log.d(Config.LOGTAG, "user pressed start()");
1177 // TODO there is a 'connected' check apparently?
1178 if (isInState(State.SESSION_INITIALIZED)) {
1179 sendSessionAccept();
1180 }
1181 return true;
1182 }
1183
1184 @Override
1185 public int getStatus() {
1186 return getTransferableStatus();
1187 }
1188
1189 @Override
1190 public Long getFileSize() {
1191 final var transceiver = this.fileTransceiver;
1192 if (transceiver != null) {
1193 return transceiver.total;
1194 }
1195 final var contentMap = this.initiatorFileTransferContentMap;
1196 if (contentMap != null) {
1197 return contentMap.requireOnlyFile().size;
1198 }
1199 return null;
1200 }
1201
1202 @Override
1203 public int getProgress() {
1204 final var transceiver = this.fileTransceiver;
1205 return transceiver != null ? transceiver.getProgress() : 0;
1206 }
1207
1208 @Override
1209 public void cancel() {
1210 if (stopFileTransfer()) {
1211 Log.d(Config.LOGTAG, "user has stopped file transfer");
1212 } else {
1213 Log.d(Config.LOGTAG, "user pressed cancel but file transfer was already terminated?");
1214 }
1215 }
1216
1217 private boolean stopFileTransfer() {
1218 if (isInitiator()) {
1219 return stopFileTransfer(Reason.CANCEL);
1220 } else {
1221 return stopFileTransfer(Reason.DECLINE);
1222 }
1223 }
1224
1225 private boolean stopFileTransfer(final Reason reason) {
1226 final State target = reasonToState(reason);
1227 if (transition(target)) {
1228 // we change state before terminating transport so we don't consume the following
1229 // IOException and turn it into a connectivity error
1230 terminateTransport();
1231 final JinglePacket jinglePacket =
1232 new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId);
1233 jinglePacket.setReason(reason, "User requested to stop file transfer");
1234 send(jinglePacket);
1235 finish();
1236 return true;
1237 } else {
1238 return false;
1239 }
1240 }
1241
1242 private abstract static class AbstractFileTransceiver implements Runnable {
1243
1244 protected final SettableFuture<List<FileTransferDescription.Hash>> complete =
1245 SettableFuture.create();
1246
1247 protected final File file;
1248 protected final TransportSecurity transportSecurity;
1249
1250 protected final CountDownLatch transportTerminationLatch;
1251 protected final long total;
1252 protected long transmitted = 0;
1253 private int progress = Integer.MIN_VALUE;
1254 private final Runnable updateRunnable;
1255
1256 private AbstractFileTransceiver(
1257 final File file,
1258 final TransportSecurity transportSecurity,
1259 final CountDownLatch transportTerminationLatch,
1260 final long total,
1261 final Runnable updateRunnable) {
1262 this.file = file;
1263 this.transportSecurity = transportSecurity;
1264 this.transportTerminationLatch = transportTerminationLatch;
1265 this.total = transportSecurity == null ? total : (total + 16);
1266 this.updateRunnable = updateRunnable;
1267 }
1268
1269 static void closeTransport(final Closeable stream) {
1270 try {
1271 stream.close();
1272 } catch (final IOException e) {
1273 Log.d(Config.LOGTAG, "transport has already been closed. good");
1274 }
1275 }
1276
1277 public int getProgress() {
1278 return Ints.saturatedCast(Math.round((1.0 * transmitted / total) * 100));
1279 }
1280
1281 public void updateProgress() {
1282 final int current = getProgress();
1283 final boolean update;
1284 synchronized (this) {
1285 if (this.progress != current) {
1286 this.progress = current;
1287 update = true;
1288 } else {
1289 update = false;
1290 }
1291 if (update) {
1292 this.updateRunnable.run();
1293 }
1294 }
1295 }
1296
1297 protected void awaitTransportTermination() {
1298 try {
1299 this.transportTerminationLatch.await();
1300 } catch (final InterruptedException ignored) {
1301 return;
1302 }
1303 Log.d(Config.LOGTAG, getClass().getSimpleName() + " says Goodbye!");
1304 }
1305 }
1306
1307 private static class FileTransmitter extends AbstractFileTransceiver {
1308
1309 private final OutputStream outputStream;
1310
1311 private FileTransmitter(
1312 final File file,
1313 final TransportSecurity transportSecurity,
1314 final OutputStream outputStream,
1315 final CountDownLatch transportTerminationLatch,
1316 final long total,
1317 final Runnable updateRunnable) {
1318 super(file, transportSecurity, transportTerminationLatch, total, updateRunnable);
1319 this.outputStream = outputStream;
1320 }
1321
1322 private InputStream openFileInputStream() throws FileNotFoundException {
1323 final var fileInputStream = new FileInputStream(this.file);
1324 if (this.transportSecurity == null) {
1325 return fileInputStream;
1326 } else {
1327 final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
1328 cipher.init(
1329 true,
1330 new AEADParameters(
1331 new KeyParameter(transportSecurity.key),
1332 128,
1333 transportSecurity.iv));
1334 Log.d(Config.LOGTAG, "setting up CipherInputStream");
1335 return new CipherInputStream(fileInputStream, cipher);
1336 }
1337 }
1338
1339 @Override
1340 public void run() {
1341 Log.d(Config.LOGTAG, "file transmitter attempting to send " + total + " bytes");
1342 final var sha1Hasher = Hashing.sha1().newHasher();
1343 final var sha256Hasher = Hashing.sha256().newHasher();
1344 try (final var fileInputStream = openFileInputStream()) {
1345 final var buffer = new byte[4096];
1346 while (total - transmitted > 0) {
1347 final int count = fileInputStream.read(buffer);
1348 if (count == -1) {
1349 throw new EOFException(
1350 String.format("reached EOF after %d/%d", transmitted, total));
1351 }
1352 outputStream.write(buffer, 0, count);
1353 sha1Hasher.putBytes(buffer, 0, count);
1354 sha256Hasher.putBytes(buffer, 0, count);
1355 transmitted += count;
1356 updateProgress();
1357 }
1358 outputStream.flush();
1359 Log.d(
1360 Config.LOGTAG,
1361 "transmitted " + transmitted + " bytes from " + file.getAbsolutePath());
1362 final List<FileTransferDescription.Hash> hashes =
1363 ImmutableList.of(
1364 new FileTransferDescription.Hash(
1365 sha1Hasher.hash().asBytes(),
1366 FileTransferDescription.Algorithm.SHA_1),
1367 new FileTransferDescription.Hash(
1368 sha256Hasher.hash().asBytes(),
1369 FileTransferDescription.Algorithm.SHA_256));
1370 complete.set(hashes);
1371 } catch (final Exception e) {
1372 complete.setException(e);
1373 }
1374 // the transport implementations backed by PipedOutputStreams do not like it when
1375 // the writing Thread (this thread) goes away. so we just wait until the other peer
1376 // has received our file and we are shutting down the transport
1377 Log.d(Config.LOGTAG, "waiting for transport to terminate before stopping thread");
1378 awaitTransportTermination();
1379 closeTransport(outputStream);
1380 }
1381 }
1382
1383 private static class FileReceiver extends AbstractFileTransceiver {
1384
1385 private final InputStream inputStream;
1386
1387 private FileReceiver(
1388 final File file,
1389 final TransportSecurity transportSecurity,
1390 final InputStream inputStream,
1391 final CountDownLatch transportTerminationLatch,
1392 final long total,
1393 final Runnable updateRunnable) {
1394 super(file, transportSecurity, transportTerminationLatch, total, updateRunnable);
1395 this.inputStream = inputStream;
1396 }
1397
1398 private OutputStream openFileOutputStream() throws FileNotFoundException {
1399 final var directory = this.file.getParentFile();
1400 if (directory != null && directory.mkdirs()) {
1401 Log.d(Config.LOGTAG, "created directory " + directory.getAbsolutePath());
1402 }
1403 final var fileOutputStream = new FileOutputStream(this.file);
1404 if (this.transportSecurity == null) {
1405 return fileOutputStream;
1406 } else {
1407 final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
1408 cipher.init(
1409 false,
1410 new AEADParameters(
1411 new KeyParameter(transportSecurity.key),
1412 128,
1413 transportSecurity.iv));
1414 Log.d(Config.LOGTAG, "setting up CipherOutputStream");
1415 return new CipherOutputStream(fileOutputStream, cipher);
1416 }
1417 }
1418
1419 @Override
1420 public void run() {
1421 Log.d(Config.LOGTAG, "file receiver attempting to receive " + total + " bytes");
1422 final var sha1Hasher = Hashing.sha1().newHasher();
1423 final var sha256Hasher = Hashing.sha256().newHasher();
1424 try (final var fileOutputStream = openFileOutputStream()) {
1425 final var buffer = new byte[4096];
1426 while (total - transmitted > 0) {
1427 final int count = inputStream.read(buffer);
1428 if (count == -1) {
1429 throw new EOFException(
1430 String.format("reached EOF after %d/%d", transmitted, total));
1431 }
1432 fileOutputStream.write(buffer, 0, count);
1433 sha1Hasher.putBytes(buffer, 0, count);
1434 sha256Hasher.putBytes(buffer, 0, count);
1435 transmitted += count;
1436 updateProgress();
1437 }
1438 Log.d(
1439 Config.LOGTAG,
1440 "written " + transmitted + " bytes to " + file.getAbsolutePath());
1441 final List<FileTransferDescription.Hash> hashes =
1442 ImmutableList.of(
1443 new FileTransferDescription.Hash(
1444 sha1Hasher.hash().asBytes(),
1445 FileTransferDescription.Algorithm.SHA_1),
1446 new FileTransferDescription.Hash(
1447 sha256Hasher.hash().asBytes(),
1448 FileTransferDescription.Algorithm.SHA_256));
1449 complete.set(hashes);
1450 } catch (final Exception e) {
1451 complete.setException(e);
1452 }
1453 Log.d(Config.LOGTAG, "waiting for transport to terminate before stopping thread");
1454 awaitTransportTermination();
1455 closeTransport(inputStream);
1456 }
1457 }
1458
1459 private static final class TransportSecurity {
1460 final byte[] key;
1461 final byte[] iv;
1462
1463 private TransportSecurity(byte[] key, byte[] iv) {
1464 this.key = key;
1465 this.iv = iv;
1466 }
1467 }
1468}