Repository: jchambers/pushy
Pull request #1043 (2023-10-21 12:27:50)

Support apns unique id
Read 'apns-unique-id' from response headers. Closes https://github.com/jchambers/pushy/issues/1028


Patch:
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientBuilder.java b/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientBuilder.java
index 6225407c..332735f1 100644
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientBuilder.java
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientBuilder.java
@@ -84,6 +84,8 @@ public class ApnsClientBuilder {
 
     private Http2FrameLogger frameLogger;
 
+    private boolean useAlpn = false;
+
     /**
      * The default idle time in milliseconds after which the client will send a PING frame to the APNs server.
      *
@@ -526,6 +528,29 @@ public class ApnsClientBuilder {
         return this;
     }
 
+    /**
+     * Sets whether this client should perform application-layer protocol negotiation (ALPN) when connecting to an APNs
+     * server. APNs servers always use HTTP/2, and so protocol negotiation is generally not necessary (i.e. clients have
+     * prior knowledge that the server supports HTTP/2). By default, clients will not use ALPN. Most callers will not
+     * need to change this setting, and generally should not unless they have a specific reason to do so (e.g. when
+     * working with certain reverse proxies).
+     * <p/>
+     * Please note that ALPN support was added to OpenJDK in version 8u252, and prior versions do not include native
+     * ALPN support.
+     *
+     * @param useAlpn if {@code true}, then the client under construction will perform application-layer protocol
+     * negotiation when connecting to the APNs server; otherwise, the client will start an HTTP/2 connecting with prior
+     * knowledge
+     *
+     * @return a reference to this builder
+     *
+     * @see <a href="https://httpwg.org/specs/rfc7540.html#known-http">Hypertext Transfer Protocol Version 2 (HTTP/2) - Starting HTTP/2 with Prior Knowledge</a>
+     */
+    public ApnsClientBuilder setUseAlpn(boolean useAlpn) {
+        this.useAlpn = useAlpn;
+        return this;
+    }
+
     /**
      * Constructs a new {@link ApnsClient} with the previously-set configuration.
      *
@@ -566,6 +591,17 @@ public class ApnsClientBuilder {
                     .sslProvider(sslProvider)
                     .ciphers(Http2SecurityUtil.CIPHERS, SupportedCipherSuiteFilter.INSTANCE);
 
+            if (useAlpn) {
+                sslContextBuilder.applicationProtocolConfig(
+                        new ApplicationProtocolConfig(
+                                ApplicationProtocolConfig.Protocol.ALPN,
+                                // NO_ADVERTISE is currently the only mode supported by both OpenSsl and JDK providers.
+                                ApplicationProtocolConfig.SelectorFailureBehavior.NO_ADVERTISE,
+                                // ACCEPT is currently the only mode supported by both OpenSsl and JDK providers.
+                                ApplicationProtocolConfig.SelectedListenerFailureBehavior.ACCEPT,
+                                ApplicationProtocolNames.HTTP_2));
+            }
+
             if (this.clientCertificate != null && this.privateKey != null) {
                 sslContextBuilder.keyManager(this.privateKey, this.privateKeyPassword, this.clientCertificate);
             }
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientHandler.java b/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientHandler.java
index ed493697..42200c61 100644
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientHandler.java
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientHandler.java
@@ -76,6 +76,7 @@ class ApnsClientHandler extends Http2ConnectionHandler implements Http2FrameList
     private static final AsciiString APNS_PRIORITY_HEADER = new AsciiString("apns-priority");
     private static final AsciiString APNS_COLLAPSE_ID_HEADER = new AsciiString("apns-collapse-id");
     private static final AsciiString APNS_ID_HEADER = new AsciiString("apns-id");
+    private static final AsciiString APNS_UNIQUE_ID_HEADER = new AsciiString("apns-unique-id");
     private static final AsciiString APNS_PUSH_TYPE_HEADER = new AsciiString("apns-push-type");
 
     private static final IOException STREAMS_EXHAUSTED_EXCEPTION =
@@ -325,7 +326,8 @@ class ApnsClientHandler extends Http2ConnectionHandler implements Http2FrameList
 
         if (HttpResponseStatus.OK.equals(status)) {
             responseFuture.complete(new SimplePushNotificationResponse<>(responseFuture.getPushNotification(),
-                    true, getApnsIdFromHeaders(headers), status.code(), null, null));
+                    true, getApnsIdFromHeaders(headers), getApnsUniqueIdFromHeaders(headers),
+                    status.code(), null, null));
         } else {
             if (data != null) {
                 ErrorResponse errorResponse;
@@ -352,17 +354,26 @@ class ApnsClientHandler extends Http2ConnectionHandler implements Http2FrameList
         final HttpResponseStatus status = HttpResponseStatus.parseLine(headers.status());
 
         responseFuture.complete(new SimplePushNotificationResponse<>(responseFuture.getPushNotification(),
-                HttpResponseStatus.OK.equals(status), getApnsIdFromHeaders(headers), status.code(),
+                HttpResponseStatus.OK.equals(status), getApnsIdFromHeaders(headers),
+                getApnsUniqueIdFromHeaders(headers), status.code(),
                 errorResponse.getReason(), errorResponse.getTimestamp()));
     }
 
     private static UUID getApnsIdFromHeaders(final Http2Headers headers) {
-        final CharSequence apnsIdSequence = headers.get(APNS_ID_HEADER);
+        return getUUIDFromHeaders(headers, APNS_ID_HEADER);
+    }
+
+    private static UUID getApnsUniqueIdFromHeaders(final Http2Headers headers) {
+        return getUUIDFromHeaders(headers, APNS_UNIQUE_ID_HEADER);
+    }
+
+    private static UUID getUUIDFromHeaders(final Http2Headers headers, final AsciiString header) {
+        final CharSequence apnsIdSequence = headers.get(header);
 
         try {
             return apnsIdSequence != null ? FastUUID.parseUUID(apnsIdSequence) : null;
         } catch (final IllegalArgumentException e) {
-            log.error("Failed to parse `apns-id` header: {}", apnsIdSequence, e);
+            log.error("Failed to parse `{}` header: {}", header, apnsIdSequence, e);
             return null;
         }
     }
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/PushNotificationResponse.java b/pushy/src/main/java/com/eatthepath/pushy/apns/PushNotificationResponse.java
index a24fd4c0..2447815a 100644
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/PushNotificationResponse.java
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/PushNotificationResponse.java
@@ -62,6 +62,16 @@ public interface PushNotificationResponse<T extends ApnsPushNotification> {
      */
     UUID getApnsId();
 
+
+    /**
+     * Returns a unique ID only available in the development environment.
+     * Useful to query push information in Push Notifications Console.
+     * @return UUID The apns unique id
+     */
+    default Optional<UUID> getApnsUniqueId() {
+        return Optional.empty();
+    }
+
     /**
      * Returns the HTTP status code reported by the APNs server.
      *
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/SimplePushNotificationResponse.java b/pushy/src/main/java/com/eatthepath/pushy/apns/SimplePushNotificationResponse.java
index 899ae862..bd4245b5 100644
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/SimplePushNotificationResponse.java
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/SimplePushNotificationResponse.java
@@ -37,14 +37,16 @@ class SimplePushNotificationResponse<T extends ApnsPushNotification> implements
     private final T pushNotification;
     private final boolean success;
     private final UUID apnsId;
+    private final UUID apnsUniqueId;
     private final int statusCode;
     private final String rejectionReason;
     private final Instant tokenExpirationTimestamp;
 
-    SimplePushNotificationResponse(final T pushNotification, final boolean success, final UUID apnsId, final int statusCode, final String rejectionReason, final Instant tokenExpirationTimestamp) {
+    SimplePushNotificationResponse(final T pushNotification, final boolean success, final UUID apnsId, final UUID apnsUniqueId, final int statusCode, final String rejectionReason, final Instant tokenExpirationTimestamp) {
         this.pushNotification = pushNotification;
         this.success = success;
         this.apnsId = apnsId;
+        this.apnsUniqueId = apnsUniqueId;
         this.statusCode = statusCode;
         this.rejectionReason = rejectionReason;
         this.tokenExpirationTimestamp = tokenExpirationTimestamp;
@@ -65,6 +67,11 @@ class SimplePushNotificationResponse<T extends ApnsPushNotification> implements
         return this.apnsId;
     }
 
+    @Override
+    public Optional<UUID> getApnsUniqueId() {
+        return Optional.ofNullable(apnsUniqueId);
+    }
+
     @Override
     public int getStatusCode() {
         return this.statusCode;
@@ -86,6 +93,7 @@ class SimplePushNotificationResponse<T extends ApnsPushNotification> implements
                 "pushNotification=" + pushNotification +
                 ", success=" + success +
                 ", apnsId=" + (apnsId != null ? FastUUID.toString(apnsId) : null) +
+                ", apnsUniqueId=" + getApnsUniqueId().map(FastUUID::toString).orElse(null) +
                 ", rejectionReason='" + rejectionReason + '\'' +
                 ", tokenExpirationTimestamp=" + tokenExpirationTimestamp +
                 '}';
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/auth/AuthenticationToken.java b/pushy/src/main/java/com/eatthepath/pushy/apns/auth/AuthenticationToken.java
index 42230cc1..c183979c 100644
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/auth/AuthenticationToken.java
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/auth/AuthenticationToken.java
@@ -24,10 +24,6 @@ package com.eatthepath.pushy.apns.auth;
 
 import com.eatthepath.json.JsonParser;
 import com.eatthepath.json.JsonSerializer;
-import io.netty.buffer.ByteBuf;
-import io.netty.buffer.Unpooled;
-import io.netty.handler.codec.base64.Base64;
-import io.netty.handler.codec.base64.Base64Dialect;
 import io.netty.util.AsciiString;
 
 import java.nio.charset.StandardCharsets;
@@ -37,6 +33,7 @@ import java.security.Signature;
 import java.security.SignatureException;
 import java.text.ParseException;
 import java.time.Instant;
+import java.util.Base64;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Objects;
@@ -141,8 +138,8 @@ public class AuthenticationToken {
     private final AuthenticationTokenClaims claims;
     private final byte[] signatureBytes;
 
-    private transient final String base64EncodedToken;
-    private transient final AsciiString authorizationHeader;
+    private final String base64EncodedToken;
+    private final AsciiString authorizationHeader;
 
     /**
      * Constructs a new authentication token using the given signing key (and associated metadata) issued at the given
@@ -159,11 +156,10 @@ public class AuthenticationToken {
         final String claimsJson = JsonSerializer.writeJsonTextAsString(this.claims.toMap());
 
         final StringBuilder payloadBuilder = new StringBuilder();
-        payloadBuilder.append(encodeUnpaddedBase64UrlString(headerJson.getBytes(StandardCharsets.US_ASCII)));
+        payloadBuilder.append(Base64.getUrlEncoder().withoutPadding().encodeToString(headerJson.getBytes(StandardCharsets.US_ASCII)));
         payloadBuilder.append('.');
-        payloadBuilder.append(encodeUnpaddedBase64UrlString(claimsJson.getBytes(StandardCharsets.US_ASCII)));
+        payloadBuilder.append(Base64.getUrlEncoder().withoutPadding().encodeToString(claimsJson.getBytes(StandardCharsets.US_ASCII)));
 
-        //noinspection TryWithIdenticalCatches
         try {
             final Signature signature = Signature.getInstance(ApnsKey.APNS_SIGNATURE_ALGORITHM);
             signature.initSign(signingKey);
@@ -183,10 +179,10 @@ public class AuthenticationToken {
         }
 
         payloadBuilder.append('.');
-        payloadBuilder.append(encodeUnpaddedBase64UrlString(this.signatureBytes));
+        payloadBuilder.append(Base64.getUrlEncoder().withoutPadding().encodeToString(this.signatureBytes));
 
         this.base64EncodedToken = payloadBuilder.toString();
-        this.authorizationHeader = new AsciiString("bearer " + payloadBuilder.toString());
+        this.authorizationHeader = new AsciiString("bearer " + payloadBuilder);
     }
 
     /**
@@ -212,22 +208,22 @@ public class AuthenticationToken {
         try {
             this.header = AuthenticationTokenHeader.fromMap(
                     jsonParser.parseJsonObject(
-                            new String(decodeBase64UrlEncodedString(jwtSegments[0]), StandardCharsets.US_ASCII)));
+                            new String(Base64.getUrlDecoder().decode(jwtSegments[0]), StandardCharsets.US_ASCII)));
         } catch (final ParseException e) {
             throw new IllegalArgumentException("Could not parse header as a JSON object: " +
-                    new String(decodeBase64UrlEncodedString(jwtSegments[0]), StandardCharsets.US_ASCII));
+                    new String(Base64.getUrlDecoder().decode(jwtSegments[0]), StandardCharsets.US_ASCII));
         }
 
         try {
             this.claims = AuthenticationTokenClaims.fromMap(
                     jsonParser.parseJsonObject(
-                            new String(decodeBase64UrlEncodedString(jwtSegments[1]), StandardCharsets.US_ASCII)));
+                            new String(Base64.getUrlDecoder().decode(jwtSegments[1]), StandardCharsets.US_ASCII)));
         } catch (final ParseException e) {
             throw new IllegalArgumentException("Could not parse claims as a JSON object: " +
-                    new String(decodeBase64UrlEncodedString(jwtSegments[1]), StandardCharsets.US_ASCII));
+                    new String(Base64.getUrlDecoder().decode(jwtSegments[1]), StandardCharsets.US_ASCII));
         }
 
-        this.signatureBytes = decodeBase64UrlEncodedString(jwtSegments[2]);
+        this.signatureBytes = Base64.getUrlDecoder().decode(jwtSegments[2]);
     }
 
     /**
@@ -273,18 +269,9 @@ public class AuthenticationToken {
             return false;
         }
 
-        final byte[] headerAndClaimsBytes;
+        final byte[] headerAndClaimsBytes =
+            base64EncodedToken.substring(0, base64EncodedToken.lastIndexOf('.')).getBytes(StandardCharsets.UTF_8);
 
-        final String headerJson = JsonSerializer.writeJsonTextAsString(this.header.toMap());
-        final String claimsJson = JsonSerializer.writeJsonTextAsString(this.claims.toMap());
-
-        final String encodedHeaderAndClaims =
-                encodeUnpaddedBase64UrlString(headerJson.getBytes(StandardCharsets.US_ASCII)) + '.' +
-                encodeUnpaddedBase64UrlString(claimsJson.getBytes(StandardCharsets.US_ASCII));
-
-        headerAndClaimsBytes = encodedHeaderAndClaims.getBytes(StandardCharsets.US_ASCII);
-
-        //noinspection TryWithIdenticalCatches
         try {
             final Signature signature = Signature.getInstance(ApnsKey.APNS_SIGNATURE_ALGORITHM);
             signature.initVerify(verificationKey);
@@ -346,49 +333,4 @@ public class AuthenticationToken {
             return this.base64EncodedToken.equals(other.base64EncodedToken);
         }
     }
-
-    static String encodeUnpaddedBase64UrlString(final byte[] data) {
-        final ByteBuf wrappedString = Unpooled.wrappedBuffer(data);
-        final ByteBuf encodedString = Base64.encode(wrappedString, Base64Dialect.URL_SAFE);
-
-        final String encodedUnpaddedString = encodedString.toString(StandardCharsets.US_ASCII).replace("=", "");
-
-        wrappedString.release();
-        encodedString.release();
-
-        return encodedUnpaddedString;
-    }
-
-    static byte[] decodeBase64UrlEncodedString(final String base64UrlEncodedString) {
-        final String paddedBase64UrlEncodedString;
-
-        switch (base64UrlEncodedString.length() % 4) {
-            case 2: {
-                paddedBase64UrlEncodedString = base64UrlEncodedString + "==";
-                break;
-            }
-
-            case 3: {
-                paddedBase64UrlEncodedString = base64UrlEncodedString + "=";
-                break;
-            }
-
-            default: {
-                paddedBase64UrlEncodedString = base64UrlEncodedString;
-            }
-        }
-
-        final ByteBuf base64EncodedByteBuf =
-                Unpooled.wrappedBuffer(paddedBase64UrlEncodedString.getBytes(StandardCharsets.US_ASCII));
-
-        final ByteBuf decodedByteBuf = Base64.decode(base64EncodedByteBuf, Base64Dialect.URL_SAFE);
-        final byte[] decodedBytes = new byte[decodedByteBuf.readableBytes()];
-
-        decodedByteBuf.readBytes(decodedBytes);
-
-        base64EncodedByteBuf.release();
-        decodedByteBuf.release();
-
-        return decodedBytes;
-    }
 }
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/server/MockApnsServer.java b/pushy/src/main/java/com/eatthepath/pushy/apns/server/MockApnsServer.java
index 1f5927f4..f192a2cb 100644
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/server/MockApnsServer.java
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/server/MockApnsServer.java
@@ -53,10 +53,11 @@ public class MockApnsServer extends BaseHttp2Server {
     private final MockApnsServerListener listener;
 
     private final int maxConcurrentStreams;
+    private final boolean generateApnsUniqueId;
 
     MockApnsServer(final SslContext sslContext, final EventLoopGroup eventLoopGroup,
                    final PushNotificationHandlerFactory handlerFactory, final MockApnsServerListener listener,
-                   final int maxConcurrentStreams) {
+                   final int maxConcurrentStreams, boolean generateApnsUniqueId) {
 
         super(sslContext, eventLoopGroup);
 
@@ -64,6 +65,7 @@ public class MockApnsServer extends BaseHttp2Server {
         this.listener = listener;
 
         this.maxConcurrentStreams = maxConcurrentStreams;
+        this.generateApnsUniqueId = generateApnsUniqueId;
     }
 
     @Override
@@ -74,6 +76,7 @@ public class MockApnsServer extends BaseHttp2Server {
                 .pushNotificationHandler(pushNotificationHandler)
                 .initialSettings(Http2Settings.defaultSettings().maxConcurrentStreams(this.maxConcurrentStreams))
                 .listener(this.listener)
+                .generateApnsUniqueId(generateApnsUniqueId)
                 .build();
 
         pipeline.addLast(serverHandler);
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/server/MockApnsServerBuilder.java b/pushy/src/main/java/com/eatthepath/pushy/apns/server/MockApnsServerBuilder.java
index 11e687a8..35259d41 100644
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/server/MockApnsServerBuilder.java
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/server/MockApnsServerBuilder.java
@@ -48,6 +48,7 @@ public class MockApnsServerBuilder extends BaseHttp2ServerBuilder<MockApnsServer
 
     private PushNotificationHandlerFactory handlerFactory;
     private MockApnsServerListener listener;
+    private boolean generateApnsUniqueId = false;
 
     @Override
     public MockApnsServerBuilder setServerCredentials(final File certificatePemFile, final File privateKeyPkcs8File, final String privateKeyPassword) {
@@ -133,6 +134,11 @@ public class MockApnsServerBuilder extends BaseHttp2ServerBuilder<MockApnsServer
         return this;
     }
 
+    public MockApnsServerBuilder generateApnsUniqueId(boolean generateApnsUniqueId) {
+        this.generateApnsUniqueId = generateApnsUniqueId;
+        return this;
+    }
+
     @Override
     public MockApnsServer build() throws SSLException {
         return super.build();
@@ -144,6 +150,6 @@ public class MockApnsServerBuilder extends BaseHttp2ServerBuilder<MockApnsServer
             throw new IllegalStateException("Must provide a push notification handler factory before building a mock server.");
         }
 
-        return new MockApnsServer(sslContext, this.eventLoopGroup, this.handlerFactory, this.listener, this.maxConcurrentStreams);
+        return new MockApnsServer(sslContext, this.eventLoopGroup, this.handlerFactory, this.listener, this.maxConcurrentStreams, generateApnsUniqueId);
     }
 }
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/server/MockApnsServerHandler.java b/pushy/src/main/java/com/eatthepath/pushy/apns/server/MockApnsServerHandler.java
index 59d3ac6b..d5540445 100644
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/server/MockApnsServerHandler.java
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/server/MockApnsServerHandler.java
@@ -40,6 +40,7 @@ import org.slf4j.LoggerFactory;
 import java.time.Instant;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Optional;
 import java.util.UUID;
 
 class MockApnsServerHandler extends Http2ConnectionHandler implements Http2FrameListener {
@@ -50,8 +51,12 @@ class MockApnsServerHandler extends Http2ConnectionHandler implements Http2Frame
     private final Http2Connection.PropertyKey headersPropertyKey;
     private final Http2Connection.PropertyKey payloadPropertyKey;
 
+    private final boolean generateApnsUniqueId;
+
     private static final AsciiString APNS_ID_HEADER = new AsciiString("apns-id");
 
+    private static final AsciiString APNS_UNIQUE_ID_HEADER = new AsciiString("apns-unique-id");
+
     private static final int MAX_CONTENT_LENGTH = 4096;
 
     private static final Logger log = LoggerFactory.getLogger(MockApnsServerHandler.class);
@@ -60,6 +65,7 @@ class MockApnsServerHandler extends Http2ConnectionHandler implements Http2Frame
 
         private PushNotificationHandler pushNotificationHandler;
         private MockApnsServerListener listener;
+        private boolean generateApnsUniqueId = false;
 
         MockApnsServerHandlerBuilder pushNotificationHandler(final PushNotificationHandler pushNotificationHandler) {
             this.pushNotificationHandler = pushNotificationHandler;
@@ -76,9 +82,14 @@ class MockApnsServerHandler extends Http2ConnectionHandler implements Http2Frame
             return super.initialSettings(initialSettings);
         }
 
+        public MockApnsServerHandlerBuilder generateApnsUniqueId(final boolean generateApnsUniqueId) {
+            this.generateApnsUniqueId = generateApnsUniqueId;
+            return this;
+        }
+
         @Override
         public MockApnsServerHandler build(final Http2ConnectionDecoder decoder, final Http2ConnectionEncoder encoder, final Http2Settings initialSettings) {
-            final MockApnsServerHandler handler = new MockApnsServerHandler(decoder, encoder, initialSettings, this.pushNotificationHandler, this.listener);
+            final MockApnsServerHandler handler = new MockApnsServerHandler(decoder, encoder, initialSettings, this.pushNotificationHandler, this.listener, generateApnsUniqueId);
             this.frameListener(handler);
             return handler;
         }
@@ -92,10 +103,12 @@ class MockApnsServerHandler extends Http2ConnectionHandler implements Http2Frame
     private static abstract class ApnsResponse {
         private final int streamId;
         private final UUID apnsId;
+        private final UUID apnsUniqueId;
 
-        private ApnsResponse(final int streamId, final UUID apnsId) {
+        private ApnsResponse(final int streamId, final UUID apnsId, UUID apnsUniqueId) {
             this.streamId = streamId;
             this.apnsId = apnsId;
+            this.apnsUniqueId = apnsUniqueId;
         }
 
         int getStreamId() {
@@ -105,11 +118,15 @@ class MockApnsServerHandler extends Http2ConnectionHandler implements Http2Frame
         UUID getApnsId() {
             return apnsId;
         }
+
+        Optional<UUID> getApnsUniqueId() {
+            return Optional.ofNullable(apnsUniqueId);
+        }
     }
 
     private static class AcceptNotificationResponse extends ApnsResponse {
-        private AcceptNotificationResponse(final int streamId, final UUID apnsId) {
-            super(streamId, apnsId);
+        private AcceptNotificationResponse(final int streamId, final UUID apnsId, final UUID apnsUniqueId) {
+            super(streamId, apnsId, apnsUniqueId);
         }
     }
 
@@ -117,8 +134,8 @@ class MockApnsServerHandler extends Http2ConnectionHandler implements Http2Frame
         private final RejectionReason errorReason;
         private final Instant timestamp;
 
-        RejectNotificationResponse(final int streamId, final UUID apnsId, final RejectionReason errorReason, final Instant timestamp) {
-            super(streamId, apnsId);
+        RejectNotificationResponse(final int streamId, final UUID apnsId, final UUID apnsUniqueId, final RejectionReason errorReason, final Instant timestamp) {
+            super(streamId, apnsId, apnsUniqueId);
 
             this.errorReason = errorReason;
             this.timestamp = timestamp;
@@ -159,9 +176,11 @@ class MockApnsServerHandler extends Http2ConnectionHandler implements Http2Frame
                           final Http2ConnectionEncoder encoder,
                           final Http2Settings initialSettings,
                           final PushNotificationHandler pushNotificationHandler,
-                          final MockApnsServerListener listener) {
+                          final MockApnsServerListener listener,
+                          boolean generateApnsUniqueId) {
 
         super(decoder, encoder, initialSettings);
+        this.generateApnsUniqueId = generateApnsUniqueId;
 
         this.headersPropertyKey = this.connection().newKey();
         this.payloadPropertyKey = this.connection().newKey();
@@ -267,19 +286,21 @@ class MockApnsServerHandler extends Http2ConnectionHandler implements Http2Frame
             apnsId = apnsIdFromHeaders;
         }
 
+        final UUID apnsUniqueId = generateApnsUniqueId ? UUID.randomUUID() : null;
+
         try {
             this.pushNotificationHandler.handlePushNotification(headers, payload);
 
-            this.write(context, new AcceptNotificationResponse(stream.id(), apnsId), writePromise);
+            this.write(context, new AcceptNotificationResponse(stream.id(), apnsId, apnsUniqueId), writePromise);
             this.listener.handlePushNotificationAccepted(headers, payload);
         } catch (final RejectedNotificationException e) {
             final Instant deviceTokenExpirationTimestamp = e instanceof UnregisteredDeviceTokenException ?
                     ((UnregisteredDeviceTokenException) e).getDeviceTokenExpirationTimestamp() : null;
 
-            this.write(context, new RejectNotificationResponse(stream.id(), apnsId, e.getRejectionReason(), deviceTokenExpirationTimestamp), writePromise);
+            this.write(context, new RejectNotificationResponse(stream.id(), apnsId, apnsUniqueId, e.getRejectionReason(), deviceTokenExpirationTimestamp), writePromise);
             this.listener.handlePushNotificationRejected(headers, payload, e.getRejectionReason(), deviceTokenExpirationTimestamp);
         } catch (final Exception e) {
-            this.write(context, new RejectNotificationResponse(stream.id(), apnsId, RejectionReason.INTERNAL_SERVER_ERROR, null), writePromise);
+            this.write(context, new RejectNotificationResponse(stream.id(), apnsId, apnsUniqueId, RejectionReason.INTERNAL_SERVER_ERROR, null), writePromise);
             this.listener.handlePushNotificationRejected(headers, payload, RejectionReason.INTERNAL_SERVER_ERROR, null);
         } finally {
             if (stream.getProperty(this.payloadPropertyKey) != null) {
@@ -299,6 +320,9 @@ class MockApnsServerHandler extends Http2ConnectionHandler implements Http2Frame
                     .status(HttpResponseStatus.OK.codeAsText())
                     .add(APNS_ID_HEADER, FastUUID.toString(acceptNotificationResponse.getApnsId()));
 
+            acceptNotificationResponse.getApnsUniqueId()
+                    .ifPresent(apnsUniqueId -> headers.add(APNS_UNIQUE_ID_HEADER, FastUUID.toString(apnsUniqueId)));
+
             this.encoder().writeHeaders(context, acceptNotificationResponse.getStreamId(), headers, 0, true, writePromise);
 
             log.trace("Accepted push notification on stream {}", acceptNotificationResponse.getStreamId());
@@ -310,6 +334,9 @@ class MockApnsServerHandler extends Http2ConnectionHandler implements Http2Frame
                     .add(HttpHeaderNames.CONTENT_TYPE, "application/json")
                     .add(APNS_ID_HEADER, FastUUID.toString(rejectNotificationResponse.getApnsId()));
 
+            rejectNotificationResponse.getApnsUniqueId()
+                    .ifPresent(apnsUniqueId -> headers.add(APNS_UNIQUE_ID_HEADER, FastUUID.toString(apnsUniqueId)));
+
             final byte[] payloadBytes;
             {
                 final Map<String, Object> errorPayload = new HashMap<>(2, 1);
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/util/ApnsPayloadBuilder.java b/pushy/src/main/java/com/eatthepath/pushy/apns/util/ApnsPayloadBuilder.java
index c2819f9e..b2534daf 100644
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/util/ApnsPayloadBuilder.java
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/util/ApnsPayloadBuilder.java
@@ -87,6 +87,8 @@ public abstract class ApnsPayloadBuilder {
 
     private Instant dismissalDate = null;
 
+    private Instant staleDate = null;
+
     private Map<String, Object> contentState = null;
 
     private static final String APS_KEY = "aps";
@@ -121,6 +123,7 @@ public abstract class ApnsPayloadBuilder {
 
     private static final String TIMESTAMP_KEY = "timestamp";
     private static final String DISMISSAL_DATE_KEY = "dismissal-date";
+    private static final String STALE_DATE_KEY = "stale-date";
     private static final String EVENT_KEY = "event";
     private static final String CONTENT_STATE_KEY = "content-state";
 
@@ -131,7 +134,7 @@ public abstract class ApnsPayloadBuilder {
      *
      * @see ApnsPayloadBuilder#setSoundFileName(String)
      */
-    public static final String DEFAULT_SOUND_FILENAME = "default"; 
+    public static final String DEFAULT_SOUND_FILENAME = "default";
 
     /**
     /**
@@ -626,19 +629,19 @@ public abstract class ApnsPayloadBuilder {
      *
      * <p>The relevance score is:</p>
      *
-     * <blockquote>…a number between 0 and 1, that the system uses to sort the notifications from your app. The highest
-     * score gets featured in the notification summary.</blockquote>
+     * <blockquote>…a number… that the system uses to sort the notifications from your app. The highest score gets
+     * featured in the notification summary.</blockquote>
      *
-     * @param relevanceScore a relevance score between 0 and 1, inclusive, or {@code null} if no relevance score should
-     * be included in the notification
+     * @param relevanceScore a relevance score, or {@code null} if no relevance score should be included in the
+     * notification
      *
      * @return a reference to this payload builder
      *
      * @since 0.15.0
      */
     public ApnsPayloadBuilder setRelevanceScore(final Double relevanceScore) {
-        if (relevanceScore != null && (relevanceScore < 0 || relevanceScore > 1 || relevanceScore.isNaN())) {
-            throw new IllegalArgumentException("Relevance score must be a number between 0 and 1, inclusive, but was actually " + relevanceScore);
+        if (relevanceScore != null &&  relevanceScore.isNaN()) {
+            throw new IllegalArgumentException("Relevance score must be a number, but was actually " + relevanceScore);
         }
 
         this.relevanceScore = relevanceScore;
@@ -646,6 +649,30 @@ public abstract class ApnsPayloadBuilder {
         return this;
     }
 
+    /**
+     * <p>Sets a "stale" timestamp for Live Activity notification payloads. According to Apple's documentation:</p>
+     *
+     * <blockquote>To provide the best possible user experience and let the person know that the activity displays
+     * outdated information, add a timestamp in the optional stale-date field to provide the time when the system will
+     * consider the Live Activity to be stale. With each update to the Live Activity, you can advance this stale-date.
+     * When you can’t update the Live Activity for some time until it becomes stale, the {@code activityState} changes
+     * to {@code ActivityState.stale} at the specified date. Use the {@code activityStateUpdates} stream in your app to
+     * monitor the activity state and respond to outdated Live Activities that haven’t received updates</blockquote>
+     *
+     * @param staleDate Instant when the Live Activity will be considered stale
+     *
+     * @return a reference to this payload builder
+     *
+     * @see <a href="https://developer.apple.com/documentation/activitykit/updating-live-activities-with-activitykit-push-notifications#Mark-a-Live-Activity-as-outdated-by-setting-a-stale-date">
+     *      Mark a Live Activity as outdated by setting a stale date</a>
+     *
+     * @since 0.15.3
+     */
+    public ApnsPayloadBuilder setStaleDate(final Instant staleDate) {
+        this.staleDate = staleDate;
+        return this;
+    }
+
     /**
      * <p>Sets the list of arguments to populate placeholders in the {@code urlFormatString} associated with a Safari
      * push notification. Has no effect for non-Safari notifications. According to the Notification Programming Guide
@@ -779,8 +806,7 @@ public abstract class ApnsPayloadBuilder {
     }
 
     /**
-     * <p>Sets a timestamp for the push notification payload. The timestamp is used to discard older
-     * push notifications. According to Apple's documentation:</p>
+     * <p>Sets a dismissal timestamp for Live Activity notifications. According to Apple's documentation:</p>
      *
      * <blockquote>When you end a Live Activity, by default the Live Activity appears on the Lock Screen for up to
      * four hours after it ends to allow the user to glance at their phone to see the latest information. To dismiss
@@ -873,6 +899,10 @@ public abstract class ApnsPayloadBuilder {
                 aps.put(DISMISSAL_DATE_KEY, this.dismissalDate.getEpochSecond());
             }
 
+            if (this.staleDate != null) {
+                aps.put(STALE_DATE_KEY, this.staleDate.getEpochSecond());
+            }
+
             final Map<String, Object> alert = new HashMap<>();
             {
                 if (this.alertBody != null) {


Test patch:
diff --git a/pushy/src/test/java/com/eatthepath/pushy/apns/AbstractClientServerTest.java b/pushy/src/test/java/com/eatthepath/pushy/apns/AbstractClientServerTest.java
index 58ad9142..8b73fc5f 100644
--- a/pushy/src/test/java/com/eatthepath/pushy/apns/AbstractClientServerTest.java
+++ b/pushy/src/test/java/com/eatthepath/pushy/apns/AbstractClientServerTest.java
@@ -25,10 +25,7 @@ package com.eatthepath.pushy.apns;
 import com.eatthepath.pushy.apns.auth.ApnsSigningKey;
 import com.eatthepath.pushy.apns.auth.ApnsVerificationKey;
 import com.eatthepath.pushy.apns.auth.KeyPairUtil;
-import com.eatthepath.pushy.apns.server.MockApnsServer;
-import com.eatthepath.pushy.apns.server.MockApnsServerBuilder;
-import com.eatthepath.pushy.apns.server.MockApnsServerListener;
-import com.eatthepath.pushy.apns.server.PushNotificationHandlerFactory;
+import com.eatthepath.pushy.apns.server.*;
 import com.eatthepath.pushy.apns.util.ApnsPayloadBuilder;
 import com.eatthepath.pushy.apns.util.SimpleApnsPayloadBuilder;
 import io.netty.channel.nio.NioEventLoopGroup;
@@ -144,13 +141,22 @@ public class AbstractClientServerTest {
         return this.buildServer(handlerFactory, null);
     }
 
+    protected MockApnsServer buildServer(final ValidatingPushNotificationHandlerFactory handlerFactory, final boolean generateApnsUniqueId) throws SSLException {
+        return this.buildServer(handlerFactory, null, generateApnsUniqueId);
+    }
+
     protected MockApnsServer buildServer(final PushNotificationHandlerFactory handlerFactory, final MockApnsServerListener listener) throws SSLException {
+        return this.buildServer(handlerFactory, listener, false);
+    }
+
+    protected MockApnsServer buildServer(final PushNotificationHandlerFactory handlerFactory, final MockApnsServerListener listener, final boolean generateApnsUniqueId) throws SSLException {
         return new MockApnsServerBuilder()
                 .setServerCredentials(getClass().getResourceAsStream(SERVER_CERTIFICATES_FILENAME), getClass().getResourceAsStream(SERVER_KEY_FILENAME), null)
                 .setTrustedClientCertificateChain(getClass().getResourceAsStream(CA_CERTIFICATE_FILENAME))
                 .setEventLoopGroup(SERVER_EVENT_LOOP_GROUP)
                 .setHandlerFactory(handlerFactory)
                 .setListener(listener)
+                .generateApnsUniqueId(generateApnsUniqueId)
                 .build();
     }
 
diff --git a/pushy/src/test/java/com/eatthepath/pushy/apns/ApnsClientTest.java b/pushy/src/test/java/com/eatthepath/pushy/apns/ApnsClientTest.java
index b93a6a55..9afdafaf 100644
--- a/pushy/src/test/java/com/eatthepath/pushy/apns/ApnsClientTest.java
+++ b/pushy/src/test/java/com/eatthepath/pushy/apns/ApnsClientTest.java
@@ -328,6 +328,8 @@ public class ApnsClientTest extends AbstractClientServerTest {
                     "Clients must send notifications that conform to the APNs protocol specification.");
 
             assertNotNull(response.getApnsId());
+            assertNotNull(response.getApnsUniqueId());
+            assertFalse(response.getApnsUniqueId().isPresent());
         } finally {
             client.close().get();
             serverWithUntrustedHostname.shutdown().get();
@@ -397,6 +399,8 @@ public class ApnsClientTest extends AbstractClientServerTest {
 
             assertNotNull(response.getApnsId());
             assertEquals(200, response.getStatusCode());
+            assertNotNull(response.getApnsUniqueId());
+            assertFalse(response.getApnsUniqueId().isPresent());
         } finally {
             client.close().get();
             server.shutdown().get();
@@ -749,4 +753,36 @@ public class ApnsClientTest extends AbstractClientServerTest {
                 arguments(PushType.FILEPROVIDER),
                 arguments(PushType.MDM));
     }
+
+    @ParameterizedTest
+    @ValueSource(booleans = { true, false })
+    void testSendNotificationWhenApnsUniqueIdIsGenerated(final boolean useTokenAuthentication) throws Exception {
+        final ValidatingPushNotificationHandlerFactory handlerFactory = new ValidatingPushNotificationHandlerFactory(
+                DEVICE_TOKENS_BY_TOPIC, EXPIRATION_TIMESTAMPS_BY_DEVICE_TOKEN, this.verificationKeysByKeyId,
+                this.topicsByVerificationKey);
+
+        final MockApnsServer server = this.buildServer(handlerFactory, true);
+        final ApnsClient client = useTokenAuthentication ?
+                this.buildTokenAuthenticationClient() : this.buildTlsAuthenticationClient();
+
+        try {
+            server.start(PORT).get();
+
+            final SimpleApnsPushNotification pushNotification = new SimpleApnsPushNotification(DEVICE_TOKEN, TOPIC, PAYLOAD);
+
+            final PushNotificationResponse<SimpleApnsPushNotification> response =
+                    client.sendNotification(pushNotification).get();
+
+            assertTrue(response.isAccepted(),
+                    "Clients must send notifications that conform to the APNs protocol specification.");
+
+            assertNotNull(response.getApnsId());
+            assertEquals(200, response.getStatusCode());
+            assertNotNull(response.getApnsUniqueId());
+            assertTrue(response.getApnsUniqueId().isPresent());
+        } finally {
+            client.close().get();
+            server.shutdown().get();
+        }
+    }
 }
diff --git a/pushy/src/test/java/com/eatthepath/pushy/apns/auth/AuthenticationTokenTest.java b/pushy/src/test/java/com/eatthepath/pushy/apns/auth/AuthenticationTokenTest.java
index 04fcc0bb..3acdfc40 100644
--- a/pushy/src/test/java/com/eatthepath/pushy/apns/auth/AuthenticationTokenTest.java
+++ b/pushy/src/test/java/com/eatthepath/pushy/apns/auth/AuthenticationTokenTest.java
@@ -159,14 +159,14 @@ public class AuthenticationTokenTest {
     }
 
     @Test
-    void testAuthenticationTokenFromString() throws Exception {
+    void testAuthenticationTokenFromString() {
         final String base64EncodedToken = new AuthenticationToken(this.signingKey, Instant.now()).toString();
 
         assertDoesNotThrow(() -> new AuthenticationToken(base64EncodedToken));
     }
 
     @Test
-    void testGetIssuedAt() throws Exception {
+    void testGetIssuedAt() {
         final Instant now = Instant.now();
         final AuthenticationToken token = new AuthenticationToken(this.signingKey, now);
 
@@ -206,19 +206,9 @@ public class AuthenticationTokenTest {
     }
 
     @Test
-    void testToString() throws Exception {
+    void testToString() {
         final AuthenticationToken token = new AuthenticationToken(this.signingKey, Instant.now());
 
         assertTrue(Pattern.matches("^[a-zA-Z0-9_\\-]+\\.[a-zA-Z0-9_\\-]+\\.[a-zA-Z0-9_\\-]+$", token.toString()));
     }
-
-    @Test
-    void testEncodeDecodeBase64() {
-        final byte[] originalBytes =
-                "We expect to get these bytes back after encoding, then decoding as Base64.".getBytes();
-
-        final String encodedString = AuthenticationToken.encodeUnpaddedBase64UrlString(originalBytes);
-
-        assertArrayEquals(originalBytes, AuthenticationToken.decodeBase64UrlEncodedString(encodedString));
-    }
 }
diff --git a/pushy/src/test/java/com/eatthepath/pushy/apns/server/ValidatingPushNotificationHandlerTest.java b/pushy/src/test/java/com/eatthepath/pushy/apns/server/ValidatingPushNotificationHandlerTest.java
index 08dd4353..86eae571 100644
--- a/pushy/src/test/java/com/eatthepath/pushy/apns/server/ValidatingPushNotificationHandlerTest.java
+++ b/pushy/src/test/java/com/eatthepath/pushy/apns/server/ValidatingPushNotificationHandlerTest.java
@@ -23,6 +23,7 @@
 package com.eatthepath.pushy.apns.server;
 
 import com.eatthepath.pushy.apns.DeliveryPriority;
+import com.eatthepath.uuid.FastUUID;
 import io.netty.buffer.ByteBuf;
 import io.netty.buffer.UnpooledByteBufAllocator;
 import io.netty.handler.codec.http.HttpMethod;
@@ -35,10 +36,7 @@ import org.junit.jupiter.api.Test;
 
 import java.nio.charset.StandardCharsets;
 import java.time.Instant;
-import java.util.Collections;
-import java.util.Map;
-import java.util.Random;
-import java.util.Set;
+import java.util.*;
 
 import static org.junit.jupiter.api.Assertions.*;
 
@@ -53,6 +51,7 @@ public abstract class ValidatingPushNotificationHandlerTest {
     private static final AsciiString APNS_TOPIC_HEADER = new AsciiString("apns-topic");
     private static final AsciiString APNS_PRIORITY_HEADER = new AsciiString("apns-priority");
     private static final AsciiString APNS_COLLAPSE_ID_HEADER = new AsciiString("apns-collapse-id");
+    private static final AsciiString APNS_UNIQUE_ID_HEADER = new AsciiString("apns-unique-id");
     private static final AsciiString APNS_ID_HEADER = new AsciiString("apns-id");
 
     protected static final Map<String, Set<String>> DEVICE_TOKENS_BY_TOPIC =
@@ -111,6 +110,22 @@ public abstract class ValidatingPushNotificationHandlerTest {
                 .handlePushNotification(this.headers, this.payload);
     }
 
+    @Test
+    void testHandleNotificationWithApnsUniqueId() throws Exception {
+        this.headers.set(APNS_UNIQUE_ID_HEADER, UUID.randomUUID().toString());
+
+        this.getHandler(DEVICE_TOKENS_BY_TOPIC, Collections.emptyMap())
+                .handlePushNotification(this.headers, this.payload);
+    }
+
+    @Test
+    void testHandleNotificationWithAnInvalidApnsUniqueId() throws Exception {
+        this.headers.set(APNS_UNIQUE_ID_HEADER, "It's no a valid UUID");
+
+        this.getHandler(DEVICE_TOKENS_BY_TOPIC, Collections.emptyMap())
+                .handlePushNotification(this.headers, this.payload);
+    }
+
     @Test
     void testHandleNotificationWithOversizedCollapseId() {
         this.headers.set(APNS_COLLAPSE_ID_HEADER, "1234567890123456789012345678901234567890123456789012345678901234567890");
diff --git a/pushy/src/test/java/com/eatthepath/pushy/apns/util/ApnsPayloadBuilderTest.java b/pushy/src/test/java/com/eatthepath/pushy/apns/util/ApnsPayloadBuilderTest.java
index d40dd500..21c06dec 100644
--- a/pushy/src/test/java/com/eatthepath/pushy/apns/util/ApnsPayloadBuilderTest.java
+++ b/pushy/src/test/java/com/eatthepath/pushy/apns/util/ApnsPayloadBuilderTest.java
@@ -507,10 +507,9 @@ public abstract class ApnsPayloadBuilderTest {
         }
     }
 
-    @ParameterizedTest
-    @ValueSource(doubles = {-7, 1.1, Double.NaN, Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY})
-    void testSetRelevanceScoreIllegalValue(final double illegalRelevanceScore) {
-        assertThrows(IllegalArgumentException.class, () -> this.builder.setRelevanceScore(illegalRelevanceScore));
+    @Test
+    void testSetRelevanceScoreIllegalValue() {
+        assertThrows(IllegalArgumentException.class, () -> this.builder.setRelevanceScore(Double.NaN));
     }
 
     @Test
@@ -646,6 +645,16 @@ public abstract class ApnsPayloadBuilderTest {
         assertEquals(dismissalDate.getEpochSecond(), aps.get("dismissal-date"));
     }
 
+    @Test
+    void setStaleDate() {
+        final Instant staleDate = Instant.now();
+        this.builder.setStaleDate(staleDate);
+
+        final Map<String, Object> aps = this.extractApsObjectFromPayloadString(this.builder.build());
+
+        assertEquals(staleDate.getEpochSecond(), aps.get("stale-date"));
+    }
+
     @ParameterizedTest
     @EnumSource(LiveActivityEvent.class)
     void setEvent(LiveActivityEvent event) {