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