001/**
002 *
003 * Copyright 2012-2018 Florian Schmaus
004 *
005 * Licensed under the Apache License, Version 2.0 (the "License");
006 * you may not use this file except in compliance with the License.
007 * You may obtain a copy of the License at
008 *
009 *     http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.jivesoftware.smackx.ping;
018
019import java.util.Map;
020import java.util.Set;
021import java.util.WeakHashMap;
022import java.util.concurrent.CopyOnWriteArraySet;
023import java.util.concurrent.TimeUnit;
024import java.util.logging.Logger;
025
026import org.jivesoftware.smack.AbstractConnectionClosedListener;
027import org.jivesoftware.smack.ConnectionCreationListener;
028import org.jivesoftware.smack.Manager;
029import org.jivesoftware.smack.ScheduledAction;
030import org.jivesoftware.smack.SmackException.NoResponseException;
031import org.jivesoftware.smack.SmackException.NotConnectedException;
032import org.jivesoftware.smack.SmackFuture;
033import org.jivesoftware.smack.SmackFuture.InternalProcessStanzaSmackFuture;
034import org.jivesoftware.smack.XMPPConnection;
035import org.jivesoftware.smack.XMPPConnectionRegistry;
036import org.jivesoftware.smack.XMPPException.XMPPErrorException;
037import org.jivesoftware.smack.iqrequest.AbstractIqRequestHandler;
038import org.jivesoftware.smack.iqrequest.IQRequestHandler.Mode;
039import org.jivesoftware.smack.packet.IQ;
040import org.jivesoftware.smack.packet.IQ.Type;
041import org.jivesoftware.smack.packet.Stanza;
042import org.jivesoftware.smack.packet.StanzaError;
043import org.jivesoftware.smack.util.ExceptionCallback;
044import org.jivesoftware.smack.util.SuccessCallback;
045
046import org.jivesoftware.smackx.disco.ServiceDiscoveryManager;
047import org.jivesoftware.smackx.ping.packet.Ping;
048
049import org.jxmpp.jid.Jid;
050
051/**
052 * Implements the XMPP Ping as defined by XEP-0199. The XMPP Ping protocol allows one entity to
053 * ping any other entity by simply sending a ping to the appropriate JID. PingManger also
054 * periodically sends XMPP pings to the server to avoid NAT timeouts and to test
055 * the connection status.
056 * <p>
057 * The default server ping interval is 30 minutes and can be modified with
058 * {@link #setDefaultPingInterval(int)} and {@link #setPingInterval(int)}.
059 * </p>
060 *
061 * @author Florian Schmaus
062 * @see <a href="http://www.xmpp.org/extensions/xep-0199.html">XEP-0199:XMPP Ping</a>
063 */
064public final class PingManager extends Manager {
065    private static final Logger LOGGER = Logger.getLogger(PingManager.class.getName());
066
067    private static final Map<XMPPConnection, PingManager> INSTANCES = new WeakHashMap<>();
068
069    static {
070        XMPPConnectionRegistry.addConnectionCreationListener(new ConnectionCreationListener() {
071            @Override
072            public void connectionCreated(XMPPConnection connection) {
073                getInstanceFor(connection);
074            }
075        });
076    }
077
078    /**
079     * Retrieves a {@link PingManager} for the specified {@link XMPPConnection}, creating one if it doesn't already
080     * exist.
081     *
082     * @param connection TODO javadoc me please
083     * The connection the manager is attached to.
084     * @return The new or existing manager.
085     */
086    public static synchronized PingManager getInstanceFor(XMPPConnection connection) {
087        PingManager pingManager = INSTANCES.get(connection);
088        if (pingManager == null) {
089            pingManager = new PingManager(connection);
090            INSTANCES.put(connection, pingManager);
091        }
092        return pingManager;
093    }
094
095    /**
096     * The default ping interval in seconds used by new PingManager instances. The default is 30 minutes.
097     */
098    private static int defaultPingInterval = 60 * 30;
099
100    /**
101     * Set the default ping interval which will be used for new connections.
102     *
103     * @param interval the interval in seconds
104     */
105    public static void setDefaultPingInterval(int interval) {
106        defaultPingInterval = interval;
107    }
108
109    private final Set<PingFailedListener> pingFailedListeners = new CopyOnWriteArraySet<>();
110
111    /**
112     * The interval in seconds between pings are send to the users server.
113     */
114    private int pingInterval = defaultPingInterval;
115
116    private ScheduledAction nextAutomaticPing;
117
118    private PingManager(XMPPConnection connection) {
119        super(connection);
120        ServiceDiscoveryManager sdm = ServiceDiscoveryManager.getInstanceFor(connection);
121        sdm.addFeature(Ping.NAMESPACE);
122
123        connection.registerIQRequestHandler(new AbstractIqRequestHandler(Ping.ELEMENT, Ping.NAMESPACE, Type.get, Mode.async) {
124            @Override
125            public IQ handleIQRequest(IQ iqRequest) {
126                Ping ping = (Ping) iqRequest;
127                return ping.getPong();
128            }
129        });
130        connection.addConnectionListener(new AbstractConnectionClosedListener() {
131            @Override
132            public void authenticated(XMPPConnection connection, boolean resumed) {
133                maybeSchedulePingServerTask();
134            }
135            @Override
136            public void connectionTerminated() {
137                maybeStopPingServerTask();
138            }
139        });
140        maybeSchedulePingServerTask();
141    }
142
143    private boolean isValidErrorPong(Jid destinationJid, XMPPErrorException xmppErrorException) {
144        // If it is an error error response and the destination was our own service, then this must mean that the
145        // service responded, i.e. is up and pingable.
146        if (destinationJid.equals(connection().getXMPPServiceDomain())) {
147            return true;
148        }
149
150        final StanzaError xmppError = xmppErrorException.getStanzaError();
151
152        // We may received an error response from an intermediate service returning an error like
153        // 'remote-server-not-found' or 'remote-server-timeout' to us (which would fake the 'from' address,
154        // see RFC 6120 § 8.3.1 2.). Or the recipient could became unavailable.
155
156        // Sticking with the current rules of RFC 6120/6121, it is undecidable at this point whether we received an
157        // error response from the pinged entity or not. This is because a service-unavailable error condition is
158        // *required* (as per the RFCs) to be send back in both relevant cases:
159        // 1. When the receiving entity is unaware of the IQ request type. RFC 6120 § 8.4.:
160        //    "If an intended recipient receives an IQ stanza of type "get" or
161        //    "set" containing a child element qualified by a namespace it does
162        //    not understand, then the entity MUST return an IQ stanza of type
163        //    "error" with an error condition of <service-unavailable/>.
164        //  2. When the receiving resource is not available. RFC 6121 § 8.5.3.2.3.
165
166        // Some clients don't obey the first rule and instead send back a feature-not-implement condition with type 'cancel',
167        // which allows us to consider this response as valid "error response" pong.
168        StanzaError.Type type = xmppError.getType();
169        StanzaError.Condition condition = xmppError.getCondition();
170        return type == StanzaError.Type.CANCEL && condition == StanzaError.Condition.feature_not_implemented;
171    }
172
173    public SmackFuture<Boolean, Exception> pingAsync(Jid jid) {
174        return pingAsync(jid, connection().getReplyTimeout());
175    }
176
177    public SmackFuture<Boolean, Exception> pingAsync(final Jid jid, long pongTimeout) {
178        final InternalProcessStanzaSmackFuture<Boolean, Exception> future = new InternalProcessStanzaSmackFuture<Boolean, Exception>() {
179            @Override
180            public void handleStanza(Stanza packet) {
181                setResult(true);
182            }
183            @Override
184            public boolean isNonFatalException(Exception exception) {
185                if (exception instanceof XMPPErrorException) {
186                    XMPPErrorException xmppErrorException = (XMPPErrorException) exception;
187                    if (isValidErrorPong(jid, xmppErrorException)) {
188                        setResult(true);
189                        return true;
190                    }
191                }
192                return false;
193            }
194        };
195
196        XMPPConnection connection = connection();
197        Ping ping = new Ping(connection, jid);
198        connection.sendIqRequestAsync(ping, pongTimeout)
199        .onSuccess(new SuccessCallback<IQ>() {
200            @Override
201            public void onSuccess(IQ result) {
202                future.processStanza(result);
203            }
204        })
205        .onError(new ExceptionCallback<Exception>() {
206            @Override
207            public void processException(Exception exception) {
208                future.processException(exception);
209            }
210        });
211
212        return future;
213    }
214
215    /**
216     * Pings the given jid. This method will return false if an error occurs.  The exception
217     * to this, is a server ping, which will always return true if the server is reachable,
218     * event if there is an error on the ping itself (i.e. ping not supported).
219     * <p>
220     * Use {@link #isPingSupported(Jid)} to determine if XMPP Ping is supported
221     * by the entity.
222     *
223     * @param jid The id of the entity the ping is being sent to
224     * @param pingTimeout The time to wait for a reply in milliseconds
225     * @return true if a reply was received from the entity, false otherwise.
226     * @throws NoResponseException if there was no response from the jid.
227     * @throws NotConnectedException if the XMPP connection is not connected.
228     * @throws InterruptedException if the calling thread was interrupted.
229     */
230    public boolean ping(Jid jid, long pingTimeout) throws NotConnectedException, NoResponseException, InterruptedException {
231        final XMPPConnection connection = connection();
232        // Packet collector for IQs needs an connection that was at least authenticated once,
233        // otherwise the client JID will be null causing an NPE
234        if (!connection.isAuthenticated()) {
235            throw new NotConnectedException();
236        }
237        Ping ping = new Ping(connection, jid);
238        try {
239            connection.createStanzaCollectorAndSend(ping).nextResultOrThrow(pingTimeout);
240        }
241        catch (XMPPErrorException e) {
242            return isValidErrorPong(jid, e);
243        }
244        return true;
245    }
246
247    /**
248     * Same as calling {@link #ping(Jid, long)} with the defaultpacket reply
249     * timeout.
250     *
251     * @param jid The id of the entity the ping is being sent to
252     * @return true if a reply was received from the entity, false otherwise.
253     * @throws NotConnectedException if the XMPP connection is not connected.
254     * @throws NoResponseException if there was no response from the jid.
255     * @throws InterruptedException if the calling thread was interrupted.
256     */
257    public boolean ping(Jid jid) throws NotConnectedException, NoResponseException, InterruptedException {
258        return ping(jid, connection().getReplyTimeout());
259    }
260
261    /**
262     * Query the specified entity to see if it supports the Ping protocol (XEP-0199).
263     *
264     * @param jid The id of the entity the query is being sent to
265     * @return true if it supports ping, false otherwise.
266     * @throws XMPPErrorException An XMPP related error occurred during the request
267     * @throws NoResponseException if there was no response from the jid.
268     * @throws NotConnectedException if the XMPP connection is not connected.
269     * @throws InterruptedException if the calling thread was interrupted.
270     */
271    public boolean isPingSupported(Jid jid) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException  {
272        return ServiceDiscoveryManager.getInstanceFor(connection()).supportsFeature(jid, Ping.NAMESPACE);
273    }
274
275    /**
276     * Pings the server. This method will return true if the server is reachable.  It
277     * is the equivalent of calling <code>ping</code> with the XMPP domain.
278     * <p>
279     * Unlike the {@link #ping(Jid)} case, this method will return true even if
280     * {@link #isPingSupported(Jid)} is false.
281     *
282     * @return true if a reply was received from the server, false otherwise.
283     * @throws NotConnectedException if the XMPP connection is not connected.
284     * @throws InterruptedException if the calling thread was interrupted.
285     */
286    public boolean pingMyServer() throws NotConnectedException, InterruptedException {
287        return pingMyServer(true);
288    }
289
290    /**
291     * Pings the server. This method will return true if the server is reachable.  It
292     * is the equivalent of calling <code>ping</code> with the XMPP domain.
293     * <p>
294     * Unlike the {@link #ping(Jid)} case, this method will return true even if
295     * {@link #isPingSupported(Jid)} is false.
296     *
297     * @param notifyListeners Notify the PingFailedListener in case of error if true
298     * @return true if the user's server could be pinged.
299     * @throws NotConnectedException if the XMPP connection is not connected.
300     * @throws InterruptedException if the calling thread was interrupted.
301     */
302    public boolean pingMyServer(boolean notifyListeners) throws NotConnectedException, InterruptedException {
303        return pingMyServer(notifyListeners, connection().getReplyTimeout());
304    }
305
306    /**
307     * Pings the server. This method will return true if the server is reachable.  It
308     * is the equivalent of calling <code>ping</code> with the XMPP domain.
309     * <p>
310     * Unlike the {@link #ping(Jid)} case, this method will return true even if
311     * {@link #isPingSupported(Jid)} is false.
312     *
313     * @param notifyListeners Notify the PingFailedListener in case of error if true
314     * @param pingTimeout The time to wait for a reply in milliseconds
315     * @return true if the user's server could be pinged.
316     * @throws NotConnectedException if the XMPP connection is not connected.
317     * @throws InterruptedException if the calling thread was interrupted.
318     */
319    public boolean pingMyServer(boolean notifyListeners, long pingTimeout) throws NotConnectedException, InterruptedException {
320        boolean res;
321        try {
322            res = ping(connection().getXMPPServiceDomain(), pingTimeout);
323        }
324        catch (NoResponseException e) {
325            res = false;
326        }
327        if (!res && notifyListeners) {
328            for (PingFailedListener l : pingFailedListeners)
329                l.pingFailed();
330        }
331        return res;
332    }
333
334    /**
335     * Set the interval in seconds between a automated server ping is send. A negative value disables automatic server
336     * pings. All settings take effect immediately. If there is an active scheduled server ping it will be canceled and,
337     * if <code>pingInterval</code> is positive, a new one will be scheduled in pingInterval seconds.
338     * <p>
339     * If the ping fails after 3 attempts waiting the connections reply timeout for an answer, then the ping failed
340     * listeners will be invoked.
341     * </p>
342     *
343     * @param pingInterval the interval in seconds between the automated server pings
344     */
345    public void setPingInterval(int pingInterval) {
346        this.pingInterval = pingInterval;
347        maybeSchedulePingServerTask();
348    }
349
350    /**
351     * Get the current ping interval.
352     *
353     * @return the interval between pings in seconds
354     */
355    public int getPingInterval() {
356        return pingInterval;
357    }
358
359    /**
360     * Register a new PingFailedListener.
361     *
362     * @param listener the listener to invoke
363     */
364    public void registerPingFailedListener(PingFailedListener listener) {
365        pingFailedListeners.add(listener);
366    }
367
368    /**
369     * Unregister a PingFailedListener.
370     *
371     * @param listener the listener to remove
372     */
373    public void unregisterPingFailedListener(PingFailedListener listener) {
374        pingFailedListeners.remove(listener);
375    }
376
377    private void maybeSchedulePingServerTask() {
378        maybeSchedulePingServerTask(0);
379    }
380
381    /**
382     * Cancels any existing periodic ping task if there is one and schedules a new ping task if
383     * pingInterval is greater then zero.
384     *
385     * @param delta the delta to the last received stanza in seconds
386     */
387    private synchronized void maybeSchedulePingServerTask(int delta) {
388        maybeStopPingServerTask();
389        if (pingInterval > 0) {
390            int nextPingIn = pingInterval - delta;
391            LOGGER.fine("Scheduling ServerPingTask in " + nextPingIn + " seconds (pingInterval="
392                            + pingInterval + ", delta=" + delta + ")");
393            nextAutomaticPing = schedule(this::pingServerIfNecessary, nextPingIn, TimeUnit.SECONDS);
394        }
395    }
396
397    private void maybeStopPingServerTask() {
398        final ScheduledAction nextAutomaticPing = this.nextAutomaticPing;
399        if (nextAutomaticPing != null) {
400            nextAutomaticPing.cancel();
401            this.nextAutomaticPing = null;
402        }
403    }
404
405    /**
406     * Ping the server if deemed necessary because automatic server pings are
407     * enabled ({@link #setPingInterval(int)}) and the ping interval has expired.
408     */
409    public void pingServerIfNecessary() {
410        final XMPPConnection connection = connection();
411        if (connection == null) {
412            // connection has been collected by GC
413            // which means we can stop the thread by breaking the loop
414            return;
415        }
416        if (pingInterval <= 0) {
417            // Ping has been disabled
418            return;
419        }
420        long lastStanzaReceived = connection.getLastStanzaReceived();
421        if (lastStanzaReceived > 0) {
422            long now = System.currentTimeMillis();
423            // Delta since the last stanza was received
424            int deltaInSeconds = (int)  ((now - lastStanzaReceived) / 1000);
425            // If the delta is small then the ping interval, then we can defer the ping
426            if (deltaInSeconds < pingInterval) {
427                maybeSchedulePingServerTask(deltaInSeconds);
428                return;
429            }
430        }
431        if (!connection.isAuthenticated()) {
432            LOGGER.warning(connection + " was not authenticated");
433            return;
434        }
435
436        final long minimumTimeout = TimeUnit.MINUTES.toMillis(2);
437        final long connectionReplyTimeout = connection.getReplyTimeout();
438        final long timeout = connectionReplyTimeout > minimumTimeout ? connectionReplyTimeout : minimumTimeout;
439
440        SmackFuture<Boolean, Exception> pingFuture = pingAsync(connection.getXMPPServiceDomain(), timeout);
441        pingFuture.onSuccess(new SuccessCallback<Boolean>() {
442            @Override
443            public void onSuccess(Boolean result) {
444                // Ping was successful, wind-up the periodic task again
445                maybeSchedulePingServerTask();
446            }
447        });
448        pingFuture.onError(new ExceptionCallback<Exception>() {
449            @Override
450            public void processException(Exception exception) {
451                long lastStanzaReceived = connection.getLastStanzaReceived();
452                if (lastStanzaReceived > 0) {
453                    long now = System.currentTimeMillis();
454                    // Delta since the last stanza was received
455                    int deltaInSeconds = (int)  ((now - lastStanzaReceived) / 1000);
456                    // If the delta is smaller then the ping interval, we have got an valid stanza in time
457                    // So not error notification needed
458                    if (deltaInSeconds < pingInterval) {
459                        maybeSchedulePingServerTask(deltaInSeconds);
460                        return;
461                    }
462                }
463
464                for (PingFailedListener l : pingFailedListeners) {
465                    l.pingFailed();
466                }
467            }
468        });
469    }
470}