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