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