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