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.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}