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