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