Repository: jchambers/pushy (java)

Issue:
How to close idle connection 
There is a option to configure maximum connections for a apns client, but it seems that there is no option to configure how to close a connection which is idle for long time. Is there any method to meet this requirement?

Pull request description:
Close idle channels instead of sending PING frames
Rather than keeping idle connections open by sending PING frames, this change will simply close channels after they've been idle for a while. My hope is that this will make Pushy modestly more resource-efficient for high-volume users.

This fixes #791.

Patch:
diff --git a/README.md b/README.md
index 6052643b..09947977 100644
--- a/README.md
+++ b/README.md
@@ -25,11 +25,11 @@ If you use [Maven](http://maven.apache.org/), you can add Pushy to your project
 <dependency>
     <groupId>com.eatthepath</groupId>
     <artifactId>pushy</artifactId>
-    <version>0.15.3</version>
+    <version>0.15.4</version>
 </dependency>
 ```
 
-If you don't use Maven (or something else that understands Maven dependencies, like Gradle), you can [download Pushy as a `.jar` file](https://github.com/jchambers/pushy/releases/download/pushy-0.15.3/pushy-0.15.3.jar) and add it to your project directly. You'll also need to make sure you have Pushy's runtime dependencies on your classpath. They are:
+If you don't use Maven (or something else that understands Maven dependencies, like Gradle), you can [download Pushy as a `.jar` file](https://github.com/jchambers/pushy/releases/download/pushy-0.15.4/pushy-0.15.4.jar) and add it to your project directly. You'll also need to make sure you have Pushy's runtime dependencies on your classpath. They are:
 
 - [netty 4.1.104](http://netty.io/)
 - [slf4j 1.7](http://www.slf4j.org/) (and possibly an SLF4J binding, as described in the [logging](#logging) section below)
@@ -245,4 +245,4 @@ Callers may also provide a [`MockApnsServerListener`](https://pushy-apns.org/api
 
 Pushy is available under the [MIT License](https://github.com/jchambers/pushy/blob/master/LICENSE.md).
 
-The current version of Pushy is 0.15.3. It's fully functional and widely used in production environments, but the public API may change significantly before a 1.0 release.
+The current version of Pushy is 0.15.4. It's fully functional and widely used in production environments, but the public API may change significantly before a 1.0 release.
diff --git a/benchmark/pom.xml b/benchmark/pom.xml
index 6f3ce31c..a85b0ac5 100644
--- a/benchmark/pom.xml
+++ b/benchmark/pom.xml
@@ -27,11 +27,11 @@
     <parent>
         <artifactId>pushy-parent</artifactId>
         <groupId>com.eatthepath</groupId>
-        <version>0.15.4-SNAPSHOT</version>
+        <version>0.15.5-SNAPSHOT</version>
     </parent>
 
     <artifactId>pushy-benchmark</artifactId>
-    <version>0.15.4-SNAPSHOT</version>
+    <version>0.15.5-SNAPSHOT</version>
     <packaging>jar</packaging>
 
     <name>Pushy benchmarks</name>
diff --git a/dropwizard-metrics-listener/pom.xml b/dropwizard-metrics-listener/pom.xml
index ad9f5ada..b2884bbb 100644
--- a/dropwizard-metrics-listener/pom.xml
+++ b/dropwizard-metrics-listener/pom.xml
@@ -25,14 +25,14 @@
     <modelVersion>4.0.0</modelVersion>
 
     <artifactId>pushy-dropwizard-metrics-listener</artifactId>
-    <version>0.15.4-SNAPSHOT</version>
+    <version>0.15.5-SNAPSHOT</version>
     <name>Dropwizard Metrics listener for Pushy</name>
     <description>A metrics listener for Pushy that uses the Dropwizard Metrics library for gathering and reporting metrics.</description>
 
     <parent>
         <groupId>com.eatthepath</groupId>
         <artifactId>pushy-parent</artifactId>
-        <version>0.15.4-SNAPSHOT</version>
+        <version>0.15.5-SNAPSHOT</version>
     </parent>
 
     <dependencies>
diff --git a/gson-payload-builder/pom.xml b/gson-payload-builder/pom.xml
index 9d2c0c76..56a9c1a9 100644
--- a/gson-payload-builder/pom.xml
+++ b/gson-payload-builder/pom.xml
@@ -26,14 +26,14 @@
     <modelVersion>4.0.0</modelVersion>
 
     <artifactId>pushy-gson-payload-builder</artifactId>
-    <version>0.15.4-SNAPSHOT</version>
+    <version>0.15.5-SNAPSHOT</version>
     <name>Gson APNs payload builder for Pushy</name>
     <description>A payload builder for Pushy that uses Gson to serialize payloads.</description>
 
     <parent>
         <artifactId>pushy-parent</artifactId>
         <groupId>com.eatthepath</groupId>
-        <version>0.15.4-SNAPSHOT</version>
+        <version>0.15.5-SNAPSHOT</version>
     </parent>
 
     <dependencies>
diff --git a/jackson-payload-builder/pom.xml b/jackson-payload-builder/pom.xml
index 7c552ef5..3798840d 100644
--- a/jackson-payload-builder/pom.xml
+++ b/jackson-payload-builder/pom.xml
@@ -26,14 +26,14 @@
     <modelVersion>4.0.0</modelVersion>
 
     <artifactId>pushy-jackson-payload-builder</artifactId>
-    <version>0.15.4-SNAPSHOT</version>
+    <version>0.15.5-SNAPSHOT</version>
     <name>Jackson APNs payload builder for Pushy</name>
     <description>A payload builder for Pushy that uses Jackson to serialize payloads.</description>
 
     <parent>
         <artifactId>pushy-parent</artifactId>
         <groupId>com.eatthepath</groupId>
-        <version>0.15.4-SNAPSHOT</version>
+        <version>0.15.5-SNAPSHOT</version>
     </parent>
 
     <dependencies>
diff --git a/micrometer-metrics-listener/pom.xml b/micrometer-metrics-listener/pom.xml
index c6cb099a..ea928687 100644
--- a/micrometer-metrics-listener/pom.xml
+++ b/micrometer-metrics-listener/pom.xml
@@ -31,7 +31,7 @@
     <parent>
         <artifactId>pushy-parent</artifactId>
         <groupId>com.eatthepath</groupId>
-        <version>0.15.4-SNAPSHOT</version>
+        <version>0.15.5-SNAPSHOT</version>
     </parent>
 
     <dependencies>
diff --git a/pom.xml b/pom.xml
index 8a432be8..1a5d4930 100644
--- a/pom.xml
+++ b/pom.xml
@@ -27,7 +27,7 @@
     <groupId>com.eatthepath</groupId>
     <artifactId>pushy-parent</artifactId>
     <packaging>pom</packaging>
-    <version>0.15.4-SNAPSHOT</version>
+    <version>0.15.5-SNAPSHOT</version>
 
     <name>Pushy parent</name>
     <description>A Java library for sending APNs (iOS/macOS/Safari) push notifications</description>
diff --git a/pushy/pom.xml b/pushy/pom.xml
index a06a7283..d547bcc2 100644
--- a/pushy/pom.xml
+++ b/pushy/pom.xml
@@ -25,14 +25,14 @@
     <modelVersion>4.0.0</modelVersion>
 
     <artifactId>pushy</artifactId>
-    <version>0.15.4-SNAPSHOT</version>
+    <version>0.15.5-SNAPSHOT</version>
     <packaging>bundle</packaging>
     <name>Pushy</name>
 
     <parent>
         <groupId>com.eatthepath</groupId>
         <artifactId>pushy-parent</artifactId>
-        <version>0.15.4-SNAPSHOT</version>
+        <version>0.15.5-SNAPSHOT</version>
     </parent>
 
     <dependencies>
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsChannelFactory.java b/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsChannelFactory.java
index c41b4575..2a730192 100644
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsChannelFactory.java
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsChannelFactory.java
@@ -116,12 +116,10 @@ class ApnsChannelFactory implements PooledObjectFactory<Channel>, Closeable {
                         clientHandlerBuilder = new TokenAuthenticationApnsClientHandler.TokenAuthenticationApnsClientHandlerBuilder()
                                 .signingKey(clientConfiguration.getSigningKey().get())
                                 .tokenExpiration(clientConfiguration.getTokenExpiration())
-                                .authority(authority)
-                                .idlePingInterval(clientConfiguration.getIdlePingInterval());
+                                .authority(authority);
                     } else {
                         clientHandlerBuilder = new ApnsClientHandler.ApnsClientHandlerBuilder()
-                                .authority(authority)
-                                .idlePingInterval(clientConfiguration.getIdlePingInterval());
+                                .authority(authority);
                     }
 
                     clientConfiguration.getFrameLogger().ifPresent(clientHandlerBuilder::frameLogger);
@@ -139,7 +137,7 @@ class ApnsChannelFactory implements PooledObjectFactory<Channel>, Closeable {
 
                 pipeline.addLast(sslHandler);
                 pipeline.addLast(new FlushConsolidationHandler(FlushConsolidationHandler.DEFAULT_EXPLICIT_FLUSH_AFTER_FLUSHES, true));
-                pipeline.addLast(new IdleStateHandler(clientConfiguration.getIdlePingInterval().toMillis(), 0, 0, TimeUnit.MILLISECONDS));
+                pipeline.addLast(new IdleStateHandler(clientConfiguration.getCloseAfterIdleDuration().toMillis(), 0, 0, TimeUnit.MILLISECONDS));
                 pipeline.addLast(apnsClientHandler);
             }
         });
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 44971304..3ce5fb7b 100644
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientBuilder.java
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientBuilder.java
@@ -79,7 +79,7 @@ public class ApnsClientBuilder {
     private ProxyHandlerFactory proxyHandlerFactory;
 
     private Duration connectionTimeout;
-    private Duration idlePingInterval = DEFAULT_IDLE_PING_INTERVAL;
+    private Duration closeAfterIdleDuration = DEFAULT_CLOSE_AFTER_IDLE_DURATION;
     private Duration gracefulShutdownTimeout;
 
     private Http2FrameLogger frameLogger;
@@ -87,11 +87,11 @@ public class ApnsClientBuilder {
     private boolean useAlpn = false;
 
     /**
-     * The default idle time in milliseconds after which the client will send a PING frame to the APNs server.
+     * The default idle time after which the client will close a connection (which may be reopened later).
      *
      * @since 0.11
      */
-    public static final Duration DEFAULT_IDLE_PING_INTERVAL = Duration.ofMinutes(1);
+    public static final Duration DEFAULT_CLOSE_AFTER_IDLE_DURATION = Duration.ofMinutes(1);
 
     /**
      * The hostname for the production APNs gateway.
@@ -476,18 +476,18 @@ public class ApnsClientBuilder {
     }
 
     /**
-     * Sets the amount of idle time (in milliseconds) after which the client under construction will send a PING frame
-     * to the APNs server. By default, clients will send a PING frame after an idle period of
-     * {@link com.eatthepath.pushy.apns.ApnsClientBuilder#DEFAULT_IDLE_PING_INTERVAL}.
+     * Sets the amount of idle time after which the client under construction will close a connection (which may be
+     * reopened later). By default, clients will close connections after an idle time of
+     * {@link com.eatthepath.pushy.apns.ApnsClientBuilder#DEFAULT_CLOSE_AFTER_IDLE_DURATION}.
      *
-     * @param idlePingInterval the amount of idle time after which the client will send a PING frame
+     * @param closeAfterIdleDuration the amount of idle time after which the client will close a connection
      *
      * @return a reference to this builder
      *
-     * @since 0.10
+     * @since 0.16.0
      */
-    public ApnsClientBuilder setIdlePingInterval(final Duration idlePingInterval) {
-        this.idlePingInterval = idlePingInterval;
+    public ApnsClientBuilder setCloseAfterIdleDuration(final Duration closeAfterIdleDuration) {
+        this.closeAfterIdleDuration = closeAfterIdleDuration;
         return this;
     }
 
@@ -628,7 +628,7 @@ public class ApnsClientBuilder {
                             this.tokenExpiration,
                             this.proxyHandlerFactory,
                             this.connectionTimeout,
-                            this.idlePingInterval,
+                            this.closeAfterIdleDuration,
                             this.gracefulShutdownTimeout,
                             this.concurrentConnections,
                             this.metricsListener,
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientConfiguration.java b/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientConfiguration.java
index 6a00e6e0..afde7d2f 100644
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientConfiguration.java
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientConfiguration.java
@@ -46,7 +46,7 @@ class ApnsClientConfiguration {
     private final Duration tokenExpiration;
     private final ProxyHandlerFactory proxyHandlerFactory;
     private final Duration connectionTimeout;
-    private final Duration idlePingInterval;
+    private final Duration closeAfterIdleDuration;
     private final Duration gracefulShutdownTimeout;
     private final int concurrentConnections;
     private final ApnsClientMetricsListener metricsListener;
@@ -59,7 +59,7 @@ class ApnsClientConfiguration {
                                    final Duration tokenExpiration,
                                    final ProxyHandlerFactory proxyHandlerFactory,
                                    final Duration connectionTimeout,
-                                   final Duration idlePingInterval,
+                                   final Duration closeAfterIdleDuration,
                                    final Duration gracefulShutdownTimeout,
                                    final int concurrentConnections,
                                    final ApnsClientMetricsListener metricsListener,
@@ -72,7 +72,7 @@ class ApnsClientConfiguration {
         this.tokenExpiration = tokenExpiration != null ? tokenExpiration : DEFAULT_TOKEN_EXPIRATION;
         this.proxyHandlerFactory = proxyHandlerFactory;
         this.connectionTimeout = connectionTimeout;
-        this.idlePingInterval = idlePingInterval;
+        this.closeAfterIdleDuration = closeAfterIdleDuration;
         this.gracefulShutdownTimeout = gracefulShutdownTimeout;
         this.concurrentConnections = concurrentConnections;
         this.metricsListener = metricsListener;
@@ -107,8 +107,8 @@ class ApnsClientConfiguration {
         return Optional.ofNullable(connectionTimeout);
     }
 
-    public Duration getIdlePingInterval() {
-        return idlePingInterval;
+    public Duration getCloseAfterIdleDuration() {
+        return closeAfterIdleDuration;
     }
 
     public Optional<Duration> getGracefulShutdownTimeout() {
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 e2087266..79022152 100644
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientHandler.java
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/ApnsClientHandler.java
@@ -38,22 +38,18 @@ import io.netty.handler.codec.http2.*;
 import io.netty.handler.timeout.IdleStateEvent;
 import io.netty.util.AsciiString;
 import io.netty.util.collection.IntObjectHashMap;
-import io.netty.util.concurrent.GenericFutureListener;
 import io.netty.util.concurrent.Promise;
 import io.netty.util.concurrent.PromiseCombiner;
-import io.netty.util.concurrent.ScheduledFuture;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.text.ParseException;
-import java.time.Duration;
 import java.util.Map;
 import java.util.Objects;
 import java.util.UUID;
 import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.TimeUnit;
 
 class ApnsClientHandler extends Http2ConnectionHandler implements Http2FrameListener, Http2Connection.Listener {
 
@@ -65,9 +61,6 @@ class ApnsClientHandler extends Http2ConnectionHandler implements Http2FrameList
 
     private final String authority;
 
-    private final Duration pingTimeout;
-    private ScheduledFuture<?> pingTimeoutFuture;
-
     private Throwable connectionErrorCause;
 
     private static final AsciiString APNS_PATH_PREFIX = new AsciiString("/3/device/");
@@ -92,7 +85,6 @@ class ApnsClientHandler extends Http2ConnectionHandler implements Http2FrameList
     public static class ApnsClientHandlerBuilder extends AbstractHttp2ConnectionHandlerBuilder<ApnsClientHandler, ApnsClientHandlerBuilder> {
 
         private String authority;
-        private Duration idlePingInterval;
 
         ApnsClientHandlerBuilder authority(final String authority) {
             this.authority = authority;
@@ -103,15 +95,6 @@ class ApnsClientHandler extends Http2ConnectionHandler implements Http2FrameList
             return this.authority;
         }
 
-        Duration idlePingInterval() {
-            return idlePingInterval;
-        }
-
-        ApnsClientHandlerBuilder idlePingInterval(final Duration idlePingIntervalMillis) {
-            this.idlePingInterval = idlePingIntervalMillis;
-            return this;
-        }
-
         @Override
         public ApnsClientHandlerBuilder frameLogger(final Http2FrameLogger frameLogger) {
             return super.frameLogger(frameLogger);
@@ -136,7 +119,7 @@ class ApnsClientHandler extends Http2ConnectionHandler implements Http2FrameList
         public ApnsClientHandler build(final Http2ConnectionDecoder decoder, final Http2ConnectionEncoder encoder, final Http2Settings initialSettings) {
             Objects.requireNonNull(this.authority(), "Authority must be set before building an ApnsClientHandler.");
 
-            final ApnsClientHandler handler = new ApnsClientHandler(decoder, encoder, initialSettings, this.authority(), this.idlePingInterval());
+            final ApnsClientHandler handler = new ApnsClientHandler(decoder, encoder, initialSettings, this.authority());
             this.frameListener(handler);
             return handler;
         }
@@ -147,7 +130,7 @@ class ApnsClientHandler extends Http2ConnectionHandler implements Http2FrameList
         }
     }
 
-    ApnsClientHandler(final Http2ConnectionDecoder decoder, final Http2ConnectionEncoder encoder, final Http2Settings initialSettings, final String authority, final Duration idlePingInterval) {
+    ApnsClientHandler(final Http2ConnectionDecoder decoder, final Http2ConnectionEncoder encoder, final Http2Settings initialSettings, final String authority) {
         super(decoder, encoder, initialSettings);
 
         this.authority = authority;
@@ -157,8 +140,6 @@ class ApnsClientHandler extends Http2ConnectionHandler implements Http2FrameList
         this.streamErrorCausePropertyKey = this.connection().newKey();
 
         this.connection().addListener(this);
-
-        this.pingTimeout = idlePingInterval.dividedBy(2);
     }
 
     @Override
@@ -264,22 +245,8 @@ class ApnsClientHandler extends Http2ConnectionHandler implements Http2FrameList
     @Override
     public void userEventTriggered(final ChannelHandlerContext context, final Object event) throws Exception {
         if (event instanceof IdleStateEvent) {
-            log.trace("Sending ping due to inactivity.");
-
-            this.encoder().writePing(context, false, System.currentTimeMillis(), context.newPromise()).addListener(
-                    (GenericFutureListener<ChannelFuture>) future -> {
-                        if (!future.isSuccess()) {
-                            log.debug("Failed to write PING frame.", future.cause());
-                            future.channel().close();
-                        }
-                    });
-
-            this.pingTimeoutFuture = context.channel().eventLoop().schedule(() -> {
-                log.debug("Closing channel due to ping timeout.");
-                context.channel().close();
-            }, pingTimeout.toMillis(), TimeUnit.MILLISECONDS);
-
-            this.flush(context);
+            log.debug("Closing idle channel.");
+            context.close();
         }
 
         super.userEventTriggered(context, event);
@@ -413,11 +380,6 @@ class ApnsClientHandler extends Http2ConnectionHandler implements Http2FrameList
 
     @Override
     public void onPingAckRead(final ChannelHandlerContext context, final long pingData) {
-        if (this.pingTimeoutFuture != null) {
-            this.pingTimeoutFuture.cancel(false);
-        } else {
-            log.error("Received PING ACK, but no corresponding outbound PING found.");
-        }
     }
 
     @Override
@@ -508,10 +470,6 @@ class ApnsClientHandler extends Http2ConnectionHandler implements Http2FrameList
 
     @Override
     public void channelInactive(final ChannelHandlerContext context) throws Exception {
-        if (this.pingTimeoutFuture != null) {
-            this.pingTimeoutFuture.cancel(false);
-        }
-
         for (final PushNotificationFuture<?, ?> future : this.unattachedResponsePromisesByStreamId.values()) {
             future.completeExceptionally(STREAM_CLOSED_BEFORE_REPLY_EXCEPTION);
         }
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/TokenAuthenticationApnsClientHandler.java b/pushy/src/main/java/com/eatthepath/pushy/apns/TokenAuthenticationApnsClientHandler.java
index 42541b01..81670262 100644
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/TokenAuthenticationApnsClientHandler.java
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/TokenAuthenticationApnsClientHandler.java
@@ -81,14 +81,14 @@ class TokenAuthenticationApnsClientHandler extends ApnsClientHandler {
             Objects.requireNonNull(this.signingKey(), "Signing key must be set before building a TokenAuthenticationApnsClientHandler.");
             Objects.requireNonNull(this.tokenExpiration(), "Token expiration duration must be set before building a TokenAuthenticationApnsClientHandler.");
 
-            final ApnsClientHandler handler = new TokenAuthenticationApnsClientHandler(decoder, encoder, initialSettings, this.authority(), this.idlePingInterval(), this.signingKey(), this.tokenExpiration());
+            final ApnsClientHandler handler = new TokenAuthenticationApnsClientHandler(decoder, encoder, initialSettings, this.authority(), this.signingKey(), this.tokenExpiration());
             this.frameListener(handler);
             return handler;
         }
     }
 
-    protected TokenAuthenticationApnsClientHandler(final Http2ConnectionDecoder decoder, final Http2ConnectionEncoder encoder, final Http2Settings initialSettings, final String authority, final Duration idlePingInterval, final ApnsSigningKey signingKey, final Duration tokenExpiration) {
-        super(decoder, encoder, initialSettings, authority, idlePingInterval);
+    protected TokenAuthenticationApnsClientHandler(final Http2ConnectionDecoder decoder, final Http2ConnectionEncoder encoder, final Http2Settings initialSettings, final String authority, final ApnsSigningKey signingKey, final Duration tokenExpiration) {
+        super(decoder, encoder, initialSettings, authority);
 
         this.signingKey = Objects.requireNonNull(signingKey, "Signing key must not be null for token-based client handlers.");
         this.tokenExpiration = Objects.requireNonNull(tokenExpiration, "Token expiration must not be null for token-based client handlers");
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 b2534daf..7b4083e5 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
@@ -89,6 +89,9 @@ public abstract class ApnsPayloadBuilder {
 
     private Instant staleDate = null;
 
+    private String attributesType = null;
+    private Map<String, Object> attributes = null;
+
     private Map<String, Object> contentState = null;
 
     private static final String APS_KEY = "aps";
@@ -125,6 +128,8 @@ public abstract class ApnsPayloadBuilder {
     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 ATTRIBUTES_TYPE_KEY = "attributes-type";
+    private static final String ATTRIBUTES_KEY = "attributes";
     private static final String CONTENT_STATE_KEY = "content-state";
 
     public static final String[] EMPTY_STRING_ARRAY = new String[0];
@@ -756,6 +761,46 @@ public abstract class ApnsPayloadBuilder {
         return this;
     }
 
+    /**
+     * Sets the type of iOS {@code ActivityAttributes} used for a Live Activity. Must be set for a notification that
+     * starts a live activity.
+     *
+     * @param attributesType the type of {@code ActivityAttributes} used for a Live Activity
+     *
+     * @return a reference to this payload builder
+     *
+     * @see #setAttributes(Map) 
+     * @see <a href="https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications#Construct-the-payload-that-starts-a-Live-Activity">
+     *      Construct the payload that starts a Live Activity</a>
+     * @see <a href="https://developer.apple.com/documentation/activitykit/activityattributes">ActivityKit - ActivityAttributes</a>
+     *
+     * @since 0.15.4
+     */
+    public ApnsPayloadBuilder setAttributesType(final String attributesType) {
+        this.attributesType = attributesType;
+        return this;
+    }
+
+    /**
+     * Sets the values of iOS {@code ContentState} attributes when starting a Live Activity. Must be set for a
+     * notification that starts a live activity.
+     *
+     * @param attributes the attributes used to populate the {@code ContentState} for the newly-started live activity
+     *
+     * @return a reference to this payload builder
+     *
+     * @see #setAttributesType(String) 
+     * @see <a href="https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications#Construct-the-payload-that-starts-a-Live-Activity">
+     *      Construct the payload that starts a Live Activity</a>
+     * @see <a href="https://developer.apple.com/documentation/activitykit/activityattributes/contentstate">ActivityKit - ContentState</a>
+     *
+     * @since 0.15.4
+     */
+    public ApnsPayloadBuilder setAttributes(final Map<String, Object> attributes) {
+        this.attributes = attributes;
+        return this;
+    }
+
     /**
      * <p>Sets the content state object inside content-state payload for Live Activities.</p>
      *
@@ -768,6 +813,8 @@ public abstract class ApnsPayloadBuilder {
      *
      * @see <a href="https://developer.apple.com/documentation/activitykit/update-and-end-your-live-activity-with-remote-push-notifications">
      *      Updating and ending your Live Activity with remote push notifications</a>
+     *
+     * @since 0.15.2
      */
     public ApnsPayloadBuilder setContentState(final Map<String, Object> contentState) {
         this.contentState = contentState;
@@ -784,6 +831,8 @@ public abstract class ApnsPayloadBuilder {
      *
      * @see <a href="https://developer.apple.com/documentation/activitykit/update-and-end-your-live-activity-with-remote-push-notifications">
      *      Updating and ending your Live Activity with remote push notifications</a>
+     *
+     * @since 0.15.2
      */
     public ApnsPayloadBuilder setEvent(final LiveActivityEvent event) {
         this.event = event;
@@ -799,6 +848,8 @@ public abstract class ApnsPayloadBuilder {
      *
      * @see <a href="https://developer.apple.com/documentation/activitykit/update-and-end-your-live-activity-with-remote-push-notifications">
      *      Updating and ending your Live Activity with remote push notifications</a>
+     *
+     * @since 0.15.2
      */
     public ApnsPayloadBuilder setTimestamp(final Instant timestamp) {
         this.timestamp = timestamp;
@@ -819,6 +870,8 @@ public abstract class ApnsPayloadBuilder {
      *
      * @see <a href="https://developer.apple.com/documentation/activitykit/update-and-end-your-live-activity-with-remote-push-notifications">
      *      Updating and ending your Live Activity with remote push notifications</a>
+     *
+     * @since 0.15.2
      */
     public ApnsPayloadBuilder setDismissalDate(final Instant dismissalDate) {
         this.dismissalDate = dismissalDate;
@@ -891,6 +944,14 @@ public abstract class ApnsPayloadBuilder {
                 aps.put(EVENT_KEY, this.event.getValue());
             }
 
+            if (this.attributesType != null) {
+                aps.put(ATTRIBUTES_TYPE_KEY, this.attributesType);
+            }
+
+            if (this.attributes != null) {
+                aps.put(ATTRIBUTES_KEY, this.attributes);
+            }
+
             if (this.contentState != null) {
                 aps.put(CONTENT_STATE_KEY, this.contentState);
             }
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/util/LiveActivityEvent.java b/pushy/src/main/java/com/eatthepath/pushy/apns/util/LiveActivityEvent.java
index ccd1460b..78ff9a17 100644
--- a/pushy/src/main/java/com/eatthepath/pushy/apns/util/LiveActivityEvent.java
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/util/LiveActivityEvent.java
@@ -25,11 +25,16 @@ package com.eatthepath.pushy.apns.util;
 /**
  * An enumeration of accepted actions for Live Activity notification payloads.
  *
- * @see <a href="https://developer.apple.com/documentation/activitykit/update-and-end-your-live-activity-with-remote-push-notifications">Updating and ending your Live Activity with remote push notifications</a>
+ * @see <a href="https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications#Construct-the-payload-that-starts-a-Live-Activity">Starting and updating Live Activities with ActivityKit push notifications</a>
  *
  * @since 0.15.2
 */
 public enum LiveActivityEvent {
+    /**
+     * Used to start a Live Activity.
+     */
+    START("start"),
+
     /**
      * Used to update a Live Activity.
      */


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index c86f9f30..544983b1 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -7,22 +7,26 @@ jobs:
     runs-on: ${{ matrix.os }}
     strategy:
       matrix:
-        os: [ubuntu-22.04, macos-latest]
+        os: [ubuntu-22.04, macos-13, macos-latest]
         java: [8, 17, 21]
         transport: [native, JDK]
         tls: [native, JDK]
+        # The macos-14 (and presumably newer) runners are using arm64, and Temurin doesn't support Java 8 on arm64
+        exclude:
+          - os: macos-latest
+            java: 8
       fail-fast: false
     name: JDK ${{ matrix.java }}, ${{ matrix.os }}, ${{ matrix.transport }} transport, ${{ matrix.tls }} SSL provider
 
     steps:
-    - uses: actions/checkout@v4
+    - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
     - name: Set up JDK
-      uses: actions/setup-java@v4
+      uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
       with:
         distribution: temurin
         java-version: ${{ matrix.java }}
     - name: Cache local Maven repository
-      uses: actions/cache@v3
+      uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
       with:
         path: ~/.m2/repository
         key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
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 21c06dec..ff9a4815 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
@@ -29,7 +29,6 @@ import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.EnumSource;
 import org.junit.jupiter.params.provider.MethodSource;
-import org.junit.jupiter.params.provider.ValueSource;
 
 import java.text.ParseException;
 import java.time.Instant;
@@ -665,6 +664,42 @@ public abstract class ApnsPayloadBuilderTest {
         assertEquals(event.getValue(), aps.get("event"));
     }
 
+    @Test
+    void testSetAttributesType() {
+        final String attributesType = "TestAttributes";
+        this.builder.setAttributesType(attributesType);
+
+        final Map<String, Object> aps = this.extractApsObjectFromPayloadString(this.builder.build());
+
+        assertEquals(attributesType, aps.get("attributes-type"));
+    }
+
+    @Test
+    void testSetAttributes() {
+        final String keyForStringValue = "string";
+        final String stringValue = "Hello";
+
+        final String keyForLongValue = "integer";
+        final long longValue = 12;
+
+        final String keyForMapValue = "map";
+        final Map<String, Object> attributes = new HashMap<>();
+        final Map<String, Object> subMap = new HashMap<>();
+        subMap.put("boolean", true);
+        attributes.put(keyForLongValue, longValue);
+        attributes.put(keyForStringValue, stringValue);
+        attributes.put(keyForMapValue, subMap);
+
+        this.builder.setAttributes(attributes);
+
+        final Map<String, Object> aps = this.extractApsObjectFromPayloadString(this.builder.build());
+        final Map<String, Object> serializedAttributes = (Map<String, Object>) aps.get("attributes");
+
+        assertEquals(stringValue, serializedAttributes.get(keyForStringValue));
+        assertEquals(longValue, serializedAttributes.get(keyForLongValue));
+        assertEquals(subMap, serializedAttributes.get(keyForMapValue));
+    }
+
     @Test
     void testAddContentStateProperty() {
         final String keyForStringValue = "string";