mlaccetti/JavaPNS

View on GitHub
src/main/java/javapns/notification/PushNotificationManager.java

Summary

Maintainability
F
3 days
Test Coverage
package javapns.notification;

import javapns.communication.ConnectionToAppleServer;
import javapns.communication.exceptions.CommunicationException;
import javapns.communication.exceptions.InvalidCertificateChainException;
import javapns.communication.exceptions.KeystoreException;
import javapns.devices.Device;
import javapns.devices.DeviceFactory;
import javapns.devices.exceptions.*;
import javapns.devices.implementations.basic.BasicDevice;
import javapns.devices.implementations.basic.BasicDeviceFactory;
import javapns.notification.exceptions.PayloadIsEmptyException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocket;
import javax.security.cert.X509Certificate;
import java.io.*;
import java.net.Socket;
import java.nio.ByteBuffer;
import java.security.cert.Certificate;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * The main class used to send notification and handle a connection to Apple SSLServerSocket.
 * This class is not multi-threaded.  One instance per thread must be created.
 *
 * @author Maxime Pilon
 * @author Sylvain Pedneault
 * @author Others...
 */
public class PushNotificationManager {
  private static final Logger logger = LoggerFactory.getLogger(PushNotificationManager.class);

  /* Default retries for a connection */
  private static final int DEFAULT_RETRIES = 3;

  /* Special identifier that tells the manager to generate a sequential identifier for each payload pushed */
  private static final int SEQUENTIAL_IDENTIFIER = -1;

  private static int testsSerialNumber = 1;

  private static boolean useEnhancedNotificationFormat = true;
  private static boolean heavyDebugMode = false;
  /*
   * Number of milliseconds to use as socket timeout.
   * Set to -1 to leave the timeout to its default setting.
   */
  private int sslSocketTimeout = 30 * 1000;
  /* Connection helper */
  private ConnectionToAppleServer connectionToAppleServer;

  /* The always connected SSLSocket */
  private SSLSocket socket;

  /* Default retry attempts */
  private int retryAttempts = DEFAULT_RETRIES;

  private int nextMessageIdentifier = 1;

  /*
   * To circumvent an issue with invalid server certificates,
   * set to true to use a trust manager that will always accept
   * server certificates, regardless of their validity.
   */
  private boolean trustAllServerCertificates = true;

  /* The DeviceFactory to use with this PushNotificationManager */
  @Deprecated
  private DeviceFactory deviceFactory;

  private final LinkedHashMap<Integer, PushedNotification> pushedNotifications = new LinkedHashMap<>();

  /**
   * Constructs a PushNotificationManager
   */
  @SuppressWarnings("deprecation")
  public PushNotificationManager() {
    deviceFactory = new BasicDeviceFactory();
  }

  /**
   * Constructs a PushNotificationManager using a supplied DeviceFactory
   *
   * @param deviceManager
   * @deprecated The DeviceFactory-based architecture is deprecated.
   */
  @Deprecated
  private PushNotificationManager(final DeviceFactory deviceManager) {
    this.deviceFactory = deviceManager;
  }

  private static byte[] intTo4ByteArray(final int value) {
    return ByteBuffer.allocate(4).putInt(value).array();
  }

  private static byte[] intTo2ByteArray(final int value) {
    final int s1 = (value & 0xFF00) >> 8;
    final int s2 = value & 0xFF;
    return new byte[]{(byte) s1, (byte) s2};
  }

  /**
   * Check if the enhanced notification format is currently enabled.
   *
   * @return the status of the enhanced notification format
   */
  protected static boolean isEnhancedNotificationFormatEnabled() {
    return useEnhancedNotificationFormat;
  }

  /**
   * Enable or disable the enhanced notification format (enabled by default).
   *
   * @param enabled true to enable, false to disable
   */
  public static void setEnhancedNotificationFormatEnabled(final boolean enabled) {
    useEnhancedNotificationFormat = enabled;
  }

  /**
   * Enable or disable a special heavy debug mode which causes verbose details to be written to local files.
   * The last raw APSN message will be written to a "apns-message.bytes" file in the working directory.
   * A detailed description of local and peer SSL certificates will be written to a "apns-certificatechain.txt" file in the working directory.
   *
   * @param enabled true to enable, false to disable
   */
  public static void setHeavyDebugMode(final boolean enabled) {
    heavyDebugMode = enabled;
  }

  /**
   * Initialize a connection and create a SSLSocket
   *
   * @param server The Apple server to connect to.
   * @throws CommunicationException thrown if a communication error occurs
   * @throws KeystoreException      thrown if there is a problem with your keystore
   */
  public void initializeConnection(final AppleNotificationServer server) throws CommunicationException, KeystoreException {
    try {
      this.connectionToAppleServer = new ConnectionToNotificationServer(server);
      this.socket = connectionToAppleServer.getSSLSocket();

      if (heavyDebugMode) {
        dumpCertificateChainDescription();
      }
      logger.debug("Initialized Connection to Host: [" + server.getNotificationServerHost() + "] Port: [" + server.getNotificationServerPort() + "]: " + socket);
    } catch (final KeystoreException | CommunicationException e) {
      throw e;
    } catch (final Exception e) {
      throw new CommunicationException("Error creating connection with Apple server", e);
    }
  }

  private void dumpCertificateChainDescription() {
    try {
      final File file = new File("apns-certificatechain.txt");
      final FileOutputStream outf = new FileOutputStream(file);
      final DataOutputStream outd = new DataOutputStream(outf);
      outd.writeBytes(getCertificateChainDescription());
      outd.close();
    } catch (final Exception e) {
      // empty
    }
  }

  private String getCertificateChainDescription() {
    final StringBuilder buf = new StringBuilder();
    try {
      final SSLSession session = socket.getSession();

      for (final Certificate certificate : session.getLocalCertificates()) {
        buf.append(certificate.toString());
      }

      buf.append("\n--------------------------------------------------------------------------\n");

      for (final X509Certificate certificate : session.getPeerCertificateChain()) {
        buf.append(certificate.toString());
      }

    } catch (final Exception e) {
      buf.append(e);
    }
    return buf.toString();
  }

  /**
   * Initialize a connection using server settings from the previous connection.
   *
   * @throws CommunicationException thrown if a communication error occurs
   * @throws KeystoreException      thrown if there is a problem with your keystore
   */
  private void initializePreviousConnection() throws CommunicationException, KeystoreException {
    initializeConnection((AppleNotificationServer) this.connectionToAppleServer.getServer());
  }

  /**
   * Stop and restart the current connection to the Apple server
   *
   * @param server the server to start
   * @throws CommunicationException thrown if a communication error occurs
   * @throws KeystoreException      thrown if there is a problem with your keystore
   */
  public void restartConnection(final AppleNotificationServer server) throws CommunicationException, KeystoreException {
    stopConnection();
    initializeConnection(server);
  }

  /**
   * Stop and restart the current connection to the Apple server using server settings from the previous connection.
   *
   * @throws CommunicationException thrown if a communication error occurs
   * @throws KeystoreException      thrown if there is a problem with your keystore
   */
  private void restartPreviousConnection() throws CommunicationException, KeystoreException {
    try {
      logger.debug("Closing connection to restart previous one");
      this.socket.close();
    } catch (final Exception e) {
      /* Do not complain if connection is already closed... */
    }
    initializePreviousConnection();
  }

  /**
   * Read and process any pending error-responses, and then close the connection.
   *
   * @throws CommunicationException thrown if a communication error occurs
   * @throws KeystoreException      thrown if there is a problem with your keystore
   */
  public void stopConnection() throws CommunicationException, KeystoreException {
    processedFailedNotifications();
    try {
      logger.debug("Closing connection");
      this.socket.close();
    } catch (final Exception e) {
      /* Do not complain if connection is already closed... */
    }
  }

  /**
   * Read and process any pending error-responses.
   * <p>
   * If an error-response packet is received for a particular message, this
   * method assumes that messages following the one identified in the packet
   * were completely ignored by Apple, and as such automatically retries to
   * send all messages after the problematic one.
   *
   * @return the number of error-response packets received
   * @throws CommunicationException thrown if a communication error occurs
   * @throws KeystoreException      thrown if there is a problem with your keystore
   */
  private int processedFailedNotifications() throws CommunicationException, KeystoreException {
    if (useEnhancedNotificationFormat) {
      logger.debug("Reading responses");
      int responsesReceived = ResponsePacketReader.processResponses(this);
      while (responsesReceived > 0) {
        final List<PushedNotification> notificationsToResend = new ArrayList<>();
        boolean foundFirstFail = false;
        for (final PushedNotification notification : pushedNotifications.values()) {
          if (foundFirstFail || !notification.isSuccessful()) {
            if (foundFirstFail) {
              notificationsToResend.add(notification);
            } else {
              foundFirstFail = true;
            }
          }
        }
        pushedNotifications.clear();
        final int toResend = notificationsToResend.size();
        logger.debug("Found " + toResend + " notifications that must be re-sent");
        if (toResend > 0) {
          logger.debug("Restarting connection to resend notifications");
          restartPreviousConnection();
          for (final PushedNotification pushedNotification : notificationsToResend) {
            sendNotification(pushedNotification, false);
          }
        }
        final int remaining = responsesReceived = ResponsePacketReader.processResponses(this);
        if (remaining == 0) {
          logger.debug("No notifications remaining to be resent");
          return 0;
        }
      }
      return responsesReceived;
    } else {
      logger.debug("Not reading responses because using simple notification format");
      return 0;
    }
  }

  /**
   * Send a notification to a single device and close the connection.
   *
   * @param device  the device to be notified
   * @param payload the payload to send
   * @return a pushed notification with details on transmission result and error (if any)
   * @throws CommunicationException thrown if a communication error occurs
   */
  public PushedNotification sendNotification(final Device device, final Payload payload) throws CommunicationException {
    return sendNotification(device, payload, true);
  }

  /**
   * Send a notification to a multiple devices in a single connection and close the connection.
   *
   * @param payload the payload to send
   * @param devices the device to be notified
   * @return a list of pushed notifications, each with details on transmission results and error (if any)
   * @throws CommunicationException thrown if a communication error occurs
   * @throws KeystoreException      thrown if there is a problem with your keystore
   */
  public PushedNotifications sendNotifications(final Payload payload, final List<Device> devices) throws CommunicationException, KeystoreException {
    final PushedNotifications notifications = new PushedNotifications();
    for (final Device device : devices) {
      notifications.add(sendNotification(device, payload, false, SEQUENTIAL_IDENTIFIER));
    }
    stopConnection();
    return notifications;
  }

  /**
   * Send a notification to a multiple devices in a single connection and close the connection.
   *
   * @param payload the payload to send
   * @param devices the device to be notified
   * @return a list of pushed notifications, each with details on transmission results and error (if any)
   * @throws CommunicationException thrown if a communication error occurs
   * @throws KeystoreException      thrown if there is a problem with your keystore
   */
  public PushedNotifications sendNotifications(final Payload payload, final Device... devices) throws CommunicationException, KeystoreException {
    final PushedNotifications notifications = new PushedNotifications();
    for (final Device device : devices) {
      notifications.add(sendNotification(device, payload, false, SEQUENTIAL_IDENTIFIER));
    }
    stopConnection();
    return notifications;
  }

  /**
   * Send a notification (Payload) to the given device
   *
   * @param device     the device to be notified
   * @param payload    the payload to send
   * @param closeAfter indicates if the connection should be closed after the payload has been sent
   * @return a pushed notification with details on transmission result and error (if any)
   * @throws CommunicationException thrown if a communication error occurs
   */
  public PushedNotification sendNotification(final Device device, final Payload payload, final boolean closeAfter) throws CommunicationException {
    return sendNotification(device, payload, closeAfter, SEQUENTIAL_IDENTIFIER);
  }

  /**
   * Send a notification (Payload) to the given device
   *
   * @param device     the device to be notified
   * @param payload    the payload to send
   * @param identifier a unique identifier which will match any error reported later (if any)
   * @return a pushed notification with details on transmission result and error (if any)
   * @throws CommunicationException thrown if a communication error occurs
   */
  public PushedNotification sendNotification(final Device device, final Payload payload, final int identifier) throws CommunicationException {
    return sendNotification(device, payload, false, identifier);
  }

  /**
   * Send a notification (Payload) to the given device
   *
   * @param device     the device to be notified
   * @param payload    the payload to send
   * @param closeAfter indicates if the connection should be closed after the payload has been sent
   * @param identifier a unique identifier which will match any error reported later (if any)
   * @return a pushed notification with details on transmission result and error (if any)
   * @throws CommunicationException thrown if a communication error occurs
   */
  public PushedNotification sendNotification(final Device device, final Payload payload, final boolean closeAfter, final int identifier) throws CommunicationException {
    final PushedNotification pushedNotification = new PushedNotification(device, payload, identifier);
    sendNotification(pushedNotification, closeAfter);
    return pushedNotification;
  }

  /**
   * Actual action of sending a notification
   *
   * @param notification the ready-to-push notification
   * @param closeAfter   indicates if the connection should be closed after the payload has been sent
   * @throws CommunicationException thrown if a communication error occurs
   */
  private void sendNotification(final PushedNotification notification, final boolean closeAfter) throws CommunicationException {
    try {
      final Device device = notification.getDevice();
      final Payload payload = notification.getPayload();
      try {
        payload.verifyPayloadIsNotEmpty();
      } catch (final IllegalArgumentException e) {
        throw new PayloadIsEmptyException();
      } catch (final Exception e) {
        // empty
      }

      if (notification.getIdentifier() <= 0) {
        notification.setIdentifier(newMessageIdentifier());
      }
      if (!pushedNotifications.containsKey(notification.getIdentifier())) {
        pushedNotifications.put(notification.getIdentifier(), notification);
      }
      final int identifier = notification.getIdentifier();

      final String token = device.getToken();
      // even though the BasicDevice constructor validates the token, we revalidate it in case we were passed another implementation of Device
      BasicDevice.validateTokenFormat(token);
      final byte[] bytes = getMessage(token, payload, identifier, notification);

      /* Special simulation mode to skip actual streaming of message */
      final boolean simulationMode = payload.getExpiry() == 919191;

      boolean success = false;

      final int socketTimeout = getSslSocketTimeout();
      if (socketTimeout > 0) {
        this.socket.setSoTimeout(socketTimeout);
      }
      notification.setTransmissionAttempts(0);
      // Keep trying until we have a success
      while (!success) {
        try {
          logger.debug("Attempting to send notification: " + payload.toString() + "");
          logger.debug("  to device: " + token + "");
          notification.addTransmissionAttempt();
          boolean streamConfirmed = false;
          try {
            if (!simulationMode) {
              this.socket.getOutputStream().write(bytes);
              streamConfirmed = true;
            } else {
              logger.debug("* Simulation only: would have streamed " + bytes.length + "-bytes message now..");
            }
          } catch (final Exception e) {
            if (e.toString().contains("certificate_unknown")) {
              throw new InvalidCertificateChainException(e.getMessage());
            }
            throw e;
          }
          logger.debug("Flushing");
          this.socket.getOutputStream().flush();
          if (streamConfirmed) {
            logger.debug("At this point, the entire " + bytes.length + "-bytes message has been streamed out successfully through the SSL connection");
          }

          success = true;
          logger.debug("Notification sent on " + notification.getLatestTransmissionAttempt());
          notification.setTransmissionCompleted(true);

        } catch (final IOException e) {
          // throw exception if we surpassed the valid number of retry attempts
          if (notification.getTransmissionAttempts() >= retryAttempts) {
            logger.error("Attempt to send Notification failed and beyond the maximum number of attempts permitted");
            notification.setTransmissionCompleted(false);
            notification.setException(e);
            logger.error("Delivery error", e);
            throw e;

          } else {
            logger.info("Attempt failed (" + e.getMessage() + ")... trying again");
            //Try again
            try {
              this.socket.close();
            } catch (final Exception e2) {
              // do nothing
            }
            this.socket = connectionToAppleServer.getSSLSocket();
            if (socketTimeout > 0) {
              this.socket.setSoTimeout(socketTimeout);
            }
          }
        }
      }
    } catch (final CommunicationException e) {
      throw e;
    } catch (final Exception ex) {

      notification.setException(ex);
      logger.error("Delivery error: " + ex);
      try {
        if (closeAfter) {
          logger.error("Closing connection after error");
          stopConnection();
        }
      } catch (final Exception e) {
        // empty
      }
    }
  }

  /**
   * Add a device
   *
   * @param id    The device id
   * @param token The device token
   * @throws DuplicateDeviceException
   * @throws NullDeviceTokenException
   * @throws NullIdException
   * @deprecated The DeviceFactory-based architecture is deprecated.
   */
  @Deprecated
  public void addDevice(final String id, final String token) throws Exception {
    logger.debug("Adding Token [" + token + "] to Device [" + id + "]");
    deviceFactory.addDevice(id, token);
  }

  /**
   * Get a device according to his id
   *
   * @param id The device id
   * @return The device
   * @throws UnknownDeviceException
   * @throws NullIdException
   * @deprecated The DeviceFactory-based architecture is deprecated.
   */
  @Deprecated
  public Device getDevice(final String id) throws UnknownDeviceException, NullIdException {
    logger.debug("Getting Token from Device [" + id + "]");
    return deviceFactory.getDevice(id);
  }

  /**
   * Remove a device
   *
   * @param id The device id
   * @throws UnknownDeviceException
   * @throws NullIdException
   * @deprecated The DeviceFactory-based architecture is deprecated.
   */
  @Deprecated
  public void removeDevice(final String id) throws UnknownDeviceException, NullIdException {
    logger.debug("Removing Token from Device [" + id + "]");
    deviceFactory.removeDevice(id);
  }

  /**
   * Compose the Raw Interface that will be sent through the SSLSocket
   * A notification message is
   * COMMAND | TOKENLENGTH | DEVICETOKEN | PAYLOADLENGTH | PAYLOAD
   * or enhanced notification format:
   * COMMAND | !Identifier! | !Expiry! | TOKENLENGTH| DEVICETOKEN | PAYLOADLENGTH | PAYLOAD
   * See page 30 of Apple Push Notification Service Programming Guide
   *
   * @param deviceToken the deviceToken
   * @param payload     the payload
   * @param message
   * @return the byteArray to write to the SSLSocket OutputStream
   * @throws IOException
   */
  private byte[] getMessage(String deviceToken, final Payload payload, final int identifier, final PushedNotification message) throws IOException, Exception {
    logger.debug("Building Raw message from deviceToken and payload");

    /* To test with a corrupted or invalid token, uncomment following line*/

    // First convert the deviceToken (in hexa form) to a binary format
    final byte[] deviceTokenAsBytes = new byte[deviceToken.length() / 2];
    final String upperCasedDeviceToken = deviceToken.toUpperCase();
    int j = 0;
    try {
      for (int i = 0; i < upperCasedDeviceToken.length(); i += 2) {
        final String t = upperCasedDeviceToken.substring(i, i + 2);
        final int tmp = Integer.parseInt(t, 16);
        deviceTokenAsBytes[j++] = (byte) tmp;
      }
    } catch (final NumberFormatException e1) {
      throw new InvalidDeviceTokenFormatException(upperCasedDeviceToken, e1.getMessage());
    }
    preconfigurePayload(payload, identifier, upperCasedDeviceToken);
    // Create the ByteArrayOutputStream which will contain the raw interface
    final byte[] payloadAsBytes = payload.getPayloadAsBytes();
    final int size = (Byte.SIZE / Byte.SIZE) + (Character.SIZE / Byte.SIZE) + deviceTokenAsBytes.length + (Character.SIZE / Byte.SIZE) + payloadAsBytes.length;
    final ByteArrayOutputStream bao = new ByteArrayOutputStream(size);

    // Write command to ByteArrayOutputStream
    // 0 = simple
    // 1 = enhanced
    if (useEnhancedNotificationFormat) {
      final byte b = 1;
      bao.write(b);
    } else {
      final byte b = 0;
      bao.write(b);
    }

    if (useEnhancedNotificationFormat) {
      // 4 bytes identifier (which will match any error packet received later on)
      bao.write(intTo4ByteArray(identifier));
      message.setIdentifier(identifier);

      // 4 bytes
      final int requestedExpiry = payload.getExpiry();
      if (requestedExpiry <= 0) {
        bao.write(intTo4ByteArray(requestedExpiry));
        message.setExpiry(0);
      } else {
        final long ctime = System.currentTimeMillis();
        final long ttl = requestedExpiry * 1000; // time-to-live in milliseconds
        final Long expiryDateInSeconds = (ctime + ttl) / 1000L;
        bao.write(intTo4ByteArray(expiryDateInSeconds.intValue()));
        message.setExpiry(ctime + ttl);
      }
    }
    // Write the TokenLength as a 16bits unsigned int, in big endian
    final int tl = deviceTokenAsBytes.length;
    bao.write(intTo2ByteArray(tl));

    // Write the Token in bytes
    bao.write(deviceTokenAsBytes);

    // Write the PayloadLength as a 16bits unsigned int, in big endian
    final int pl = payloadAsBytes.length;
    bao.write(intTo2ByteArray(pl));

    // Finally write the Payload
    bao.write(payloadAsBytes);
    bao.flush();

    final byte[] bytes = bao.toByteArray();

    if (heavyDebugMode) {
      try {
        final FileOutputStream outf = new FileOutputStream("apns-message.bytes");
        outf.write(bytes);
        outf.close();
      } catch (final Exception e) {
        // empty
      }
    }

    logger.debug("Built raw message ID " + identifier + " of total length " + bytes.length);
    return bytes;
  }

  /**
   * Get the number of retry attempts
   *
   * @return int
   */
  public int getRetryAttempts() {
    return this.retryAttempts;
  }

  /**
   * Set the number of retry attempts
   *
   * @param retryAttempts
   */
  public void setRetryAttempts(final int retryAttempts) {
    this.retryAttempts = retryAttempts;
  }

  /**
   * Returns the DeviceFactory used by this PushNotificationManager.
   *
   * @return the DeviceFactory in use
   * @deprecated The DeviceFactory-based architecture is deprecated.
   */
  @Deprecated
  public DeviceFactory getDeviceFactory() {
    return deviceFactory;
  }

  /**
   * Sets the DeviceFactory used by this PushNotificationManager.
   * Usually useful for dependency injection.
   *
   * @param deviceFactory an object implementing DeviceFactory
   * @deprecated The DeviceFactory-based architecture is deprecated.
   */
  @Deprecated
  public void setDeviceFactory(final DeviceFactory deviceFactory) {
    this.deviceFactory = deviceFactory;
  }

  /**
   * Get the SSL socket timeout currently in use.
   *
   * @return the current SSL socket timeout value.
   */
  private int getSslSocketTimeout() {
    return sslSocketTimeout;
  }

  /**
   * Set the SSL socket timeout to use.
   *
   * @param sslSocketTimeout
   */
  public void setSslSocketTimeout(final int sslSocketTimeout) {
    this.sslSocketTimeout = sslSocketTimeout;
  }

  /**
   * Get the status of the "trust all server certificates" feature to simplify SSL communications.
   *
   * @return the status of the "trust all server certificates" feature
   */
  protected boolean isTrustAllServerCertificates() {
    return trustAllServerCertificates;
  }

  /**
   * Set whether or not to enable the "trust all server certificates" feature to simplify SSL communications.
   *
   * @param trustAllServerCertificates
   */
  public void setTrustAllServerCertificates(final boolean trustAllServerCertificates) {
    this.trustAllServerCertificates = trustAllServerCertificates;
  }

  /**
   * Return a new sequential message identifier.
   *
   * @return a message identifier unique to this PushNotificationManager
   */
  private int newMessageIdentifier() {
    final int id = nextMessageIdentifier;
    nextMessageIdentifier++;
    return id;
  }

  Socket getActiveSocket() {
    return socket;
  }

  /**
   * Get the internal list of pushed notifications.
   *
   * @return
   */
  Map<Integer, PushedNotification> getPushedNotifications() {
    return pushedNotifications;
  }

  private void preconfigurePayload(final Payload payload, final int identifier, final String deviceToken) {
    try {
      final int config = payload.getPreSendConfiguration();
      if (payload instanceof PushNotificationPayload) {
        final PushNotificationPayload pnpayload = (PushNotificationPayload) payload;
        if (config == 1) {
          pnpayload.getPayload().remove("alert");
          pnpayload.addAlert(buildDebugAlert(payload, identifier, deviceToken));
        }
      }
    } catch (final Exception e) {
      // empty
    }
  }

  private String buildDebugAlert(final Payload payload, final int identifier, final String deviceToken) {
    final StringBuilder alert = new StringBuilder();
    alert.append("JAVAPNS DEBUG ALERT ").append(testsSerialNumber++).append("\n");

    /* Current date & time */
    alert.append(new SimpleDateFormat("yyyy/MM/dd HH:mm:ss").format(System.currentTimeMillis())).append("\n");

    /* Selected Apple server */
    alert.append(this.connectionToAppleServer.getServerHost()).append("\n");

    /* Device token (shortened), Identifier and expiry */
    final int l = useEnhancedNotificationFormat ? 4 : 8;
    alert.append("").append(deviceToken.substring(0, l)).append("�").append(deviceToken.substring(64 - l, 64)).append(useEnhancedNotificationFormat ? " [Id:" + identifier + "] " + (payload.getExpiry() <= 0 ? "No-store" : "Exp:T+" + payload.getExpiry()) : "").append("\n");

    /* Format & encoding */
    alert.append(useEnhancedNotificationFormat ? "Enhanced" : "Simple").append(" format / ").append(payload.getCharacterEncoding()).append("").append("");

    return alert.toString();
  }
}