1package eu.siacs.conversations.xmpp.jingle;
2
3import android.util.Log;
4
5import com.google.common.base.Strings;
6import com.google.common.collect.ImmutableList;
7import com.google.common.collect.ImmutableMap;
8import com.google.common.primitives.Ints;
9
10import org.webrtc.IceCandidate;
11import org.webrtc.PeerConnection;
12
13import java.util.ArrayDeque;
14import java.util.Arrays;
15import java.util.Collection;
16import java.util.Collections;
17import java.util.List;
18import java.util.Map;
19
20import eu.siacs.conversations.Config;
21import eu.siacs.conversations.xml.Element;
22import eu.siacs.conversations.xml.Namespace;
23import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
24import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
25import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
26import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
27import eu.siacs.conversations.xmpp.stanzas.IqPacket;
28import eu.siacs.conversations.xmpp.stanzas.MessagePacket;
29import rocks.xmpp.addr.Jid;
30
31public class JingleRtpConnection extends AbstractJingleConnection implements WebRTCWrapper.EventCallback {
32
33 private static final Map<State, Collection<State>> VALID_TRANSITIONS;
34
35 static {
36 final ImmutableMap.Builder<State, Collection<State>> transitionBuilder = new ImmutableMap.Builder<>();
37 transitionBuilder.put(State.NULL, ImmutableList.of(
38 State.PROPOSED,
39 State.SESSION_INITIALIZED,
40 State.TERMINATED_APPLICATION_FAILURE
41 ));
42 transitionBuilder.put(State.PROPOSED, ImmutableList.of(
43 State.ACCEPTED,
44 State.PROCEED,
45 State.REJECTED,
46 State.RETRACTED,
47 State.TERMINATED_APPLICATION_FAILURE
48 ));
49 transitionBuilder.put(State.PROCEED, ImmutableList.of(
50 State.SESSION_INITIALIZED_PRE_APPROVED,
51 State.TERMINATED_SUCCESS,
52 State.TERMINATED_APPLICATION_FAILURE,
53 State.TERMINATED_CONNECTIVITY_ERROR //at this state used for error bounces of the proceed message
54 ));
55 transitionBuilder.put(State.SESSION_INITIALIZED, ImmutableList.of(
56 State.SESSION_ACCEPTED,
57 State.TERMINATED_CANCEL_OR_TIMEOUT,
58 State.TERMINATED_DECLINED_OR_BUSY,
59 State.TERMINATED_APPLICATION_FAILURE,
60 State.TERMINATED_CONNECTIVITY_ERROR //at this state used for IQ errors and IQ timeouts
61 ));
62 transitionBuilder.put(State.SESSION_INITIALIZED_PRE_APPROVED, ImmutableList.of(
63 State.SESSION_ACCEPTED,
64 State.TERMINATED_CANCEL_OR_TIMEOUT,
65 State.TERMINATED_DECLINED_OR_BUSY,
66 State.TERMINATED_APPLICATION_FAILURE,
67 State.TERMINATED_CONNECTIVITY_ERROR //at this state used for IQ errors and IQ timeouts
68 ));
69 transitionBuilder.put(State.SESSION_ACCEPTED, ImmutableList.of(
70 State.TERMINATED_SUCCESS,
71 State.TERMINATED_CONNECTIVITY_ERROR,
72 State.TERMINATED_APPLICATION_FAILURE
73 ));
74 VALID_TRANSITIONS = transitionBuilder.build();
75 }
76
77 public static final List<State> STATES_SHOWING_ONGOING_CALL = Arrays.asList(
78 State.PROCEED,
79 State.SESSION_INITIALIZED,
80 State.SESSION_INITIALIZED_PRE_APPROVED,
81 State.SESSION_ACCEPTED
82 );
83
84 private final WebRTCWrapper webRTCWrapper = new WebRTCWrapper(this);
85 private final ArrayDeque<IceCandidate> pendingIceCandidates = new ArrayDeque<>();
86 private State state = State.NULL;
87 private RtpContentMap initiatorRtpContentMap;
88 private RtpContentMap responderRtpContentMap;
89
90
91 JingleRtpConnection(JingleConnectionManager jingleConnectionManager, Id id, Jid initiator) {
92 super(jingleConnectionManager, id, initiator);
93 }
94
95 private static State reasonToState(Reason reason) {
96 switch (reason) {
97 case SUCCESS:
98 return State.TERMINATED_SUCCESS;
99 case DECLINE:
100 case BUSY:
101 return State.TERMINATED_DECLINED_OR_BUSY;
102 case CANCEL:
103 case TIMEOUT:
104 return State.TERMINATED_CANCEL_OR_TIMEOUT;
105 case FAILED_APPLICATION:
106 return State.TERMINATED_APPLICATION_FAILURE;
107 default:
108 return State.TERMINATED_CONNECTIVITY_ERROR;
109 }
110 }
111
112 @Override
113 void deliverPacket(final JinglePacket jinglePacket) {
114 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": packet delivered to JingleRtpConnection");
115 switch (jinglePacket.getAction()) {
116 case SESSION_INITIATE:
117 receiveSessionInitiate(jinglePacket);
118 break;
119 case TRANSPORT_INFO:
120 receiveTransportInfo(jinglePacket);
121 break;
122 case SESSION_ACCEPT:
123 receiveSessionAccept(jinglePacket);
124 break;
125 case SESSION_TERMINATE:
126 receiveSessionTerminate(jinglePacket);
127 break;
128 default:
129 respondOk(jinglePacket);
130 Log.d(Config.LOGTAG, String.format("%s: received unhandled jingle action %s", id.account.getJid().asBareJid(), jinglePacket.getAction()));
131 break;
132 }
133 }
134
135 private void receiveSessionTerminate(final JinglePacket jinglePacket) {
136 respondOk(jinglePacket);
137 final Reason reason = jinglePacket.getReason();
138 final State previous = this.state;
139 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received session terminate reason=" + reason + " while in state " + previous);
140 webRTCWrapper.close();
141 transitionOrThrow(reasonToState(reason));
142 if (previous == State.PROPOSED || previous == State.SESSION_INITIALIZED) {
143 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
144 }
145 jingleConnectionManager.finishConnection(this);
146 }
147
148 private void receiveTransportInfo(final JinglePacket jinglePacket) {
149 if (isInState(State.SESSION_INITIALIZED, State.SESSION_INITIALIZED_PRE_APPROVED, State.SESSION_ACCEPTED)) {
150 respondOk(jinglePacket);
151 final RtpContentMap contentMap;
152 try {
153 contentMap = RtpContentMap.of(jinglePacket);
154 } catch (IllegalArgumentException | NullPointerException e) {
155 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": improperly formatted contents; ignoring", e);
156 return;
157 }
158 final RtpContentMap rtpContentMap = isInitiator() ? this.responderRtpContentMap : this.initiatorRtpContentMap;
159 final Group originalGroup = rtpContentMap != null ? rtpContentMap.group : null;
160 final List<String> identificationTags = originalGroup == null ? Collections.emptyList() : originalGroup.getIdentificationTags();
161 if (identificationTags.size() == 0) {
162 Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": no identification tags found in initial offer. we won't be able to calculate mLineIndices");
163 }
164 for (final Map.Entry<String, RtpContentMap.DescriptionTransport> content : contentMap.contents.entrySet()) {
165 final String ufrag = content.getValue().transport.getAttribute("ufrag");
166 for (final IceUdpTransportInfo.Candidate candidate : content.getValue().transport.getCandidates()) {
167 final String sdp = candidate.toSdpAttribute(ufrag);
168 final String sdpMid = content.getKey();
169 final int mLineIndex = identificationTags.indexOf(sdpMid);
170 final IceCandidate iceCandidate = new IceCandidate(sdpMid, mLineIndex, sdp);
171 if (isInState(State.SESSION_ACCEPTED)) {
172 Log.d(Config.LOGTAG, "received candidate: " + iceCandidate);
173 this.webRTCWrapper.addIceCandidate(iceCandidate);
174 } else {
175 this.pendingIceCandidates.offer(iceCandidate);
176 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": put ICE candidate on backlog");
177 }
178 }
179 }
180 } else {
181 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received transport info while in state=" + this.state);
182 terminateWithOutOfOrder(jinglePacket);
183 }
184 }
185
186 private void receiveSessionInitiate(final JinglePacket jinglePacket) {
187 if (isInitiator()) {
188 Log.d(Config.LOGTAG, String.format("%s: received session-initiate even though we were initiating", id.account.getJid().asBareJid()));
189 terminateWithOutOfOrder(jinglePacket);
190 return;
191 }
192 final RtpContentMap contentMap;
193 try {
194 contentMap = RtpContentMap.of(jinglePacket);
195 contentMap.requireContentDescriptions();
196 contentMap.requireDTLSFingerprint();
197 } catch (final IllegalArgumentException | IllegalStateException | NullPointerException e) {
198 respondOk(jinglePacket);
199 sendSessionTerminate(Reason.FAILED_APPLICATION);
200 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": improperly formatted contents", e);
201 return;
202 }
203 Log.d(Config.LOGTAG, "processing session-init with " + contentMap.contents.size() + " contents");
204 final State target;
205 if (this.state == State.PROCEED) {
206 target = State.SESSION_INITIALIZED_PRE_APPROVED;
207 } else {
208 target = State.SESSION_INITIALIZED;
209 }
210 if (transition(target)) {
211 respondOk(jinglePacket);
212 this.initiatorRtpContentMap = contentMap;
213 if (target == State.SESSION_INITIALIZED_PRE_APPROVED) {
214 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": automatically accepting session-initiate");
215 sendSessionAccept();
216 } else {
217 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received not pre-approved session-initiate. start ringing");
218 startRinging();
219 }
220 } else {
221 Log.d(Config.LOGTAG, String.format("%s: received session-initiate while in state %s", id.account.getJid().asBareJid(), state));
222 terminateWithOutOfOrder(jinglePacket);
223 }
224 }
225
226 private void receiveSessionAccept(final JinglePacket jinglePacket) {
227 if (!isInitiator()) {
228 Log.d(Config.LOGTAG, String.format("%s: received session-accept even though we were responding", id.account.getJid().asBareJid()));
229 terminateWithOutOfOrder(jinglePacket);
230 return;
231 }
232 final RtpContentMap contentMap;
233 try {
234 contentMap = RtpContentMap.of(jinglePacket);
235 contentMap.requireContentDescriptions();
236 contentMap.requireDTLSFingerprint();
237 } catch (IllegalArgumentException | IllegalStateException | NullPointerException e) {
238 respondOk(jinglePacket);
239 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": improperly formatted contents in session-accept", e);
240 webRTCWrapper.close();
241 sendSessionTerminate(Reason.FAILED_APPLICATION);
242 return;
243 }
244 Log.d(Config.LOGTAG, "processing session-accept with " + contentMap.contents.size() + " contents");
245 if (transition(State.SESSION_ACCEPTED)) {
246 respondOk(jinglePacket);
247 receiveSessionAccept(contentMap);
248 } else {
249 Log.d(Config.LOGTAG, String.format("%s: received session-accept while in state %s", id.account.getJid().asBareJid(), state));
250 respondOk(jinglePacket);
251 }
252 }
253
254 private void receiveSessionAccept(final RtpContentMap contentMap) {
255 this.responderRtpContentMap = contentMap;
256 final SessionDescription sessionDescription;
257 try {
258 sessionDescription = SessionDescription.of(contentMap);
259 } catch (final IllegalArgumentException | NullPointerException e) {
260 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable convert offer from session-accept to SDP", e);
261 webRTCWrapper.close();
262 sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage());
263 return;
264 }
265 org.webrtc.SessionDescription answer = new org.webrtc.SessionDescription(
266 org.webrtc.SessionDescription.Type.ANSWER,
267 sessionDescription.toString()
268 );
269 try {
270 this.webRTCWrapper.setRemoteDescription(answer).get();
271 } catch (Exception e) {
272 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to set remote description after receiving session-accept", e);
273 webRTCWrapper.close();
274 sendSessionTerminate(Reason.FAILED_APPLICATION);
275 }
276 }
277
278 private void sendSessionAccept() {
279 final RtpContentMap rtpContentMap = this.initiatorRtpContentMap;
280 if (rtpContentMap == null) {
281 throw new IllegalStateException("initiator RTP Content Map has not been set");
282 }
283 final SessionDescription offer;
284 try {
285 offer = SessionDescription.of(rtpContentMap);
286 } catch (final IllegalArgumentException | NullPointerException e) {
287 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable convert offer from session-initiate to SDP", e);
288 webRTCWrapper.close();
289 sendSessionTerminate(Reason.FAILED_APPLICATION, e.getMessage());
290 return;
291 }
292 sendSessionAccept(offer);
293 }
294
295 private void sendSessionAccept(SessionDescription offer) {
296 discoverIceServers(iceServers -> {
297 try {
298 setupWebRTC(iceServers);
299 } catch (WebRTCWrapper.InitializationException e) {
300 sendSessionTerminate(Reason.FAILED_APPLICATION);
301 return;
302 }
303 final org.webrtc.SessionDescription sdp = new org.webrtc.SessionDescription(
304 org.webrtc.SessionDescription.Type.OFFER,
305 offer.toString()
306 );
307 try {
308 this.webRTCWrapper.setRemoteDescription(sdp).get();
309 addIceCandidatesFromBlackLog();
310 org.webrtc.SessionDescription webRTCSessionDescription = this.webRTCWrapper.createAnswer().get();
311 final SessionDescription sessionDescription = SessionDescription.parse(webRTCSessionDescription.description);
312 final RtpContentMap respondingRtpContentMap = RtpContentMap.of(sessionDescription);
313 sendSessionAccept(respondingRtpContentMap);
314 this.webRTCWrapper.setLocalDescription(webRTCSessionDescription);
315 } catch (Exception e) {
316 Log.d(Config.LOGTAG, "unable to send session accept", e);
317
318 }
319 });
320 }
321
322 private void addIceCandidatesFromBlackLog() {
323 while (!this.pendingIceCandidates.isEmpty()) {
324 final IceCandidate iceCandidate = this.pendingIceCandidates.poll();
325 this.webRTCWrapper.addIceCandidate(iceCandidate);
326 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": added ICE candidate from back log " + iceCandidate);
327 }
328 }
329
330 private void sendSessionAccept(final RtpContentMap rtpContentMap) {
331 this.responderRtpContentMap = rtpContentMap;
332 this.transitionOrThrow(State.SESSION_ACCEPTED);
333 final JinglePacket sessionAccept = rtpContentMap.toJinglePacket(JinglePacket.Action.SESSION_ACCEPT, id.sessionId);
334 Log.d(Config.LOGTAG, sessionAccept.toString());
335 send(sessionAccept);
336 }
337
338 void deliveryMessage(final Jid from, final Element message) {
339 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": delivered message to JingleRtpConnection " + message);
340 switch (message.getName()) {
341 case "propose":
342 receivePropose(from, message);
343 break;
344 case "proceed":
345 receiveProceed(from, message);
346 break;
347 case "retract":
348 receiveRetract(from, message);
349 break;
350 case "reject":
351 receiveReject(from, message);
352 break;
353 case "accept":
354 receiveAccept(from, message);
355 break;
356 default:
357 break;
358 }
359 }
360
361 void deliverFailedProceed() {
362 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": receive message error for proceed message");
363 if (transition(State.TERMINATED_CONNECTIVITY_ERROR)) {
364 webRTCWrapper.close();
365 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": transitioned into connectivity error");
366 this.jingleConnectionManager.finishConnection(this);
367 }
368 }
369
370 private void receiveAccept(Jid from, Element message) {
371 final boolean originatedFromMyself = from.asBareJid().equals(id.account.getJid().asBareJid());
372 if (originatedFromMyself) {
373 if (transition(State.ACCEPTED)) {
374 this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
375 this.jingleConnectionManager.finishConnection(this);
376 } else {
377 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to transition to accept because already in state=" + this.state);
378 }
379 } else {
380 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": ignoring 'accept' from " + from);
381 }
382 }
383
384 private void receiveReject(Jid from, Element message) {
385 final boolean originatedFromMyself = from.asBareJid().equals(id.account.getJid().asBareJid());
386 //reject from another one of my clients
387 if (originatedFromMyself) {
388 if (transition(State.REJECTED)) {
389 this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
390 this.jingleConnectionManager.finishConnection(this);
391 } else {
392 Log.d(Config.LOGTAG, "not able to transition into REJECTED because already in " + this.state);
393 }
394 } else {
395 Log.d(Config.LOGTAG, id.account.getJid() + ": ignoring reject from " + from + " for session with " + id.with);
396 }
397 }
398
399 private void receivePropose(final Jid from, final Element propose) {
400 final boolean originatedFromMyself = from.asBareJid().equals(id.account.getJid().asBareJid());
401 //TODO we can use initiator logic here
402 if (originatedFromMyself) {
403 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": saw proposal from mysql. ignoring");
404 } else if (transition(State.PROPOSED)) {
405 startRinging();
406 } else {
407 Log.d(Config.LOGTAG, id.account.getJid() + ": ignoring session proposal because already in " + state);
408 }
409 }
410
411 private void startRinging() {
412 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received call from " + id.with + ". start ringing");
413 xmppConnectionService.getNotificationService().showIncomingCallNotification(id);
414 }
415
416 private void receiveProceed(final Jid from, final Element proceed) {
417 if (from.equals(id.with)) {
418 if (isInitiator()) {
419 if (transition(State.PROCEED)) {
420 this.sendSessionInitiate(State.SESSION_INITIALIZED_PRE_APPROVED);
421 } else {
422 Log.d(Config.LOGTAG, String.format("%s: ignoring proceed because already in %s", id.account.getJid().asBareJid(), this.state));
423 }
424 } else {
425 Log.d(Config.LOGTAG, String.format("%s: ignoring proceed because we were not initializing", id.account.getJid().asBareJid()));
426 }
427 } else if (from.asBareJid().equals(id.account.getJid().asBareJid())) {
428 if (transition(State.ACCEPTED)) {
429 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": moved session with " + id.with + " into state accepted after received carbon copied procced");
430 this.xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
431 this.jingleConnectionManager.finishConnection(this);
432 }
433 } else {
434 Log.d(Config.LOGTAG, String.format("%s: ignoring proceed from %s. was expected from %s", id.account.getJid().asBareJid(), from, id.with));
435 }
436 }
437
438 private void receiveRetract(final Jid from, final Element retract) {
439 if (from.equals(id.with)) {
440 if (transition(State.RETRACTED)) {
441 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
442 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": session with " + id.with + " has been retracted");
443 //TODO create missed call notification/message
444 jingleConnectionManager.finishConnection(this);
445 } else {
446 Log.d(Config.LOGTAG, "ignoring retract because already in " + this.state);
447 }
448 } else {
449 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received retract from " + from + ". expected retract from" + id.with + ". ignoring");
450 }
451 }
452
453 private void sendSessionInitiate(final State targetState) {
454 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": prepare session-initiate");
455 discoverIceServers(iceServers -> {
456 try {
457 setupWebRTC(iceServers);
458 } catch (WebRTCWrapper.InitializationException e) {
459 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to initialize webrtc");
460 transitionOrThrow(State.TERMINATED_APPLICATION_FAILURE);
461 return;
462 }
463 try {
464 org.webrtc.SessionDescription webRTCSessionDescription = this.webRTCWrapper.createOffer().get();
465 final SessionDescription sessionDescription = SessionDescription.parse(webRTCSessionDescription.description);
466 Log.d(Config.LOGTAG, "description: " + webRTCSessionDescription.description);
467 final RtpContentMap rtpContentMap = RtpContentMap.of(sessionDescription);
468 sendSessionInitiate(rtpContentMap, targetState);
469 this.webRTCWrapper.setLocalDescription(webRTCSessionDescription).get();
470 } catch (final Exception e) {
471 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to sendSessionInitiate", e);
472 webRTCWrapper.close();
473 if (isInState(targetState)) {
474 sendSessionTerminate(Reason.FAILED_APPLICATION);
475 } else {
476 transitionOrThrow(State.TERMINATED_APPLICATION_FAILURE);
477 }
478 }
479 });
480 }
481
482 private void sendSessionInitiate(RtpContentMap rtpContentMap, final State targetState) {
483 this.initiatorRtpContentMap = rtpContentMap;
484 this.transitionOrThrow(targetState);
485 final JinglePacket sessionInitiate = rtpContentMap.toJinglePacket(JinglePacket.Action.SESSION_INITIATE, id.sessionId);
486 Log.d(Config.LOGTAG, sessionInitiate.toString());
487 send(sessionInitiate);
488 }
489
490 private void sendSessionTerminate(final Reason reason) {
491 sendSessionTerminate(reason, null);
492 }
493
494 private void sendSessionTerminate(final Reason reason, final String text) {
495 final State target = reasonToState(reason);
496 transitionOrThrow(target);
497 final JinglePacket jinglePacket = new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId);
498 jinglePacket.setReason(reason, text);
499 send(jinglePacket);
500 Log.d(Config.LOGTAG, jinglePacket.toString());
501 jingleConnectionManager.finishConnection(this);
502 }
503
504 private void sendTransportInfo(final String contentName, IceUdpTransportInfo.Candidate candidate) {
505 final RtpContentMap transportInfo;
506 try {
507 final RtpContentMap rtpContentMap = isInitiator() ? this.initiatorRtpContentMap : this.responderRtpContentMap;
508 transportInfo = rtpContentMap.transportInfo(contentName, candidate);
509 } catch (Exception e) {
510 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to prepare transport-info from candidate for content=" + contentName);
511 return;
512 }
513 final JinglePacket jinglePacket = transportInfo.toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
514 Log.d(Config.LOGTAG, jinglePacket.toString());
515 send(jinglePacket);
516 }
517
518 private void send(final JinglePacket jinglePacket) {
519 jinglePacket.setTo(id.with);
520 xmppConnectionService.sendIqPacket(id.account, jinglePacket, (account, response) -> {
521 if (response.getType() == IqPacket.TYPE.ERROR) {
522 final String errorCondition = response.getErrorCondition();
523 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received IQ-error from " + response.getFrom() + " in RTP session. " + errorCondition);
524 this.webRTCWrapper.close();
525 final State target;
526 if (Arrays.asList(
527 "service-unavailable",
528 "recipient-unavailable",
529 "remote-server-not-found",
530 "remote-server-timeout"
531 ).contains(errorCondition)) {
532 target = State.TERMINATED_CONNECTIVITY_ERROR;
533 } else {
534 target = State.TERMINATED_APPLICATION_FAILURE;
535 }
536 if (transition(target)) {
537 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": terminated session with " + id.with);
538 } else {
539 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": not transitioning because already at state=" + this.state);
540 }
541
542 } else if (response.getType() == IqPacket.TYPE.TIMEOUT) {
543 this.webRTCWrapper.close();
544 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received IQ timeout in RTP session with " + id.with + ". terminating with connectivity error");
545 transition(State.TERMINATED_CONNECTIVITY_ERROR);
546 this.jingleConnectionManager.finishConnection(this);
547 }
548 });
549 }
550
551 private void terminateWithOutOfOrder(final JinglePacket jinglePacket) {
552 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": terminating session with out-of-order");
553 webRTCWrapper.close();
554 transitionOrThrow(State.TERMINATED_APPLICATION_FAILURE);
555 respondWithOutOfOrder(jinglePacket);
556 jingleConnectionManager.finishConnection(this);
557 }
558
559 private void respondWithOutOfOrder(final JinglePacket jinglePacket) {
560 jingleConnectionManager.respondWithJingleError(id.account, jinglePacket, "out-of-order", "unexpected-request", "wait");
561 }
562
563 private void respondOk(final JinglePacket jinglePacket) {
564 xmppConnectionService.sendIqPacket(id.account, jinglePacket.generateResponse(IqPacket.TYPE.RESULT), null);
565 }
566
567 public RtpEndUserState getEndUserState() {
568 switch (this.state) {
569 case PROPOSED:
570 case SESSION_INITIALIZED:
571 if (isInitiator()) {
572 return RtpEndUserState.RINGING;
573 } else {
574 return RtpEndUserState.INCOMING_CALL;
575 }
576 case PROCEED:
577 if (isInitiator()) {
578 return RtpEndUserState.RINGING;
579 } else {
580 return RtpEndUserState.ACCEPTING_CALL;
581 }
582 case SESSION_INITIALIZED_PRE_APPROVED:
583 if (isInitiator()) {
584 return RtpEndUserState.RINGING;
585 } else {
586 return RtpEndUserState.CONNECTING;
587 }
588 case SESSION_ACCEPTED:
589 final PeerConnection.PeerConnectionState state = webRTCWrapper.getState();
590 if (state == PeerConnection.PeerConnectionState.CONNECTED) {
591 return RtpEndUserState.CONNECTED;
592 } else if (state == PeerConnection.PeerConnectionState.NEW || state == PeerConnection.PeerConnectionState.CONNECTING) {
593 return RtpEndUserState.CONNECTING;
594 } else if (state == PeerConnection.PeerConnectionState.CLOSED) {
595 return RtpEndUserState.ENDING_CALL;
596 } else if (state == PeerConnection.PeerConnectionState.FAILED) {
597 return RtpEndUserState.CONNECTIVITY_ERROR;
598 } else {
599 return RtpEndUserState.ENDING_CALL;
600 }
601 case REJECTED:
602 case TERMINATED_DECLINED_OR_BUSY:
603 if (isInitiator()) {
604 return RtpEndUserState.DECLINED_OR_BUSY;
605 } else {
606 return RtpEndUserState.ENDED;
607 }
608 case TERMINATED_SUCCESS:
609 case ACCEPTED:
610 case RETRACTED:
611 case TERMINATED_CANCEL_OR_TIMEOUT:
612 return RtpEndUserState.ENDED;
613 case TERMINATED_CONNECTIVITY_ERROR:
614 return RtpEndUserState.CONNECTIVITY_ERROR;
615 case TERMINATED_APPLICATION_FAILURE:
616 return RtpEndUserState.APPLICATION_ERROR;
617 }
618 throw new IllegalStateException(String.format("%s has no equivalent EndUserState", this.state));
619 }
620
621
622 public void acceptCall() {
623 switch (this.state) {
624 case PROPOSED:
625 acceptCallFromProposed();
626 break;
627 case SESSION_INITIALIZED:
628 acceptCallFromSessionInitialized();
629 break;
630 default:
631 throw new IllegalStateException("Can not accept call from " + this.state);
632 }
633 }
634
635 public void rejectCall() {
636 switch (this.state) {
637 case PROPOSED:
638 rejectCallFromProposed();
639 break;
640 case SESSION_INITIALIZED:
641 rejectCallFromSessionInitiate();
642 break;
643 default:
644 throw new IllegalStateException("Can not reject call from " + this.state);
645 }
646 }
647
648 public void endCall() {
649 if (isInState(State.PROPOSED) && !isInitiator()) {
650 rejectCallFromProposed();
651 return;
652 }
653 if (isInState(State.PROCEED)) {
654 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": ending call while in state PROCEED just means ending the connection");
655 webRTCWrapper.close();
656 jingleConnectionManager.finishConnection(this);
657 transitionOrThrow(State.TERMINATED_SUCCESS); //arguably this wasn't success; but not a real failure either
658 return;
659 }
660 if (isInitiator() && isInState(State.SESSION_INITIALIZED, State.SESSION_INITIALIZED_PRE_APPROVED)) {
661 webRTCWrapper.close();
662 sendSessionTerminate(Reason.CANCEL);
663 return;
664 }
665 if (isInState(State.SESSION_INITIALIZED)) {
666 rejectCallFromSessionInitiate();
667 return;
668 }
669 if (isInState(State.SESSION_INITIALIZED_PRE_APPROVED, State.SESSION_ACCEPTED)) {
670 webRTCWrapper.close();
671 sendSessionTerminate(Reason.SUCCESS);
672 return;
673 }
674 if (isInState(State.TERMINATED_APPLICATION_FAILURE, State.TERMINATED_CONNECTIVITY_ERROR, State.TERMINATED_DECLINED_OR_BUSY)) {
675 Log.d(Config.LOGTAG, "ignoring request to end call because already in state " + this.state);
676 return;
677 }
678 throw new IllegalStateException("called 'endCall' while in state " + this.state + ". isInitiator=" + isInitiator());
679 }
680
681 private void setupWebRTC(final List<PeerConnection.IceServer> iceServers) throws WebRTCWrapper.InitializationException {
682 this.webRTCWrapper.setup(this.xmppConnectionService);
683 this.webRTCWrapper.initializePeerConnection(iceServers);
684 }
685
686 private void acceptCallFromProposed() {
687 transitionOrThrow(State.PROCEED);
688 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
689 this.sendJingleMessage("accept", id.account.getJid().asBareJid());
690 this.sendJingleMessage("proceed");
691 }
692
693 private void rejectCallFromProposed() {
694 transitionOrThrow(State.REJECTED);
695 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
696 this.sendJingleMessage("reject");
697 jingleConnectionManager.finishConnection(this);
698 }
699
700 private void rejectCallFromSessionInitiate() {
701 webRTCWrapper.close();
702 sendSessionTerminate(Reason.DECLINE);
703 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
704 }
705
706 private void sendJingleMessage(final String action) {
707 sendJingleMessage(action, id.with);
708 }
709
710 private void sendJingleMessage(final String action, final Jid to) {
711 final MessagePacket messagePacket = new MessagePacket();
712 if ("proceed".equals(action)) {
713 messagePacket.setId(JINGLE_MESSAGE_PROCEED_ID_PREFIX + id.sessionId);
714 }
715 messagePacket.setType(MessagePacket.TYPE_CHAT); //we want to carbon copy those
716 messagePacket.setTo(to);
717 messagePacket.addChild(action, Namespace.JINGLE_MESSAGE).setAttribute("id", id.sessionId);
718 Log.d(Config.LOGTAG, messagePacket.toString());
719 xmppConnectionService.sendMessagePacket(id.account, messagePacket);
720 }
721
722 private void acceptCallFromSessionInitialized() {
723 xmppConnectionService.getNotificationService().cancelIncomingCallNotification();
724 sendSessionAccept();
725 }
726
727 private synchronized boolean isInState(State... state) {
728 return Arrays.asList(state).contains(this.state);
729 }
730
731 private synchronized boolean transition(final State target) {
732 final Collection<State> validTransitions = VALID_TRANSITIONS.get(this.state);
733 if (validTransitions != null && validTransitions.contains(target)) {
734 this.state = target;
735 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": transitioned into " + target);
736 updateEndUserState();
737 updateOngoingCallNotification();
738 return true;
739 } else {
740 return false;
741 }
742 }
743
744 public void transitionOrThrow(final State target) {
745 if (!transition(target)) {
746 throw new IllegalStateException(String.format("Unable to transition from %s to %s", this.state, target));
747 }
748 }
749
750 @Override
751 public void onIceCandidate(final IceCandidate iceCandidate) {
752 final IceUdpTransportInfo.Candidate candidate = IceUdpTransportInfo.Candidate.fromSdpAttribute(iceCandidate.sdp);
753 Log.d(Config.LOGTAG, "sending candidate: " + iceCandidate.toString());
754 sendTransportInfo(iceCandidate.sdpMid, candidate);
755 }
756
757 @Override
758 public void onConnectionChange(final PeerConnection.PeerConnectionState newState) {
759 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": PeerConnectionState changed to " + newState);
760 updateEndUserState();
761 if (newState == PeerConnection.PeerConnectionState.FAILED) { //TODO guard this in isState(initiated,initiated_approved,accepted) otherwise it might fire too late
762 sendSessionTerminate(Reason.CONNECTIVITY_ERROR);
763 }
764 }
765
766 private void updateEndUserState() {
767 xmppConnectionService.notifyJingleRtpConnectionUpdate(id.account, id.with, id.sessionId, getEndUserState());
768 }
769
770 private void updateOngoingCallNotification() {
771 if (STATES_SHOWING_ONGOING_CALL.contains(this.state)) {
772 xmppConnectionService.getNotificationService().showOngoingCallNotification(id);
773 } else {
774 xmppConnectionService.getNotificationService().cancelOngoingCallNotification();
775 }
776 }
777
778 private void discoverIceServers(final OnIceServersDiscovered onIceServersDiscovered) {
779 if (id.account.getXmppConnection().getFeatures().extendedServiceDiscovery()) {
780 final IqPacket request = new IqPacket(IqPacket.TYPE.GET);
781 request.setTo(Jid.of(id.account.getJid().getDomain()));
782 request.addChild("services", Namespace.EXTERNAL_SERVICE_DISCOVERY);
783 xmppConnectionService.sendIqPacket(id.account, request, (account, response) -> {
784 ImmutableList.Builder<PeerConnection.IceServer> listBuilder = new ImmutableList.Builder<>();
785 if (response.getType() == IqPacket.TYPE.RESULT) {
786 final Element services = response.findChild("services", Namespace.EXTERNAL_SERVICE_DISCOVERY);
787 final List<Element> children = services == null ? Collections.emptyList() : services.getChildren();
788 for (final Element child : children) {
789 if ("service".equals(child.getName())) {
790 final String type = child.getAttribute("type");
791 final String host = child.getAttribute("host");
792 final String sport = child.getAttribute("port");
793 final Integer port = sport == null ? null : Ints.tryParse(sport);
794 final String transport = child.getAttribute("transport");
795 final String username = child.getAttribute("username");
796 final String password = child.getAttribute("password");
797 if (Strings.isNullOrEmpty(host) || port == null) {
798 continue;
799 }
800 if (port < 0 || port > 65535) {
801 continue;
802 }
803 if (Arrays.asList("stun", "turn").contains(type) || Arrays.asList("udp", "tcp").contains(transport)) {
804 //TODO wrap ipv6 addresses
805 PeerConnection.IceServer.Builder iceServerBuilder = PeerConnection.IceServer.builder(String.format("%s:%s:%s?transport=%s", type, host, port, transport));
806 if (username != null && password != null) {
807 iceServerBuilder.setUsername(username);
808 iceServerBuilder.setPassword(password);
809 } else if (Arrays.asList("turn", "turns").contains(type)) {
810 //The WebRTC spec requires throwing an InvalidAccessError when username (from libwebrtc source coder)
811 //https://chromium.googlesource.com/external/webrtc/+/master/pc/ice_server_parsing.cc
812 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": skipping " + type + "/" + transport + " without username and password");
813 continue;
814 }
815 final PeerConnection.IceServer iceServer = iceServerBuilder.createIceServer();
816 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": discovered ICE Server: " + iceServer);
817 listBuilder.add(iceServer);
818 }
819 }
820 }
821 }
822 List<PeerConnection.IceServer> iceServers = listBuilder.build();
823 if (iceServers.size() == 0) {
824 Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": no ICE server found " + response);
825 }
826 onIceServersDiscovered.onIceServersDiscovered(iceServers);
827 });
828 } else {
829 Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": has no external service discovery");
830 onIceServersDiscovered.onIceServersDiscovered(Collections.emptyList());
831 }
832 }
833
834 public State getState() {
835 return this.state;
836 }
837
838 private interface OnIceServersDiscovered {
839 void onIceServersDiscovered(List<PeerConnection.IceServer> iceServers);
840 }
841}