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 void sendJingleMessageFinish(
750 final Contact contact, final String sessionId, final Reason reason) {
751 final var account = contact.getAccount();
752 final MessagePacket messagePacket =
753 mXmppConnectionService
754 .getMessageGenerator()
755 .sessionFinish(contact.getJid(), sessionId, reason);
756 mXmppConnectionService.sendMessagePacket(account, messagePacket);
757 }
758
759 public boolean hasMatchingProposal(final Account account, final Jid with) {
760 synchronized (this.rtpSessionProposals) {
761 for (Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry :
762 this.rtpSessionProposals.entrySet()) {
763 final var state = entry.getValue();
764 final RtpSessionProposal proposal = entry.getKey();
765 if (proposal.account == account && with.asBareJid().equals(proposal.with)) {
766 // CallIntegrationConnectionService starts RtpSessionActivity with ACTION_VIEW
767 // and an EXTRA_LAST_REPORTED_STATE of DISCOVERING devices. however due to
768 // possible race conditions the state might have already moved on so we are
769 // going
770 // to update the UI
771 final RtpEndUserState endUserState = state.toEndUserState();
772 mXmppConnectionService.notifyJingleRtpConnectionUpdate(
773 account, proposal.with, proposal.sessionId, endUserState);
774 return true;
775 }
776 }
777 }
778 return false;
779 }
780
781 public void deliverIbbPacket(final Account account, final IqPacket packet) {
782 final String sid;
783 final Element payload;
784 final InbandBytestreamsTransport.PacketType packetType;
785 if (packet.hasChild("open", Namespace.IBB)) {
786 packetType = InbandBytestreamsTransport.PacketType.OPEN;
787 payload = packet.findChild("open", Namespace.IBB);
788 sid = payload.getAttribute("sid");
789 } else if (packet.hasChild("data", Namespace.IBB)) {
790 packetType = InbandBytestreamsTransport.PacketType.DATA;
791 payload = packet.findChild("data", Namespace.IBB);
792 sid = payload.getAttribute("sid");
793 } else if (packet.hasChild("close", Namespace.IBB)) {
794 packetType = InbandBytestreamsTransport.PacketType.CLOSE;
795 payload = packet.findChild("close", Namespace.IBB);
796 sid = payload.getAttribute("sid");
797 } else {
798 packetType = null;
799 payload = null;
800 sid = null;
801 }
802 if (sid == null) {
803 Log.d(
804 Config.LOGTAG,
805 account.getJid().asBareJid() + ": unable to deliver ibb packet. missing sid");
806 account.getXmppConnection()
807 .sendIqPacket(packet.generateResponse(IqPacket.TYPE.ERROR), null);
808 return;
809 }
810 for (final AbstractJingleConnection connection : this.connections.values()) {
811 if (connection instanceof JingleFileTransferConnection fileTransfer) {
812 final Transport transport = fileTransfer.getTransport();
813 if (transport instanceof InbandBytestreamsTransport inBandTransport) {
814 if (sid.equals(inBandTransport.getStreamId())) {
815 if (inBandTransport.deliverPacket(packetType, packet.getFrom(), payload)) {
816 account.getXmppConnection()
817 .sendIqPacket(
818 packet.generateResponse(IqPacket.TYPE.RESULT), null);
819 } else {
820 account.getXmppConnection()
821 .sendIqPacket(
822 packet.generateResponse(IqPacket.TYPE.ERROR), null);
823 }
824 return;
825 }
826 }
827 }
828 }
829 Log.d(
830 Config.LOGTAG,
831 account.getJid().asBareJid() + ": unable to deliver ibb packet with sid=" + sid);
832 account.getXmppConnection()
833 .sendIqPacket(packet.generateResponse(IqPacket.TYPE.ERROR), null);
834 }
835
836 public void notifyRebound(final Account account) {
837 for (final AbstractJingleConnection connection : this.connections.values()) {
838 connection.notifyRebound();
839 }
840 final XmppConnection xmppConnection = account.getXmppConnection();
841 if (xmppConnection != null && xmppConnection.getFeatures().sm()) {
842 resendSessionProposals(account);
843 }
844 }
845
846 public WeakReference<JingleRtpConnection> findJingleRtpConnection(
847 Account account, Jid with, String sessionId) {
848 final AbstractJingleConnection.Id id =
849 AbstractJingleConnection.Id.of(account, with, sessionId);
850 final AbstractJingleConnection connection = connections.get(id);
851 if (connection instanceof JingleRtpConnection) {
852 return new WeakReference<>((JingleRtpConnection) connection);
853 }
854 return null;
855 }
856
857 public JingleRtpConnection findJingleRtpConnection(final Account account, final Jid with) {
858 for (final AbstractJingleConnection connection : this.connections.values()) {
859 if (connection instanceof JingleRtpConnection rtpConnection) {
860 if (rtpConnection.isTerminated()) {
861 continue;
862 }
863 final var id = rtpConnection.getId();
864 if (id.account == account && account.getJid().equals(with)) {
865 return rtpConnection;
866 }
867 }
868 }
869 return null;
870 }
871
872 private void resendSessionProposals(final Account account) {
873 synchronized (this.rtpSessionProposals) {
874 for (final Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry :
875 this.rtpSessionProposals.entrySet()) {
876 final RtpSessionProposal proposal = entry.getKey();
877 if (entry.getValue() == DeviceDiscoveryState.SEARCHING
878 && proposal.account == account) {
879 Log.d(
880 Config.LOGTAG,
881 account.getJid().asBareJid()
882 + ": resending session proposal to "
883 + proposal.with);
884 final MessagePacket messagePacket =
885 mXmppConnectionService.getMessageGenerator().sessionProposal(proposal);
886 mXmppConnectionService.sendMessagePacket(account, messagePacket);
887 }
888 }
889 }
890 }
891
892 public void updateProposedSessionDiscovered(
893 Account account, Jid from, String sessionId, final DeviceDiscoveryState target) {
894 synchronized (this.rtpSessionProposals) {
895 final RtpSessionProposal sessionProposal =
896 getRtpSessionProposal(account, from.asBareJid(), sessionId);
897 final DeviceDiscoveryState currentState =
898 sessionProposal == null ? null : rtpSessionProposals.get(sessionProposal);
899 if (currentState == null) {
900 Log.d(Config.LOGTAG, "unable to find session proposal for session id " + sessionId);
901 return;
902 }
903 if (currentState == DeviceDiscoveryState.DISCOVERED) {
904 Log.d(
905 Config.LOGTAG,
906 "session proposal already at discovered. not going to fall back");
907 return;
908 }
909 this.rtpSessionProposals.put(sessionProposal, target);
910 final RtpEndUserState endUserState = target.toEndUserState();
911 if (endUserState == RtpEndUserState.RINGING) {
912 sessionProposal.callIntegration.setDialing();
913 }
914 // toneManager.transition(endUserState, sessionProposal.media);
915 mXmppConnectionService.notifyJingleRtpConnectionUpdate(
916 account, sessionProposal.with, sessionProposal.sessionId, endUserState);
917 Log.d(
918 Config.LOGTAG,
919 account.getJid().asBareJid()
920 + ": flagging session "
921 + sessionId
922 + " as "
923 + target);
924 }
925 }
926
927 public void rejectRtpSession(final String sessionId) {
928 for (final AbstractJingleConnection connection : this.connections.values()) {
929 if (connection.getId().sessionId.equals(sessionId)) {
930 if (connection instanceof JingleRtpConnection) {
931 try {
932 ((JingleRtpConnection) connection).rejectCall();
933 return;
934 } catch (final IllegalStateException e) {
935 Log.w(
936 Config.LOGTAG,
937 "race condition on rejecting call from notification",
938 e);
939 }
940 }
941 }
942 }
943 }
944
945 public void endRtpSession(final String sessionId) {
946 for (final AbstractJingleConnection connection : this.connections.values()) {
947 if (connection.getId().sessionId.equals(sessionId)) {
948 if (connection instanceof JingleRtpConnection) {
949 ((JingleRtpConnection) connection).endCall();
950 }
951 }
952 }
953 }
954
955 public void failProceed(
956 Account account, final Jid with, final String sessionId, final String message) {
957 final AbstractJingleConnection.Id id =
958 AbstractJingleConnection.Id.of(account, with, sessionId);
959 final AbstractJingleConnection existingJingleConnection = connections.get(id);
960 if (existingJingleConnection instanceof JingleRtpConnection) {
961 ((JingleRtpConnection) existingJingleConnection).deliverFailedProceed(message);
962 }
963 }
964
965 void ensureConnectionIsRegistered(final AbstractJingleConnection connection) {
966 if (connections.containsValue(connection)) {
967 return;
968 }
969 final IllegalStateException e =
970 new IllegalStateException(
971 "JingleConnection has not been registered with connection manager");
972 Log.e(Config.LOGTAG, "ensureConnectionIsRegistered() failed. Going to throw", e);
973 throw e;
974 }
975
976 void setTerminalSessionState(
977 AbstractJingleConnection.Id id, final RtpEndUserState state, final Set<Media> media) {
978 this.terminatedSessions.put(
979 PersistableSessionId.of(id), new TerminatedRtpSession(state, media));
980 }
981
982 public TerminatedRtpSession getTerminalSessionState(final Jid with, final String sessionId) {
983 return this.terminatedSessions.getIfPresent(new PersistableSessionId(with, sessionId));
984 }
985
986 private static class PersistableSessionId {
987 private final Jid with;
988 private final String sessionId;
989
990 private PersistableSessionId(Jid with, String sessionId) {
991 this.with = with;
992 this.sessionId = sessionId;
993 }
994
995 public static PersistableSessionId of(AbstractJingleConnection.Id id) {
996 return new PersistableSessionId(id.with, id.sessionId);
997 }
998
999 @Override
1000 public boolean equals(Object o) {
1001 if (this == o) return true;
1002 if (o == null || getClass() != o.getClass()) return false;
1003 PersistableSessionId that = (PersistableSessionId) o;
1004 return Objects.equal(with, that.with) && Objects.equal(sessionId, that.sessionId);
1005 }
1006
1007 @Override
1008 public int hashCode() {
1009 return Objects.hashCode(with, sessionId);
1010 }
1011 }
1012
1013 public static class TerminatedRtpSession {
1014 public final RtpEndUserState state;
1015 public final Set<Media> media;
1016
1017 TerminatedRtpSession(RtpEndUserState state, Set<Media> media) {
1018 this.state = state;
1019 this.media = media;
1020 }
1021 }
1022
1023 public enum DeviceDiscoveryState {
1024 SEARCHING,
1025 SEARCHING_ACKNOWLEDGED,
1026 DISCOVERED,
1027 FAILED;
1028
1029 public RtpEndUserState toEndUserState() {
1030 return switch (this) {
1031 case SEARCHING, SEARCHING_ACKNOWLEDGED -> RtpEndUserState.FINDING_DEVICE;
1032 case DISCOVERED -> RtpEndUserState.RINGING;
1033 default -> RtpEndUserState.CONNECTIVITY_ERROR;
1034 };
1035 }
1036 }
1037
1038 public static class RtpSessionProposal implements OngoingRtpSession {
1039 public final Jid with;
1040 public final String sessionId;
1041 public final Set<Media> media;
1042 private final Account account;
1043 private final CallIntegration callIntegration;
1044
1045 private RtpSessionProposal(
1046 Account account,
1047 Jid with,
1048 String sessionId,
1049 Set<Media> media,
1050 final CallIntegration callIntegration) {
1051 this.account = account;
1052 this.with = with;
1053 this.sessionId = sessionId;
1054 this.media = media;
1055 this.callIntegration = callIntegration;
1056 }
1057
1058 public static RtpSessionProposal of(
1059 Account account,
1060 Jid with,
1061 Set<Media> media,
1062 final CallIntegration callIntegration) {
1063 return new RtpSessionProposal(account, with, nextRandomId(), media, callIntegration);
1064 }
1065
1066 @Override
1067 public boolean equals(Object o) {
1068 if (this == o) return true;
1069 if (o == null || getClass() != o.getClass()) return false;
1070 RtpSessionProposal proposal = (RtpSessionProposal) o;
1071 return Objects.equal(account.getJid(), proposal.account.getJid())
1072 && Objects.equal(with, proposal.with)
1073 && Objects.equal(sessionId, proposal.sessionId);
1074 }
1075
1076 @Override
1077 public int hashCode() {
1078 return Objects.hashCode(account.getJid(), with, sessionId);
1079 }
1080
1081 @Override
1082 public Account getAccount() {
1083 return account;
1084 }
1085
1086 @Override
1087 public Jid getWith() {
1088 return with;
1089 }
1090
1091 @Override
1092 public String getSessionId() {
1093 return sessionId;
1094 }
1095
1096 public CallIntegration getCallIntegration() {
1097 return this.callIntegration;
1098 }
1099 }
1100
1101 public class ProposalStateCallback implements CallIntegration.Callback {
1102
1103 private final RtpSessionProposal proposal;
1104
1105 public ProposalStateCallback(final RtpSessionProposal proposal) {
1106 this.proposal = proposal;
1107 }
1108
1109 @Override
1110 public void onCallIntegrationShowIncomingCallUi() {}
1111
1112 @Override
1113 public void onCallIntegrationDisconnect() {
1114 Log.d(Config.LOGTAG, "a phone call has just been started. retracting proposal");
1115 retractSessionProposal(this.proposal);
1116 }
1117
1118 @Override
1119 public void onAudioDeviceChanged(
1120 CallIntegration.AudioDevice selectedAudioDevice,
1121 Set<CallIntegration.AudioDevice> availableAudioDevices) {}
1122
1123 @Override
1124 public void onCallIntegrationReject() {}
1125
1126 @Override
1127 public void onCallIntegrationAnswer() {}
1128 }
1129}