1package eu.siacs.conversations.xmpp.jingle;
2
3import android.util.Log;
4
5import androidx.annotation.NonNull;
6
7import com.google.common.base.MoreObjects;
8import com.google.common.base.Objects;
9import com.google.common.base.Preconditions;
10import com.google.common.base.Strings;
11import com.google.common.collect.ImmutableList;
12import com.google.common.collect.ImmutableMap;
13
14import eu.siacs.conversations.Config;
15import eu.siacs.conversations.entities.Account;
16import eu.siacs.conversations.entities.Contact;
17import eu.siacs.conversations.entities.Message;
18import eu.siacs.conversations.entities.Presence;
19import eu.siacs.conversations.entities.ServiceDiscoveryResult;
20import eu.siacs.conversations.services.XmppConnectionService;
21import eu.siacs.conversations.xmpp.Jid;
22import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
23
24import im.conversations.android.xmpp.model.jingle.Jingle;
25import im.conversations.android.xmpp.model.stanza.Iq;
26
27import java.util.Arrays;
28import java.util.Collection;
29import java.util.List;
30import java.util.Map;
31import java.util.function.Consumer;
32
33public abstract class AbstractJingleConnection {
34
35 public static final String JINGLE_MESSAGE_PROPOSE_ID_PREFIX = "jm-propose-";
36 public static final String JINGLE_MESSAGE_PROCEED_ID_PREFIX = "jm-proceed-";
37
38 protected static final List<State> TERMINATED =
39 Arrays.asList(
40 State.ACCEPTED,
41 State.REJECTED,
42 State.REJECTED_RACED,
43 State.RETRACTED,
44 State.RETRACTED_RACED,
45 State.TERMINATED_SUCCESS,
46 State.TERMINATED_DECLINED_OR_BUSY,
47 State.TERMINATED_CONNECTIVITY_ERROR,
48 State.TERMINATED_CANCEL_OR_TIMEOUT,
49 State.TERMINATED_APPLICATION_FAILURE,
50 State.TERMINATED_SECURITY_ERROR);
51
52 private static final Map<State, Collection<State>> VALID_TRANSITIONS;
53
54 static {
55 final ImmutableMap.Builder<State, Collection<State>> transitionBuilder =
56 new ImmutableMap.Builder<>();
57 transitionBuilder.put(
58 State.NULL,
59 ImmutableList.of(
60 State.PROPOSED,
61 State.SESSION_INITIALIZED,
62 State.TERMINATED_APPLICATION_FAILURE,
63 State.TERMINATED_SECURITY_ERROR));
64 transitionBuilder.put(
65 State.PROPOSED,
66 ImmutableList.of(
67 State.ACCEPTED,
68 State.PROCEED,
69 State.REJECTED,
70 State.RETRACTED,
71 State.TERMINATED_APPLICATION_FAILURE,
72 State.TERMINATED_SECURITY_ERROR,
73 State.TERMINATED_CONNECTIVITY_ERROR // only used when the xmpp connection
74 // rebinds
75 ));
76 transitionBuilder.put(
77 State.PROCEED,
78 ImmutableList.of(
79 State.REJECTED_RACED,
80 State.RETRACTED_RACED,
81 State.SESSION_INITIALIZED_PRE_APPROVED,
82 State.TERMINATED_SUCCESS,
83 State.TERMINATED_APPLICATION_FAILURE,
84 State.TERMINATED_SECURITY_ERROR,
85 State.TERMINATED_CONNECTIVITY_ERROR // at this state used for error
86 // bounces of the proceed message
87 ));
88 transitionBuilder.put(
89 State.SESSION_INITIALIZED,
90 ImmutableList.of(
91 State.SESSION_ACCEPTED,
92 State.TERMINATED_SUCCESS,
93 State.TERMINATED_DECLINED_OR_BUSY,
94 State.TERMINATED_CONNECTIVITY_ERROR, // at this state used for IQ errors
95 // and IQ timeouts
96 State.TERMINATED_CANCEL_OR_TIMEOUT,
97 State.TERMINATED_APPLICATION_FAILURE,
98 State.TERMINATED_SECURITY_ERROR));
99 transitionBuilder.put(
100 State.SESSION_INITIALIZED_PRE_APPROVED,
101 ImmutableList.of(
102 State.SESSION_ACCEPTED,
103 State.TERMINATED_SUCCESS,
104 State.TERMINATED_DECLINED_OR_BUSY,
105 State.TERMINATED_CONNECTIVITY_ERROR, // at this state used for IQ errors
106 // and IQ timeouts
107 State.TERMINATED_CANCEL_OR_TIMEOUT,
108 State.TERMINATED_APPLICATION_FAILURE,
109 State.TERMINATED_SECURITY_ERROR));
110 transitionBuilder.put(
111 State.SESSION_ACCEPTED,
112 ImmutableList.of(
113 State.TERMINATED_SUCCESS,
114 State.TERMINATED_DECLINED_OR_BUSY,
115 State.TERMINATED_CONNECTIVITY_ERROR,
116 State.TERMINATED_CANCEL_OR_TIMEOUT,
117 State.TERMINATED_APPLICATION_FAILURE,
118 State.TERMINATED_SECURITY_ERROR));
119 VALID_TRANSITIONS = transitionBuilder.build();
120 }
121
122 final JingleConnectionManager jingleConnectionManager;
123 protected final XmppConnectionService xmppConnectionService;
124 protected final Id id;
125 private final Jid initiator;
126
127 protected State state = State.NULL;
128
129 AbstractJingleConnection(
130 final JingleConnectionManager jingleConnectionManager,
131 final Id id,
132 final Jid initiator) {
133 this.jingleConnectionManager = jingleConnectionManager;
134 this.xmppConnectionService = jingleConnectionManager.getXmppConnectionService();
135 this.id = id;
136 this.initiator = initiator;
137 }
138
139 public Id getId() {
140 return id;
141 }
142
143 boolean isInitiator() {
144 return initiator.equals(id.account.getJid());
145 }
146
147 boolean isResponder() {
148 return !initiator.equals(id.account.getJid());
149 }
150
151 public State getState() {
152 return this.state;
153 }
154
155 protected synchronized boolean isInState(State... state) {
156 return Arrays.asList(state).contains(this.state);
157 }
158
159 protected boolean transition(final State target) {
160 return transition(target, null);
161 }
162
163 protected synchronized boolean transition(final State target, final Runnable runnable) {
164 final Collection<State> validTransitions = VALID_TRANSITIONS.get(this.state);
165 if (validTransitions != null && validTransitions.contains(target)) {
166 this.state = target;
167 if (runnable != null) {
168 runnable.run();
169 }
170 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": transitioned into " + target);
171 return true;
172 } else {
173 return false;
174 }
175 }
176
177 protected void transitionOrThrow(final State target) {
178 if (!transition(target)) {
179 throw new IllegalStateException(
180 String.format("Unable to transition from %s to %s", this.state, target));
181 }
182 }
183
184 boolean isTerminated() {
185 return TERMINATED.contains(this.state);
186 }
187
188 abstract void deliverPacket(Iq jinglePacket);
189
190 protected void receiveOutOfOrderAction(
191 final Iq jinglePacket, final Jingle.Action action) {
192 Log.d(
193 Config.LOGTAG,
194 String.format(
195 "%s: received %s even though we are in state %s",
196 id.account.getJid().asBareJid(), action, getState()));
197 if (isTerminated()) {
198 Log.d(
199 Config.LOGTAG,
200 String.format(
201 "%s: got a reason to terminate with out-of-order. but already in state %s",
202 id.account.getJid().asBareJid(), getState()));
203 respondWithOutOfOrder(jinglePacket);
204 } else {
205 terminateWithOutOfOrder(jinglePacket);
206 }
207 }
208
209 protected void terminateWithOutOfOrder(final Iq jinglePacket) {
210 Log.d(
211 Config.LOGTAG,
212 id.account.getJid().asBareJid() + ": terminating session with out-of-order");
213 terminateTransport();
214 transitionOrThrow(State.TERMINATED_APPLICATION_FAILURE);
215 respondWithOutOfOrder(jinglePacket);
216 this.finish();
217 }
218
219 protected void finish() {
220 if (isTerminated()) {
221 this.jingleConnectionManager.finishConnectionOrThrow(this);
222 } else {
223 throw new AssertionError(String.format("Unable to call finish from %s", this.state));
224 }
225 }
226
227 protected abstract void terminateTransport();
228
229 abstract void notifyRebound();
230
231 protected void sendSessionTerminate(
232 final Reason reason, final String text, final Consumer<State> trigger) {
233 final State previous = this.state;
234 final State target = reasonToState(reason);
235 transitionOrThrow(target);
236 if (previous != State.NULL && trigger != null) {
237 trigger.accept(target);
238 }
239 final var iq = new Iq(Iq.Type.SET);
240 final var jinglePacket =
241 iq.addExtension(new Jingle(Jingle.Action.SESSION_TERMINATE, id.sessionId));
242 jinglePacket.setReason(reason, text);
243 send(iq);
244 finish();
245 }
246
247 protected void send(final Iq jinglePacket) {
248 jinglePacket.setTo(id.with);
249 xmppConnectionService.sendIqPacket(id.account, jinglePacket, this::handleIqResponse);
250 }
251
252 protected void respondOk(final Iq jinglePacket) {
253 xmppConnectionService.sendIqPacket(
254 id.account, jinglePacket.generateResponse(Iq.Type.RESULT), null);
255 }
256
257 protected void respondWithTieBreak(final Iq jinglePacket) {
258 respondWithJingleError(jinglePacket, "tie-break", "conflict", "cancel");
259 }
260
261 protected void respondWithOutOfOrder(final Iq jinglePacket) {
262 respondWithJingleError(jinglePacket, "out-of-order", "unexpected-request", "wait");
263 }
264
265 protected void respondWithItemNotFound(final Iq jinglePacket) {
266 respondWithJingleError(jinglePacket, null, "item-not-found", "cancel");
267 }
268
269 private void respondWithJingleError(
270 final Iq original,
271 String jingleCondition,
272 String condition,
273 String conditionType) {
274 jingleConnectionManager.respondWithJingleError(
275 id.account, original, jingleCondition, condition, conditionType);
276 }
277
278 private synchronized void handleIqResponse(final Iq response) {
279 if (response.getType() == Iq.Type.ERROR) {
280 handleIqErrorResponse(response);
281 return;
282 }
283 if (response.getType() == Iq.Type.TIMEOUT) {
284 handleIqTimeoutResponse(response);
285 }
286 }
287
288 protected void handleIqErrorResponse(final Iq response) {
289 Preconditions.checkArgument(response.getType() == Iq.Type.ERROR);
290 final String errorCondition = response.getErrorCondition();
291 Log.d(
292 Config.LOGTAG,
293 id.account.getJid().asBareJid()
294 + ": received IQ-error from "
295 + response.getFrom()
296 + " in RTP session. "
297 + errorCondition);
298 if (isTerminated()) {
299 Log.i(
300 Config.LOGTAG,
301 id.account.getJid().asBareJid()
302 + ": ignoring error because session was already terminated");
303 return;
304 }
305 this.terminateTransport();
306 final State target;
307 if (Arrays.asList(
308 "service-unavailable",
309 "recipient-unavailable",
310 "remote-server-not-found",
311 "remote-server-timeout")
312 .contains(errorCondition)) {
313 target = State.TERMINATED_CONNECTIVITY_ERROR;
314 } else {
315 target = State.TERMINATED_APPLICATION_FAILURE;
316 }
317 transitionOrThrow(target);
318 this.finish();
319 }
320
321 protected void handleIqTimeoutResponse(final Iq response) {
322 Preconditions.checkArgument(response.getType() == Iq.Type.TIMEOUT);
323 Log.d(
324 Config.LOGTAG,
325 id.account.getJid().asBareJid()
326 + ": received IQ timeout in RTP session with "
327 + id.with
328 + ". terminating with connectivity error");
329 if (isTerminated()) {
330 Log.i(
331 Config.LOGTAG,
332 id.account.getJid().asBareJid()
333 + ": ignoring error because session was already terminated");
334 return;
335 }
336 this.terminateTransport();
337 transitionOrThrow(State.TERMINATED_CONNECTIVITY_ERROR);
338 this.finish();
339 }
340
341 protected boolean remoteHasFeature(final String feature) {
342 final Contact contact = id.getContact();
343 final Presence presence =
344 contact.getPresences().get(Strings.nullToEmpty(id.with.getResource()));
345 final ServiceDiscoveryResult serviceDiscoveryResult =
346 presence == null ? null : presence.getServiceDiscoveryResult();
347 final List<String> features =
348 serviceDiscoveryResult == null ? null : serviceDiscoveryResult.getFeatures();
349 return features != null && features.contains(feature);
350 }
351
352 public static class Id {
353 public final Account account;
354 public final Jid with;
355 public final String sessionId;
356
357 private Id(final Account account, final Jid with, final String sessionId) {
358 Preconditions.checkNotNull(account);
359 Preconditions.checkNotNull(with);
360 Preconditions.checkNotNull(sessionId);
361 this.account = account;
362 this.with = with;
363 this.sessionId = sessionId;
364 }
365
366 public static Id of(Account account, Iq iq, final Jingle jingle) {
367 return new Id(account, iq.getFrom(), jingle.getSessionId());
368 }
369
370 public static Id of(Account account, Jid with, final String sessionId) {
371 return new Id(account, with, sessionId);
372 }
373
374 public static Id of(Account account, Jid with) {
375 return new Id(account, with, JingleConnectionManager.nextRandomId());
376 }
377
378 public static Id of(Message message) {
379 return new Id(
380 message.getConversation().getAccount(),
381 message.getCounterpart(),
382 JingleConnectionManager.nextRandomId());
383 }
384
385 public Contact getContact() {
386 return account.getRoster().getContact(with);
387 }
388
389 @Override
390 public boolean equals(Object o) {
391 if (this == o) return true;
392 if (o == null || getClass() != o.getClass()) return false;
393 Id id = (Id) o;
394 return Objects.equal(account.getUuid(), id.account.getUuid())
395 && Objects.equal(with, id.with)
396 && Objects.equal(sessionId, id.sessionId);
397 }
398
399 @Override
400 public int hashCode() {
401 return Objects.hashCode(account.getUuid(), with, sessionId);
402 }
403
404 public Account getAccount() {
405 return account;
406 }
407
408 public Jid getWith() {
409 return with;
410 }
411
412 public String getSessionId() {
413 return sessionId;
414 }
415
416 @Override
417 @NonNull
418 public String toString() {
419 return MoreObjects.toStringHelper(this)
420 .add("account", account.getJid())
421 .add("with", with)
422 .add("sessionId", sessionId)
423 .toString();
424 }
425 }
426
427 protected static State reasonToState(Reason reason) {
428 return switch (reason) {
429 case SUCCESS -> State.TERMINATED_SUCCESS;
430 case DECLINE, BUSY -> State.TERMINATED_DECLINED_OR_BUSY;
431 case CANCEL, TIMEOUT -> State.TERMINATED_CANCEL_OR_TIMEOUT;
432 case SECURITY_ERROR -> State.TERMINATED_SECURITY_ERROR;
433 case FAILED_APPLICATION, UNSUPPORTED_TRANSPORTS, UNSUPPORTED_APPLICATIONS -> State
434 .TERMINATED_APPLICATION_FAILURE;
435 default -> State.TERMINATED_CONNECTIVITY_ERROR;
436 };
437 }
438
439 public enum State {
440 NULL, // default value; nothing has been sent or received yet
441 PROPOSED,
442 ACCEPTED,
443 PROCEED,
444 REJECTED,
445 REJECTED_RACED, // used when we want to reject but haven’t received session init yet
446 RETRACTED,
447 RETRACTED_RACED, // used when receiving a retract after we already asked to proceed
448 SESSION_INITIALIZED, // equal to 'PENDING'
449 SESSION_INITIALIZED_PRE_APPROVED,
450 SESSION_ACCEPTED, // equal to 'ACTIVE'
451 TERMINATED_SUCCESS, // equal to 'ENDED' (after successful call) ui will just close
452 TERMINATED_DECLINED_OR_BUSY, // equal to 'ENDED' (after other party declined the call)
453 TERMINATED_CONNECTIVITY_ERROR, // equal to 'ENDED' (but after network failures; ui will
454 // display retry button)
455 TERMINATED_CANCEL_OR_TIMEOUT, // more or less the same as retracted; caller pressed end call
456 // before session was accepted
457 TERMINATED_APPLICATION_FAILURE,
458 TERMINATED_SECURITY_ERROR
459 }
460}