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