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