1package eu.siacs.conversations.xmpp.jingle;
2
3import android.telecom.TelecomManager;
4import android.telecom.VideoProfile;
5import android.util.Base64;
6import android.util.Log;
7
8import androidx.annotation.Nullable;
9
10import com.google.common.base.Objects;
11import com.google.common.base.Optional;
12import com.google.common.base.Preconditions;
13import com.google.common.cache.Cache;
14import com.google.common.cache.CacheBuilder;
15import com.google.common.collect.Collections2;
16import com.google.common.collect.ComparisonChain;
17import com.google.common.collect.ImmutableSet;
18
19import eu.siacs.conversations.Config;
20import eu.siacs.conversations.entities.Account;
21import eu.siacs.conversations.entities.Contact;
22import eu.siacs.conversations.entities.Conversation;
23import eu.siacs.conversations.entities.Conversational;
24import eu.siacs.conversations.entities.Message;
25import eu.siacs.conversations.entities.RtpSessionStatus;
26import eu.siacs.conversations.entities.Transferable;
27import eu.siacs.conversations.services.AbstractConnectionManager;
28import eu.siacs.conversations.services.CallIntegration;
29import eu.siacs.conversations.services.CallIntegrationConnectionService;
30import eu.siacs.conversations.services.XmppConnectionService;
31import eu.siacs.conversations.xml.Element;
32import eu.siacs.conversations.xml.Namespace;
33import eu.siacs.conversations.xmpp.Jid;
34import eu.siacs.conversations.xmpp.XmppConnection;
35import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
36import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription;
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 im.conversations.android.xmpp.model.jingle.Jingle;
43import im.conversations.android.xmpp.model.stanza.Iq;
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 Iq packet) {
80 final var jingle = packet.getExtension(Jingle.class);
81 Preconditions.checkNotNull(jingle,"Passed iq packet w/o jingle extension to Connection Manager");
82 final String sessionId = jingle.getSessionId();
83 final Jingle.Action action = jingle.getAction();
84 if (sessionId == null) {
85 respondWithJingleError(account, packet, "unknown-session", "item-not-found", "cancel");
86 return;
87 }
88 if (action == null) {
89 respondWithJingleError(account, packet, null, "bad-request", "cancel");
90 return;
91 }
92 final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(account, packet, jingle);
93 final AbstractJingleConnection existingJingleConnection = connections.get(id);
94 if (existingJingleConnection != null) {
95 existingJingleConnection.deliverPacket(packet);
96 } else if (action == Jingle.Action.SESSION_INITIATE) {
97 final Jid from = packet.getFrom();
98 final Content content = jingle.getJingleContent();
99 final String descriptionNamespace =
100 content == null ? null : content.getDescriptionNamespace();
101 final AbstractJingleConnection connection;
102 if (Namespace.JINGLE_APPS_FILE_TRANSFER.equals(descriptionNamespace)) {
103 connection = new JingleFileTransferConnection(this, id, from);
104 } else if (Namespace.JINGLE_APPS_RTP.equals(descriptionNamespace)
105 && isUsingClearNet(account)) {
106 final boolean sessionEnded =
107 this.terminatedSessions.asMap().containsKey(PersistableSessionId.of(id));
108 final boolean stranger =
109 isWithStrangerAndStrangerNotificationsAreOff(account, id.with);
110 final boolean busy = isBusy();
111 if (busy || sessionEnded || stranger) {
112 Log.d(
113 Config.LOGTAG,
114 id.account.getJid().asBareJid()
115 + ": rejected session with "
116 + id.with
117 + " because busy. sessionEnded="
118 + sessionEnded
119 + ", stranger="
120 + stranger);
121 sendSessionTerminate(account, packet, id);
122 if (busy || stranger) {
123 writeLogMissedIncoming(
124 account,
125 id.with,
126 id.sessionId,
127 null,
128 System.currentTimeMillis(),
129 stranger);
130 }
131 return;
132 }
133 connection = new JingleRtpConnection(this, id, from);
134 } else {
135 respondWithJingleError(
136 account, packet, "unsupported-info", "feature-not-implemented", "cancel");
137 return;
138 }
139 connections.put(id, connection);
140 mXmppConnectionService.updateConversationUi();
141 connection.deliverPacket(packet);
142 if (connection instanceof JingleRtpConnection rtpConnection) {
143 addNewIncomingCall(rtpConnection);
144 }
145 } else {
146 Log.d(Config.LOGTAG, "unable to route jingle packet: " + packet);
147 respondWithJingleError(account, packet, "unknown-session", "item-not-found", "cancel");
148 }
149 }
150
151 private void addNewIncomingCall(final JingleRtpConnection rtpConnection) {
152 if (rtpConnection.isTerminated()) {
153 Log.d(
154 Config.LOGTAG,
155 "skip call integration because something must have gone during initiate");
156 return;
157 }
158 if (CallIntegrationConnectionService.addNewIncomingCall(
159 mXmppConnectionService, rtpConnection.getId())) {
160 return;
161 }
162 rtpConnection.integrationFailure();
163 }
164
165 private void sendSessionTerminate(
166 final Account account, final Iq request, final AbstractJingleConnection.Id id) {
167 mXmppConnectionService.sendIqPacket(
168 account, request.generateResponse(Iq.Type.RESULT), null);
169 final var iq = new Iq(Iq.Type.SET);
170 iq.setTo(id.with);
171 final var sessionTermination = iq.addExtension(new Jingle(Jingle.Action.SESSION_TERMINATE, id.sessionId));
172 sessionTermination.setReason(Reason.BUSY, null);
173 mXmppConnectionService.sendIqPacket(account, iq, null);
174 }
175
176 private boolean isUsingClearNet(final Account account) {
177 return !account.isOnion() && !mXmppConnectionService.useTorToConnect();
178 }
179
180 public boolean isBusy() {
181 for (final AbstractJingleConnection connection : this.connections.values()) {
182 if (connection instanceof JingleRtpConnection rtpConnection) {
183 if (connection.isTerminated() && rtpConnection.getCallIntegration().isDestroyed()) {
184 continue;
185 }
186 return true;
187 }
188 }
189 synchronized (this.rtpSessionProposals) {
190 return this.rtpSessionProposals.containsValue(DeviceDiscoveryState.DISCOVERED)
191 || this.rtpSessionProposals.containsValue(DeviceDiscoveryState.SEARCHING)
192 || this.rtpSessionProposals.containsValue(
193 DeviceDiscoveryState.SEARCHING_ACKNOWLEDGED);
194 }
195 }
196
197 public boolean hasJingleRtpConnection(final Account account) {
198 for (AbstractJingleConnection connection : this.connections.values()) {
199 if (connection instanceof JingleRtpConnection rtpConnection) {
200 if (rtpConnection.isTerminated()) {
201 continue;
202 }
203 if (rtpConnection.id.account == account) {
204 return true;
205 }
206 }
207 }
208 return false;
209 }
210
211 private Optional<RtpSessionProposal> findMatchingSessionProposal(
212 final Account account, final Jid with, final Set<Media> media) {
213 synchronized (this.rtpSessionProposals) {
214 for (Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry :
215 this.rtpSessionProposals.entrySet()) {
216 final RtpSessionProposal proposal = entry.getKey();
217 final DeviceDiscoveryState state = entry.getValue();
218 final boolean openProposal =
219 state == DeviceDiscoveryState.DISCOVERED
220 || state == DeviceDiscoveryState.SEARCHING
221 || state == DeviceDiscoveryState.SEARCHING_ACKNOWLEDGED;
222 if (openProposal
223 && proposal.account == account
224 && proposal.with.equals(with.asBareJid())
225 && proposal.media.equals(media)) {
226 return Optional.of(proposal);
227 }
228 }
229 }
230 return Optional.absent();
231 }
232
233 private boolean hasMatchingRtpSession(
234 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 true;
244 }
245 }
246 }
247 return false;
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 Iq original,
268 final String jingleCondition,
269 final String condition,
270 final String conditionType) {
271 final Iq response = original.generateResponse(Iq.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())) {
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 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 var 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 var errorMessage =
497 new im.conversations.android.xmpp.model.stanza.Message();
498 errorMessage.setTo(from);
499 errorMessage.setId(remoteMsgId);
500 errorMessage.setType(im.conversations.android.xmpp.model.stanza.Message.Type.ERROR);
501 final Element error = errorMessage.addChild("error");
502 error.setAttribute("code", "404");
503 error.setAttribute("type", "cancel");
504 error.addChild("item-not-found", "urn:ietf:params:xml:ns:xmpp-stanzas");
505 mXmppConnectionService.sendMessagePacket(account, errorMessage);
506 }
507 }
508 } else if (addressedDirectly && "reject".equals(message.getName())) {
509 final RtpSessionProposal proposal =
510 getRtpSessionProposal(account, from.asBareJid(), sessionId);
511 synchronized (rtpSessionProposals) {
512 if (proposal != null) {
513 setTerminalSessionState(proposal, RtpEndUserState.DECLINED_OR_BUSY);
514 rtpSessionProposals.remove(proposal);
515 proposal.callIntegration.busy();
516 writeLogMissedOutgoing(
517 account, proposal.with, proposal.sessionId, serverMsgId, timestamp);
518 mXmppConnectionService.notifyJingleRtpConnectionUpdate(
519 account,
520 proposal.with,
521 proposal.sessionId,
522 RtpEndUserState.DECLINED_OR_BUSY);
523 } else {
524 Log.d(
525 Config.LOGTAG,
526 account.getJid().asBareJid()
527 + ": no rtp session proposal found for "
528 + from
529 + " to deliver reject");
530 }
531 }
532 } else if (addressedDirectly && "ringing".equals(message.getName())) {
533 Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": " + from + " started ringing");
534 updateProposedSessionDiscovered(
535 account, from, sessionId, DeviceDiscoveryState.DISCOVERED);
536 } else {
537 Log.d(
538 Config.LOGTAG,
539 account.getJid()
540 + ": received out of order jingle message from="
541 + from
542 + ", message="
543 + message
544 + ", addressedDirectly="
545 + addressedDirectly);
546 }
547 }
548
549 private RtpSessionProposal getRtpSessionProposal(
550 final Account account, Jid from, String sessionId) {
551 for (RtpSessionProposal rtpSessionProposal : rtpSessionProposals.keySet()) {
552 if (rtpSessionProposal.sessionId.equals(sessionId)
553 && rtpSessionProposal.with.equals(from)
554 && rtpSessionProposal.account.getJid().equals(account.getJid())) {
555 return rtpSessionProposal;
556 }
557 }
558 return null;
559 }
560
561 private void writeLogMissedOutgoing(
562 final Account account,
563 Jid with,
564 final String sessionId,
565 String serverMsgId,
566 long timestamp) {
567 final Conversation conversation =
568 mXmppConnectionService.findOrCreateConversation(
569 account, with.asBareJid(), false, false);
570 final Message message =
571 new Message(conversation, Message.STATUS_SEND, Message.TYPE_RTP_SESSION, sessionId);
572 message.setBody(new RtpSessionStatus(false, 0).toString());
573 message.setServerMsgId(serverMsgId);
574 message.setTime(timestamp);
575 writeMessage(message);
576 }
577
578 private void writeLogMissedIncoming(
579 final Account account,
580 final Jid with,
581 final String sessionId,
582 final String serverMsgId,
583 final long timestamp,
584 final boolean stranger) {
585 final Conversation conversation =
586 mXmppConnectionService.findOrCreateConversation(
587 account, with.asBareJid(), false, false);
588 final Message message =
589 new Message(
590 conversation, Message.STATUS_RECEIVED, Message.TYPE_RTP_SESSION, sessionId);
591 message.setBody(new RtpSessionStatus(false, 0).toString());
592 message.setServerMsgId(serverMsgId);
593 message.setTime(timestamp);
594 message.setCounterpart(with);
595 writeMessage(message);
596 if (stranger) {
597 return;
598 }
599 mXmppConnectionService.getNotificationService().pushMissedCallNow(message);
600 }
601
602 private void writeMessage(final Message message) {
603 final Conversational conversational = message.getConversation();
604 if (conversational instanceof Conversation) {
605 ((Conversation) conversational).add(message);
606 mXmppConnectionService.databaseBackend.createMessage(message);
607 mXmppConnectionService.updateConversationUi();
608 } else {
609 throw new IllegalStateException("Somehow the conversation in a message was a stub");
610 }
611 }
612
613 public void startJingleFileTransfer(final Message message) {
614 Preconditions.checkArgument(
615 message.isFileOrImage(), "Message is not of type file or image");
616 final Transferable old = message.getTransferable();
617 if (old != null) {
618 old.cancel();
619 }
620 final JingleFileTransferConnection connection =
621 new JingleFileTransferConnection(this, message);
622 this.connections.put(connection.getId(), connection);
623 connection.sendSessionInitialize();
624 }
625
626 public Optional<OngoingRtpSession> getOngoingRtpConnection(final Contact contact) {
627 for (final Map.Entry<AbstractJingleConnection.Id, AbstractJingleConnection> entry :
628 this.connections.entrySet()) {
629 if (entry.getValue() instanceof JingleRtpConnection jingleRtpConnection) {
630 final AbstractJingleConnection.Id id = entry.getKey();
631 if (id.account == contact.getAccount()
632 && id.with.asBareJid().equals(contact.getJid().asBareJid())) {
633 return Optional.of(jingleRtpConnection);
634 }
635 }
636 }
637 synchronized (this.rtpSessionProposals) {
638 for (final Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry :
639 this.rtpSessionProposals.entrySet()) {
640 final RtpSessionProposal proposal = entry.getKey();
641 if (proposal.account == contact.getAccount()
642 && contact.getJid().asBareJid().equals(proposal.with)) {
643 final DeviceDiscoveryState preexistingState = entry.getValue();
644 if (preexistingState != null
645 && preexistingState != DeviceDiscoveryState.FAILED) {
646 return Optional.of(proposal);
647 }
648 }
649 }
650 }
651 return Optional.absent();
652 }
653
654 public JingleRtpConnection getOngoingRtpConnection() {
655 for (final AbstractJingleConnection jingleConnection : this.connections.values()) {
656 if (jingleConnection instanceof JingleRtpConnection jingleRtpConnection) {
657 if (jingleRtpConnection.isTerminated()) {
658 continue;
659 }
660 return jingleRtpConnection;
661 }
662 }
663 return null;
664 }
665
666 void finishConnectionOrThrow(final AbstractJingleConnection connection) {
667 final AbstractJingleConnection.Id id = connection.getId();
668 if (this.connections.remove(id) == null) {
669 throw new IllegalStateException(
670 String.format("Unable to finish connection with id=%s", id));
671 }
672 // update chat UI to remove 'ongoing call' icon
673 mXmppConnectionService.updateConversationUi();
674 }
675
676 public boolean fireJingleRtpConnectionStateUpdates() {
677 for (final AbstractJingleConnection connection : this.connections.values()) {
678 if (connection instanceof JingleRtpConnection jingleRtpConnection) {
679 if (jingleRtpConnection.isTerminated()) {
680 continue;
681 }
682 jingleRtpConnection.fireStateUpdate();
683 return true;
684 }
685 }
686 return false;
687 }
688
689 public void retractSessionProposal(final Account account, final Jid with) {
690 synchronized (this.rtpSessionProposals) {
691 RtpSessionProposal matchingProposal = null;
692 for (RtpSessionProposal proposal : this.rtpSessionProposals.keySet()) {
693 if (proposal.account == account && with.asBareJid().equals(proposal.with)) {
694 matchingProposal = proposal;
695 break;
696 }
697 }
698 if (matchingProposal != null) {
699 retractSessionProposal(matchingProposal, false);
700 }
701 }
702 }
703
704 private void retractSessionProposal(final RtpSessionProposal rtpSessionProposal) {
705 retractSessionProposal(rtpSessionProposal, true);
706 }
707
708 private void retractSessionProposal(
709 final RtpSessionProposal rtpSessionProposal, final boolean refresh) {
710 final Account account = rtpSessionProposal.account;
711 Log.d(
712 Config.LOGTAG,
713 account.getJid().asBareJid()
714 + ": retracting rtp session proposal with "
715 + rtpSessionProposal.with);
716 this.rtpSessionProposals.remove(rtpSessionProposal);
717 rtpSessionProposal.callIntegration.retracted();
718 if (refresh) {
719 mXmppConnectionService.notifyJingleRtpConnectionUpdate(
720 account,
721 rtpSessionProposal.with,
722 rtpSessionProposal.sessionId,
723 RtpEndUserState.RETRACTED);
724 }
725 final var messagePacket =
726 mXmppConnectionService.getMessageGenerator().sessionRetract(rtpSessionProposal);
727 writeLogMissedOutgoing(
728 account,
729 rtpSessionProposal.with,
730 rtpSessionProposal.sessionId,
731 null,
732 System.currentTimeMillis());
733 mXmppConnectionService.sendMessagePacket(account, messagePacket);
734 }
735
736 public JingleRtpConnection initializeRtpSession(
737 final Account account, final Jid with, final Set<Media> media) {
738 final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(account, with);
739 final JingleRtpConnection rtpConnection =
740 new JingleRtpConnection(this, id, account.getJid());
741 rtpConnection.setProposedMedia(media);
742 rtpConnection.getCallIntegration().startAudioRouting();
743 this.connections.put(id, rtpConnection);
744 rtpConnection.sendSessionInitiate();
745 return rtpConnection;
746 }
747
748 public @Nullable RtpSessionProposal proposeJingleRtpSession(
749 final Account account, final Jid with, final Set<Media> media) {
750 synchronized (this.rtpSessionProposals) {
751 for (final Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry :
752 this.rtpSessionProposals.entrySet()) {
753 final RtpSessionProposal proposal = entry.getKey();
754 if (proposal.account == account && with.asBareJid().equals(proposal.with)) {
755 final DeviceDiscoveryState preexistingState = entry.getValue();
756 if (preexistingState != null
757 && preexistingState != DeviceDiscoveryState.FAILED) {
758 final RtpEndUserState endUserState = preexistingState.toEndUserState();
759 mXmppConnectionService.notifyJingleRtpConnectionUpdate(
760 account, with, proposal.sessionId, endUserState);
761 return proposal;
762 }
763 }
764 }
765 if (isBusy()) {
766 if (hasMatchingRtpSession(account, with, media)) {
767 Log.d(
768 Config.LOGTAG,
769 "ignoring request to propose jingle session because the other party already created one for us");
770 // TODO return something that we can parse the connection of of
771 return null;
772 }
773 throw new IllegalStateException(
774 "There is already a running RTP session. This should have been caught by the UI");
775 }
776 final CallIntegration callIntegration =
777 new CallIntegration(mXmppConnectionService.getApplicationContext());
778 callIntegration.setVideoState(
779 Media.audioOnly(media)
780 ? VideoProfile.STATE_AUDIO_ONLY
781 : VideoProfile.STATE_BIDIRECTIONAL);
782 callIntegration.setAddress(
783 CallIntegration.address(with.asBareJid()), TelecomManager.PRESENTATION_ALLOWED);
784 final var contact = account.getRoster().getContact(with);
785 callIntegration.setCallerDisplayName(
786 contact.getDisplayName(), TelecomManager.PRESENTATION_ALLOWED);
787 callIntegration.setInitialAudioDevice(CallIntegration.initialAudioDevice(media));
788 callIntegration.startAudioRouting();
789 final RtpSessionProposal proposal =
790 RtpSessionProposal.of(account, with.asBareJid(), media, callIntegration);
791 callIntegration.setCallback(new ProposalStateCallback(proposal));
792 this.rtpSessionProposals.put(proposal, DeviceDiscoveryState.SEARCHING);
793 mXmppConnectionService.notifyJingleRtpConnectionUpdate(
794 account, proposal.with, proposal.sessionId, RtpEndUserState.FINDING_DEVICE);
795 final var messagePacket =
796 mXmppConnectionService.getMessageGenerator().sessionProposal(proposal);
797 mXmppConnectionService.sendMessagePacket(account, messagePacket);
798 return proposal;
799 }
800 }
801
802 public void sendJingleMessageFinish(
803 final Contact contact, final String sessionId, final Reason reason) {
804 final var account = contact.getAccount();
805 final var messagePacket =
806 mXmppConnectionService
807 .getMessageGenerator()
808 .sessionFinish(contact.getJid(), sessionId, reason);
809 mXmppConnectionService.sendMessagePacket(account, messagePacket);
810 }
811
812 public Optional<RtpSessionProposal> matchingProposal(final Account account, final Jid with) {
813 synchronized (this.rtpSessionProposals) {
814 for (final Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry :
815 this.rtpSessionProposals.entrySet()) {
816 final RtpSessionProposal proposal = entry.getKey();
817 if (proposal.account == account && with.asBareJid().equals(proposal.with)) {
818 return Optional.of(proposal);
819 }
820 }
821 }
822 return Optional.absent();
823 }
824
825 public boolean hasMatchingProposal(final Account account, final Jid with) {
826 synchronized (this.rtpSessionProposals) {
827 for (final Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry :
828 this.rtpSessionProposals.entrySet()) {
829 final var state = entry.getValue();
830 final RtpSessionProposal proposal = entry.getKey();
831 if (proposal.account == account && with.asBareJid().equals(proposal.with)) {
832 // CallIntegrationConnectionService starts RtpSessionActivity with ACTION_VIEW
833 // and an EXTRA_LAST_REPORTED_STATE of DISCOVERING devices. however due to
834 // possible race conditions the state might have already moved on so we are
835 // going
836 // to update the UI
837 final RtpEndUserState endUserState = state.toEndUserState();
838 mXmppConnectionService.notifyJingleRtpConnectionUpdate(
839 account, proposal.with, proposal.sessionId, endUserState);
840 return true;
841 }
842 }
843 }
844 return false;
845 }
846
847 public void deliverIbbPacket(final Account account, final Iq packet) {
848 final String sid;
849 final Element payload;
850 final InbandBytestreamsTransport.PacketType packetType;
851 if (packet.hasChild("open", Namespace.IBB)) {
852 packetType = InbandBytestreamsTransport.PacketType.OPEN;
853 payload = packet.findChild("open", Namespace.IBB);
854 sid = payload.getAttribute("sid");
855 } else if (packet.hasChild("data", Namespace.IBB)) {
856 packetType = InbandBytestreamsTransport.PacketType.DATA;
857 payload = packet.findChild("data", Namespace.IBB);
858 sid = payload.getAttribute("sid");
859 } else if (packet.hasChild("close", Namespace.IBB)) {
860 packetType = InbandBytestreamsTransport.PacketType.CLOSE;
861 payload = packet.findChild("close", Namespace.IBB);
862 sid = payload.getAttribute("sid");
863 } else {
864 packetType = null;
865 payload = null;
866 sid = null;
867 }
868 if (sid == null) {
869 Log.d(
870 Config.LOGTAG,
871 account.getJid().asBareJid() + ": unable to deliver ibb packet. missing sid");
872 account.getXmppConnection()
873 .sendIqPacket(packet.generateResponse(Iq.Type.ERROR), null);
874 return;
875 }
876 for (final AbstractJingleConnection connection : this.connections.values()) {
877 if (connection instanceof JingleFileTransferConnection fileTransfer) {
878 final Transport transport = fileTransfer.getTransport();
879 if (transport instanceof InbandBytestreamsTransport inBandTransport) {
880 if (sid.equals(inBandTransport.getStreamId())) {
881 if (inBandTransport.deliverPacket(packetType, packet.getFrom(), payload)) {
882 account.getXmppConnection()
883 .sendIqPacket(
884 packet.generateResponse(Iq.Type.RESULT), null);
885 } else {
886 account.getXmppConnection()
887 .sendIqPacket(
888 packet.generateResponse(Iq.Type.ERROR), null);
889 }
890 return;
891 }
892 }
893 }
894 }
895 Log.d(
896 Config.LOGTAG,
897 account.getJid().asBareJid() + ": unable to deliver ibb packet with sid=" + sid);
898 account.getXmppConnection()
899 .sendIqPacket(packet.generateResponse(Iq.Type.ERROR), null);
900 }
901
902 public void notifyRebound(final Account account) {
903 for (final AbstractJingleConnection connection : this.connections.values()) {
904 connection.notifyRebound();
905 }
906 final XmppConnection xmppConnection = account.getXmppConnection();
907 if (xmppConnection != null && xmppConnection.getFeatures().sm()) {
908 resendSessionProposals(account);
909 }
910 }
911
912 public WeakReference<JingleRtpConnection> findJingleRtpConnection(
913 Account account, Jid with, String sessionId) {
914 final AbstractJingleConnection.Id id =
915 AbstractJingleConnection.Id.of(account, with, sessionId);
916 final AbstractJingleConnection connection = connections.get(id);
917 if (connection instanceof JingleRtpConnection) {
918 return new WeakReference<>((JingleRtpConnection) connection);
919 }
920 return null;
921 }
922
923 public JingleRtpConnection findJingleRtpConnection(final Account account, final Jid with) {
924 for (final AbstractJingleConnection connection : this.connections.values()) {
925 if (connection instanceof JingleRtpConnection rtpConnection) {
926 if (rtpConnection.isTerminated()) {
927 continue;
928 }
929 final var id = rtpConnection.getId();
930 if (id.account == account && account.getJid().equals(with)) {
931 return rtpConnection;
932 }
933 }
934 }
935 return null;
936 }
937
938 private void resendSessionProposals(final Account account) {
939 synchronized (this.rtpSessionProposals) {
940 for (final Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry :
941 this.rtpSessionProposals.entrySet()) {
942 final RtpSessionProposal proposal = entry.getKey();
943 if (entry.getValue() == DeviceDiscoveryState.SEARCHING
944 && proposal.account == account) {
945 Log.d(
946 Config.LOGTAG,
947 account.getJid().asBareJid()
948 + ": resending session proposal to "
949 + proposal.with);
950 final var messagePacket =
951 mXmppConnectionService.getMessageGenerator().sessionProposal(proposal);
952 mXmppConnectionService.sendMessagePacket(account, messagePacket);
953 }
954 }
955 }
956 }
957
958 public void updateProposedSessionDiscovered(
959 Account account, Jid from, String sessionId, final DeviceDiscoveryState target) {
960 synchronized (this.rtpSessionProposals) {
961 final RtpSessionProposal sessionProposal =
962 getRtpSessionProposal(account, from.asBareJid(), sessionId);
963 final DeviceDiscoveryState currentState =
964 sessionProposal == null ? null : rtpSessionProposals.get(sessionProposal);
965 if (currentState == null) {
966 Log.d(
967 Config.LOGTAG,
968 "unable to find session proposal for session id "
969 + sessionId
970 + " target="
971 + target);
972 return;
973 }
974 if (currentState == DeviceDiscoveryState.DISCOVERED) {
975 Log.d(
976 Config.LOGTAG,
977 "session proposal already at discovered. not going to fall back");
978 return;
979 }
980
981 Log.d(
982 Config.LOGTAG,
983 account.getJid().asBareJid()
984 + ": flagging session "
985 + sessionId
986 + " as "
987 + target);
988
989 final RtpEndUserState endUserState = target.toEndUserState();
990
991 if (target == DeviceDiscoveryState.FAILED) {
992 Log.d(Config.LOGTAG, "removing session proposal after failure");
993 setTerminalSessionState(sessionProposal, endUserState);
994 this.rtpSessionProposals.remove(sessionProposal);
995 sessionProposal.getCallIntegration().error();
996 mXmppConnectionService.notifyJingleRtpConnectionUpdate(
997 account, sessionProposal.with, sessionProposal.sessionId, endUserState);
998 return;
999 }
1000
1001 this.rtpSessionProposals.put(sessionProposal, target);
1002
1003 if (endUserState == RtpEndUserState.RINGING) {
1004 sessionProposal.callIntegration.setDialing();
1005 }
1006
1007 mXmppConnectionService.notifyJingleRtpConnectionUpdate(
1008 account, sessionProposal.with, sessionProposal.sessionId, endUserState);
1009 }
1010 }
1011
1012 public void rejectRtpSession(final String sessionId) {
1013 for (final AbstractJingleConnection connection : this.connections.values()) {
1014 if (connection.getId().sessionId.equals(sessionId)) {
1015 if (connection instanceof JingleRtpConnection) {
1016 try {
1017 ((JingleRtpConnection) connection).rejectCall();
1018 return;
1019 } catch (final IllegalStateException e) {
1020 Log.w(
1021 Config.LOGTAG,
1022 "race condition on rejecting call from notification",
1023 e);
1024 }
1025 }
1026 }
1027 }
1028 }
1029
1030 public void endRtpSession(final String sessionId) {
1031 for (final AbstractJingleConnection connection : this.connections.values()) {
1032 if (connection.getId().sessionId.equals(sessionId)) {
1033 if (connection instanceof JingleRtpConnection) {
1034 ((JingleRtpConnection) connection).endCall();
1035 }
1036 }
1037 }
1038 }
1039
1040 public void failProceed(
1041 Account account, final Jid with, final String sessionId, final String message) {
1042 final AbstractJingleConnection.Id id =
1043 AbstractJingleConnection.Id.of(account, with, sessionId);
1044 final AbstractJingleConnection existingJingleConnection = connections.get(id);
1045 if (existingJingleConnection instanceof JingleRtpConnection) {
1046 ((JingleRtpConnection) existingJingleConnection).deliverFailedProceed(message);
1047 }
1048 }
1049
1050 void ensureConnectionIsRegistered(final AbstractJingleConnection connection) {
1051 if (connections.containsValue(connection)) {
1052 return;
1053 }
1054 final IllegalStateException e =
1055 new IllegalStateException(
1056 "JingleConnection has not been registered with connection manager");
1057 Log.e(Config.LOGTAG, "ensureConnectionIsRegistered() failed. Going to throw", e);
1058 throw e;
1059 }
1060
1061 void setTerminalSessionState(
1062 AbstractJingleConnection.Id id, final RtpEndUserState state, final Set<Media> media) {
1063 this.terminatedSessions.put(
1064 PersistableSessionId.of(id), new TerminatedRtpSession(state, media));
1065 }
1066
1067 void setTerminalSessionState(final RtpSessionProposal proposal, final RtpEndUserState state) {
1068 this.terminatedSessions.put(
1069 PersistableSessionId.of(proposal), new TerminatedRtpSession(state, proposal.media));
1070 }
1071
1072 public TerminatedRtpSession getTerminalSessionState(final Jid with, final String sessionId) {
1073 return this.terminatedSessions.getIfPresent(new PersistableSessionId(with, sessionId));
1074 }
1075
1076 private static class PersistableSessionId {
1077 private final Jid with;
1078 private final String sessionId;
1079
1080 private PersistableSessionId(Jid with, String sessionId) {
1081 this.with = with;
1082 this.sessionId = sessionId;
1083 }
1084
1085 public static PersistableSessionId of(final AbstractJingleConnection.Id id) {
1086 return new PersistableSessionId(id.with, id.sessionId);
1087 }
1088
1089 public static PersistableSessionId of(final RtpSessionProposal proposal) {
1090 return new PersistableSessionId(proposal.with, proposal.sessionId);
1091 }
1092
1093 @Override
1094 public boolean equals(Object o) {
1095 if (this == o) return true;
1096 if (o == null || getClass() != o.getClass()) return false;
1097 PersistableSessionId that = (PersistableSessionId) o;
1098 return Objects.equal(with, that.with) && Objects.equal(sessionId, that.sessionId);
1099 }
1100
1101 @Override
1102 public int hashCode() {
1103 return Objects.hashCode(with, sessionId);
1104 }
1105 }
1106
1107 public static class TerminatedRtpSession {
1108 public final RtpEndUserState state;
1109 public final Set<Media> media;
1110
1111 TerminatedRtpSession(RtpEndUserState state, Set<Media> media) {
1112 this.state = state;
1113 this.media = media;
1114 }
1115 }
1116
1117 public enum DeviceDiscoveryState {
1118 SEARCHING,
1119 SEARCHING_ACKNOWLEDGED,
1120 DISCOVERED,
1121 FAILED;
1122
1123 public RtpEndUserState toEndUserState() {
1124 return switch (this) {
1125 case SEARCHING, SEARCHING_ACKNOWLEDGED -> RtpEndUserState.FINDING_DEVICE;
1126 case DISCOVERED -> RtpEndUserState.RINGING;
1127 default -> RtpEndUserState.CONNECTIVITY_ERROR;
1128 };
1129 }
1130 }
1131
1132 public static class RtpSessionProposal implements OngoingRtpSession {
1133 public final Jid with;
1134 public final String sessionId;
1135 public final Set<Media> media;
1136 private final Account account;
1137 private final CallIntegration callIntegration;
1138
1139 private RtpSessionProposal(
1140 Account account,
1141 Jid with,
1142 String sessionId,
1143 Set<Media> media,
1144 final CallIntegration callIntegration) {
1145 this.account = account;
1146 this.with = with;
1147 this.sessionId = sessionId;
1148 this.media = media;
1149 this.callIntegration = callIntegration;
1150 }
1151
1152 public static RtpSessionProposal of(
1153 Account account,
1154 Jid with,
1155 Set<Media> media,
1156 final CallIntegration callIntegration) {
1157 return new RtpSessionProposal(account, with, nextRandomId(), media, callIntegration);
1158 }
1159
1160 @Override
1161 public boolean equals(Object o) {
1162 if (this == o) return true;
1163 if (o == null || getClass() != o.getClass()) return false;
1164 RtpSessionProposal proposal = (RtpSessionProposal) o;
1165 return Objects.equal(account.getJid(), proposal.account.getJid())
1166 && Objects.equal(with, proposal.with)
1167 && Objects.equal(sessionId, proposal.sessionId);
1168 }
1169
1170 @Override
1171 public int hashCode() {
1172 return Objects.hashCode(account.getJid(), with, sessionId);
1173 }
1174
1175 @Override
1176 public Account getAccount() {
1177 return account;
1178 }
1179
1180 @Override
1181 public Jid getWith() {
1182 return with;
1183 }
1184
1185 @Override
1186 public String getSessionId() {
1187 return sessionId;
1188 }
1189
1190 @Override
1191 public CallIntegration getCallIntegration() {
1192 return this.callIntegration;
1193 }
1194
1195 @Override
1196 public Set<Media> getMedia() {
1197 return this.media;
1198 }
1199 }
1200
1201 public class ProposalStateCallback implements CallIntegration.Callback {
1202
1203 private final RtpSessionProposal proposal;
1204
1205 public ProposalStateCallback(final RtpSessionProposal proposal) {
1206 this.proposal = proposal;
1207 }
1208
1209 @Override
1210 public void onCallIntegrationShowIncomingCallUi() {}
1211
1212 @Override
1213 public void onCallIntegrationDisconnect() {
1214 Log.d(Config.LOGTAG, "a phone call has just been started. retracting proposal");
1215 retractSessionProposal(this.proposal);
1216 }
1217
1218 @Override
1219 public void onAudioDeviceChanged(
1220 final CallIntegration.AudioDevice selectedAudioDevice,
1221 final Set<CallIntegration.AudioDevice> availableAudioDevices) {
1222 mXmppConnectionService.notifyJingleRtpConnectionUpdate(
1223 selectedAudioDevice, availableAudioDevices);
1224 }
1225
1226 @Override
1227 public void onCallIntegrationReject() {}
1228
1229 @Override
1230 public void onCallIntegrationAnswer() {}
1231
1232 @Override
1233 public void onCallIntegrationSilence() {}
1234
1235 @Override
1236 public void onCallIntegrationMicrophoneEnabled(boolean enabled) {}
1237 }
1238}