Java tutorial
/* * Copyright (C) 2017 Kai-Chung Yan (?) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License */ package chat.viska.xmpp; import io.reactivex.Flowable; import io.reactivex.Maybe; import io.reactivex.disposables.Disposable; import io.reactivex.exceptions.OnErrorNotImplementedException; import io.reactivex.functions.Consumer; import io.reactivex.processors.FlowableProcessor; import io.reactivex.processors.PublishProcessor; import io.reactivex.schedulers.Schedulers; import java.util.Collection; import java.util.EventObject; import java.util.Map; import java.util.NoSuchElementException; import java.util.Set; import java.util.UUID; import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.atomic.AtomicBoolean; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; import javax.annotation.concurrent.ThreadSafe; import javax.xml.namespace.QName; import org.apache.commons.lang3.StringUtils; import org.checkerframework.checker.initialization.qual.UnknownInitialization; import org.w3c.dom.Document; import org.w3c.dom.Element; import rxbeans.ExceptionCaughtEvent; import rxbeans.MutableProperty; import rxbeans.Property; import rxbeans.StandardObject; import rxbeans.StandardProperty; /** * XMPP session defining abstract concept for all implementations. * * <p>This type emits the following types of {@link EventObject}:</p> * * <ul> * <li>{@link ExceptionCaughtEvent}</li> * </ul> */ @ThreadSafe public abstract class Session extends StandardObject implements AutoCloseable { /** * State of a {@link Session}. * * <pre>{@code * +--------------+ * | | * | DISPOSED | * | | * +--------------+ * * ^ * | close() * + * * +---------------+ * | | Connection loss * +---------> | DISCONNECTED | <------------+------------------------------+-----------------------+ * | | | | | | * | +---------------+ | | | * | | | | * | + ^ | | | * | login() | | Connection loss | | | * | v + + + + * | * | +--------------+ +--------------+ +--------------+ +--------------+ * | | | | | | | | | * | | CONNECTING | +-------> | CONNECTED | +----------> | HANDSHAKING | +----> | ONLINE | * | | | | | | | | | * | +--------------+ +--------------+ +--------------+ +--------------+ * | * | + + + + * | | disconnect() | | | * | v | | | * | | | | * | +---------------+ | | | * | | | | | | * +---------+ | DISCONNECTING | <----------------+-----------------------------+-----------------------+ * | | disconnect() * +---------------+ * }</pre> */ public enum State { /** * Indicates a network connection to the server is established and is * about to login or perform in-band registration. */ CONNECTED, /** * Indicates the {@link Session} is establishing a network * connection to a server. */ CONNECTING, /** * Indicates the network connection is lost and waiting to reconnect and * resume the XMPP stream. However, it enters {@link State#DISPOSED} directly * upon losing the connection if * <a href="https://xmpp.org/extensions/xep-0198.html">Stream * Management</a> is disabled. */ DISCONNECTED, /** * Indicates the {@link Session} is closing the connection or the * server is doing so. */ DISCONNECTING, /** * Indicates the {@link Session} has been shut down. Most actions * that changes the state will throw an {@link IllegalStateException}. */ DISPOSED, /** * Indicates the {@link Session} is logging in. */ HANDSHAKING, /** * Indicates the user has logged into the server. */ ONLINE } /** * Contains information of {@link Plugin}s applied on an {@link Session}. */ @ThreadSafe public class PluginManager implements SessionAware { private final Set<PluginContext> contexts = new CopyOnWriteArraySet<>(); private PluginManager() { final Consumer<Stanza> action = stanza -> { final QName qname = stanza.getIqQName(); final AtomicBoolean stanzaHandled = new AtomicBoolean(false); contexts.parallelStream().filter(it -> it.plugin.getSupportedIqs().contains(qname)).forEach(it -> { it.feedStanza(stanza); stanzaHandled.compareAndSet(false, true); }); if (!stanzaHandled.get()) { sendStanza(new XmlWrapperStanza(XmlWrapperStanza.createIqError(stanza, StanzaErrorException.Condition.SERVICE_UNAVAILABLE, StanzaErrorException.Type.CANCEL, ""))); } }; final Consumer<Throwable> handler = it -> { if (it instanceof StreamErrorException) { sendError((StreamErrorException) it); } else if (it instanceof Exception) { log(new ExceptionCaughtEvent(Session.this, (Exception) it)); } }; getInboundStanzaStream().filter(it -> it.getType() == Stanza.Type.IQ).subscribeOn(Schedulers.io()) .subscribe(action, handler); } /** * Applies a {@link Plugin}. This method does nothing if the plugin * has already been applied. * @throws IllegalArgumentException If it fails to apply the {@link Plugin}. */ public void apply(final Class<? extends Plugin> type) { synchronized (contexts) { if (getPlugins().parallelStream().anyMatch(type::isInstance)) { return; } final Plugin plugin; try { plugin = type.getConstructor().newInstance(); } catch (Exception ex) { throw new IllegalArgumentException(ex); } for (Class<? extends Plugin> it : plugin.getAllDependencies()) { apply(it); } final PluginContext context = new PluginContext(plugin); this.contexts.add(context); plugin.onApply(context); } } /** * Gets an applied plugin which is of a particular type. * @throws NoSuchElementException If no such plugin found. */ public <T extends Plugin> T getPlugin(final Class<T> type) { return contexts.parallelStream().filter(it -> type.isInstance(it.plugin)).findFirst() .map(it -> type.cast(it.plugin)).orElseThrow(NoSuchElementException::new); } /** * Gets all applied {@link Plugin}. */ public Set<Plugin> getPlugins() { return contexts.parallelStream().map(it -> it.plugin).collect(Collectors.toSet()); } /** * Enable or disable a {@link Plugin}. */ public void setEnabled(final Class<? extends Plugin> type, final boolean enabled) { contexts.parallelStream().filter(it -> type.isInstance(it.plugin)) .forEach(it -> it.enabled.change(enabled)); } /** * Removes a {@link Plugin}. */ public void remove(final Class<? extends Plugin> type) { synchronized (contexts) { final Collection<PluginContext> toRemove = contexts.parallelStream() .filter(it -> type.isInstance(it.plugin)).collect(Collectors.toList()); contexts.removeAll(toRemove); toRemove.parallelStream().forEach(it -> { it.plugin.onRemove(); it.onRemove(); }); } } /** * Gets all features provided by the currently applied plugins. * @see <a href="https://xmpp.org/extensions/xep-0030.html">Service Discovery</a> */ public Set<String> getAllFeatures() { return contexts.parallelStream().flatMap(it -> it.plugin.getAllFeatures().parallelStream()) .collect(Collectors.toSet()); } @Override public Session getSession() { return Session.this; } } /** * Represents a processing window offered by a {@link Session} to a {@link Plugin}. When the * {@link Plugin} is disabled or the {@link Session} is not online, the context will become * unavailable during which no {@link Stanza} may be sent or received. The context will however * buffer all pending outbound {@link Stanza}s and send them all once it becomes available. When * the {@link Plugin} is removed from the {@link Session}, the context is destroyed and all its * stream properties will signal a completion. */ @ThreadSafe public class PluginContext implements SessionAware { private final MutableProperty<Boolean> enabled = new StandardProperty<>(true); private final MutableProperty<Boolean> available = new StandardProperty<>( enabled.get() && stateProperty().get() == State.ONLINE); private final Plugin plugin; private final FlowableProcessor<Stanza> inboundIqStream = PublishProcessor.<Stanza>create().toSerialized(); private Disposable availableSubscription = Flowable .combineLatest(enabled.getStream(), stateProperty().getStream(), (enabled, state) -> enabled && state == State.ONLINE) .observeOn(Schedulers.io()).subscribe(available::change); private final StreamErrorRxHandler streamErrorRxHandler = new StreamErrorRxHandler(this); private PluginContext(final Plugin plugin) { this.plugin = plugin; } private void onRemove() { availableSubscription.dispose(); } private void feedStanza(final Stanza stanza) { inboundIqStream.onNext(stanza); } /** * Sends a stanza. */ public StanzaReceipt sendStanza(final Stanza stanza) { this.available.getStream().filter(it -> it).firstElement() .subscribe(it -> Session.this.sendStanza(stanza)); return new StanzaReceipt(Maybe.empty()); } /** * Sends an {@code <iq/>}. */ public IqReceipt sendIq(final Stanza iq) { if (StringUtils.isBlank(iq.getId())) { throw new IllegalArgumentException("No ID"); } final Maybe<Stanza> response; if (iq.getType() != Stanza.Type.IQ) { throw new IllegalArgumentException("Not an <iq/>."); } else if (iq.getIqType() == Stanza.IqType.GET || iq.getIqType() == Stanza.IqType.SET) { response = getInboundIqStream().filter(it -> iq.getId().equals(it.getId())).firstElement() .doOnSuccess(it -> { if (it.getIqType() == Stanza.IqType.ERROR) { try { throw StanzaErrorException.fromXml(it.toXml()); } catch (StreamErrorException ex) { sendError(ex); throw ex; } } }); } else if (iq.getIqType() == null) { throw new IllegalArgumentException("<iq/> has no type."); } else { response = Maybe.empty(); } return new IqReceipt(sendStanza(iq).getServerAcknowledment(), response); } /** * Sends a simple query. This query will look like: * <p>{@code <iq id="..." to="target"><query xmlns="namespace" (more params) />}</p> */ public IqReceipt sendIq(final String namespace, final Jid recipient, final Map<String, String> attributes) { final String id = UUID.randomUUID().toString(); final Document iq = XmlWrapperStanza.createIq(Stanza.IqType.GET, id, getNegotiatedJid(), recipient); final Element element = (Element) iq.getDocumentElement() .appendChild(iq.createElementNS(namespace, "query")); for (Map.Entry<String, String> it : attributes.entrySet()) { element.setAttribute(it.getKey(), it.getValue()); } return sendIq(new XmlWrapperStanza(iq)); } /** * Sends a stream error and closes the connection. */ public void sendError(final StreamErrorException error) { enabled.getAndDo(enabled -> { if (enabled) { Session.this.sendError(error); } }); } /** * Gets a stream of inbound {@code <iq/>}s that only matches the {@link QName}s registered * in {@link Plugin#getSupportedIqs()}. */ public Flowable<Stanza> getInboundIqStream() { return inboundIqStream; } /** * Gets an instance of an {@link StreamErrorRxHandler} ready for use. */ public StreamErrorRxHandler getStreamErrorRxHandler() { return streamErrorRxHandler; } @Override public Session getSession() { return Session.this; } } /** * Convenient class for using a subscriber to an RxJava stream that might throw a * {@link StreamErrorException}. */ public class StreamErrorRxHandler implements Consumer<Throwable> { private final PluginContext context; /** * Default constructor. */ public StreamErrorRxHandler(final PluginContext context) { this.context = context; } @Override public void accept(Throwable ex) { if (ex instanceof StreamErrorException) { context.sendError((StreamErrorException) ex); } else if (ex instanceof OnErrorNotImplementedException) { log(new ExceptionCaughtEvent(context.plugin, ex.getCause())); } else { log(new ExceptionCaughtEvent(context.plugin, ex)); } } } private final MutableProperty<State> state = new StandardProperty<>(State.DISCONNECTED); private final PluginManager pluginManager = new PluginManager(); private final Logger logger = Logger.getAnonymousLogger(); private Jid loginJid = Jid.EMPTY; private Jid authzId = Jid.EMPTY; private boolean neverOnline = true; /** * Performs cleaning up resources. This method is called by {@link Session} when it is being * closed. */ protected abstract void onDisposing(); protected void log(final EventObject event) { logger.log(Level.INFO, event.toString()); } protected void log(final ExceptionCaughtEvent event) { logger.log(Level.SEVERE, event.toString(), event.getCause()); } protected Session() { state.getStream().filter(it -> it == State.ONLINE).firstElement().subscribe(it -> neverOnline = false); // Logging getEventStream().observeOn(Schedulers.io()).subscribe(this::log); state.getStream().subscribe(it -> this.logger.fine("Session is now " + it.name())); } /** * Gets a stream of inbound {@link Stanza}s. It is usually subscribed by {@link PluginContext}s. */ protected abstract Flowable<Stanza> getInboundStanzaStream(); /** * Sends a {@link Stanza} into the XMPP stream. Usually invoked by {@link PluginContext}s. */ protected abstract void sendStanza(final Stanza stanza); /** * Sends a stream error and then disconnects. Since a {@link Session} with a stream error is quite * fragile, this method won't complain even if it is disconnected already. */ protected abstract void sendError(final StreamErrorException error); /** * Changes the state to {@link State#DISCONNECTED}. * @throws IllegalStateException If the session is disposed. */ protected final void changeStateToDisconnected() { state.getAndDo(state -> { if (state == State.DISPOSED) { throw new IllegalStateException(); } this.state.change(State.DISCONNECTED); }); } /** * Changes the state to {@link State#CONNECTED}. * @throws IllegalStateException If the current state if not {@link State#CONNECTING}. */ protected final void changeStateToConnected() { state.getAndDo(state -> { if (state != State.CONNECTING) { throw new IllegalStateException(); } this.state.change(State.CONNECTED); }); } /** * Changes the state to {@link State#CONNECTING}. * @throws IllegalStateException If the current state if not {@link State#DISCONNECTED}. */ protected final void changeStateToConnecting() { state.getAndDo(state -> { if (state != State.DISCONNECTED) { throw new IllegalStateException(); } this.state.change(State.CONNECTING); }); } /** * Changes the state to {@link State#HANDSHAKING}. * @throws IllegalStateException If the current state if not {@link State#CONNECTED}. */ protected final void changeStateToHandshaking() { state.getAndDo(state -> { if (state != State.CONNECTED) { throw new IllegalStateException(); } this.state.change(State.HANDSHAKING); }); } /** * Changes the state to {@link State#ONLINE}. */ protected final void changeStateToOnline() { state.getAndDo(state -> { if (state == State.DISPOSED || state != State.HANDSHAKING) { throw new IllegalStateException(); } this.state.change(State.ONLINE); }); } /** * Gets the negotiated {@link StreamFeature}s. */ public abstract Set<StreamFeature> getStreamFeatures(); /** * Gets the negotiated {@link Jid} after handshake. * @return {@link Jid#EMPTY} if the handshake has not completed yet or it is an anonymous login. */ public abstract Jid getNegotiatedJid(); /** * Starts closing the XMPP stream and the network connection. */ public void disconnect() { stateProperty().getAndDo(state -> { if (state == State.DISCONNECTED || state == State.DISCONNECTING || state == State.DISPOSED) { return; } this.state.change(State.DISCONNECTING); }); } /** * Gets the logger. */ public final Logger getLogger(@UnknownInitialization(Session.class) Session this) { return logger; } /** * Gets the current {@link State}. */ public final Property<State> stateProperty(@UnknownInitialization(Session.class) Session this) { return state; } /** * Gets the plugin manager. */ public PluginManager getPluginManager() { return pluginManager; } /** * Sets the {@link Jid} used for login. It is by default set to {@link Jid#EMPTY} which means * anonymous login. This property can only be changed while disconnected and before going online * the first time. */ public void setLoginJid(final Jid jid) { if (!neverOnline || stateProperty().get() != State.DISCONNECTED) { throw new IllegalStateException(); } this.loginJid = Jid.isEmpty(jid) ? Jid.EMPTY : jid; } /** * Sets the authorization identity. It is by default set to {@link Jid#EMPTY}. This property * can only be changed while disconnected and before going online the first time. */ public void setAuthorizationId(final Jid jid) { if (!neverOnline || stateProperty().get() != State.DISCONNECTED) { throw new IllegalStateException(); } this.authzId = jid; } public Jid getLoginJid() { return loginJid; } public Jid getAutorizationId() { return authzId; } @Override public final void close() { state.getAndDo(state -> { switch (state) { case DISPOSED: return; case DISCONNECTED: break; case DISCONNECTING: break; default: disconnect(); break; } onDisposing(); stateProperty().getStream().filter(it -> it == State.DISCONNECTED).firstOrError().toCompletable() .doOnComplete(() -> { onDisposing(); this.state.change(State.DISPOSED); }).observeOn(Schedulers.io()).subscribe(); }); } }