1package eu.siacs.conversations.xmpp.jingle;
2
3import android.telecom.VideoProfile;
4import android.util.Base64;
5import android.util.Log;
6
7import androidx.annotation.Nullable;
8
9import com.google.common.base.Objects;
10import com.google.common.base.Optional;
11import com.google.common.base.Preconditions;
12import com.google.common.cache.Cache;
13import com.google.common.cache.CacheBuilder;
14import com.google.common.collect.Collections2;
15import com.google.common.collect.ComparisonChain;
16import com.google.common.collect.ImmutableSet;
17
18import eu.siacs.conversations.Config;
19import eu.siacs.conversations.entities.Account;
20import eu.siacs.conversations.entities.Contact;
21import eu.siacs.conversations.entities.Conversation;
22import eu.siacs.conversations.entities.Conversational;
23import eu.siacs.conversations.entities.Message;
24import eu.siacs.conversations.entities.RtpSessionStatus;
25import eu.siacs.conversations.entities.Transferable;
26import eu.siacs.conversations.services.AbstractConnectionManager;
27import eu.siacs.conversations.services.CallIntegration;
28import eu.siacs.conversations.services.CallIntegrationConnectionService;
29import eu.siacs.conversations.services.XmppConnectionService;
30import eu.siacs.conversations.xml.Element;
31import eu.siacs.conversations.xml.Namespace;
32import eu.siacs.conversations.xmpp.Jid;
33import eu.siacs.conversations.xmpp.XmppConnection;
34import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
35import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription;
36import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
37import eu.siacs.conversations.xmpp.jingle.stanzas.Propose;
38import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
39import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
40import eu.siacs.conversations.xmpp.jingle.transports.InbandBytestreamsTransport;
41import eu.siacs.conversations.xmpp.jingle.transports.Transport;
42import eu.siacs.conversations.xmpp.stanzas.IqPacket;
43import eu.siacs.conversations.xmpp.stanzas.MessagePacket;
44
45import java.lang.ref.WeakReference;
46import java.security.SecureRandom;
47import java.util.Collection;
48import java.util.HashMap;
49import java.util.List;
50import java.util.Map;
51import java.util.Set;
52import java.util.concurrent.ConcurrentHashMap;
53import java.util.concurrent.Executors;
54import java.util.concurrent.ScheduledExecutorService;
55import java.util.concurrent.ScheduledFuture;
56import java.util.concurrent.TimeUnit;
57
58public class JingleConnectionManager extends AbstractConnectionManager {
59 public static final ScheduledExecutorService SCHEDULED_EXECUTOR_SERVICE =
60 Executors.newSingleThreadScheduledExecutor();
61 private final HashMap<RtpSessionProposal, DeviceDiscoveryState> rtpSessionProposals =
62 new HashMap<>();
63 private final ConcurrentHashMap<AbstractJingleConnection.Id, AbstractJingleConnection>
64 connections = new ConcurrentHashMap<>();
65
66 private final Cache<PersistableSessionId, TerminatedRtpSession> terminatedSessions =
67 CacheBuilder.newBuilder().expireAfterWrite(24, TimeUnit.HOURS).build();
68
69 public JingleConnectionManager(XmppConnectionService service) {
70 super(service);
71 }
72
73 static String nextRandomId() {
74 final byte[] id = new byte[16];
75 new SecureRandom().nextBytes(id);
76 return Base64.encodeToString(id, Base64.NO_WRAP | Base64.NO_PADDING | Base64.URL_SAFE);
77 }
78
79 public void deliverPacket(final Account account, final JinglePacket packet) {
80 final String sessionId = packet.getSessionId();
81 final JinglePacket.Action action = packet.getAction();
82 if (sessionId == null) {
83 respondWithJingleError(account, packet, "unknown-session", "item-not-found", "cancel");
84 return;
85 }
86 if (action == null) {
87 respondWithJingleError(account, packet, null, "bad-request", "cancel");
88 return;
89 }
90 final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(account, packet);
91 final AbstractJingleConnection existingJingleConnection = connections.get(id);
92 if (existingJingleConnection != null) {
93 existingJingleConnection.deliverPacket(packet);
94 } else if (action == JinglePacket.Action.SESSION_INITIATE) {
95 final Jid from = packet.getFrom();
96 final Content content = packet.getJingleContent();
97 final String descriptionNamespace =
98 content == null ? null : content.getDescriptionNamespace();
99 final AbstractJingleConnection connection;
100 if (Namespace.JINGLE_APPS_FILE_TRANSFER.equals(descriptionNamespace)) {
101 connection = new JingleFileTransferConnection(this, id, from);
102 } else if (Namespace.JINGLE_APPS_RTP.equals(descriptionNamespace)
103 && isUsingClearNet(account)) {
104 final boolean sessionEnded =
105 this.terminatedSessions.asMap().containsKey(PersistableSessionId.of(id));
106 final boolean stranger =
107 isWithStrangerAndStrangerNotificationsAreOff(account, id.with);
108 final boolean busy = isBusy();
109 if (busy || sessionEnded || stranger) {
110 Log.d(
111 Config.LOGTAG,
112 id.account.getJid().asBareJid()
113 + ": rejected session with "
114 + id.with
115 + " because busy. sessionEnded="
116 + sessionEnded
117 + ", stranger="
118 + stranger);
119 sendSessionTerminate(account, packet, id);
120 if (busy || stranger) {
121 writeLogMissedIncoming(
122 account,
123 id.with,
124 id.sessionId,
125 null,
126 System.currentTimeMillis(),
127 stranger);
128 }
129 return;
130 }
131 connection = new JingleRtpConnection(this, id, from);
132 } else {
133 respondWithJingleError(
134 account, packet, "unsupported-info", "feature-not-implemented", "cancel");
135 return;
136 }
137 connections.put(id, connection);
138 mXmppConnectionService.updateConversationUi();
139 connection.deliverPacket(packet);
140 if (connection instanceof JingleRtpConnection rtpConnection) {
141 addNewIncomingCall(rtpConnection);
142 }
143 } else {
144 Log.d(Config.LOGTAG, "unable to route jingle packet: " + packet);
145 respondWithJingleError(account, packet, "unknown-session", "item-not-found", "cancel");
146 }
147 }
148
149 private void addNewIncomingCall(final JingleRtpConnection rtpConnection) {
150 if (true) {
151 return; // We do this inside the startRinging in the rtpConnection now so that fallback is possible
152 }
153 if (rtpConnection.isTerminated()) {
154 Log.d(
155 Config.LOGTAG,
156 "skip call integration because something must have gone during initiate");
157 return;
158 }
159 if (CallIntegrationConnectionService.addNewIncomingCall(
160 mXmppConnectionService, rtpConnection.getId())) {
161 return;
162 }
163 rtpConnection.integrationFailure();
164 }
165
166 private void sendSessionTerminate(
167 final Account account, final IqPacket request, final AbstractJingleConnection.Id id) {
168 mXmppConnectionService.sendIqPacket(
169 account, request.generateResponse(IqPacket.TYPE.RESULT), null);
170 final JinglePacket sessionTermination =
171 new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId);
172 sessionTermination.setTo(id.with);
173 sessionTermination.setReason(Reason.BUSY, null);
174 mXmppConnectionService.sendIqPacket(account, sessionTermination, null);
175 }
176
177 private boolean isUsingClearNet(final Account account) {
178 return !account.isOnion() && !mXmppConnectionService.useTorToConnect();
179 }
180
181 public boolean isBusy() {
182 for (final AbstractJingleConnection connection : this.connections.values()) {
183 if (connection instanceof JingleRtpConnection rtpConnection) {
184 if (connection.isTerminated() && rtpConnection.getCallIntegration().isDestroyed()) {
185 continue;
186 }
187 return true;
188 }
189 }
190 synchronized (this.rtpSessionProposals) {
191 if (this.rtpSessionProposals.containsValue(DeviceDiscoveryState.DISCOVERED)) return true;
192 if (this.rtpSessionProposals.containsValue(DeviceDiscoveryState.SEARCHING)) return true;
193 if (this.rtpSessionProposals.containsValue(DeviceDiscoveryState.SEARCHING_ACKNOWLEDGED)) return true;
194 return false;
195 }
196 }
197
198 public boolean hasJingleRtpConnection(final Account account) {
199 for (AbstractJingleConnection connection : this.connections.values()) {
200 if (connection instanceof JingleRtpConnection rtpConnection) {
201 if (rtpConnection.isTerminated()) {
202 continue;
203 }
204 if (rtpConnection.id.account == account) {
205 return true;
206 }
207 }
208 }
209 return false;
210 }
211
212 private Optional<RtpSessionProposal> findMatchingSessionProposal(
213 final Account account, final Jid with, final Set<Media> media) {
214 synchronized (this.rtpSessionProposals) {
215 for (Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry :
216 this.rtpSessionProposals.entrySet()) {
217 final RtpSessionProposal proposal = entry.getKey();
218 final DeviceDiscoveryState state = entry.getValue();
219 final boolean openProposal =
220 state == DeviceDiscoveryState.DISCOVERED
221 || state == DeviceDiscoveryState.SEARCHING
222 || state == DeviceDiscoveryState.SEARCHING_ACKNOWLEDGED;
223 if (openProposal
224 && proposal.account == account
225 && proposal.with.equals(with.asBareJid())
226 && proposal.media.equals(media)) {
227 return Optional.of(proposal);
228 }
229 }
230 }
231 return Optional.absent();
232 }
233
234 private String hasMatchingRtpSession(final Account account, final Jid with, final Set<Media> media) {
235 for (AbstractJingleConnection connection : this.connections.values()) {
236 if (connection instanceof JingleRtpConnection rtpConnection) {
237 if (rtpConnection.isTerminated()) {
238 continue;
239 }
240 if (rtpConnection.getId().account == account
241 && rtpConnection.getId().with.asBareJid().equals(with.asBareJid())
242 && rtpConnection.getMedia().equals(media)) {
243 return rtpConnection.getId().sessionId;
244 }
245 }
246 }
247 return null;
248 }
249
250 private boolean isWithStrangerAndStrangerNotificationsAreOff(final Account account, Jid with) {
251 final boolean notifyForStrangers =
252 mXmppConnectionService.getNotificationService().notificationsFromStrangers();
253 if (notifyForStrangers) {
254 return false;
255 }
256 final Contact contact = account.getRoster().getContact(with);
257 return !contact.showInContactList();
258 }
259
260 ScheduledFuture<?> schedule(
261 final Runnable runnable, final long delay, final TimeUnit timeUnit) {
262 return SCHEDULED_EXECUTOR_SERVICE.schedule(runnable, delay, timeUnit);
263 }
264
265 void respondWithJingleError(
266 final Account account,
267 final IqPacket original,
268 final String jingleCondition,
269 final String condition,
270 final String conditionType) {
271 final IqPacket response = original.generateResponse(IqPacket.TYPE.ERROR);
272 final Element error = response.addChild("error");
273 error.setAttribute("type", conditionType);
274 error.addChild(condition, "urn:ietf:params:xml:ns:xmpp-stanzas");
275 if (jingleCondition != null) {
276 error.addChild(jingleCondition, Namespace.JINGLE_ERRORS);
277 }
278 account.getXmppConnection().sendIqPacket(response, null);
279 }
280
281 public void deliverMessage(
282 final Account account,
283 final Jid to,
284 final Jid from,
285 final Element message,
286 String remoteMsgId,
287 String serverMsgId,
288 long timestamp) {
289 Preconditions.checkArgument(Namespace.JINGLE_MESSAGE.equals(message.getNamespace()));
290 final String sessionId = message.getAttribute("id");
291 if (sessionId == null) {
292 return;
293 }
294 if ("accept".equals(message.getName()) || "reject".equals(message.getName())) {
295 for (AbstractJingleConnection connection : connections.values()) {
296 if (connection instanceof JingleRtpConnection rtpConnection) {
297 final AbstractJingleConnection.Id id = connection.getId();
298 if (id.account == account && id.sessionId.equals(sessionId)) {
299 rtpConnection.deliveryMessage(from, message, serverMsgId, timestamp);
300 return;
301 }
302 }
303 }
304 if ("accept".equals(message.getName())) return;
305 }
306 final boolean fromSelf = from.asBareJid().equals(account.getJid().asBareJid());
307 // XEP version 0.6.0 sends proceed, reject, ringing to bare jid
308 final boolean addressedDirectly = to != null && to.equals(account.getJid());
309 final AbstractJingleConnection.Id id;
310 if (fromSelf) {
311 if (to != null && to.isFullJid()) {
312 id = AbstractJingleConnection.Id.of(account, to, sessionId);
313 } else {
314 return;
315 }
316 } else {
317 id = AbstractJingleConnection.Id.of(account, from, sessionId);
318 }
319 final AbstractJingleConnection existingJingleConnection = connections.get(id);
320 if (existingJingleConnection != null) {
321 if (existingJingleConnection instanceof JingleRtpConnection) {
322 ((JingleRtpConnection) existingJingleConnection)
323 .deliveryMessage(from, message, serverMsgId, timestamp);
324 } else {
325 Log.d(
326 Config.LOGTAG,
327 account.getJid().asBareJid()
328 + ": "
329 + existingJingleConnection.getClass().getName()
330 + " does not support jingle messages");
331 }
332 return;
333 }
334
335 if (fromSelf) {
336 if ("proceed".equals(message.getName())) {
337 final Conversation c =
338 mXmppConnectionService.findOrCreateConversation(
339 account, id.with, false, false);
340 final Message previousBusy = c.findRtpSession(sessionId, Message.STATUS_RECEIVED);
341 if (previousBusy != null) {
342 previousBusy.setBody(new RtpSessionStatus(true, 0).toString());
343 if (serverMsgId != null) {
344 previousBusy.setServerMsgId(serverMsgId);
345 }
346 previousBusy.setTime(timestamp);
347 mXmppConnectionService.updateMessage(previousBusy, true);
348 Log.d(
349 Config.LOGTAG,
350 id.account.getJid().asBareJid()
351 + ": updated previous busy because call got picked up by another device");
352 mXmppConnectionService.getNotificationService().clearMissedCall(previousBusy);
353 return;
354 }
355 }
356 // TODO handle reject for cases where we don’t have carbon copies (normally reject is to
357 // be sent to own bare jid as well)
358 Log.d(
359 Config.LOGTAG,
360 account.getJid().asBareJid() + ": ignore jingle message from self");
361 return;
362 }
363
364 if ("propose".equals(message.getName())) {
365 final Propose propose = Propose.upgrade(message);
366 final List<GenericDescription> descriptions = propose.getDescriptions();
367 final Collection<RtpDescription> rtpDescriptions =
368 Collections2.transform(
369 Collections2.filter(descriptions, d -> d instanceof RtpDescription),
370 input -> (RtpDescription) input);
371 if (rtpDescriptions.size() > 0
372 && rtpDescriptions.size() == descriptions.size()
373 && isUsingClearNet(account)) {
374 final Collection<Media> media =
375 Collections2.transform(rtpDescriptions, RtpDescription::getMedia);
376 if (media.contains(Media.UNKNOWN)) {
377 Log.d(
378 Config.LOGTAG,
379 account.getJid().asBareJid()
380 + ": encountered unknown media in session proposal. "
381 + propose);
382 return;
383 }
384 final Optional<RtpSessionProposal> matchingSessionProposal =
385 findMatchingSessionProposal(account, id.with, ImmutableSet.copyOf(media));
386 if (matchingSessionProposal.isPresent()) {
387 final String ourSessionId = matchingSessionProposal.get().sessionId;
388 final String theirSessionId = id.sessionId;
389 if (ComparisonChain.start()
390 .compare(ourSessionId, theirSessionId)
391 .compare(
392 account.getJid().toEscapedString(),
393 id.with.toEscapedString())
394 .result()
395 > 0) {
396 Log.d(
397 Config.LOGTAG,
398 account.getJid().asBareJid()
399 + ": our session lost tie break. automatically accepting their session. winning Session="
400 + theirSessionId);
401 // TODO a retract for this reason should probably include some indication of
402 // tie break
403 retractSessionProposal(matchingSessionProposal.get());
404 final JingleRtpConnection rtpConnection =
405 new JingleRtpConnection(this, id, from);
406 this.connections.put(id, rtpConnection);
407 rtpConnection.setProposedMedia(ImmutableSet.copyOf(media));
408 rtpConnection.deliveryMessage(from, message, serverMsgId, timestamp);
409 addNewIncomingCall(rtpConnection);
410 // TODO actually do the automatic accept?!
411 } else {
412 Log.d(
413 Config.LOGTAG,
414 account.getJid().asBareJid()
415 + ": our session won tie break. waiting for other party to accept. winningSession="
416 + ourSessionId);
417 // TODO reject their session with <tie-break/>?
418 }
419 return;
420 }
421 final boolean stranger =
422 isWithStrangerAndStrangerNotificationsAreOff(account, id.with);
423 if (isBusy() || stranger) {
424 writeLogMissedIncoming(
425 account,
426 id.with.asBareJid(),
427 id.sessionId,
428 serverMsgId,
429 timestamp,
430 stranger);
431 if (stranger) {
432 Log.d(
433 Config.LOGTAG,
434 id.account.getJid().asBareJid()
435 + ": ignoring call proposal from stranger "
436 + id.with);
437 return;
438 }
439 final int activeDevices = account.activeDevicesWithRtpCapability();
440 Log.d(Config.LOGTAG, "active devices with rtp capability: " + activeDevices);
441 if (activeDevices == 0) {
442 final MessagePacket reject =
443 mXmppConnectionService
444 .getMessageGenerator()
445 .sessionReject(from, sessionId);
446 mXmppConnectionService.sendMessagePacket(account, reject);
447 } else {
448 Log.d(
449 Config.LOGTAG,
450 id.account.getJid().asBareJid()
451 + ": ignoring proposal because busy on this device but there are other devices");
452 }
453 } else {
454 final JingleRtpConnection rtpConnection =
455 new JingleRtpConnection(this, id, from);
456 this.connections.put(id, rtpConnection);
457 rtpConnection.setProposedMedia(ImmutableSet.copyOf(media));
458 rtpConnection.deliveryMessage(from, message, serverMsgId, timestamp);
459 addNewIncomingCall(rtpConnection);
460 }
461 } else {
462 Log.d(
463 Config.LOGTAG,
464 account.getJid().asBareJid()
465 + ": unable to react to proposed session with "
466 + rtpDescriptions.size()
467 + " rtp descriptions of "
468 + descriptions.size()
469 + " total descriptions");
470 }
471 } else if (addressedDirectly && "proceed".equals(message.getName())) {
472 synchronized (rtpSessionProposals) {
473 final RtpSessionProposal proposal =
474 getRtpSessionProposal(account, from.asBareJid(), sessionId);
475 if (proposal != null) {
476 rtpSessionProposals.remove(proposal);
477 final JingleRtpConnection rtpConnection =
478 new JingleRtpConnection(
479 this, id, account.getJid(), proposal.callIntegration);
480 rtpConnection.setProposedMedia(proposal.media);
481 this.connections.put(id, rtpConnection);
482 rtpConnection.transitionOrThrow(AbstractJingleConnection.State.PROPOSED);
483 rtpConnection.deliveryMessage(from, message, serverMsgId, timestamp);
484 } else {
485 Log.d(
486 Config.LOGTAG,
487 account.getJid().asBareJid()
488 + ": no rtp session ("
489 + sessionId
490 + ") proposal found for "
491 + from
492 + " to deliver proceed");
493 if (remoteMsgId == null) {
494 return;
495 }
496 final MessagePacket errorMessage = new MessagePacket();
497 errorMessage.setTo(from);
498 errorMessage.setId(remoteMsgId);
499 errorMessage.setType(MessagePacket.TYPE_ERROR);
500 final Element error = errorMessage.addChild("error");
501 error.setAttribute("code", "404");
502 error.setAttribute("type", "cancel");
503 error.addChild("item-not-found", "urn:ietf:params:xml:ns:xmpp-stanzas");
504 mXmppConnectionService.sendMessagePacket(account, errorMessage);
505 }
506 }
507 } else if (addressedDirectly && "reject".equals(message.getName())) {
508 final RtpSessionProposal proposal =
509 getRtpSessionProposal(account, from.asBareJid(), sessionId);
510 synchronized (rtpSessionProposals) {
511 if (proposal != null) {
512 setTerminalSessionState(proposal, RtpEndUserState.DECLINED_OR_BUSY);
513 rtpSessionProposals.remove(proposal);
514 proposal.callIntegration.busy();
515 writeLogMissedOutgoing(
516 account, proposal.with, proposal.sessionId, serverMsgId, timestamp);
517 mXmppConnectionService.notifyJingleRtpConnectionUpdate(
518 account,
519 proposal.with,
520 proposal.sessionId,
521 RtpEndUserState.DECLINED_OR_BUSY);
522 } else {
523 Log.d(
524 Config.LOGTAG,
525 account.getJid().asBareJid()
526 + ": no rtp session proposal found for "
527 + from
528 + " to deliver reject");
529 }
530 }
531 } else if (addressedDirectly && "ringing".equals(message.getName())) {
532 Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": " + from + " started ringing");
533 updateProposedSessionDiscovered(
534 account, from, sessionId, DeviceDiscoveryState.DISCOVERED);
535 } else {
536 Log.d(
537 Config.LOGTAG,
538 account.getJid()
539 + ": received out of order jingle message from="
540 + from
541 + ", message="
542 + message
543 + ", addressedDirectly="
544 + addressedDirectly);
545 }
546 }
547
548 private RtpSessionProposal getRtpSessionProposal(
549 final Account account, Jid from, String sessionId) {
550 for (RtpSessionProposal rtpSessionProposal : rtpSessionProposals.keySet()) {
551 if (rtpSessionProposal.sessionId.equals(sessionId)
552 && rtpSessionProposal.with.equals(from)
553 && rtpSessionProposal.account.getJid().equals(account.getJid())) {
554 return rtpSessionProposal;
555 }
556 }
557 return null;
558 }
559
560 private void writeLogMissedOutgoing(
561 final Account account,
562 Jid with,
563 final String sessionId,
564 String serverMsgId,
565 long timestamp) {
566 final Conversation conversation =
567 mXmppConnectionService.findOrCreateConversation(
568 account, with.asBareJid(), false, false);
569 final Message message =
570 new Message(conversation, Message.STATUS_SEND, Message.TYPE_RTP_SESSION, sessionId);
571 message.setBody(new RtpSessionStatus(false, 0).toString());
572 message.setServerMsgId(serverMsgId);
573 message.setTime(timestamp);
574 writeMessage(message);
575 }
576
577 private void writeLogMissedIncoming(
578 final Account account,
579 final Jid with,
580 final String sessionId,
581 final String serverMsgId,
582 final long timestamp,
583 final boolean stranger) {
584 final Conversation conversation =
585 mXmppConnectionService.findOrCreateConversation(
586 account, with.asBareJid(), false, false);
587 final Message message =
588 new Message(
589 conversation, Message.STATUS_RECEIVED, Message.TYPE_RTP_SESSION, sessionId);
590 message.setBody(new RtpSessionStatus(false, 0).toString());
591 message.setServerMsgId(serverMsgId);
592 message.setTime(timestamp);
593 message.setCounterpart(with);
594 writeMessage(message);
595 if (stranger) {
596 return;
597 }
598 mXmppConnectionService.getNotificationService().pushMissedCallNow(message);
599 }
600
601 private void writeMessage(final Message message) {
602 final Conversational conversational = message.getConversation();
603 if (conversational instanceof Conversation) {
604 ((Conversation) conversational).add(message);
605 mXmppConnectionService.databaseBackend.createMessage(message);
606 mXmppConnectionService.updateConversationUi();
607 } else {
608 throw new IllegalStateException("Somehow the conversation in a message was a stub");
609 }
610 }
611
612 public void startJingleFileTransfer(final Message message) {
613 Preconditions.checkArgument(
614 message.isFileOrImage(), "Message is not of type file or image");
615 final Transferable old = message.getTransferable();
616 if (old != null) {
617 old.cancel();
618 }
619 final JingleFileTransferConnection connection =
620 new JingleFileTransferConnection(this, message);
621 this.connections.put(connection.getId(), connection);
622 connection.sendSessionInitialize();
623 }
624
625 public Optional<OngoingRtpSession> getOngoingRtpConnection(final Contact contact) {
626 for (final Map.Entry<AbstractJingleConnection.Id, AbstractJingleConnection> entry :
627 this.connections.entrySet()) {
628 if (entry.getValue() instanceof JingleRtpConnection jingleRtpConnection) {
629 final AbstractJingleConnection.Id id = entry.getKey();
630 if (id.account == contact.getAccount()
631 && id.with.asBareJid().equals(contact.getJid().asBareJid())) {
632 return Optional.of(jingleRtpConnection);
633 }
634 }
635 }
636 synchronized (this.rtpSessionProposals) {
637 for (final Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry :
638 this.rtpSessionProposals.entrySet()) {
639 final RtpSessionProposal proposal = entry.getKey();
640 if (proposal.account == contact.getAccount()
641 && contact.getJid().asBareJid().equals(proposal.with)) {
642 final DeviceDiscoveryState preexistingState = entry.getValue();
643 if (preexistingState != null
644 && preexistingState != DeviceDiscoveryState.FAILED) {
645 return Optional.of(proposal);
646 }
647 }
648 }
649 }
650 return Optional.absent();
651 }
652
653 public JingleRtpConnection getOngoingRtpConnection() {
654 for (final AbstractJingleConnection jingleConnection : this.connections.values()) {
655 if (jingleConnection instanceof JingleRtpConnection jingleRtpConnection) {
656 if (jingleRtpConnection.isTerminated()) {
657 continue;
658 }
659 return jingleRtpConnection;
660 }
661 }
662 return null;
663 }
664
665 void finishConnectionOrThrow(final AbstractJingleConnection connection) {
666 final AbstractJingleConnection.Id id = connection.getId();
667 if (this.connections.remove(id) == null) {
668 throw new IllegalStateException(
669 String.format("Unable to finish connection with id=%s", id));
670 }
671 // update chat UI to remove 'ongoing call' icon
672 mXmppConnectionService.updateConversationUi();
673 }
674
675 public boolean fireJingleRtpConnectionStateUpdates() {
676 for (final AbstractJingleConnection connection : this.connections.values()) {
677 if (connection instanceof JingleRtpConnection jingleRtpConnection) {
678 if (jingleRtpConnection.isTerminated()) {
679 continue;
680 }
681 jingleRtpConnection.fireStateUpdate();
682 return true;
683 }
684 }
685 return false;
686 }
687
688 public void retractSessionProposal(final Account account, final Jid with) {
689 synchronized (this.rtpSessionProposals) {
690 RtpSessionProposal matchingProposal = null;
691 for (RtpSessionProposal proposal : this.rtpSessionProposals.keySet()) {
692 if (proposal.account == account && with.asBareJid().equals(proposal.with)) {
693 matchingProposal = proposal;
694 break;
695 }
696 }
697 if (matchingProposal != null) {
698 retractSessionProposal(matchingProposal, false);
699 }
700 }
701 }
702
703 private void retractSessionProposal(final RtpSessionProposal rtpSessionProposal) {
704 retractSessionProposal(rtpSessionProposal, true);
705 }
706
707 private void retractSessionProposal(
708 final RtpSessionProposal rtpSessionProposal, final boolean refresh) {
709 final Account account = rtpSessionProposal.account;
710 Log.d(
711 Config.LOGTAG,
712 account.getJid().asBareJid()
713 + ": retracting rtp session proposal with "
714 + rtpSessionProposal.with);
715 this.rtpSessionProposals.remove(rtpSessionProposal);
716 rtpSessionProposal.callIntegration.retracted();
717 if (refresh) {
718 mXmppConnectionService.notifyJingleRtpConnectionUpdate(
719 account,
720 rtpSessionProposal.with,
721 rtpSessionProposal.sessionId,
722 RtpEndUserState.RETRACTED);
723 }
724 final MessagePacket messagePacket =
725 mXmppConnectionService.getMessageGenerator().sessionRetract(rtpSessionProposal);
726 writeLogMissedOutgoing(
727 account,
728 rtpSessionProposal.with,
729 rtpSessionProposal.sessionId,
730 null,
731 System.currentTimeMillis());
732 mXmppConnectionService.sendMessagePacket(account, messagePacket);
733 }
734
735 public JingleRtpConnection initializeRtpSession(
736 final Account account, final Jid with, final Set<Media> media) {
737 final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(account, with);
738 final JingleRtpConnection rtpConnection =
739 new JingleRtpConnection(this, id, account.getJid());
740 rtpConnection.setProposedMedia(media);
741 rtpConnection.getCallIntegration().startAudioRouting();
742 this.connections.put(id, rtpConnection);
743 rtpConnection.sendSessionInitiate();
744 return rtpConnection;
745 }
746
747 public @Nullable RtpSessionProposal proposeJingleRtpSession(
748 final Account account, final Jid with, final Set<Media> media) {
749 synchronized (this.rtpSessionProposals) {
750 for (final Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry :
751 this.rtpSessionProposals.entrySet()) {
752 final RtpSessionProposal proposal = entry.getKey();
753 if (proposal.account == account && with.asBareJid().equals(proposal.with)) {
754 final DeviceDiscoveryState preexistingState = entry.getValue();
755 if (preexistingState != null
756 && preexistingState != DeviceDiscoveryState.FAILED) {
757 final RtpEndUserState endUserState = preexistingState.toEndUserState();
758 mXmppConnectionService.notifyJingleRtpConnectionUpdate(
759 account, with, proposal.sessionId, endUserState);
760 return proposal;
761 }
762 }
763 }
764 if (isBusy()) {
765 if (hasMatchingRtpSession(account, with, media) != null) {
766 Log.d(
767 Config.LOGTAG,
768 "ignoring request to propose jingle session because the other party already created one for us");
769 // TODO return something that we can parse the connection of of
770 return null;
771 }
772 throw new IllegalStateException("There is already a running RTP session");
773 }
774 final CallIntegration callIntegration =
775 new CallIntegration(mXmppConnectionService.getApplicationContext());
776 callIntegration.setVideoState(
777 Media.audioOnly(media)
778 ? VideoProfile.STATE_AUDIO_ONLY
779 : VideoProfile.STATE_BIDIRECTIONAL);
780 callIntegration.setInitialAudioDevice(CallIntegration.initialAudioDevice(media));
781 callIntegration.startAudioRouting();
782 final RtpSessionProposal proposal =
783 RtpSessionProposal.of(account, with.asBareJid(), media, callIntegration);
784 callIntegration.setCallback(new ProposalStateCallback(proposal));
785 this.rtpSessionProposals.put(proposal, DeviceDiscoveryState.SEARCHING);
786 mXmppConnectionService.notifyJingleRtpConnectionUpdate(
787 account, proposal.with, proposal.sessionId, RtpEndUserState.FINDING_DEVICE);
788 final MessagePacket messagePacket =
789 mXmppConnectionService.getMessageGenerator().sessionProposal(proposal);
790 mXmppConnectionService.sendMessagePacket(account, messagePacket);
791 return proposal;
792 }
793 }
794
795 public void sendJingleMessageFinish(
796 final Contact contact, final String sessionId, final Reason reason) {
797 final var account = contact.getAccount();
798 final MessagePacket messagePacket =
799 mXmppConnectionService
800 .getMessageGenerator()
801 .sessionFinish(contact.getJid(), sessionId, reason);
802 mXmppConnectionService.sendMessagePacket(account, messagePacket);
803 }
804
805 public Optional<RtpSessionProposal> matchingProposal(final Account account, final Jid with) {
806 synchronized (this.rtpSessionProposals) {
807 for (final Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry :
808 this.rtpSessionProposals.entrySet()) {
809 final RtpSessionProposal proposal = entry.getKey();
810 if (proposal.account == account && with.asBareJid().equals(proposal.with)) {
811 return Optional.of(proposal);
812 }
813 }
814 }
815 return Optional.absent();
816 }
817
818 public boolean hasMatchingProposal(final Account account, final Jid with) {
819 synchronized (this.rtpSessionProposals) {
820 for (final Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry :
821 this.rtpSessionProposals.entrySet()) {
822 final var state = entry.getValue();
823 final RtpSessionProposal proposal = entry.getKey();
824 if (proposal.account == account && with.asBareJid().equals(proposal.with)) {
825 // CallIntegrationConnectionService starts RtpSessionActivity with ACTION_VIEW
826 // and an EXTRA_LAST_REPORTED_STATE of DISCOVERING devices. however due to
827 // possible race conditions the state might have already moved on so we are
828 // going
829 // to update the UI
830 final RtpEndUserState endUserState = state.toEndUserState();
831 mXmppConnectionService.notifyJingleRtpConnectionUpdate(
832 account, proposal.with, proposal.sessionId, endUserState);
833 return true;
834 }
835 }
836 }
837 return false;
838 }
839
840 public void deliverIbbPacket(final Account account, final IqPacket packet) {
841 final String sid;
842 final Element payload;
843 final InbandBytestreamsTransport.PacketType packetType;
844 if (packet.hasChild("open", Namespace.IBB)) {
845 packetType = InbandBytestreamsTransport.PacketType.OPEN;
846 payload = packet.findChild("open", Namespace.IBB);
847 sid = payload.getAttribute("sid");
848 } else if (packet.hasChild("data", Namespace.IBB)) {
849 packetType = InbandBytestreamsTransport.PacketType.DATA;
850 payload = packet.findChild("data", Namespace.IBB);
851 sid = payload.getAttribute("sid");
852 } else if (packet.hasChild("close", Namespace.IBB)) {
853 packetType = InbandBytestreamsTransport.PacketType.CLOSE;
854 payload = packet.findChild("close", Namespace.IBB);
855 sid = payload.getAttribute("sid");
856 } else {
857 packetType = null;
858 payload = null;
859 sid = null;
860 }
861 if (sid == null) {
862 Log.d(
863 Config.LOGTAG,
864 account.getJid().asBareJid() + ": unable to deliver ibb packet. missing sid");
865 account.getXmppConnection()
866 .sendIqPacket(packet.generateResponse(IqPacket.TYPE.ERROR), null);
867 return;
868 }
869 for (final AbstractJingleConnection connection : this.connections.values()) {
870 if (connection instanceof JingleFileTransferConnection fileTransfer) {
871 final Transport transport = fileTransfer.getTransport();
872 if (transport instanceof InbandBytestreamsTransport inBandTransport) {
873 if (sid.equals(inBandTransport.getStreamId())) {
874 if (inBandTransport.deliverPacket(packetType, packet.getFrom(), payload)) {
875 account.getXmppConnection()
876 .sendIqPacket(
877 packet.generateResponse(IqPacket.TYPE.RESULT), null);
878 } else {
879 account.getXmppConnection()
880 .sendIqPacket(
881 packet.generateResponse(IqPacket.TYPE.ERROR), null);
882 }
883 return;
884 }
885 }
886 }
887 }
888 Log.d(
889 Config.LOGTAG,
890 account.getJid().asBareJid() + ": unable to deliver ibb packet with sid=" + sid);
891 account.getXmppConnection()
892 .sendIqPacket(packet.generateResponse(IqPacket.TYPE.ERROR), null);
893 }
894
895 public void notifyRebound(final Account account) {
896 for (final AbstractJingleConnection connection : this.connections.values()) {
897 connection.notifyRebound();
898 }
899 final XmppConnection xmppConnection = account.getXmppConnection();
900 if (xmppConnection != null && xmppConnection.getFeatures().sm()) {
901 resendSessionProposals(account);
902 }
903 }
904
905 public WeakReference<JingleRtpConnection> findJingleRtpConnection(
906 Account account, Jid with, String sessionId) {
907 final AbstractJingleConnection.Id id =
908 AbstractJingleConnection.Id.of(account, with, sessionId);
909 final AbstractJingleConnection connection = connections.get(id);
910 if (connection instanceof JingleRtpConnection) {
911 return new WeakReference<>((JingleRtpConnection) connection);
912 }
913 return null;
914 }
915
916 public JingleRtpConnection findJingleRtpConnection(final Account account, final Jid with) {
917 for (final AbstractJingleConnection connection : this.connections.values()) {
918 if (connection instanceof JingleRtpConnection rtpConnection) {
919 if (rtpConnection.isTerminated()) {
920 continue;
921 }
922 final var id = rtpConnection.getId();
923 if (id.account == account && account.getJid().equals(with)) {
924 return rtpConnection;
925 }
926 }
927 }
928 return null;
929 }
930
931 private void resendSessionProposals(final Account account) {
932 synchronized (this.rtpSessionProposals) {
933 for (final Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry :
934 this.rtpSessionProposals.entrySet()) {
935 final RtpSessionProposal proposal = entry.getKey();
936 if (entry.getValue() == DeviceDiscoveryState.SEARCHING
937 && proposal.account == account) {
938 Log.d(
939 Config.LOGTAG,
940 account.getJid().asBareJid()
941 + ": resending session proposal to "
942 + proposal.with);
943 final MessagePacket messagePacket =
944 mXmppConnectionService.getMessageGenerator().sessionProposal(proposal);
945 mXmppConnectionService.sendMessagePacket(account, messagePacket);
946 }
947 }
948 }
949 }
950
951 public void updateProposedSessionDiscovered(
952 Account account, Jid from, String sessionId, final DeviceDiscoveryState target) {
953 synchronized (this.rtpSessionProposals) {
954 final RtpSessionProposal sessionProposal =
955 getRtpSessionProposal(account, from.asBareJid(), sessionId);
956 final DeviceDiscoveryState currentState =
957 sessionProposal == null ? null : rtpSessionProposals.get(sessionProposal);
958 if (currentState == null) {
959 Log.d(
960 Config.LOGTAG,
961 "unable to find session proposal for session id "
962 + sessionId
963 + " target="
964 + target);
965 return;
966 }
967 if (currentState == DeviceDiscoveryState.DISCOVERED) {
968 Log.d(
969 Config.LOGTAG,
970 "session proposal already at discovered. not going to fall back");
971 return;
972 }
973
974 Log.d(
975 Config.LOGTAG,
976 account.getJid().asBareJid()
977 + ": flagging session "
978 + sessionId
979 + " as "
980 + target);
981
982 final RtpEndUserState endUserState = target.toEndUserState();
983
984 if (target == DeviceDiscoveryState.FAILED) {
985 Log.d(Config.LOGTAG, "removing session proposal after failure");
986 setTerminalSessionState(sessionProposal, endUserState);
987 this.rtpSessionProposals.remove(sessionProposal);
988 sessionProposal.getCallIntegration().error();
989 mXmppConnectionService.notifyJingleRtpConnectionUpdate(
990 account, sessionProposal.with, sessionProposal.sessionId, endUserState);
991 return;
992 }
993
994 this.rtpSessionProposals.put(sessionProposal, target);
995
996 if (endUserState == RtpEndUserState.RINGING) {
997 sessionProposal.callIntegration.setDialing();
998 }
999
1000 mXmppConnectionService.notifyJingleRtpConnectionUpdate(
1001 account, sessionProposal.with, sessionProposal.sessionId, endUserState);
1002 }
1003 }
1004
1005 public void rejectRtpSession(final String sessionId) {
1006 for (final AbstractJingleConnection connection : this.connections.values()) {
1007 if (connection.getId().sessionId.equals(sessionId)) {
1008 if (connection instanceof JingleRtpConnection) {
1009 try {
1010 ((JingleRtpConnection) connection).rejectCall();
1011 return;
1012 } catch (final IllegalStateException e) {
1013 Log.w(
1014 Config.LOGTAG,
1015 "race condition on rejecting call from notification",
1016 e);
1017 }
1018 }
1019 }
1020 }
1021 }
1022
1023 public void endRtpSession(final String sessionId) {
1024 for (final AbstractJingleConnection connection : this.connections.values()) {
1025 if (connection.getId().sessionId.equals(sessionId)) {
1026 if (connection instanceof JingleRtpConnection) {
1027 ((JingleRtpConnection) connection).endCall();
1028 }
1029 }
1030 }
1031 }
1032
1033 public void failProceed(
1034 Account account, final Jid with, final String sessionId, final String message) {
1035 final AbstractJingleConnection.Id id =
1036 AbstractJingleConnection.Id.of(account, with, sessionId);
1037 final AbstractJingleConnection existingJingleConnection = connections.get(id);
1038 if (existingJingleConnection instanceof JingleRtpConnection) {
1039 ((JingleRtpConnection) existingJingleConnection).deliverFailedProceed(message);
1040 }
1041 }
1042
1043 void ensureConnectionIsRegistered(final AbstractJingleConnection connection) {
1044 if (connections.containsValue(connection)) {
1045 return;
1046 }
1047 final IllegalStateException e =
1048 new IllegalStateException(
1049 "JingleConnection has not been registered with connection manager");
1050 Log.e(Config.LOGTAG, "ensureConnectionIsRegistered() failed. Going to throw", e);
1051 throw e;
1052 }
1053
1054 void setTerminalSessionState(
1055 AbstractJingleConnection.Id id, final RtpEndUserState state, final Set<Media> media) {
1056 this.terminatedSessions.put(
1057 PersistableSessionId.of(id), new TerminatedRtpSession(state, media));
1058 }
1059
1060 void setTerminalSessionState(final RtpSessionProposal proposal, final RtpEndUserState state) {
1061 this.terminatedSessions.put(
1062 PersistableSessionId.of(proposal), new TerminatedRtpSession(state, proposal.media));
1063 }
1064
1065 public TerminatedRtpSession getTerminalSessionState(final Jid with, final String sessionId) {
1066 return this.terminatedSessions.getIfPresent(new PersistableSessionId(with, sessionId));
1067 }
1068
1069 private static class PersistableSessionId {
1070 private final Jid with;
1071 private final String sessionId;
1072
1073 private PersistableSessionId(Jid with, String sessionId) {
1074 this.with = with;
1075 this.sessionId = sessionId;
1076 }
1077
1078 public static PersistableSessionId of(final AbstractJingleConnection.Id id) {
1079 return new PersistableSessionId(id.with, id.sessionId);
1080 }
1081
1082 public static PersistableSessionId of(final RtpSessionProposal proposal) {
1083 return new PersistableSessionId(proposal.with, proposal.sessionId);
1084 }
1085
1086 @Override
1087 public boolean equals(Object o) {
1088 if (this == o) return true;
1089 if (o == null || getClass() != o.getClass()) return false;
1090 PersistableSessionId that = (PersistableSessionId) o;
1091 return Objects.equal(with, that.with) && Objects.equal(sessionId, that.sessionId);
1092 }
1093
1094 @Override
1095 public int hashCode() {
1096 return Objects.hashCode(with, sessionId);
1097 }
1098 }
1099
1100 public static class TerminatedRtpSession {
1101 public final RtpEndUserState state;
1102 public final Set<Media> media;
1103
1104 TerminatedRtpSession(RtpEndUserState state, Set<Media> media) {
1105 this.state = state;
1106 this.media = media;
1107 }
1108 }
1109
1110 public enum DeviceDiscoveryState {
1111 SEARCHING,
1112 SEARCHING_ACKNOWLEDGED,
1113 DISCOVERED,
1114 FAILED;
1115
1116 public RtpEndUserState toEndUserState() {
1117 return switch (this) {
1118 case SEARCHING, SEARCHING_ACKNOWLEDGED -> RtpEndUserState.FINDING_DEVICE;
1119 case DISCOVERED -> RtpEndUserState.RINGING;
1120 default -> RtpEndUserState.CONNECTIVITY_ERROR;
1121 };
1122 }
1123 }
1124
1125 public static class RtpSessionProposal implements OngoingRtpSession {
1126 public final Jid with;
1127 public final String sessionId;
1128 public final Set<Media> media;
1129 private final Account account;
1130 private final CallIntegration callIntegration;
1131
1132 private RtpSessionProposal(
1133 Account account,
1134 Jid with,
1135 String sessionId,
1136 Set<Media> media,
1137 final CallIntegration callIntegration) {
1138 this.account = account;
1139 this.with = with;
1140 this.sessionId = sessionId;
1141 this.media = media;
1142 this.callIntegration = callIntegration;
1143 }
1144
1145 public static RtpSessionProposal of(
1146 Account account,
1147 Jid with,
1148 Set<Media> media,
1149 final CallIntegration callIntegration) {
1150 return new RtpSessionProposal(account, with, nextRandomId(), media, callIntegration);
1151 }
1152
1153 @Override
1154 public boolean equals(Object o) {
1155 if (this == o) return true;
1156 if (o == null || getClass() != o.getClass()) return false;
1157 RtpSessionProposal proposal = (RtpSessionProposal) o;
1158 return Objects.equal(account.getJid(), proposal.account.getJid())
1159 && Objects.equal(with, proposal.with)
1160 && Objects.equal(sessionId, proposal.sessionId);
1161 }
1162
1163 @Override
1164 public int hashCode() {
1165 return Objects.hashCode(account.getJid(), with, sessionId);
1166 }
1167
1168 @Override
1169 public Account getAccount() {
1170 return account;
1171 }
1172
1173 @Override
1174 public Jid getWith() {
1175 return with;
1176 }
1177
1178 @Override
1179 public String getSessionId() {
1180 return sessionId;
1181 }
1182
1183 @Override
1184 public CallIntegration getCallIntegration() {
1185 return this.callIntegration;
1186 }
1187
1188 @Override
1189 public Set<Media> getMedia() {
1190 return this.media;
1191 }
1192 }
1193
1194 public class ProposalStateCallback implements CallIntegration.Callback {
1195
1196 private final RtpSessionProposal proposal;
1197
1198 public ProposalStateCallback(final RtpSessionProposal proposal) {
1199 this.proposal = proposal;
1200 }
1201
1202 @Override
1203 public void onCallIntegrationShowIncomingCallUi() {}
1204
1205 @Override
1206 public void onCallIntegrationDisconnect() {
1207 Log.d(Config.LOGTAG, "a phone call has just been started. retracting proposal");
1208 retractSessionProposal(this.proposal);
1209 }
1210
1211 @Override
1212 public void onAudioDeviceChanged(
1213 final CallIntegration.AudioDevice selectedAudioDevice,
1214 final Set<CallIntegration.AudioDevice> availableAudioDevices) {
1215 mXmppConnectionService.notifyJingleRtpConnectionUpdate(
1216 selectedAudioDevice, availableAudioDevices);
1217 }
1218
1219 @Override
1220 public void onCallIntegrationReject() {}
1221
1222 @Override
1223 public void onCallIntegrationAnswer() {}
1224
1225 @Override
1226 public void onCallIntegrationSilence() {}
1227
1228 @Override
1229 public void onCallIntegrationMicrophoneEnabled(boolean enabled) {}
1230
1231 @Override
1232 public boolean applyDtmfTone(final String dtmf) {
1233 return false;
1234 }
1235 }
1236}