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}