Repository: relayrides/pushy (java)

Issue:
Support for apns-push-type
Via https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/sending_notification_requests_to_apns?language=objc:

> `apns-push-type `
> (**Required when delivering notifications to devices running iOS 13 and later, or watchOS 6 and later.** Ignored on earlier system versions.) The type of the notification. The value of this header is `alert` or `background`. Specify `alert` when the delivery of your notification displays an alert, plays a sound, or badges your app's icon. Specify `background` for silent notifications that do not interact with the user.
>
> The value of this header must accurately reflect the contents of your notification's payload. If there is a mismatch, or if the header is missing on required systems, APNs may delay the delivery of the notification or drop it altogether.
>
> For information about configuring notification alerts, see [Generating a Remote Notification](https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/generating_a_remote_notification?language=objc). For more information about sending background notifications, see [Pushing Background Updates to Your App](https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/pushing_background_updates_to_your_app?language=objc).

Thanks,
Nick

Pull request:
Add support for the `apns-push-type` header
This is a first pass at adding support for the `apns-push-type` header introduced in iOS 13/watchOS 6. There are a couple points of tension I'm trying to resolve (or accept):

1. `SimpleApnsPushNotification` now has a _lot_ of constructors. Doing everything in constructors made sense early on when there were only a couple optional fields, but the constructor count will grow exponentially as the APNs API adds more and more fields over time. In a future, larger release (where there's more expectation of API breakage), it might make sense to move to a builder pattern.
2. I'm not sure that I like making `pushType` be possibly-`null`. As a header, it falls into a grey zone of "required for successful operation sometimes, but ignored at other times." Making it nullable means minimal disruption and surprise for existing use cases. Making it required reduces surprise for new use cases.

I'm also wondering about the interplay of delivery priority and `apns-push-type`. Are all combinations of priorities and push types legal? Can you have a "conserve power" priority for an "alert" push type, for example?

I'm very open to feedback on all of this.

If merged, this closes #701.

----

TODO:

- [x] Un-hose existing tests.
- [x] Add more robust tests to make sure the `apns-push-type` header is actually being sent as expected.

Patch:
diff --git a/pushy/src/main/java/com/turo/pushy/apns/ApnsClientHandler.java b/pushy/src/main/java/com/turo/pushy/apns/ApnsClientHandler.java
index 4d41ef1f..3f1ffde2 100644
--- a/pushy/src/main/java/com/turo/pushy/apns/ApnsClientHandler.java
+++ b/pushy/src/main/java/com/turo/pushy/apns/ApnsClientHandler.java
@@ -70,6 +70,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_PUSH_TYPE_HEADER = new AsciiString("apns-push-type");
 
     private static final int INITIAL_PAYLOAD_BUFFER_CAPACITY = 4096;
 
@@ -244,6 +245,10 @@ class ApnsClientHandler extends Http2ConnectionHandler implements Http2FrameList
             headers.addInt(APNS_PRIORITY_HEADER, pushNotification.getPriority().getCode());
         }
 
+        if (pushNotification.getPushType() != null) {
+            headers.add(APNS_PUSH_TYPE_HEADER, pushNotification.getPushType().getHeaderValue());
+        }
+
         if (pushNotification.getTopic() != null) {
             headers.add(APNS_TOPIC_HEADER, pushNotification.getTopic());
         }
diff --git a/pushy/src/main/java/com/turo/pushy/apns/ApnsPushNotification.java b/pushy/src/main/java/com/turo/pushy/apns/ApnsPushNotification.java
index 670b5e0b..6c329b38 100644
--- a/pushy/src/main/java/com/turo/pushy/apns/ApnsPushNotification.java
+++ b/pushy/src/main/java/com/turo/pushy/apns/ApnsPushNotification.java
@@ -85,6 +85,17 @@ public interface ApnsPushNotification {
      */
     DeliveryPriority getPriority();
 
+    /**
+     * Returns the display type this push notification. Note that push notification display types are required in iOS 13
+     * and later and watchOS 6 and later, but are ignored under earlier versions of either operating system. May be
+     * {@code null}.
+     *
+     * @return the display type this push notification
+     *
+     * @since 0.13.9
+     */
+    PushType getPushType();
+
     /**
      * Returns the topic to which this notification should be sent. This is generally the bundle ID of the receiving
      * app. Topics must not be {@code null}.
diff --git a/pushy/src/main/java/com/turo/pushy/apns/PushType.java b/pushy/src/main/java/com/turo/pushy/apns/PushType.java
new file mode 100644
index 00000000..2aa92a51
--- /dev/null
+++ b/pushy/src/main/java/com/turo/pushy/apns/PushType.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) 2013-2019 Turo
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package com.turo.pushy.apns;
+
+/**
+ * An enumeration of push notification display types. Note that push notification display types are required in iOS 13
+ * and later and watchOS 6 and later, but are ignored under earlier versions of either operating system.
+ *
+ * @see <a href="https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/sending_notification_requests_to_apns">Sending
+ * Notification Requests to APNs</a>
+ *
+ * @since 0.13.9
+ */
+public enum PushType {
+
+    /**
+     * Indicates that a push notification is expected to display an alert, play a sound, or badges the receiving apps'
+     * icon.
+     */
+    ALERT("alert"),
+
+    /**
+     * Indicates that a push notification is not expected to interact with the user on the receiving device.
+     */
+    BACKGROUND("background");
+
+    private final String headerValue;
+
+    PushType(final String headerValue) {
+        this.headerValue = headerValue;
+    }
+
+    public String getHeaderValue() {
+        return this.headerValue;
+    }
+
+    public static PushType getFromHeaderValue(final CharSequence headerValue) {
+        for (final PushType pushType : PushType.values()) {
+            if (pushType.headerValue.contentEquals(headerValue)) {
+                return pushType;
+            }
+        }
+
+        throw new IllegalArgumentException("No push type found for header value: " + headerValue);
+    }
+}
diff --git a/pushy/src/main/java/com/turo/pushy/apns/server/ParsingMockApnsServerListenerAdapter.java b/pushy/src/main/java/com/turo/pushy/apns/server/ParsingMockApnsServerListenerAdapter.java
index 9fda5f3f..db268ca8 100644
--- a/pushy/src/main/java/com/turo/pushy/apns/server/ParsingMockApnsServerListenerAdapter.java
+++ b/pushy/src/main/java/com/turo/pushy/apns/server/ParsingMockApnsServerListenerAdapter.java
@@ -25,6 +25,7 @@ package com.turo.pushy.apns.server;
 import com.eatthepath.uuid.FastUUID;
 import com.turo.pushy.apns.ApnsPushNotification;
 import com.turo.pushy.apns.DeliveryPriority;
+import com.turo.pushy.apns.PushType;
 import io.netty.buffer.ByteBuf;
 import io.netty.handler.codec.http2.Http2Headers;
 import io.netty.util.AsciiString;
@@ -55,15 +56,17 @@ public abstract class ParsingMockApnsServerListenerAdapter implements MockApnsSe
         private final String payload;
         private final Date invalidationTime;
         private final DeliveryPriority priority;
+        private final PushType pushType;
         private final String topic;
         private final String collapseId;
         private final UUID apnsId;
 
-        private LenientApnsPushNotification(final String token, final String topic, final String payload, final Date invalidationTime, final DeliveryPriority priority, final String collapseId, final UUID apnsId) {
+        private LenientApnsPushNotification(final String token, final String topic, final String payload, final Date invalidationTime, final DeliveryPriority priority, final PushType pushType, final String collapseId, final UUID apnsId) {
             this.token = token;
             this.payload = payload;
             this.invalidationTime = invalidationTime;
             this.priority = priority;
+            this.pushType = pushType;
             this.topic = topic;
             this.collapseId = collapseId;
             this.apnsId = apnsId;
@@ -89,6 +92,11 @@ public abstract class ParsingMockApnsServerListenerAdapter implements MockApnsSe
             return this.priority;
         }
 
+        @Override
+        public PushType getPushType() {
+            return this.pushType;
+        }
+
         @Override
         public String getTopic() {
             return this.topic;
@@ -111,6 +119,7 @@ public abstract class ParsingMockApnsServerListenerAdapter implements MockApnsSe
     private static final AsciiString APNS_EXPIRATION_HEADER = new AsciiString("apns-expiration");
     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_PUSH_TYPE_HEADER = new AsciiString("apns-push-type");
 
     private static final Logger log = LoggerFactory.getLogger(ParsingMockApnsServerListenerAdapter.class);
 
@@ -209,6 +218,21 @@ public abstract class ParsingMockApnsServerListenerAdapter implements MockApnsSe
             deliveryPriority = priorityFromCode;
         }
 
+        final PushType pushType;
+        {
+            final CharSequence pushTypeSequence = headers.get(APNS_PUSH_TYPE_HEADER);
+
+            PushType pushTypeFromHeader;
+
+            try {
+                pushTypeFromHeader = pushTypeSequence != null ? PushType.getFromHeaderValue(pushTypeSequence) : null;
+            } catch (final IllegalArgumentException e) {
+                pushTypeFromHeader = null;
+            }
+
+            pushType = pushTypeFromHeader;
+        }
+
         final Date expiration;
         {
             final Integer expirationTimestamp = headers.getInt(APNS_EXPIRATION_HEADER);
@@ -230,6 +254,7 @@ public abstract class ParsingMockApnsServerListenerAdapter implements MockApnsSe
                 payload != null ? payload.toString(StandardCharsets.UTF_8) : null,
                 expiration,
                 deliveryPriority,
+                pushType,
                 collapseId,
                 apnsId);
     }
diff --git a/pushy/src/main/java/com/turo/pushy/apns/server/RejectionReason.java b/pushy/src/main/java/com/turo/pushy/apns/server/RejectionReason.java
index a3e24a94..5efd150c 100644
--- a/pushy/src/main/java/com/turo/pushy/apns/server/RejectionReason.java
+++ b/pushy/src/main/java/com/turo/pushy/apns/server/RejectionReason.java
@@ -38,6 +38,7 @@ public enum RejectionReason {
     DEVICE_TOKEN_NOT_FOR_TOPIC("DeviceTokenNotForTopic", HttpResponseStatus.BAD_REQUEST),
     DUPLICATE_HEADERS("DuplicateHeaders", HttpResponseStatus.BAD_REQUEST),
     IDLE_TIMEOUT("IdleTimeout", HttpResponseStatus.BAD_REQUEST),
+    INVALID_PUSH_TYPE("InvalidPushType", HttpResponseStatus.BAD_REQUEST),
     MISSING_DEVICE_TOKEN("MissingDeviceToken", HttpResponseStatus.BAD_REQUEST),
     MISSING_TOPIC("MissingTopic", HttpResponseStatus.BAD_REQUEST),
     PAYLOAD_EMPTY("PayloadEmpty", HttpResponseStatus.BAD_REQUEST),
diff --git a/pushy/src/main/java/com/turo/pushy/apns/server/ValidatingPushNotificationHandler.java b/pushy/src/main/java/com/turo/pushy/apns/server/ValidatingPushNotificationHandler.java
index b9fb4fe0..72845876 100644
--- a/pushy/src/main/java/com/turo/pushy/apns/server/ValidatingPushNotificationHandler.java
+++ b/pushy/src/main/java/com/turo/pushy/apns/server/ValidatingPushNotificationHandler.java
@@ -24,6 +24,7 @@ package com.turo.pushy.apns.server;
 
 import com.eatthepath.uuid.FastUUID;
 import com.turo.pushy.apns.DeliveryPriority;
+import com.turo.pushy.apns.PushType;
 import io.netty.buffer.ByteBuf;
 import io.netty.handler.codec.http.HttpMethod;
 import io.netty.handler.codec.http2.Http2Headers;
@@ -46,6 +47,7 @@ abstract class ValidatingPushNotificationHandler implements PushNotificationHand
     private static final AsciiString APNS_PRIORITY_HEADER = new AsciiString("apns-priority");
     private static final AsciiString APNS_ID_HEADER = new AsciiString("apns-id");
     private static final AsciiString APNS_COLLAPSE_ID_HEADER = new AsciiString("apns-collapse-id");
+    private static final AsciiString APNS_PUSH_TYPE_HEADER = new AsciiString("apns-push-type");
 
     private static final Pattern DEVICE_TOKEN_PATTERN = Pattern.compile("[0-9a-fA-F]{64}");
 
@@ -105,6 +107,18 @@ abstract class ValidatingPushNotificationHandler implements PushNotificationHand
             }
         }
 
+        {
+            final CharSequence pushTypeSequence = headers.get(APNS_PUSH_TYPE_HEADER);
+
+            if (pushTypeSequence != null) {
+                try {
+                    PushType.getFromHeaderValue(pushTypeSequence);
+                } catch (final IllegalArgumentException e) {
+                    throw new RejectedNotificationException(RejectionReason.INVALID_PUSH_TYPE);
+                }
+            }
+        }
+
         {
             final CharSequence pathSequence = headers.get(Http2Headers.PseudoHeaderName.PATH.value());
 
diff --git a/pushy/src/main/java/com/turo/pushy/apns/util/SimpleApnsPushNotification.java b/pushy/src/main/java/com/turo/pushy/apns/util/SimpleApnsPushNotification.java
index 6d122553..8facc305 100644
--- a/pushy/src/main/java/com/turo/pushy/apns/util/SimpleApnsPushNotification.java
+++ b/pushy/src/main/java/com/turo/pushy/apns/util/SimpleApnsPushNotification.java
@@ -25,13 +25,13 @@ package com.turo.pushy.apns.util;
 import com.eatthepath.uuid.FastUUID;
 import com.turo.pushy.apns.ApnsPushNotification;
 import com.turo.pushy.apns.DeliveryPriority;
+import com.turo.pushy.apns.PushType;
 
 import java.util.Date;
 import java.util.Objects;
 import java.util.UUID;
 import java.util.concurrent.TimeUnit;
 
-
 /**
  * A simple and immutable implementation of the {@link ApnsPushNotification} interface.
  *
@@ -45,6 +45,7 @@ public class SimpleApnsPushNotification implements ApnsPushNotification {
     private final String payload;
     private final Date invalidationTime;
     private final DeliveryPriority priority;
+    private final PushType pushType;
     private final String topic;
     private final String collapseId;
     private final UUID apnsId;
@@ -58,7 +59,8 @@ public class SimpleApnsPushNotification implements ApnsPushNotification {
      * Constructs a new push notification with the given token, topic, and payload. A default expiration time is set for
      * the notification; callers that require immediate expiration or a non-default expiration period should use a
      * constructor that accepts an expiration time as an argument. An "immediate" delivery priority is used for the
-     * notification, and as such the payload should contain an alert, sound, or badge component.
+     * notification, and as such the payload should contain an alert, sound, or badge component. No push notification
+     * type is specified.
      *
      * @param token the device token to which this push notification should be delivered
      * @param topic the topic to which this notification should be sent
@@ -68,13 +70,13 @@ public class SimpleApnsPushNotification implements ApnsPushNotification {
      * @see #DEFAULT_EXPIRATION_PERIOD_MILLIS
      */
     public SimpleApnsPushNotification(final String token, final String topic, final String payload) {
-        this(token, topic, payload, new Date(System.currentTimeMillis() + DEFAULT_EXPIRATION_PERIOD_MILLIS), DeliveryPriority.IMMEDIATE, null, null);
+        this(token, topic, payload, new Date(System.currentTimeMillis() + DEFAULT_EXPIRATION_PERIOD_MILLIS), DeliveryPriority.IMMEDIATE, null, null, null);
     }
 
     /**
      * Constructs a new push notification with the given token, topic, payload, and expiration time. An "immediate"
      * delivery priority is used for the notification, and as such the payload should contain an alert, sound, or badge
-     * component.
+     * component. No push notification type is specified.
      *
      * @param token the device token to which this push notification should be delivered
      * @param topic the topic to which this notification should be sent
@@ -85,12 +87,12 @@ public class SimpleApnsPushNotification implements ApnsPushNotification {
      * @see DeliveryPriority#IMMEDIATE
      */
     public SimpleApnsPushNotification(final String token, final String topic, final String payload, final Date invalidationTime) {
-        this(token, topic, payload, invalidationTime, DeliveryPriority.IMMEDIATE, null, null);
+        this(token, topic, payload, invalidationTime, DeliveryPriority.IMMEDIATE, null, null, null);
     }
 
     /**
      * Constructs a new push notification with the given token, topic, payload, delivery expiration time, and delivery
-     * priority.
+     * priority. No push notification type is specified.
      *
      * @param token the device token to which this push notification should be delivered
      * @param topic the topic to which this notification should be sent
@@ -100,12 +102,28 @@ public class SimpleApnsPushNotification implements ApnsPushNotification {
      * @param priority the priority with which this notification should be delivered to the receiving device
      */
     public SimpleApnsPushNotification(final String token, final String topic, final String payload, final Date invalidationTime, final DeliveryPriority priority) {
-        this(token, topic, payload, invalidationTime, priority, null, null);
+        this(token, topic, payload, invalidationTime, priority, null, null, null);
+    }
+
+    /**
+     * Constructs a new push notification with the given token, topic, payload, delivery expiration time, delivery
+     * priority, and push notification type.
+     *
+     * @param token the device token to which this push notification should be delivered
+     * @param topic the topic to which this notification should be sent
+     * @param payload the payload to include in this push notification
+     * @param invalidationTime the time at which Apple's servers should stop trying to deliver this message; if
+     * {@code null}, no delivery attempts beyond the first will be made
+     * @param pushType the type of notification to be sent
+     * @param priority the priority with which this notification should be delivered to the receiving device
+     */
+    public SimpleApnsPushNotification(final String token, final String topic, final String payload, final Date invalidationTime, final DeliveryPriority priority, PushType pushType) {
+        this(token, topic, payload, invalidationTime, priority, pushType, null, null);
     }
 
     /**
      * Constructs a new push notification with the given token, topic, payload, delivery expiration time, delivery
-     * priority, and "collapse identifier."
+     * priority, and "collapse identifier." No push notification type is specified.
      *
      * @param token the device token to which this push notification should be delivered; must not be {@code null}
      * @param topic the topic to which this notification should be sent; must not be {@code null}
@@ -117,7 +135,24 @@ public class SimpleApnsPushNotification implements ApnsPushNotification {
      * by other notifications with the same collapse identifier
      */
     public SimpleApnsPushNotification(final String token, final String topic, final String payload, final Date invalidationTime, final DeliveryPriority priority, final String collapseId) {
-        this(token, topic, payload, invalidationTime, priority, collapseId, null);
+        this(token, topic, payload, invalidationTime, priority, null, collapseId, null);
+    }
+
+    /**
+     * Constructs a new push notification with the given token, topic, payload, delivery expiration time, delivery
+     * priority, push notification type, and "collapse identifier."
+     *
+     * @param token the device token to which this push notification should be delivered; must not be {@code null}
+     * @param topic the topic to which this notification should be sent; must not be {@code null}
+     * @param payload the payload to include in this push notification; must not be {@code null}
+     * @param invalidationTime the time at which Apple's servers should stop trying to deliver this message; if
+     * {@code null}, no delivery attempts beyond the first will be made
+     * @param priority the priority with which this notification should be delivered to the receiving device
+     * @param collapseId the "collapse identifier" for this notification, which allows it to supersede or be superseded
+     * by other notifications with the same collapse identifier
+     */
+    public SimpleApnsPushNotification(final String token, final String topic, final String payload, final Date invalidationTime, final DeliveryPriority priority, final PushType pushType, final String collapseId) {
+        this(token, topic, payload, invalidationTime, priority, pushType, collapseId, null);
     }
 
     /**
@@ -136,19 +171,37 @@ public class SimpleApnsPushNotification implements ApnsPushNotification {
      * will assign a unique identifier automatically
      */
     public SimpleApnsPushNotification(final String token, final String topic, final String payload, final Date invalidationTime, final DeliveryPriority priority, final String collapseId, final UUID apnsId) {
-        Objects.requireNonNull(token, "Destination device token must not be null.");
-        Objects.requireNonNull(topic, "Destination topic must not be null.");
-        Objects.requireNonNull(payload, "Payload must not be null.");
+        this(token, topic, payload, invalidationTime, priority, null, collapseId, apnsId);
+    }
 
-        this.token = token;
-        this.payload = payload;
+    /**
+     * Constructs a new push notification with the given token, topic, payload, delivery expiration time, delivery
+     * priority, "collapse identifier," and unique push notification identifier.
+     *
+     * @param token the device token to which this push notification should be delivered; must not be {@code null}
+     * @param topic the topic to which this notification should be sent; must not be {@code null}
+     * @param payload the payload to include in this push notification; must not be {@code null}
+     * @param invalidationTime the time at which Apple's servers should stop trying to deliver this message; if
+     * {@code null}, no delivery attempts beyond the first will be made
+     * @param priority the priority with which this notification should be delivered to the receiving device
+     * @param pushType the type of push notification to be delivered
+     * @param collapseId the "collapse identifier" for this notification, which allows it to supersede or be superseded
+     * by other notifications with the same collapse identifier
+     * @param apnsId the unique identifier for this notification; may be {@code null}, in which case the APNs server
+     * will assign a unique identifier automatically
+     */
+    public SimpleApnsPushNotification(final String token, final String topic, final String payload, final Date invalidationTime, final DeliveryPriority priority, final PushType pushType, final String collapseId, final UUID apnsId) {
+        this.token = Objects.requireNonNull(token, "Destination device token must not be null.");
+        this.topic = Objects.requireNonNull(topic, "Destination topic must not be null.");
+        this.payload = Objects.requireNonNull(payload, "Payload must not be null.");
         this.invalidationTime = invalidationTime;
         this.priority = priority;
-        this.topic = topic;
+        this.pushType = pushType;
         this.collapseId = collapseId;
         this.apnsId = apnsId;
     }
 
+
     /**
      * Returns the token of the device to which this push notification should be delivered.
      *
@@ -189,6 +242,20 @@ public class SimpleApnsPushNotification implements ApnsPushNotification {
         return this.priority;
     }
 
+    /**
+     * Returns the display type this push notification. Note that push notification display types are required in iOS 13
+     * and later and watchOS 6 and later, but are ignored under earlier versions of either operating system. May be
+     * {@code null}.
+     *
+     * @return the display type this push notification
+     *
+     * @since 0.13.9
+     */
+    @Override
+    public PushType getPushType() {
+        return this.pushType;
+    }
+
     /**
      * Returns the topic to which this push notification should be sent.
      *
@@ -233,6 +300,7 @@ public class SimpleApnsPushNotification implements ApnsPushNotification {
         result = prime * result + ((this.invalidationTime == null) ? 0 : this.invalidationTime.hashCode());
         result = prime * result + ((this.payload == null) ? 0 : this.payload.hashCode());
         result = prime * result + ((this.priority == null) ? 0 : this.priority.hashCode());
+        result = prime * result + ((this.pushType == null) ? 0 : this.pushType.hashCode());
         result = prime * result + ((this.token == null) ? 0 : this.token.hashCode());
         result = prime * result + ((this.topic == null) ? 0 : this.topic.hashCode());
         result = prime * result + ((this.collapseId == null) ? 0 : this.collapseId.hashCode());
@@ -272,6 +340,9 @@ public class SimpleApnsPushNotification implements ApnsPushNotification {
         if (this.priority != other.priority) {
             return false;
         }
+        if (this.pushType != other.pushType) {
+            return false;
+        }
         if (this.token == null) {
             if (other.token != null) {
                 return false;
@@ -313,6 +384,7 @@ public class SimpleApnsPushNotification implements ApnsPushNotification {
                 ", payload='" + payload + '\'' +
                 ", invalidationTime=" + invalidationTime +
                 ", priority=" + priority +
+                ", pushType=" + pushType +
                 ", topic='" + topic + '\'' +
                 ", collapseId='" + collapseId + '\'' +
                 ", apnsId=" + (apnsId != null ? FastUUID.toString(apnsId) : null) +


Test patch:
diff --git a/pushy/src/test/java/com/turo/pushy/apns/ApnsClientTest.java b/pushy/src/test/java/com/turo/pushy/apns/ApnsClientTest.java
index dfd354e0..40bed831 100644
--- a/pushy/src/test/java/com/turo/pushy/apns/ApnsClientTest.java
+++ b/pushy/src/test/java/com/turo/pushy/apns/ApnsClientTest.java
@@ -50,7 +50,7 @@ import static org.junit.Assert.*;
 @RunWith(JUnitParamsRunner.class)
 public class ApnsClientTest extends AbstractClientServerTest {
 
-    private static class TestMetricsListener implements ApnsClientMetricsListener {
+    private static class TestClientMetricsListener implements ApnsClientMetricsListener {
 
         private final List<Long> writeFailures = new ArrayList<>();
         private final List<Long> sentNotifications = new ArrayList<>();
@@ -175,6 +175,36 @@ public class ApnsClientTest extends AbstractClientServerTest {
         }
     }
 
+    private static class TestParsingServerHandler extends ParsingMockApnsServerListenerAdapter {
+
+        private final List<ApnsPushNotification> acceptedNotifications = new ArrayList<>();
+        private final List<ApnsPushNotification> rejectedNotifications = new ArrayList<>();
+
+        @Override
+        public void handlePushNotificationAccepted(final ApnsPushNotification pushNotification) {
+            synchronized (this.acceptedNotifications) {
+                this.acceptedNotifications.add(pushNotification);
+                this.acceptedNotifications.notifyAll();
+            }
+        }
+
+        @Override
+        public void handlePushNotificationRejected(final ApnsPushNotification pushNotification, final RejectionReason rejectionReason, final Date deviceTokenExpirationTimestamp) {
+            synchronized (this.rejectedNotifications) {
+                this.rejectedNotifications.add(pushNotification);
+                this.rejectedNotifications.notifyAll();
+            }
+        }
+
+        void waitForNonZeroAcceptedNotifications() throws InterruptedException {
+            synchronized (this.acceptedNotifications) {
+                while (this.acceptedNotifications.isEmpty()) {
+                    this.acceptedNotifications.wait();
+                }
+            }
+        }
+    }
+
     @Test
     public void testApnsClientWithManagedEventLoopGroup() throws Exception {
         final ApnsClient managedGroupClient = new ApnsClientBuilder()
@@ -308,7 +338,7 @@ public class ApnsClientTest extends AbstractClientServerTest {
 
         final MockApnsServer server = this.buildServer(expireFirstTokenHandlerFactory);
 
-        final TestMetricsListener metricsListener = new TestMetricsListener();
+        final TestClientMetricsListener metricsListener = new TestClientMetricsListener();
         final ApnsClient client = this.buildTokenAuthenticationClient(metricsListener);
 
         try {
@@ -507,7 +537,7 @@ public class ApnsClientTest extends AbstractClientServerTest {
     public void testAcceptedNotificationAndAddedConnectionMetrics(final boolean useTokenAuthentication) throws Exception {
         final MockApnsServer server = this.buildServer(new AcceptAllPushNotificationHandlerFactory());
 
-        final TestMetricsListener metricsListener = new TestMetricsListener();
+        final TestClientMetricsListener metricsListener = new TestClientMetricsListener();
         final ApnsClient client = useTokenAuthentication ?
                 this.buildTokenAuthenticationClient(metricsListener) : this.buildTlsAuthenticationClient(metricsListener);
 
@@ -551,7 +581,7 @@ public class ApnsClientTest extends AbstractClientServerTest {
 
         final MockApnsServer server = this.buildServer(handlerFactory);
 
-        final TestMetricsListener metricsListener = new TestMetricsListener();
+        final TestClientMetricsListener metricsListener = new TestClientMetricsListener();
         final ApnsClient client = useTokenAuthentication ?
                 this.buildTokenAuthenticationClient(metricsListener) : this.buildTlsAuthenticationClient(metricsListener);
 
@@ -577,7 +607,7 @@ public class ApnsClientTest extends AbstractClientServerTest {
     @Test
     @Parameters({"true", "false"})
     public void testFailedConnectionAndWriteFailureMetrics(final boolean useTokenAuthentication) throws Exception {
-        final TestMetricsListener metricsListener = new TestMetricsListener();
+        final TestClientMetricsListener metricsListener = new TestClientMetricsListener();
 
         final ApnsClient client = useTokenAuthentication ?
                 this.buildTokenAuthenticationClient(metricsListener) : this.buildTlsAuthenticationClient(metricsListener);
@@ -643,7 +673,7 @@ public class ApnsClientTest extends AbstractClientServerTest {
                 client.sendNotification(pushNotification).addListener(new PushNotificationResponseListener<SimpleApnsPushNotification>() {
 
                     @Override
-                    public void operationComplete(final PushNotificationFuture<SimpleApnsPushNotification, PushNotificationResponse<SimpleApnsPushNotification>> simpleApnsPushNotificationPushNotificationResponsePushNotificationFuture) throws Exception {
+                    public void operationComplete(final PushNotificationFuture<SimpleApnsPushNotification, PushNotificationResponse<SimpleApnsPushNotification>> simpleApnsPushNotificationPushNotificationResponsePushNotificationFuture) {
                         countDownLatch.countDown();
                     }
                 });
@@ -656,4 +686,47 @@ public class ApnsClientTest extends AbstractClientServerTest {
             client.close().await();
         }
     }
+
+    @Test
+    @Parameters(method = "getParametersForTestSendNotificationWithPushTypeHeader")
+    public void testSendNotificationWithPushTypeHeader(final PushType pushType) throws Exception {
+        final ValidatingPushNotificationHandlerFactory handlerFactory = new ValidatingPushNotificationHandlerFactory(
+                DEVICE_TOKENS_BY_TOPIC, EXPIRATION_TIMESTAMPS_BY_DEVICE_TOKEN, this.verificationKeysByKeyId,
+                this.topicsByVerificationKey);
+
+        final TestParsingServerHandler parsingServerHandler = new TestParsingServerHandler();
+
+        final MockApnsServer server = this.buildServer(handlerFactory, parsingServerHandler);
+
+        final ApnsClient client = this.buildTokenAuthenticationClient();
+
+        try {
+            server.start(PORT).await();
+
+            final SimpleApnsPushNotification pushNotification =
+                    new SimpleApnsPushNotification(DEVICE_TOKEN, TOPIC, PAYLOAD, null, DeliveryPriority.IMMEDIATE, pushType);
+
+            final PushNotificationResponse<SimpleApnsPushNotification> response =
+                    client.sendNotification(pushNotification).get();
+
+            assertTrue("Clients must send notifications that conform to the APNs protocol specification.",
+                    response.isAccepted());
+
+            parsingServerHandler.waitForNonZeroAcceptedNotifications();
+
+            assertEquals(pushType, parsingServerHandler.acceptedNotifications.get(0).getPushType());
+        } finally {
+            client.close().await();
+            server.shutdown().await();
+        }
+    }
+
+    @SuppressWarnings("unused")
+    private Object getParametersForTestSendNotificationWithPushTypeHeader() {
+        return new Object[] {
+                null,
+                PushType.BACKGROUND,
+                PushType.ALERT
+        };
+    }
 }
diff --git a/pushy/src/test/java/com/turo/pushy/apns/server/MockApnsServerTest.java b/pushy/src/test/java/com/turo/pushy/apns/server/MockApnsServerTest.java
index 73957140..3fb7e6b9 100644
--- a/pushy/src/test/java/com/turo/pushy/apns/server/MockApnsServerTest.java
+++ b/pushy/src/test/java/com/turo/pushy/apns/server/MockApnsServerTest.java
@@ -329,7 +329,7 @@ public class MockApnsServerTest extends AbstractClientServerTest {
                 final UUID apnsId = UUID.randomUUID();
 
                 final SimpleApnsPushNotification pushNotificationWithApnsId =
-                        new SimpleApnsPushNotification(DEVICE_TOKEN, TOPIC, PAYLOAD, null, DeliveryPriority.IMMEDIATE, null, apnsId);
+                        new SimpleApnsPushNotification(DEVICE_TOKEN, TOPIC, PAYLOAD, null, DeliveryPriority.IMMEDIATE, PushType.ALERT, null, apnsId);
 
                 final PushNotificationResponse<SimpleApnsPushNotification> response =
                         client.sendNotification(pushNotificationWithApnsId).get();
@@ -379,7 +379,7 @@ public class MockApnsServerTest extends AbstractClientServerTest {
                 final UUID apnsId = UUID.randomUUID();
 
                 final SimpleApnsPushNotification pushNotificationWithApnsId =
-                        new SimpleApnsPushNotification(DEVICE_TOKEN, TOPIC, PAYLOAD, null, DeliveryPriority.IMMEDIATE, null, apnsId);
+                        new SimpleApnsPushNotification(DEVICE_TOKEN, TOPIC, PAYLOAD, null, DeliveryPriority.IMMEDIATE, PushType.ALERT, null, apnsId);
 
                 final PushNotificationResponse<SimpleApnsPushNotification> response =
                         client.sendNotification(pushNotificationWithApnsId).get();
diff --git a/pushy/src/test/java/com/turo/pushy/apns/util/SimpleApnsPushNotificationTest.java b/pushy/src/test/java/com/turo/pushy/apns/util/SimpleApnsPushNotificationTest.java
index 481050bc..f3b456dc 100644
--- a/pushy/src/test/java/com/turo/pushy/apns/util/SimpleApnsPushNotificationTest.java
+++ b/pushy/src/test/java/com/turo/pushy/apns/util/SimpleApnsPushNotificationTest.java
@@ -23,7 +23,7 @@
 package com.turo.pushy.apns.util;
 
 import com.turo.pushy.apns.DeliveryPriority;
-import org.junit.Assert;
+import com.turo.pushy.apns.PushType;
 import org.junit.Test;
 
 import java.util.Date;
@@ -48,7 +48,8 @@ public class SimpleApnsPushNotificationTest {
         assertEquals(topic, pushNotification.getTopic());
         assertEquals(payload, pushNotification.getPayload());
         assertTrue(pushNotification.getExpiration().after(now));
-        Assert.assertEquals(DeliveryPriority.IMMEDIATE, pushNotification.getPriority());
+        assertEquals(DeliveryPriority.IMMEDIATE, pushNotification.getPriority());
+        assertNull(pushNotification.getPushType());
         assertNull(pushNotification.getCollapseId());
     }
 
@@ -82,6 +83,7 @@ public class SimpleApnsPushNotificationTest {
         assertEquals(payload, pushNotification.getPayload());
         assertEquals(expiration, pushNotification.getExpiration());
         assertEquals(DeliveryPriority.IMMEDIATE, pushNotification.getPriority());
+        assertNull(pushNotification.getPushType());
         assertNull(pushNotification.getCollapseId());
         assertNull(pushNotification.getApnsId());
     }
@@ -102,6 +104,29 @@ public class SimpleApnsPushNotificationTest {
         assertEquals(payload, pushNotification.getPayload());
         assertEquals(expiration, pushNotification.getExpiration());
         assertEquals(priority, pushNotification.getPriority());
+        assertNull(pushNotification.getPushType());
+        assertNull(pushNotification.getCollapseId());
+        assertNull(pushNotification.getApnsId());
+    }
+
+    @Test
+    public void testSimpleApnsPushNotificationTokenTopicPayloadExpirationPriorityType() {
+        final String token = "test-token";
+        final String topic = "test-topic";
+        final String payload = "{\"test\": true}";
+        final Date expiration = new Date();
+        final DeliveryPriority priority = DeliveryPriority.CONSERVE_POWER;
+        final PushType pushType = PushType.BACKGROUND;
+
+        final SimpleApnsPushNotification pushNotification =
+                new SimpleApnsPushNotification(token, topic, payload, expiration, priority, pushType);
+
+        assertEquals(token, pushNotification.getToken());
+        assertEquals(topic, pushNotification.getTopic());
+        assertEquals(payload, pushNotification.getPayload());
+        assertEquals(expiration, pushNotification.getExpiration());
+        assertEquals(priority, pushNotification.getPriority());
+        assertEquals(pushType, pushNotification.getPushType());
         assertNull(pushNotification.getCollapseId());
         assertNull(pushNotification.getApnsId());
     }
@@ -123,28 +148,54 @@ public class SimpleApnsPushNotificationTest {
         assertEquals(payload, pushNotification.getPayload());
         assertEquals(expiration, pushNotification.getExpiration());
         assertEquals(priority, pushNotification.getPriority());
+        assertNull(pushNotification.getPushType());
+        assertEquals(collapseId, pushNotification.getCollapseId());
+        assertNull(pushNotification.getApnsId());
+    }
+
+    @Test
+    public void testSimpleApnsPushNotificationTokenTopicPayloadExpirationPriorityTypeCollapseId() {
+        final String token = "test-token";
+        final String topic = "test-topic";
+        final String payload = "{\"test\": true}";
+        final Date expiration = new Date();
+        final DeliveryPriority priority = DeliveryPriority.CONSERVE_POWER;
+        final PushType pushType = PushType.BACKGROUND;
+        final String collapseId = "test-collapse-id";
+
+        final SimpleApnsPushNotification pushNotification =
+                new SimpleApnsPushNotification(token, topic, payload, expiration, priority, pushType, collapseId);
+
+        assertEquals(token, pushNotification.getToken());
+        assertEquals(topic, pushNotification.getTopic());
+        assertEquals(payload, pushNotification.getPayload());
+        assertEquals(expiration, pushNotification.getExpiration());
+        assertEquals(priority, pushNotification.getPriority());
+        assertEquals(pushType, pushNotification.getPushType());
         assertEquals(collapseId, pushNotification.getCollapseId());
         assertNull(pushNotification.getApnsId());
     }
 
     @Test
-    public void testSimpleApnsPushNotificationTokenTopicPayloadExpirationPriorityCollapseIdApnsId() {
+    public void testSimpleApnsPushNotificationTokenTopicPayloadExpirationPriorityTypeCollapseIdApnsId() {
         final String token = "test-token";
         final String topic = "test-topic";
         final String payload = "{\"test\": true}";
         final Date expiration = new Date();
         final DeliveryPriority priority = DeliveryPriority.CONSERVE_POWER;
+        final PushType pushType = PushType.BACKGROUND;
         final String collapseId = "test-collapse-id";
         final UUID apnsId = UUID.randomUUID();
 
         final SimpleApnsPushNotification pushNotification =
-                new SimpleApnsPushNotification(token, topic, payload, expiration, priority, collapseId, apnsId);
+                new SimpleApnsPushNotification(token, topic, payload, expiration, priority, pushType, collapseId, apnsId);
 
         assertEquals(token, pushNotification.getToken());
         assertEquals(topic, pushNotification.getTopic());
         assertEquals(payload, pushNotification.getPayload());
         assertEquals(expiration, pushNotification.getExpiration());
         assertEquals(priority, pushNotification.getPriority());
+        assertEquals(pushType, pushNotification.getPushType());
         assertEquals(collapseId, pushNotification.getCollapseId());
         assertEquals(apnsId, pushNotification.getApnsId());
     }