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