1package eu.siacs.conversations.xmpp.jingle;
2
3import android.util.Log;
4import androidx.annotation.NonNull;
5import com.google.common.base.MoreObjects;
6import com.google.common.base.Objects;
7import com.google.common.base.Preconditions;
8import com.google.common.collect.ImmutableList;
9import com.google.common.collect.ImmutableMap;
10import eu.siacs.conversations.Config;
11import eu.siacs.conversations.entities.Account;
12import eu.siacs.conversations.entities.Contact;
13import eu.siacs.conversations.entities.Message;
14import eu.siacs.conversations.services.XmppConnectionService;
15import eu.siacs.conversations.xmpp.Jid;
16import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
17import eu.siacs.conversations.xmpp.manager.DiscoManager;
18import im.conversations.android.xmpp.model.jingle.Jingle;
19import im.conversations.android.xmpp.model.stanza.Iq;
20import java.util.Arrays;
21import java.util.Collection;
22import java.util.List;
23import java.util.Map;
24import java.util.function.Consumer;
25
26public abstract class AbstractJingleConnection {
27
28 public static final String JINGLE_MESSAGE_PROPOSE_ID_PREFIX = "jm-propose-";
29 public static final String JINGLE_MESSAGE_PROCEED_ID_PREFIX = "jm-proceed-";
30
31 protected static final List<State> TERMINATED =
32 Arrays.asList(
33 State.ACCEPTED,
34 State.REJECTED,
35 State.REJECTED_RACED,
36 State.RETRACTED,
37 State.RETRACTED_RACED,
38 State.TERMINATED_SUCCESS,
39 State.TERMINATED_DECLINED_OR_BUSY,
40 State.TERMINATED_CONNECTIVITY_ERROR,
41 State.TERMINATED_CANCEL_OR_TIMEOUT,
42 State.TERMINATED_APPLICATION_FAILURE,
43 State.TERMINATED_SECURITY_ERROR);
44
45 private static final Map<State, Collection<State>> VALID_TRANSITIONS;
46
47 static {
48 final ImmutableMap.Builder<State, Collection<State>> transitionBuilder =
49 new ImmutableMap.Builder<>();
50 transitionBuilder.put(
51 State.NULL,
52 ImmutableList.of(
53 State.PROPOSED,
54 State.SESSION_INITIALIZED,
55 State.TERMINATED_APPLICATION_FAILURE,
56 State.TERMINATED_SECURITY_ERROR));
57 transitionBuilder.put(
58 State.PROPOSED,
59 ImmutableList.of(
60 State.ACCEPTED,
61 State.PROCEED,
62 State.REJECTED,
63 State.RETRACTED,
64 State.TERMINATED_APPLICATION_FAILURE,
65 State.TERMINATED_SECURITY_ERROR,
66 State.TERMINATED_CONNECTIVITY_ERROR // only used when the xmpp connection
67 // rebinds
68 ));
69 transitionBuilder.put(
70 State.PROCEED,
71 ImmutableList.of(
72 State.REJECTED_RACED,
73 State.RETRACTED_RACED,
74 State.SESSION_INITIALIZED_PRE_APPROVED,
75 State.TERMINATED_SUCCESS,
76 State.TERMINATED_APPLICATION_FAILURE,
77 State.TERMINATED_SECURITY_ERROR,
78 State.TERMINATED_CONNECTIVITY_ERROR // at this state used for error
79 // bounces of the proceed message
80 ));
81 transitionBuilder.put(
82 State.SESSION_INITIALIZED,
83 ImmutableList.of(
84 State.SESSION_ACCEPTED,
85 State.TERMINATED_SUCCESS,
86 State.TERMINATED_DECLINED_OR_BUSY,
87 State.TERMINATED_CONNECTIVITY_ERROR, // at this state used for IQ errors
88 // and IQ timeouts
89 State.TERMINATED_CANCEL_OR_TIMEOUT,
90 State.TERMINATED_APPLICATION_FAILURE,
91 State.TERMINATED_SECURITY_ERROR));
92 transitionBuilder.put(
93 State.SESSION_INITIALIZED_PRE_APPROVED,
94 ImmutableList.of(
95 State.SESSION_ACCEPTED,
96 State.TERMINATED_SUCCESS,
97 State.TERMINATED_DECLINED_OR_BUSY,
98 State.TERMINATED_CONNECTIVITY_ERROR, // at this state used for IQ errors
99 // and IQ timeouts
100 State.TERMINATED_CANCEL_OR_TIMEOUT,
101 State.TERMINATED_APPLICATION_FAILURE,
102 State.TERMINATED_SECURITY_ERROR));
103 transitionBuilder.put(
104 State.SESSION_ACCEPTED,
105 ImmutableList.of(
106 State.TERMINATED_SUCCESS,
107 State.TERMINATED_DECLINED_OR_BUSY,
108 State.TERMINATED_CONNECTIVITY_ERROR,
109 State.TERMINATED_CANCEL_OR_TIMEOUT,
110 State.TERMINATED_APPLICATION_FAILURE,
111 State.TERMINATED_SECURITY_ERROR));
112 VALID_TRANSITIONS = transitionBuilder.build();
113 }
114
115 final JingleConnectionManager jingleConnectionManager;
116 protected final XmppConnectionService xmppConnectionService;
117 protected final Id id;
118 private final Jid initiator;
119
120 protected State state = State.NULL;
121 protected String extraState = null;
122
123 AbstractJingleConnection(
124 final JingleConnectionManager jingleConnectionManager,
125 final Id id,
126 final Jid initiator) {
127 this.jingleConnectionManager = jingleConnectionManager;
128 this.xmppConnectionService = jingleConnectionManager.getXmppConnectionService();
129 this.id = id;
130 this.initiator = initiator;
131 }
132
133 public Id getId() {
134 return id;
135 }
136
137 boolean isInitiator() {
138 return initiator.equals(id.account.getJid());
139 }
140
141 boolean isResponder() {
142 return !initiator.equals(id.account.getJid());
143 }
144
145 public State getState() {
146 return this.state;
147 }
148
149 protected synchronized boolean isInState(State... state) {
150 return Arrays.asList(state).contains(this.state);
151 }
152
153 public synchronized String extraState() {
154 return extraState;
155 }
156
157 protected synchronized void setExtraState(final String s) {
158 extraState = s;
159 }
160
161 protected boolean transition(final State target) {
162 return transition(target, null);
163 }
164
165 protected synchronized boolean transition(final State target, final Runnable runnable) {
166 final Collection<State> validTransitions = VALID_TRANSITIONS.get(this.state);
167 if (validTransitions != null && validTransitions.contains(target)) {
168 this.state = target;
169 if (runnable != null) {
170 runnable.run();
171 }
172 Log.d(Config.LOGTAG, id.account.getJid().asBareJid() + ": transitioned into " + target);
173 return true;
174 } else {
175 return false;
176 }
177 }
178
179 protected void transitionOrThrow(final State target) {
180 if (!transition(target)) {
181 throw new IllegalStateException(
182 String.format("Unable to transition from %s to %s", this.state, target));
183 }
184 }
185
186 boolean isTerminated() {
187 return TERMINATED.contains(this.state);
188 }
189
190 abstract void deliverPacket(Iq jinglePacket);
191
192 protected void receiveOutOfOrderAction(final Iq jinglePacket, final Jingle.Action action) {
193 Log.d(
194 Config.LOGTAG,
195 String.format(
196 "%s: received %s even though we are in state %s",
197 id.account.getJid().asBareJid(), action, getState()));
198 if (isTerminated()) {
199 Log.d(
200 Config.LOGTAG,
201 String.format(
202 "%s: got a reason to terminate with out-of-order. but already in state"
203 + " %s",
204 id.account.getJid().asBareJid(), getState()));
205 respondWithOutOfOrder(jinglePacket);
206 } else {
207 terminateWithOutOfOrder(jinglePacket);
208 }
209 }
210
211 protected void terminateWithOutOfOrder(final Iq jinglePacket) {
212 Log.d(
213 Config.LOGTAG,
214 id.account.getJid().asBareJid() + ": terminating session with out-of-order");
215 terminateTransport();
216 transitionOrThrow(State.TERMINATED_APPLICATION_FAILURE);
217 respondWithOutOfOrder(jinglePacket);
218 this.finish();
219 }
220
221 protected void finish() {
222 if (isTerminated()) {
223 this.jingleConnectionManager.finishConnectionOrThrow(this);
224 } else {
225 throw new AssertionError(String.format("Unable to call finish from %s", this.state));
226 }
227 }
228
229 protected abstract void terminateTransport();
230
231 abstract void notifyRebound();
232
233 protected void sendSessionTerminate(
234 final Reason reason, final String text, final Consumer<State> trigger) {
235 final State previous = this.state;
236 final State target = reasonToState(reason);
237 transitionOrThrow(target);
238 if (previous != State.NULL && trigger != null) {
239 trigger.accept(target);
240 }
241 final var iq = new Iq(Iq.Type.SET);
242 final var jinglePacket =
243 iq.addExtension(new Jingle(Jingle.Action.SESSION_TERMINATE, id.sessionId));
244 jinglePacket.setReason(reason, text);
245 send(iq);
246 finish();
247 }
248
249 protected void send(final Iq jinglePacket) {
250 jinglePacket.setTo(id.with);
251 xmppConnectionService.sendIqPacket(id.account, jinglePacket, this::handleIqResponse);
252 }
253
254 protected void respondOk(final Iq jinglePacket) {
255 xmppConnectionService.sendIqPacket(
256 id.account, jinglePacket.generateResponse(Iq.Type.RESULT), null);
257 }
258
259 protected void respondWithTieBreak(final Iq jinglePacket) {
260 respondWithJingleError(jinglePacket, "tie-break", "conflict", "cancel");
261 }
262
263 protected void respondWithOutOfOrder(final Iq jinglePacket) {
264 respondWithJingleError(jinglePacket, "out-of-order", "unexpected-request", "wait");
265 }
266
267 protected void respondWithItemNotFound(final Iq jinglePacket) {
268 respondWithJingleError(jinglePacket, null, "item-not-found", "cancel");
269 }
270
271 private void respondWithJingleError(
272 final Iq original, String jingleCondition, String condition, String conditionType) {
273 jingleConnectionManager.respondWithJingleError(
274 id.account, original, jingleCondition, condition, conditionType);
275 }
276
277 private synchronized void handleIqResponse(final Iq response) {
278 if (response.getType() == Iq.Type.ERROR) {
279 handleIqErrorResponse(response);
280 return;
281 }
282 if (response.getType() == Iq.Type.TIMEOUT) {
283 handleIqTimeoutResponse(response);
284 }
285 }
286
287 protected void handleIqErrorResponse(final Iq response) {
288 Preconditions.checkArgument(response.getType() == Iq.Type.ERROR);
289 final String errorCondition = response.getErrorCondition();
290 Log.d(
291 Config.LOGTAG,
292 id.account.getJid().asBareJid()
293 + ": received IQ-error from "
294 + response.getFrom()
295 + " in RTP session. "
296 + errorCondition);
297 if (isTerminated()) {
298 Log.i(
299 Config.LOGTAG,
300 id.account.getJid().asBareJid()
301 + ": ignoring error because session was already terminated");
302 return;
303 }
304 this.terminateTransport();
305 final State target;
306 if (Arrays.asList(
307 "service-unavailable",
308 "recipient-unavailable",
309 "remote-server-not-found",
310 "remote-server-timeout")
311 .contains(errorCondition)) {
312 target = State.TERMINATED_CONNECTIVITY_ERROR;
313 } else {
314 target = State.TERMINATED_APPLICATION_FAILURE;
315 }
316 transitionOrThrow(target);
317 this.finish();
318 }
319
320 protected void handleIqTimeoutResponse(final Iq response) {
321 Preconditions.checkArgument(response.getType() == Iq.Type.TIMEOUT);
322 Log.d(
323 Config.LOGTAG,
324 id.account.getJid().asBareJid()
325 + ": received IQ timeout in RTP session with "
326 + id.with
327 + ". terminating with connectivity error");
328 if (isTerminated()) {
329 Log.i(
330 Config.LOGTAG,
331 id.account.getJid().asBareJid()
332 + ": ignoring error because session was already terminated");
333 return;
334 }
335 this.terminateTransport();
336 transitionOrThrow(State.TERMINATED_CONNECTIVITY_ERROR);
337 this.finish();
338 }
339
340 protected boolean remoteHasFeature(final String feature) {
341 final var connection = id.account.getXmppConnection();
342 if (connection == null) {
343 return false;
344 }
345 final var infoQuery = connection.getManager(DiscoManager.class).get(id.with);
346 if (infoQuery == null) {
347 return false;
348 }
349 return infoQuery.hasFeature(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(final Account account, final Jid with) {
375 return new Id(account, with, JingleConnectionManager.nextRandomId());
376 }
377
378 public static Id of(final Message message) {
379 return new Id(
380 message.getConversation().getAccount(),
381 message.getCounterpart(),
382 message.getUuid());
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 ->
434 State.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}