Repository: jchambers/pushy (java)

Issue:
couple of Live Activity issues: stale-date is missing, and relevance-score can now have a value greater than 1.0
Hi folks!  Just spent some time working with Live Activities and have a couple of issues I ran into:

1. "stale-date" is another live activity-related field that would be useful to support, similar to "dismissal-date" which was [added in these lines](https://github.com/jchambers/pushy/pull/990/files#diff-18de686d2d85ad5f1d21f98b4ea6ef93519cb8e246fa4f1cfcf4a68688ebed24R872-R874). More info at [the Apple docs](https://developer.apple.com/documentation/activitykit/updating-live-activities-with-activitykit-push-notifications#:~:text=To%20mark%20a%20Live%20Activity%20as%20outdated%20with%20an%20update%2C%20optionally%20set%20the%20stale%2Ddate.%20For%20more%20information%2C%20see%20the%20%E2%80%9CMark%20a%20Live%20Activity%20as%20outdated%20by%20setting%20a%20stale%20date%E2%80%9D%20section%20below.).

2. The other issue is that "relevance-score" is now permitted to have a higher value than previously, [judging by these Apple docs](https://developer.apple.com/documentation/activitykit/updating-live-activities-with-activitykit-push-notifications#:~:text=The%20system%20expects%20relative%20values%20for%20the%20relevance%20score.%20Assign%20a%20higher%20value%20for%20an%20important%20Live%20Activity%20content%20update%20%E2%80%94%20for%20example%2C%20a%20score%20of%20100%20%E2%80%94%20and%20use%20lower%20values%20for%20less%20important%20Live%20Activity%20content%20updates%20%E2%80%94%20for%20example%2C%2050.).  As I understand it, this previously was [limited to a value between 0 and 1](https://developer.apple.com/documentation/usernotifications/unnotificationcontent/3821031-relevancescore) -- so I guess this limit is no longer applied? and the limiting code in [ApnsPayloadBuilder.java](https://github.com/jchambers/pushy/blob/da5f8b97106cd41278b421ec63e4e76a7313bed9/pushy/src/main/java/com/eatthepath/pushy/apns/util/ApnsPayloadBuilder.java#L640) should be removed.  Thoughts?


Pull request description:
0.15.2 live activities changes
- Allow any number when setting relevance score
- Set stale-date on the aps payload for live activities

This fixes #1033.

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/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/util/ApnsPayloadBuilder.java b/pushy/src/main/java/com/eatthepath/pushy/apns/util/ApnsPayloadBuilder.java
index c2819f9e..71435c87 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";
 
     /**
     /**
@@ -629,7 +632,7 @@ public abstract class ApnsPayloadBuilder {
      * <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>
      *
-     * @param relevanceScore a relevance score between 0 and 1, inclusive, or {@code null} if no relevance score should
+     * @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
@@ -637,8 +640,8 @@ public abstract class ApnsPayloadBuilder {
      * @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,29 @@ public abstract class ApnsPayloadBuilder {
         return this;
     }
 
+    /**
+     * <p>Sets a timestamp for the push notification payload. To mark a Live Activity as outdated with an update,
+     * optionally set the stale-date. 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 activityState changes to ActivityState.stale
+     * at the specified date. Use the 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>
+     */
+    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
@@ -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/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/util/ApnsPayloadBuilderTest.java b/pushy/src/test/java/com/eatthepath/pushy/apns/util/ApnsPayloadBuilderTest.java
index d40dd500..528fecfd 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
@@ -508,7 +508,7 @@ public abstract class ApnsPayloadBuilderTest {
     }
 
     @ParameterizedTest
-    @ValueSource(doubles = {-7, 1.1, Double.NaN, Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY})
+    @ValueSource(doubles = {Double.NaN})
     void testSetRelevanceScoreIllegalValue(final double illegalRelevanceScore) {
         assertThrows(IllegalArgumentException.class, () -> this.builder.setRelevanceScore(illegalRelevanceScore));
     }
@@ -646,6 +646,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) {