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