1package eu.siacs.conversations.xmpp.jingle;
2
3import android.util.Log;
4
5import com.google.common.collect.ImmutableList;
6import com.google.common.collect.ImmutableMap;
7
8import org.webrtc.IceCandidate;
9
10import java.util.ArrayDeque;
11import java.util.Arrays;
12import java.util.Collection;
13import java.util.Collections;
14import java.util.List;
15import java.util.Map;
16
17import eu.siacs.conversations.Config;
18import eu.siacs.conversations.xml.Element;
19import eu.siacs.conversations.xml.Namespace;
20import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
21import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
22import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
23import eu.siacs.conversations.xmpp.stanzas.MessagePacket;
24import rocks.xmpp.addr.Jid;
25
26public class JingleRtpConnection extends AbstractJingleConnection implements WebRTCWrapper.EventCallback {
27
28 private static final Map<State, Collection<State>> VALID_TRANSITIONS;
29
30 static {
31 final ImmutableMap.Builder<State, Collection<State>> transitionBuilder = new ImmutableMap.Builder<>();
32 transitionBuilder.put(State.NULL, ImmutableList.of(State.PROPOSED, State.SESSION_INITIALIZED));
33 transitionBuilder.put(State.PROPOSED, ImmutableList.of(State.ACCEPTED, State.PROCEED));
34 transitionBuilder.put(State.PROCEED, ImmutableList.of(State.SESSION_INITIALIZED));
35 transitionBuilder.put(State.SESSION_INITIALIZED, ImmutableList.of(State.SESSION_ACCEPTED));
36 VALID_TRANSITIONS = transitionBuilder.build();
37 }
38
39 private final WebRTCWrapper webRTCWrapper = new WebRTCWrapper(this);
40 private final ArrayDeque<IceCandidate> pendingIceCandidates = new ArrayDeque<>();
41 private State state = State.NULL;
42 private RtpContentMap initiatorRtpContentMap;
43 private RtpContentMap responderRtpContentMap;
44
45
46 public JingleRtpConnection(JingleConnectionManager jingleConnectionManager, Id id, Jid initiator) {
47 super(jingleConnectionManager, id, initiator);
48 }
49
50 @Override
51 void deliverPacket(final JinglePacket jinglePacket) {
52 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": packet delivered to JingleRtpConnection");
53 switch (jinglePacket.getAction()) {
54 case SESSION_INITIATE:
55 receiveSessionInitiate(jinglePacket);
56 break;
57 case TRANSPORT_INFO:
58 receiveTransportInfo(jinglePacket);
59 break;
60 case SESSION_ACCEPT:
61 receiveSessionAccept(jinglePacket);
62 break;
63 default:
64 Log.d(Config.LOGTAG, String.format("%s: received unhandled jingle action %s", id.account.getJid().asBareJid(), jinglePacket.getAction()));
65 break;
66 }
67 }
68
69 private void receiveTransportInfo(final JinglePacket jinglePacket) {
70 if (isInState(State.SESSION_INITIALIZED, State.SESSION_ACCEPTED)) {
71 final RtpContentMap contentMap;
72 try {
73 contentMap = RtpContentMap.of(jinglePacket);
74 } catch (IllegalArgumentException | NullPointerException e) {
75 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": improperly formatted contents", e);
76 return;
77 }
78 final RtpContentMap rtpContentMap = isInitiator() ? this.initiatorRtpContentMap : this.responderRtpContentMap;
79 final Group originalGroup = rtpContentMap != null ? rtpContentMap.group : null;
80 final List<String> identificationTags = originalGroup == null ? Collections.emptyList() : originalGroup.getIdentificationTags();
81 if (identificationTags.size() == 0) {
82 Log.w(Config.LOGTAG, id.account.getJid().asBareJid() + ": no identification tags found in initial offer. we won't be able to calculate mLineIndices");
83 }
84 for (final Map.Entry<String, RtpContentMap.DescriptionTransport> content : contentMap.contents.entrySet()) {
85 final String ufrag = content.getValue().transport.getAttribute("ufrag");
86 for (final IceUdpTransportInfo.Candidate candidate : content.getValue().transport.getCandidates()) {
87 final String sdp = candidate.toSdpAttribute(ufrag);
88 final String sdpMid = content.getKey();
89 final int mLineIndex = identificationTags.indexOf(sdpMid);
90 final IceCandidate iceCandidate = new IceCandidate(sdpMid, mLineIndex, sdp);
91 Log.d(Config.LOGTAG, "received candidate: " + iceCandidate);
92 if (isInState(State.SESSION_ACCEPTED)) {
93 this.webRTCWrapper.addIceCandidate(iceCandidate);
94 } else {
95 this.pendingIceCandidates.push(iceCandidate);
96 }
97 }
98 }
99 } else {
100 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": received transport info while in state=" + this.state);
101 }
102 }
103
104 private void receiveSessionInitiate(final JinglePacket jinglePacket) {
105 if (isInitiator()) {
106 Log.d(Config.LOGTAG, String.format("%s: received session-initiate even though we were initiating", id.account.getJid().asBareJid()));
107 //TODO respond with out-of-order
108 return;
109 }
110 final RtpContentMap contentMap;
111 try {
112 contentMap = RtpContentMap.of(jinglePacket);
113 contentMap.requireContentDescriptions();
114 } catch (IllegalArgumentException | IllegalStateException | NullPointerException e) {
115 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": improperly formatted contents", e);
116 return;
117 }
118 Log.d(Config.LOGTAG, "processing session-init with " + contentMap.contents.size() + " contents");
119 final State oldState = this.state;
120 if (transition(State.SESSION_INITIALIZED)) {
121 this.initiatorRtpContentMap = contentMap;
122 if (oldState == State.PROCEED) {
123 Log.d(Config.LOGTAG, "automatically accepting");
124 sendSessionAccept();
125 } else {
126 Log.d(Config.LOGTAG, "start ringing");
127 //TODO start ringing
128 }
129 } else {
130 Log.d(Config.LOGTAG, String.format("%s: received session-initiate while in state %s", id.account.getJid().asBareJid(), state));
131 }
132 }
133
134 private void receiveSessionAccept(final JinglePacket jinglePacket) {
135 if (!isInitiator()) {
136 Log.d(Config.LOGTAG, String.format("%s: received session-accept even though we were responding", id.account.getJid().asBareJid()));
137 //TODO respond with out-of-order
138 return;
139 }
140 final RtpContentMap contentMap;
141 try {
142 contentMap = RtpContentMap.of(jinglePacket);
143 contentMap.requireContentDescriptions();
144 } catch (IllegalArgumentException | IllegalStateException | NullPointerException e) {
145 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": improperly formatted contents", e);
146 return;
147 }
148 Log.d(Config.LOGTAG, "processing session-accept with " + contentMap.contents.size() + " contents");
149 if (transition(State.SESSION_ACCEPTED)) {
150 receiveSessionAccept(contentMap);
151 } else {
152 Log.d(Config.LOGTAG, String.format("%s: received session-accept while in state %s", id.account.getJid().asBareJid(), state));
153 //TODO out-of-order
154 }
155 }
156
157 private void receiveSessionAccept(final RtpContentMap contentMap) {
158 this.responderRtpContentMap = contentMap;
159 org.webrtc.SessionDescription answer = new org.webrtc.SessionDescription(
160 org.webrtc.SessionDescription.Type.ANSWER,
161 SessionDescription.of(contentMap).toString()
162 );
163 try {
164 this.webRTCWrapper.setRemoteDescription(answer).get();
165 } catch (Exception e) {
166 Log.d(Config.LOGTAG, "unable to receive session accept", e);
167 }
168 }
169
170 private void sendSessionAccept() {
171 final RtpContentMap rtpContentMap = this.initiatorRtpContentMap;
172 if (rtpContentMap == null) {
173 throw new IllegalStateException("initiator RTP Content Map has not been set");
174 }
175 setupWebRTC();
176 final org.webrtc.SessionDescription offer = new org.webrtc.SessionDescription(
177 org.webrtc.SessionDescription.Type.OFFER,
178 SessionDescription.of(rtpContentMap).toString()
179 );
180 try {
181 this.webRTCWrapper.setRemoteDescription(offer).get();
182 org.webrtc.SessionDescription webRTCSessionDescription = this.webRTCWrapper.createAnswer().get();
183 final SessionDescription sessionDescription = SessionDescription.parse(webRTCSessionDescription.description);
184 final RtpContentMap respondingRtpContentMap = RtpContentMap.of(sessionDescription);
185 sendSessionAccept(respondingRtpContentMap);
186 this.webRTCWrapper.setLocalDescription(webRTCSessionDescription);
187 } catch (Exception e) {
188 Log.d(Config.LOGTAG, "unable to send session accept", e);
189
190 }
191 }
192
193 private void sendSessionAccept(final RtpContentMap rtpContentMap) {
194 this.responderRtpContentMap = rtpContentMap;
195 this.transitionOrThrow(State.SESSION_ACCEPTED);
196 final JinglePacket sessionAccept = rtpContentMap.toJinglePacket(JinglePacket.Action.SESSION_ACCEPT, id.sessionId);
197 Log.d(Config.LOGTAG, sessionAccept.toString());
198 send(sessionAccept);
199 }
200
201 void deliveryMessage(final Jid from, final Element message) {
202 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": delivered message to JingleRtpConnection " + message);
203 switch (message.getName()) {
204 case "propose":
205 receivePropose(from, message);
206 break;
207 case "proceed":
208 receiveProceed(from, message);
209 default:
210 break;
211 }
212 }
213
214 private void receivePropose(final Jid from, final Element propose) {
215 final boolean originatedFromMyself = from.asBareJid().equals(id.account.getJid().asBareJid());
216 //TODO we can use initiator logic here
217 if (originatedFromMyself) {
218 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": saw proposal from mysql. ignoring");
219 } else if (transition(State.PROPOSED)) {
220 //TODO start ringing or something
221 pickUpCall();
222 } else {
223 Log.d(Config.LOGTAG, id.account.getJid() + ": ignoring session proposal because already in " + state);
224 }
225 }
226
227 private void receiveProceed(final Jid from, final Element proceed) {
228 if (from.equals(id.with)) {
229 if (isInitiator()) {
230 if (transition(State.PROCEED)) {
231 this.sendSessionInitiate();
232 } else {
233 Log.d(Config.LOGTAG, String.format("%s: ignoring proceed because already in %s", id.account.getJid().asBareJid(), this.state));
234 }
235 } else {
236 Log.d(Config.LOGTAG, String.format("%s: ignoring proceed because we were not initializing", id.account.getJid().asBareJid()));
237 }
238 } else {
239 Log.d(Config.LOGTAG, String.format("%s: ignoring proceed from %s. was expected from %s", id.account.getJid().asBareJid(), from, id.with));
240 }
241 }
242
243 private void sendSessionInitiate() {
244 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": prepare session-initiate");
245 setupWebRTC();
246 try {
247 org.webrtc.SessionDescription webRTCSessionDescription = this.webRTCWrapper.createOffer().get();
248 final SessionDescription sessionDescription = SessionDescription.parse(webRTCSessionDescription.description);
249 Log.d(Config.LOGTAG, "description: " + webRTCSessionDescription.description);
250 final RtpContentMap rtpContentMap = RtpContentMap.of(sessionDescription);
251 sendSessionInitiate(rtpContentMap);
252 this.webRTCWrapper.setLocalDescription(webRTCSessionDescription).get();
253 } catch (Exception e) {
254 Log.d(Config.LOGTAG, "unable to sendSessionInitiate", e);
255 }
256 }
257
258 private void sendSessionInitiate(RtpContentMap rtpContentMap) {
259 this.initiatorRtpContentMap = rtpContentMap;
260 this.transitionOrThrow(State.SESSION_INITIALIZED);
261 final JinglePacket sessionInitiate = rtpContentMap.toJinglePacket(JinglePacket.Action.SESSION_INITIATE, id.sessionId);
262 Log.d(Config.LOGTAG, sessionInitiate.toString());
263 send(sessionInitiate);
264 }
265
266 private void sendTransportInfo(final String contentName, IceUdpTransportInfo.Candidate candidate) {
267 final RtpContentMap transportInfo;
268 try {
269 final RtpContentMap rtpContentMap = isInitiator() ? this.initiatorRtpContentMap : this.responderRtpContentMap;
270 transportInfo = rtpContentMap.transportInfo(contentName, candidate);
271 } catch (Exception e) {
272 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": unable to prepare transport-info from candidate for content=" + contentName);
273 return;
274 }
275 final JinglePacket jinglePacket = transportInfo.toJinglePacket(JinglePacket.Action.TRANSPORT_INFO, id.sessionId);
276 Log.d(Config.LOGTAG, jinglePacket.toString());
277 send(jinglePacket);
278 }
279
280 private void send(final JinglePacket jinglePacket) {
281 jinglePacket.setTo(id.with);
282 //TODO track errors
283 xmppConnectionService.sendIqPacket(id.account, jinglePacket, null);
284 }
285
286
287 public void pickUpCall() {
288 switch (this.state) {
289 case PROPOSED:
290 pickupCallFromProposed();
291 break;
292 case SESSION_INITIALIZED:
293 pickupCallFromSessionInitialized();
294 break;
295 default:
296 throw new IllegalStateException("Can not pick up call from " + this.state);
297 }
298 }
299
300 private void setupWebRTC() {
301 this.webRTCWrapper.setup(this.xmppConnectionService);
302 this.webRTCWrapper.initializePeerConnection();
303 }
304
305 private void pickupCallFromProposed() {
306 transitionOrThrow(State.PROCEED);
307 final MessagePacket messagePacket = new MessagePacket();
308 messagePacket.setTo(id.with);
309 //Note that Movim needs 'accept', correct is 'proceed' https://github.com/movim/movim/issues/916
310 messagePacket.addChild("proceed", Namespace.JINGLE_MESSAGE).setAttribute("id", id.sessionId);
311 Log.d(Config.LOGTAG, messagePacket.toString());
312 xmppConnectionService.sendMessagePacket(id.account, messagePacket);
313 }
314
315 private void pickupCallFromSessionInitialized() {
316
317 }
318
319 private synchronized boolean isInState(State... state) {
320 return Arrays.asList(state).contains(this.state);
321 }
322
323 private synchronized boolean transition(final State target) {
324 final Collection<State> validTransitions = VALID_TRANSITIONS.get(this.state);
325 if (validTransitions != null && validTransitions.contains(target)) {
326 this.state = target;
327 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": transitioned into " + target);
328 return true;
329 } else {
330 return false;
331 }
332 }
333
334 public void transitionOrThrow(final State target) {
335 if (!transition(target)) {
336 throw new IllegalStateException(String.format("Unable to transition from %s to %s", this.state, target));
337 }
338 }
339
340 @Override
341 public void onIceCandidate(final IceCandidate iceCandidate) {
342 final IceUdpTransportInfo.Candidate candidate = IceUdpTransportInfo.Candidate.fromSdpAttribute(iceCandidate.sdp);
343 Log.d(Config.LOGTAG, "sending candidate: " + iceCandidate.toString());
344 sendTransportInfo(iceCandidate.sdpMid, candidate);
345 }
346}