1package eu.siacs.conversations.xmpp.jingle;
2
3import android.util.Base64;
4import android.util.Log;
5
6import com.google.common.base.Objects;
7import com.google.common.base.Preconditions;
8
9import java.lang.ref.WeakReference;
10import java.security.SecureRandom;
11import java.util.HashMap;
12import java.util.Map;
13import java.util.concurrent.ConcurrentHashMap;
14
15import eu.siacs.conversations.Config;
16import eu.siacs.conversations.entities.Account;
17import eu.siacs.conversations.entities.Conversation;
18import eu.siacs.conversations.entities.Conversational;
19import eu.siacs.conversations.entities.Message;
20import eu.siacs.conversations.entities.Transferable;
21import eu.siacs.conversations.services.AbstractConnectionManager;
22import eu.siacs.conversations.services.XmppConnectionService;
23import eu.siacs.conversations.xml.Element;
24import eu.siacs.conversations.xml.Namespace;
25import eu.siacs.conversations.xmpp.OnIqPacketReceived;
26import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
27import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription;
28import eu.siacs.conversations.xmpp.jingle.stanzas.JinglePacket;
29import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
30import eu.siacs.conversations.xmpp.stanzas.IqPacket;
31import eu.siacs.conversations.xmpp.stanzas.MessagePacket;
32import rocks.xmpp.addr.Jid;
33
34public class JingleConnectionManager extends AbstractConnectionManager {
35 private final HashMap<RtpSessionProposal, DeviceDiscoveryState> rtpSessionProposals = new HashMap<>();
36 private final Map<AbstractJingleConnection.Id, AbstractJingleConnection> connections = new ConcurrentHashMap<>();
37
38 private HashMap<Jid, JingleCandidate> primaryCandidates = new HashMap<>();
39
40 public JingleConnectionManager(XmppConnectionService service) {
41 super(service);
42 }
43
44 static String nextRandomId() {
45 final byte[] id = new byte[16];
46 new SecureRandom().nextBytes(id);
47 return Base64.encodeToString(id, Base64.NO_WRAP | Base64.NO_PADDING);
48 }
49
50 public void deliverPacket(final Account account, final JinglePacket packet) {
51 final String sessionId = packet.getSessionId();
52 if (sessionId == null) {
53 respondWithJingleError(account, packet, "unknown-session", "item-not-found", "cancel");
54 return;
55 }
56 final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(account, packet);
57 final AbstractJingleConnection existingJingleConnection = connections.get(id);
58 if (existingJingleConnection != null) {
59 existingJingleConnection.deliverPacket(packet);
60 } else if (packet.getAction() == JinglePacket.Action.SESSION_INITIATE) {
61 final Jid from = packet.getFrom();
62 final Content content = packet.getJingleContent();
63 final String descriptionNamespace = content == null ? null : content.getDescriptionNamespace();
64 final AbstractJingleConnection connection;
65 if (FileTransferDescription.NAMESPACES.contains(descriptionNamespace)) {
66 connection = new JingleFileTransferConnection(this, id, from);
67 } else if (Namespace.JINGLE_APPS_RTP.equals(descriptionNamespace) && !usesTor(account)) {
68 if (isBusy()) {
69 mXmppConnectionService.sendIqPacket(account, packet.generateResponse(IqPacket.TYPE.RESULT), null);
70 final JinglePacket sessionTermination = new JinglePacket(JinglePacket.Action.SESSION_TERMINATE, id.sessionId);
71 sessionTermination.setTo(id.with);
72 sessionTermination.setReason(Reason.BUSY, null);
73 mXmppConnectionService.sendIqPacket(account, sessionTermination, null);
74 return;
75 }
76 connection = new JingleRtpConnection(this, id, from);
77 } else {
78 respondWithJingleError(account, packet, "unsupported-info", "feature-not-implemented", "cancel");
79 return;
80 }
81 connections.put(id, connection);
82 connection.deliverPacket(packet);
83 } else {
84 Log.d(Config.LOGTAG, "unable to route jingle packet: " + packet);
85 respondWithJingleError(account, packet, "unknown-session", "item-not-found", "cancel");
86 }
87 }
88
89 private boolean usesTor(final Account account) {
90 return account.isOnion() || mXmppConnectionService.useTorToConnect();
91 }
92
93 private boolean isBusy() {
94 for (AbstractJingleConnection connection : this.connections.values()) {
95 if (connection instanceof JingleRtpConnection) {
96 return true;
97 }
98 }
99 synchronized (this.rtpSessionProposals) {
100 return this.rtpSessionProposals.containsValue(DeviceDiscoveryState.DISCOVERED) || this.rtpSessionProposals.containsValue(DeviceDiscoveryState.SEARCHING);
101 }
102 }
103
104 public void respondWithJingleError(final Account account, final IqPacket original, String jingleCondition, String condition, String conditionType) {
105 final IqPacket response = original.generateResponse(IqPacket.TYPE.ERROR);
106 final Element error = response.addChild("error");
107 error.setAttribute("type", conditionType);
108 error.addChild(condition, "urn:ietf:params:xml:ns:xmpp-stanzas");
109 error.addChild(jingleCondition, "urn:xmpp:jingle:errors:1");
110 account.getXmppConnection().sendIqPacket(response, null);
111 }
112
113 public void deliverMessage(final Account account, final Jid to, final Jid from, final Element message, String serverMsgId, long timestamp) {
114 Preconditions.checkArgument(Namespace.JINGLE_MESSAGE.equals(message.getNamespace()));
115 final String sessionId = message.getAttribute("id");
116 if (sessionId == null) {
117 return;
118 }
119 if ("accept".equals(message.getName())) {
120 for (AbstractJingleConnection connection : connections.values()) {
121 if (connection instanceof JingleRtpConnection) {
122 final JingleRtpConnection rtpConnection = (JingleRtpConnection) connection;
123 final AbstractJingleConnection.Id id = connection.getId();
124 if (id.account == account && id.sessionId.equals(sessionId)) {
125 rtpConnection.deliveryMessage(from, message, serverMsgId, timestamp);
126 return;
127 }
128 }
129 }
130 return;
131 }
132 final boolean addressedToSelf = from.asBareJid().equals(account.getJid().asBareJid());
133 final AbstractJingleConnection.Id id;
134 if (addressedToSelf) {
135 if (to.isFullJid()) {
136 id = AbstractJingleConnection.Id.of(account, to, sessionId);
137 } else {
138 return;
139 }
140 } else {
141 id = AbstractJingleConnection.Id.of(account, from, sessionId);
142 }
143 final AbstractJingleConnection existingJingleConnection = connections.get(id);
144 if (existingJingleConnection != null) {
145 if (existingJingleConnection instanceof JingleRtpConnection) {
146 ((JingleRtpConnection) existingJingleConnection).deliveryMessage(from, message, serverMsgId, timestamp);
147 } else {
148 Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": " + existingJingleConnection.getClass().getName() + " does not support jingle messages");
149 }
150 return;
151 }
152
153 if (addressedToSelf) {
154 Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": ignore jingle message from self");
155 }
156
157 if ("propose".equals(message.getName())) {
158 final Element description = message.findChild("description");
159 final String namespace = description == null ? null : description.getNamespace();
160 if (Namespace.JINGLE_APPS_RTP.equals(namespace) && !usesTor(account)) {
161 if (isBusy()) {
162 final MessagePacket reject = mXmppConnectionService.getMessageGenerator().sessionReject(from, sessionId);
163 mXmppConnectionService.sendMessagePacket(account, reject);
164 } else {
165 final JingleRtpConnection rtpConnection = new JingleRtpConnection(this, id, from);
166 this.connections.put(id, rtpConnection);
167 rtpConnection.deliveryMessage(from, message, serverMsgId, timestamp);
168 }
169 } else {
170 Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": unable to react to proposed " + namespace + " session");
171 }
172 } else if ("proceed".equals(message.getName())) {
173
174 final RtpSessionProposal proposal = new RtpSessionProposal(account, from.asBareJid(), sessionId);
175 synchronized (rtpSessionProposals) {
176 if (rtpSessionProposals.remove(proposal) != null) {
177 final JingleRtpConnection rtpConnection = new JingleRtpConnection(this, id, account.getJid());
178 this.connections.put(id, rtpConnection);
179 rtpConnection.transitionOrThrow(AbstractJingleConnection.State.PROPOSED);
180 rtpConnection.deliveryMessage(from, message, serverMsgId, timestamp);
181 } else {
182 Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": no rtp session proposal found for " + from + " to deliver proceed");
183 }
184 }
185 } else if ("reject".equals(message.getName())) {
186 final RtpSessionProposal proposal = new RtpSessionProposal(account, from.asBareJid(), sessionId);
187 synchronized (rtpSessionProposals) {
188 if (rtpSessionProposals.remove(proposal) != null) {
189 writeLogMissedOutgoing(account, proposal.with, proposal.sessionId, serverMsgId, timestamp);
190 mXmppConnectionService.notifyJingleRtpConnectionUpdate(account, proposal.with, proposal.sessionId, RtpEndUserState.DECLINED_OR_BUSY);
191 } else {
192 Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": no rtp session proposal found for " + from + " to deliver reject");
193 }
194 }
195 } else {
196 Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": retrieved out of order jingle message");
197 }
198
199 }
200
201 private void writeLogMissedOutgoing(final Account account, Jid with, final String sessionId, String serverMsgId, long timestamp) {
202 final Conversation conversation = mXmppConnectionService.findOrCreateConversation(
203 account,
204 with.asBareJid(),
205 false,
206 false
207 );
208 final Message message = new Message(
209 conversation,
210 Message.STATUS_SEND,
211 Message.TYPE_RTP_SESSION,
212 sessionId
213 );
214 message.setServerMsgId(serverMsgId);
215 message.setTime(timestamp);
216 writeMessage(message);
217 }
218
219 private void writeMessage(final Message message) {
220 final Conversational conversational = message.getConversation();
221 if (conversational instanceof Conversation) {
222 ((Conversation) conversational).add(message);
223 mXmppConnectionService.databaseBackend.createMessage(message);
224 mXmppConnectionService.updateConversationUi();
225 } else {
226 throw new IllegalStateException("Somehow the conversation in a message was a stub");
227 }
228 }
229
230 public void startJingleFileTransfer(final Message message) {
231 Preconditions.checkArgument(message.isFileOrImage(), "Message is not of type file or image");
232 final Transferable old = message.getTransferable();
233 if (old != null) {
234 old.cancel();
235 }
236 final Account account = message.getConversation().getAccount();
237 final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(message);
238 final JingleFileTransferConnection connection = new JingleFileTransferConnection(this, id, account.getJid());
239 mXmppConnectionService.markMessage(message, Message.STATUS_WAITING);
240 this.connections.put(id, connection);
241 connection.init(message);
242 }
243
244 void finishConnection(final AbstractJingleConnection connection) {
245 this.connections.remove(connection.getId());
246 }
247
248 void getPrimaryCandidate(final Account account, final boolean initiator, final OnPrimaryCandidateFound listener) {
249 if (Config.DISABLE_PROXY_LOOKUP) {
250 listener.onPrimaryCandidateFound(false, null);
251 return;
252 }
253 if (!this.primaryCandidates.containsKey(account.getJid().asBareJid())) {
254 final Jid proxy = account.getXmppConnection().findDiscoItemByFeature(Namespace.BYTE_STREAMS);
255 if (proxy != null) {
256 IqPacket iq = new IqPacket(IqPacket.TYPE.GET);
257 iq.setTo(proxy);
258 iq.query(Namespace.BYTE_STREAMS);
259 account.getXmppConnection().sendIqPacket(iq, new OnIqPacketReceived() {
260
261 @Override
262 public void onIqPacketReceived(Account account, IqPacket packet) {
263 final Element streamhost = packet.query().findChild("streamhost", Namespace.BYTE_STREAMS);
264 final String host = streamhost == null ? null : streamhost.getAttribute("host");
265 final String port = streamhost == null ? null : streamhost.getAttribute("port");
266 if (host != null && port != null) {
267 try {
268 JingleCandidate candidate = new JingleCandidate(nextRandomId(), true);
269 candidate.setHost(host);
270 candidate.setPort(Integer.parseInt(port));
271 candidate.setType(JingleCandidate.TYPE_PROXY);
272 candidate.setJid(proxy);
273 candidate.setPriority(655360 + (initiator ? 30 : 0));
274 primaryCandidates.put(account.getJid().asBareJid(), candidate);
275 listener.onPrimaryCandidateFound(true, candidate);
276 } catch (final NumberFormatException e) {
277 listener.onPrimaryCandidateFound(false, null);
278 }
279 } else {
280 listener.onPrimaryCandidateFound(false, null);
281 }
282 }
283 });
284 } else {
285 listener.onPrimaryCandidateFound(false, null);
286 }
287
288 } else {
289 listener.onPrimaryCandidateFound(true,
290 this.primaryCandidates.get(account.getJid().asBareJid()));
291 }
292 }
293
294 public void retractSessionProposal(final Account account, final Jid with) {
295 synchronized (this.rtpSessionProposals) {
296 RtpSessionProposal matchingProposal = null;
297 for (RtpSessionProposal proposal : this.rtpSessionProposals.keySet()) {
298 if (proposal.account == account && with.asBareJid().equals(proposal.with)) {
299 matchingProposal = proposal;
300 break;
301 }
302 }
303 if (matchingProposal != null) {
304 this.rtpSessionProposals.remove(matchingProposal);
305 final MessagePacket messagePacket = mXmppConnectionService.getMessageGenerator().sessionRetract(matchingProposal);
306 writeLogMissedOutgoing(account, matchingProposal.with, matchingProposal.sessionId, null, System.currentTimeMillis());
307 mXmppConnectionService.sendMessagePacket(account, messagePacket);
308
309 }
310 }
311 }
312
313 public void proposeJingleRtpSession(final Account account, final Jid with) {
314 synchronized (this.rtpSessionProposals) {
315 for (Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry : this.rtpSessionProposals.entrySet()) {
316 RtpSessionProposal proposal = entry.getKey();
317 if (proposal.account == account && with.asBareJid().equals(proposal.with)) {
318 final DeviceDiscoveryState preexistingState = entry.getValue();
319 if (preexistingState != null && preexistingState != DeviceDiscoveryState.FAILED) {
320 mXmppConnectionService.notifyJingleRtpConnectionUpdate(
321 account,
322 with,
323 proposal.sessionId,
324 preexistingState.toEndUserState()
325 );
326 return;
327 }
328 }
329 }
330 final RtpSessionProposal proposal = RtpSessionProposal.of(account, with.asBareJid());
331 this.rtpSessionProposals.put(proposal, DeviceDiscoveryState.SEARCHING);
332 mXmppConnectionService.notifyJingleRtpConnectionUpdate(
333 account,
334 proposal.with,
335 proposal.sessionId,
336 RtpEndUserState.FINDING_DEVICE
337 );
338 final MessagePacket messagePacket = mXmppConnectionService.getMessageGenerator().sessionProposal(proposal);
339 Log.d(Config.LOGTAG, messagePacket.toString());
340 mXmppConnectionService.sendMessagePacket(account, messagePacket);
341 }
342 }
343
344 public void deliverIbbPacket(Account account, IqPacket packet) {
345 final String sid;
346 final Element payload;
347 if (packet.hasChild("open", Namespace.IBB)) {
348 payload = packet.findChild("open", Namespace.IBB);
349 sid = payload.getAttribute("sid");
350 } else if (packet.hasChild("data", Namespace.IBB)) {
351 payload = packet.findChild("data", Namespace.IBB);
352 sid = payload.getAttribute("sid");
353 } else if (packet.hasChild("close", Namespace.IBB)) {
354 payload = packet.findChild("close", Namespace.IBB);
355 sid = payload.getAttribute("sid");
356 } else {
357 payload = null;
358 sid = null;
359 }
360 if (sid != null) {
361 for (final AbstractJingleConnection connection : this.connections.values()) {
362 if (connection instanceof JingleFileTransferConnection) {
363 final JingleFileTransferConnection fileTransfer = (JingleFileTransferConnection) connection;
364 final JingleTransport transport = fileTransfer.getTransport();
365 if (transport instanceof JingleInBandTransport) {
366 final JingleInBandTransport inBandTransport = (JingleInBandTransport) transport;
367 if (inBandTransport.matches(account, sid)) {
368 inBandTransport.deliverPayload(packet, payload);
369 }
370 return;
371 }
372 }
373 }
374 }
375 Log.d(Config.LOGTAG, "unable to deliver ibb packet: " + packet.toString());
376 account.getXmppConnection().sendIqPacket(packet.generateResponse(IqPacket.TYPE.ERROR), null);
377 }
378
379 public void notifyRebound() {
380 for (final AbstractJingleConnection connection : this.connections.values()) {
381 connection.notifyRebound();
382 }
383 }
384
385 public WeakReference<JingleRtpConnection> findJingleRtpConnection(Account account, Jid with, String sessionId) {
386 final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(account, Jid.ofEscaped(with), sessionId);
387 final AbstractJingleConnection connection = connections.get(id);
388 if (connection instanceof JingleRtpConnection) {
389 return new WeakReference<>((JingleRtpConnection) connection);
390 }
391 return null;
392 }
393
394 public void updateProposedSessionDiscovered(Account account, Jid from, String sessionId, final DeviceDiscoveryState target) {
395 final RtpSessionProposal sessionProposal = new RtpSessionProposal(account, from.asBareJid(), sessionId);
396 synchronized (this.rtpSessionProposals) {
397 final DeviceDiscoveryState currentState = rtpSessionProposals.get(sessionProposal);
398 if (currentState == null) {
399 Log.d(Config.LOGTAG, "unable to find session proposal for session id " + sessionId);
400 return;
401 }
402 if (currentState == DeviceDiscoveryState.DISCOVERED) {
403 Log.d(Config.LOGTAG, "session proposal already at discovered. not going to fall back");
404 return;
405 }
406 this.rtpSessionProposals.put(sessionProposal, target);
407 mXmppConnectionService.notifyJingleRtpConnectionUpdate(account, sessionProposal.with, sessionProposal.sessionId, target.toEndUserState());
408 Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": flagging session " + sessionId + " as " + target);
409 }
410 }
411
412 public void rejectRtpSession(final String sessionId) {
413 for (final AbstractJingleConnection connection : this.connections.values()) {
414 if (connection.getId().sessionId.equals(sessionId)) {
415 if (connection instanceof JingleRtpConnection) {
416 ((JingleRtpConnection) connection).rejectCall();
417 }
418 }
419 }
420 }
421
422 public void endRtpSession(final String sessionId) {
423 for (final AbstractJingleConnection connection : this.connections.values()) {
424 if (connection.getId().sessionId.equals(sessionId)) {
425 if (connection instanceof JingleRtpConnection) {
426 ((JingleRtpConnection) connection).endCall();
427 }
428 }
429 }
430 }
431
432 public void failProceed(Account account, final Jid with, String sessionId) {
433 final AbstractJingleConnection.Id id = AbstractJingleConnection.Id.of(account, with, sessionId);
434 final AbstractJingleConnection existingJingleConnection = connections.get(id);
435 if (existingJingleConnection instanceof JingleRtpConnection) {
436 ((JingleRtpConnection) existingJingleConnection).deliverFailedProceed();
437 }
438 }
439
440 public enum DeviceDiscoveryState {
441 SEARCHING, DISCOVERED, FAILED;
442
443 public RtpEndUserState toEndUserState() {
444 switch (this) {
445 case SEARCHING:
446 return RtpEndUserState.FINDING_DEVICE;
447 case DISCOVERED:
448 return RtpEndUserState.RINGING;
449 default:
450 return RtpEndUserState.CONNECTIVITY_ERROR;
451 }
452 }
453 }
454
455 public static class RtpSessionProposal {
456 public final Jid with;
457 public final String sessionId;
458 private final Account account;
459
460 private RtpSessionProposal(Account account, Jid with, String sessionId) {
461 this.account = account;
462 this.with = with;
463 this.sessionId = sessionId;
464 }
465
466 public static RtpSessionProposal of(Account account, Jid with) {
467 return new RtpSessionProposal(account, with, nextRandomId());
468 }
469
470 @Override
471 public boolean equals(Object o) {
472 if (this == o) return true;
473 if (o == null || getClass() != o.getClass()) return false;
474 RtpSessionProposal proposal = (RtpSessionProposal) o;
475 return Objects.equal(account.getJid(), proposal.account.getJid()) &&
476 Objects.equal(with, proposal.with) &&
477 Objects.equal(sessionId, proposal.sessionId);
478 }
479
480 @Override
481 public int hashCode() {
482 return Objects.hashCode(account.getJid(), with, sessionId);
483 }
484 }
485}