AbstractJingleConnection.java

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