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