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