AbstractJingleConnection.java

  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}