Repository: jchambers/pushy (java)

Issue:
Provide alertTitle and localizedAlertTitle in ApnsPayloadBuilder
Is there any specific reason why setting the alertTitle on https://github.com/jchambers/pushy/blob/master/pushy/src/main/java/com/eatthepath/pushy/apns/util/ApnsPayloadBuilder.java wipes out the localizedAlertTitle and vice versa? According to our tests, Apple seems to support providing both at the same time. It is important to us to provide both in order to keep our server compatible to older versions of the app.

Best,
Daniel

Pull request description:
Allow for literal and localized content in the same payload
As an alternative to #851, this alters the behavior of methods in `ApnsPayloadBuilder` that set literal or localized content such that literal/localized content are no longer mutually-exclusive.

This closes #799.

Patch:
diff --git a/pushy/pom.xml b/pushy/pom.xml
index f3ba0804..dbee3990 100644
--- a/pushy/pom.xml
+++ b/pushy/pom.xml
@@ -113,15 +113,16 @@
             <plugin>
                 <groupId>org.apache.felix</groupId>
                 <artifactId>maven-bundle-plugin</artifactId>
+                <version>5.1.2</version>
                 <extensions>true</extensions>
                 <configuration>
                     <instructions>
                         <Import-Package>
                             <!-- Classes from the following packages are only loaded using Class.forName(), so BND can not detect they are required imports. -->
                             io.netty.channel.socket.nio,
-			    <!-- Avoid auto-import of exported packages -->
-			    !com.eatthepath.json,
-			    !com.eatthepath.pushy.*,
+                            <!-- Avoid auto-import of exported packages -->
+                            !com.eatthepath.json,
+                            !com.eatthepath.pushy.*,
                             <!-- This tells BND to add all other required imports that it does detect, as normal. -->
                             *,
                         </Import-Package>
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 a1204626..b18da073 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
@@ -72,6 +72,8 @@ public abstract class ApnsPayloadBuilder {
     private String summaryArgument = null;
     private Integer summaryArgumentCount = null;
 
+    private InterruptionLevel interruptionLevel = null;
+
     private String[] urlArguments = null;
 
     private final HashMap<String, Object> customProperties = new HashMap<>();
@@ -90,6 +92,7 @@ public abstract class ApnsPayloadBuilder {
     private static final String SUMMARY_ARGUMENT_KEY = "summary-arg";
     private static final String SUMMARY_ARGUMENT_COUNT_KEY = "summary-arg-count";
     private static final String URL_ARGS_KEY = "url-args";
+    private static final String INTERRUPTION_LEVEL_KEY = "interruption-level";
 
     private static final String ALERT_TITLE_KEY = "title";
     private static final String ALERT_TITLE_LOC_KEY = "title-loc-key";
@@ -113,7 +116,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"; 
 
     /**
     /**
@@ -135,10 +138,8 @@ public abstract class ApnsPayloadBuilder {
     }
 
     /**
-     * <p>Sets the literal text of the alert message to be shown for the push notification. Clears any previously-set
-     * localized alert message key and arguments.</p>
-     *
-     * <p>By default, no message is shown.</p>
+     * Sets the literal text of the alert message to be shown for the push notification. By default, no message is
+     * shown.
      *
      * @param alertBody the literal message to be shown for this push notification
      *
@@ -148,17 +149,13 @@ public abstract class ApnsPayloadBuilder {
      */
     public ApnsPayloadBuilder setAlertBody(final String alertBody) {
         this.alertBody = alertBody;
-
-        this.localizedAlertKey = null;
-        this.localizedAlertArguments = null;
-
         return this;
     }
 
     /**
      * <p>Sets the key of a message in the receiving app's localized string list to be shown for the push notification.
      * The message in the app's string list may optionally have placeholders, which will be populated by values from the
-     * given {@code alertArguments}. Clears any previously-set literal alert body.</p>
+     * given {@code alertArguments}.</p>
      *
      * <p>By default, no message is shown.</p>
      *
@@ -173,15 +170,12 @@ public abstract class ApnsPayloadBuilder {
         this.localizedAlertKey = localizedAlertKey;
         this.localizedAlertArguments = (alertArguments != null && alertArguments.length > 0) ? alertArguments : null;
 
-        this.alertBody = null;
-
         return this;
     }
 
     /**
-     * <p>Sets a short description of the notification purpose. Clears any previously-set localized title key and
-     * arguments. The Apple Watch will display the title as part of the notification. According to Apple's
-     * documentation, this should be:</p>
+     * <p>Sets a short description of the notification purpose. The Apple Watch will display the title as part of the
+     * notification. According to Apple's documentation, this should be:</p>
      *
      * <blockquote>A short string describing the purpose of the notification. Apple Watch displays this string as part
      * of the notification interface. This string is displayed only briefly and should be crafted so that it can be
@@ -197,17 +191,13 @@ public abstract class ApnsPayloadBuilder {
      */
     public ApnsPayloadBuilder setAlertTitle(final String alertTitle) {
         this.alertTitle = alertTitle;
-
-        this.localizedAlertTitleKey = null;
-        this.localizedAlertTitleArguments = null;
-
         return this;
     }
 
     /**
      * <p>Sets the key of the title string in the receiving app's localized string list to be shown for the push
-     * notification. Clears any previously-set literal alert title. The message in the app's string list may optionally
-     * have placeholders, which will be populated by values from the given {@code alertArguments}.</p>
+     * notification. The message in the app's string list may optionally have placeholders, which will be populated by
+     * values from the given {@code alertArguments}.</p>
      *
      * @param localizedAlertTitleKey a key to a string in the receiving app's localized string list
      * @param alertTitleArguments arguments to populate placeholders in the localized alert string; may be {@code null}
@@ -218,15 +208,11 @@ public abstract class ApnsPayloadBuilder {
         this.localizedAlertTitleKey = localizedAlertTitleKey;
         this.localizedAlertTitleArguments = (alertTitleArguments != null && alertTitleArguments.length > 0) ? alertTitleArguments : null;
 
-        this.alertTitle = null;
-
         return this;
     }
 
     /**
-     * <p>Sets a subtitle for the notification. Clears any previously-set localized subtitle key and arguments.</p>
-     *
-     * <p>By default, no subtitle is included. Requires iOS 10 or newer.</p>
+     * <p>Sets a subtitle for the notification. By default, no subtitle is included. Requires iOS 10 or newer.
      *
      * @param alertSubtitle the subtitle for this push notification
      *
@@ -237,16 +223,13 @@ public abstract class ApnsPayloadBuilder {
     public ApnsPayloadBuilder setAlertSubtitle(final String alertSubtitle) {
         this.alertSubtitle = alertSubtitle;
 
-        this.localizedAlertSubtitleKey = null;
-        this.localizedAlertSubtitleArguments = null;
-
         return this;
     }
 
     /**
      * <p>Sets the key of the subtitle string in the receiving app's localized string list to be shown for the push
-     * notification. Clears any previously-set literal subtitle. The message in the app's string list may optionally
-     * have placeholders, which will be populated by values from the given {@code alertSubtitleArguments}.</p>
+     * notification. The message in the app's string list may optionally have placeholders, which will be populated by
+     * values from the given {@code alertSubtitleArguments}.</p>
      *
      * <p>By default, no subtitle is included. Requires iOS 10 or newer.</p>
      *
@@ -262,8 +245,6 @@ public abstract class ApnsPayloadBuilder {
         this.localizedAlertSubtitleKey = localizedAlertSubtitleKey;
         this.localizedAlertSubtitleArguments = (alertSubtitleArguments != null && alertSubtitleArguments.length > 0) ? alertSubtitleArguments : null;
 
-        this.alertSubtitle = null;
-
         return this;
     }
 
@@ -605,6 +586,26 @@ public abstract class ApnsPayloadBuilder {
         return this;
     }
 
+    /**
+     * <p>Sets the interruption level for this notification. By default, no interruption level is included in the
+     * payload and a default of {@link InterruptionLevel#ACTIVE} is assumed by the receiving device.</p>
+     *
+     * <p>Interruption levels are supported in iOS 15 and newer.</p>
+     * 
+     * @param interruptionLevel the interruption level for this notification
+     *
+     * @return a reference to this payload builder
+     *
+     * @see <a href="https://developer.apple.com/design/human-interface-guidelines/ios/system-capabilities/notifications/">Human
+     * Interface Guidelines: Notifications</a>
+     *
+     * @since 0.15
+     */
+    public ApnsPayloadBuilder setInterruptionLevel(final InterruptionLevel interruptionLevel) {
+        this.interruptionLevel = interruptionLevel;
+        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
@@ -729,6 +730,10 @@ public abstract class ApnsPayloadBuilder {
                 aps.put(URL_ARGS_KEY, this.urlArguments);
             }
 
+            if (this.interruptionLevel != null) {
+                aps.put(INTERRUPTION_LEVEL_KEY, this.interruptionLevel.getValue());
+            }
+
             final Map<String, Object> alert = new HashMap<>();
             {
                 if (this.alertBody != null) {
diff --git a/pushy/src/main/java/com/eatthepath/pushy/apns/util/InterruptionLevel.java b/pushy/src/main/java/com/eatthepath/pushy/apns/util/InterruptionLevel.java
new file mode 100644
index 00000000..366ee5d2
--- /dev/null
+++ b/pushy/src/main/java/com/eatthepath/pushy/apns/util/InterruptionLevel.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2021 Jon Chambers
+ *
+ * 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.eatthepath.pushy.apns.util;
+
+/**
+ * An enumeration of interruption levels that may be specified in a notification payload. Interruption levels are
+ * supported in iOS 15 and newer.
+ *
+ * @see <a href="https://developer.apple.com/design/human-interface-guidelines/ios/system-capabilities/notifications/">Human
+ * Interface Guidelines: Notifications</a>
+ *
+ * @since 0.15
+ */
+public enum InterruptionLevel {
+    /**
+     * According to Apple's Human Interface Guidelines, a {@code passive} notification contains "information people can
+     * view at their leisure, like a restaurant recommendation."
+     */
+    PASSIVE("passive"),
+
+    /**
+     * According to Apple's Human Interface Guidelines, an {@code active} notification contains "information people
+     * might appreciate knowing about when it arrives, like a score update on their favorite sports team." If no
+     * interruption level is specified, the notification is assumed to have an {@code active} interruption level.
+     */
+    ACTIVE("active"),
+
+    /**
+     * According to Apple's Human Interface Guidelines, a {@code time-sensitive} notification contains "information that
+     * directly impacts the user and requires their immediate attention, like an account security issue or a package
+     * delivery."
+     */
+    TIME_SENSITIVE("time-sensitive"),
+
+    /**
+     * According to Apple's Human Interface guidelines, a {@code critical} notification contains:
+     *
+     * <blockquote>…urgent information about personal health and public safety that directly impacts the user and demands
+     * their immediate attention. Critical notifications are extremely rare and typically come from governmental and
+     * public agencies or healthcare apps. You must get an entitlement to use the Critical interruption
+     * level.</blockquote>
+     */
+    CRITICAL("critical");
+
+    private final String value;
+
+    InterruptionLevel(final String value) {
+        this.value = value;
+    }
+
+    public String getValue() {
+        return value;
+    }
+}


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 23cc010a..813af6d0 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -31,4 +31,4 @@ jobs:
       env:
         PUSHY_TEST_TRANSPORT: ${{ matrix.transport }}
         PUSHY_TEST_SSL_PROVIDER: ${{ matrix.tls }}
-      run: mvn test -B --file pom.xml
+      run: mvn verify -B --file 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 69a2b511..2ae99159 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
@@ -53,18 +53,7 @@ public abstract class ApnsPayloadBuilderTest {
     void testSetAlertBody() {
         final String alertBody = "This is a test alert message.";
 
-        this.builder.setLocalizedAlertMessage("test.alert");
         this.builder.setAlertBody(alertBody);
-
-        {
-            final Map<String, Object> aps = this.extractApsObjectFromPayloadString(this.builder.build());
-
-            @SuppressWarnings("unchecked")
-            final Map<String, Object> alert = (Map<String, Object>) aps.get("alert");
-
-            assertEquals(alertBody, alert.get("body"));
-        }
-
         this.builder.setPreferStringRepresentationForAlerts(true);
 
         {
@@ -83,7 +72,7 @@ public abstract class ApnsPayloadBuilderTest {
             final Map<String, Object> alert = (Map<String, Object>) aps.get("alert");
 
             assertEquals(alertBody, alert.get("body"));
-            assertNull(alert.get("loc-key"));
+            assertEquals("test.alert", alert.get("loc-key"));
             assertNull(alert.get("loc-args"));
         }
     }
@@ -101,8 +90,6 @@ public abstract class ApnsPayloadBuilderTest {
             final Map<String, Object> alert = (Map<String, Object>) aps.get("alert");
 
             assertEquals(alertKey, alert.get("loc-key"));
-            assertNull(alert.get("loc-args"));
-            assertNull(alert.get("body"));
         }
 
         // We're happy here as long as nothing explodes
@@ -137,8 +124,6 @@ public abstract class ApnsPayloadBuilderTest {
             final Map<String, Object> alert = (Map<String, Object>) aps.get("alert");
 
             assertEquals(alertTitle, alert.get("title"));
-            assertNull(alert.get("title-loc-key"));
-            assertNull(alert.get("title-loc-args"));
         }
     }
 
@@ -156,8 +141,6 @@ public abstract class ApnsPayloadBuilderTest {
             final Map<String, Object> alert = (Map<String, Object>) aps.get("alert");
 
             assertEquals(localizedAlertTitleKey, alert.get("title-loc-key"));
-            assertNull(alert.get("title-loc-args"));
-            assertNull(alert.get("title"));
         }
 
         // We're happy here as long as nothing explodes
@@ -195,8 +178,6 @@ public abstract class ApnsPayloadBuilderTest {
             final Map<String, Object> alert = (Map<String, Object>) aps.get("alert");
 
             assertEquals(alertSubtitle, alert.get("subtitle"));
-            assertNull(alert.get("subtitle-loc-key"));
-            assertNull(alert.get("subtitle-loc-args"));
         }
     }
 
@@ -205,7 +186,6 @@ public abstract class ApnsPayloadBuilderTest {
     void testSetLocalizedAlertSubitle() {
         final String subtitleKey = "test.subtitle";
 
-        this.builder.setAlertSubtitle("Subtitle!");
         this.builder.setLocalizedAlertSubtitle(subtitleKey);
 
         {
@@ -493,6 +473,15 @@ public abstract class ApnsPayloadBuilderTest {
 
         assertEquals(argumentSummaryCount, ((Number) alert.get("summary-arg-count")).intValue());
     }
+    
+    @Test
+    void testSetInterruptionLevel() {
+        this.builder.setInterruptionLevel(InterruptionLevel.TIME_SENSITIVE);
+
+        final Map<String, Object> aps = this.extractApsObjectFromPayloadString(this.builder.build());
+
+        assertEquals("time-sensitive", aps.get("interruption-level"));
+    }
 
     @Test
     void testSetSummaryArgumentCountNonPositive() {