Repository: relayrides/pushy
Pull request #368 (2016-09-08 01:28:27)

Add support for `subtitle` and `mutable-content` payload fields
This builds on the work by @ScienJus in #322. Beyond the initial changes (which added `subtitle` and `mutable-content`), this adds some docs and adds support for localized subtitles. Unfortunately, none of this is officially documented yet, so we just have to take our best guess.


Patch:
diff --git a/pushy/src/main/java/com/relayrides/pushy/apns/util/ApnsPayloadBuilder.java b/pushy/src/main/java/com/relayrides/pushy/apns/util/ApnsPayloadBuilder.java
index b9194051..9e34b94c 100644
--- a/pushy/src/main/java/com/relayrides/pushy/apns/util/ApnsPayloadBuilder.java
+++ b/pushy/src/main/java/com/relayrides/pushy/apns/util/ApnsPayloadBuilder.java
@@ -47,6 +47,9 @@ public class ApnsPayloadBuilder {
     private String alertTitle = null;
     private String localizedAlertTitleKey = null;
     private String[] localizedAlertTitleArguments = null;
+    private String alertSubtitle = null;
+    private String localizedAlertSubtitleKey = null;
+    private String[] localizedAlertSubtitleArguments = null;
     private String launchImageFileName = null;
     private boolean showActionButton = true;
     private String localizedActionButtonKey = null;
@@ -54,6 +57,7 @@ public class ApnsPayloadBuilder {
     private String soundFileName = null;
     private String categoryName = null;
     private boolean contentAvailable = false;
+    private boolean mutableContent = false;
 
     private final CharArrayWriter buffer = new CharArrayWriter(DEFAULT_PAYLOAD_SIZE / 4);
 
@@ -63,14 +67,18 @@ public class ApnsPayloadBuilder {
     private static final String SOUND_KEY = "sound";
     private static final String CATEGORY_KEY = "category";
     private static final String CONTENT_AVAILABLE_KEY = "content-available";
+    private static final String MUTABLE_CONTENT_KEY = "mutable-content";
 
     private static final String ALERT_TITLE_KEY = "title";
-    private static final String ALERT_BODY_KEY = "body";
     private static final String ALERT_TITLE_LOC_KEY = "title-loc-key";
     private static final String ALERT_TITLE_ARGS_KEY = "title-loc-args";
-    private static final String ACTION_LOC_KEY = "action-loc-key";
+    private static final String ALERT_SUBTITLE_KEY = "subtitle";
+    private static final String ALERT_SUBTITLE_LOC_KEY = "subtitle-loc-key";
+    private static final String ALERT_SUBTITLE_ARGS_KEY = "subtitle-loc-args";
+    private static final String ALERT_BODY_KEY = "body";
     private static final String ALERT_LOC_KEY = "loc-key";
     private static final String ALERT_ARGS_KEY = "loc-args";
+    private static final String ACTION_LOC_KEY = "action-loc-key";
     private static final String LAUNCH_IMAGE_KEY = "launch-image";
 
     private final HashMap<String, Object> customProperties = new HashMap<String, Object>();
@@ -200,6 +208,53 @@ public class ApnsPayloadBuilder {
         return this;
     }
 
+    /**
+     * Sets a subtitle for the notification. Requires iOS 10 or newer.
+     *
+     * @param alertSubtitle the subtitle for this push notification
+     *
+     * @return a reference to this payload builder
+     *
+     * @since 0.8.1
+     */
+    public ApnsPayloadBuilder setAlertSubtitle(final String alertSubtitle) {
+        if (alertSubtitle != null && this.localizedAlertSubtitleKey != null) {
+            throw new IllegalStateException("Cannot set a literal subtitle when a localized subtitle key has already been set");
+        }
+
+        this.alertSubtitle = alertSubtitle;
+
+        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. The message in the app's string list may optionally have placeholders, which will be populated by
+     * values from the given {@code alertSubtitleArguments}.</p>
+     *
+     * @param localizedAlertSubtitleKey a key to a string in the receiving app's localized string list
+     * @param alertSubtitleArguments arguments to populate placeholders in the localized subtitle string; may be
+     * {@code null}
+     *
+     * @return a reference to this payload builder
+     *
+     * @since 0.8.1
+     */
+    public ApnsPayloadBuilder setLocalizedAlertSubtitle(final String localizedAlertSubtitleKey, final String... alertSubtitleArguments) {
+        if (localizedAlertSubtitleKey != null && this.alertSubtitle != null) {
+            throw new IllegalStateException("Cannot set a localized subtitle key when a literal subtitle has already been set");
+        }
+
+        if (localizedAlertSubtitleKey == null && alertSubtitleArguments != null) {
+            throw new IllegalArgumentException("Cannot set localized subtitle arguments without a localized subtitle key.");
+        }
+
+        this.localizedAlertSubtitleKey = localizedAlertSubtitleKey;
+        this.localizedAlertSubtitleArguments = alertSubtitleArguments;
+
+        return this;
+    }
+
     /**
      * <p>Sets the image to be shown when the receiving app launches in response to this push notification. According
      * to Apple's documentation, this should be:</p>
@@ -327,6 +382,24 @@ public class ApnsPayloadBuilder {
         return this;
     }
 
+    /**
+     * Sets whether the receiving device may modify the content of the push notification before displaying it. Requires
+     * iOS 10 or newer.
+     *
+     * @param mutableContent {@code true} if the receiving device may modify the push notification before displaying it
+     * or {@code false} otherwise
+     *
+     * @return a reference to this payload builder
+     *
+     * @see <a href=
+     *      "https://developer.apple.com/reference/usernotifications/unnotificationserviceextension">
+     *      UNNotificationServiceExtension</a>
+     */
+    public ApnsPayloadBuilder setMutableContent(final boolean mutableContent) {
+        this.mutableContent = mutableContent;
+        return this;
+    }
+
     /**
      * <p>Adds a custom property to the payload. According to Apple's documentation:</p>
      *
@@ -390,6 +463,10 @@ public class ApnsPayloadBuilder {
                 aps.put(CONTENT_AVAILABLE_KEY, 1);
             }
 
+            if (this.mutableContent) {
+                aps.put(MUTABLE_CONTENT_KEY, 1);
+            }
+
             final Object alertObject = this.createAlertObject();
 
             if (alertObject != null) {
@@ -486,6 +563,10 @@ public class ApnsPayloadBuilder {
                     alert.put(ALERT_TITLE_KEY, this.alertTitle);
                 }
 
+                if (this.alertSubtitle != null) {
+                    alert.put(ALERT_SUBTITLE_KEY, this.alertSubtitle);
+                }
+
                 if (this.showActionButton) {
                     if (this.localizedActionButtonKey != null) {
                         alert.put(ACTION_LOC_KEY, this.localizedActionButtonKey);
@@ -511,6 +592,14 @@ public class ApnsPayloadBuilder {
                     }
                 }
 
+                if (this.localizedAlertSubtitleKey != null) {
+                    alert.put(ALERT_SUBTITLE_LOC_KEY, this.localizedAlertSubtitleKey);
+
+                    if (this.localizedAlertSubtitleArguments != null) {
+                        alert.put(ALERT_SUBTITLE_ARGS_KEY, Arrays.asList(this.localizedAlertSubtitleArguments));
+                    }
+                }
+
                 if (this.launchImageFileName != null) {
                     alert.put(LAUNCH_IMAGE_KEY, this.launchImageFileName);
                 }
@@ -530,7 +619,8 @@ public class ApnsPayloadBuilder {
     private boolean hasAlertContent() {
         return this.alertBody != null || this.alertTitle != null || this.localizedAlertTitleKey != null
                 || this.localizedAlertKey != null || this.localizedActionButtonKey != null
-                || this.launchImageFileName != null || this.showActionButton == false;
+                || this.launchImageFileName != null || this.showActionButton == false || this.alertSubtitle != null
+                || this.localizedAlertSubtitleKey != null;
     }
 
     /**
@@ -546,7 +636,7 @@ public class ApnsPayloadBuilder {
      */
     private boolean shouldRepresentAlertAsString() {
         return this.alertBody != null && this.launchImageFileName == null && this.showActionButton
-                && this.localizedActionButtonKey == null && this.alertTitle == null
+                && this.localizedActionButtonKey == null && this.alertTitle == null && this.alertSubtitle == null
                 && this.localizedAlertTitleKey == null && this.localizedAlertKey == null
                 && this.localizedAlertArguments == null && this.localizedAlertTitleArguments == null;
     }
@@ -575,7 +665,7 @@ public class ApnsPayloadBuilder {
         return i;
     }
 
-    static int getSizeOfJsonEscapedUtf8Character(char c) {
+    static int getSizeOfJsonEscapedUtf8Character(final char c) {
         final int charSize;
 
         if (c == '"' || c == '\\' || c == '\b' || c == '\f' || c == '\n' || c == '\r' || c == '\t') {


Test patch:
diff --git a/pushy/src/test/java/com/relayrides/pushy/apns/util/ApnsPayloadBuilderTest.java b/pushy/src/test/java/com/relayrides/pushy/apns/util/ApnsPayloadBuilderTest.java
index 05ffc52c..db5fc4e8 100644
--- a/pushy/src/test/java/com/relayrides/pushy/apns/util/ApnsPayloadBuilderTest.java
+++ b/pushy/src/test/java/com/relayrides/pushy/apns/util/ApnsPayloadBuilderTest.java
@@ -76,6 +76,47 @@ public class ApnsPayloadBuilderTest {
         }
     }
 
+    @SuppressWarnings("unchecked")
+    @Test
+    public void testSetLocalizedAlertBody() {
+        final String alertKey = "test.alert";
+        this.builder.setLocalizedAlertMessage(alertKey, null);
+
+        {
+            final Map<String, Object> aps = this.extractApsObjectFromPayloadString(this.builder.buildWithDefaultMaximumLength());
+            final Map<String, Object> alert = (Map<String, Object>) aps.get("alert");
+
+            assertEquals(alertKey, alert.get("loc-key"));
+            assertNull(alert.get("loc-args"));
+        }
+
+        final String[] alertArgs = new String[] { "Moose", "helicopter" };
+        this.builder.setLocalizedAlertMessage(alertKey, alertArgs);
+
+        {
+            final Map<String, Object> aps = this.extractApsObjectFromPayloadString(this.builder.buildWithDefaultMaximumLength());
+            final Map<String, Object> alert = (Map<String, Object>) aps.get("alert");
+
+            assertEquals(alertKey, alert.get("loc-key"));
+
+            final List<Object> argsArray = (List<Object>) alert.get("loc-args");
+            assertEquals(alertArgs.length, argsArray.size());
+            assertTrue(argsArray.containsAll(java.util.Arrays.asList(alertArgs)));
+        }
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void testSetAlertBodyWithExistingLocalizedAlert() {
+        this.builder.setLocalizedAlertMessage("Test", null);
+        this.builder.setAlertBody("Test");
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void testSetLocalizedAlertWithExistingAlertBody() {
+        this.builder.setAlertBody("Test");
+        this.builder.setLocalizedAlertMessage("Test", null);
+    }
+
     @Test
     public void testSetAlertTitle() {
         final String alertTitle = "This is a test alert message.";
@@ -93,12 +134,10 @@ public class ApnsPayloadBuilderTest {
     }
 
     @Test
-    public void testSetAlertTitleAndBody() {
-        final String alertTitle = "This is a short alert title";
-        final String alertBody = "This is a longer alert body";
+    public void testSetLocalizedAlertTitle() {
+        final String localizedAlertTitleKey = "alert.title";
 
-        this.builder.setAlertBody(alertBody);
-        this.builder.setAlertTitle(alertTitle);
+        this.builder.setLocalizedAlertTitle(localizedAlertTitleKey, null);
 
         {
             final Map<String, Object> aps = this.extractApsObjectFromPayloadString(this.builder.buildWithDefaultMaximumLength());
@@ -106,62 +145,83 @@ public class ApnsPayloadBuilderTest {
             @SuppressWarnings("unchecked")
             final Map<String, Object> alert = (Map<String, Object>) aps.get("alert");
 
-            assertEquals(alertTitle, alert.get("title"));
-            assertEquals(alertBody, alert.get("body"));
+            assertEquals(localizedAlertTitleKey, alert.get("title-loc-key"));
         }
     }
 
-    @SuppressWarnings("unchecked")
+    @Test(expected = IllegalStateException.class)
+    public void testSetAlertTitleWithExistingLocalizedAlertTitle() {
+        this.builder.setLocalizedAlertTitle("Test", null);
+        this.builder.setAlertTitle("Test");
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void testSetLocalizedAlertTitleWithExistingAlertTitle() {
+        this.builder.setAlertTitle("Test");
+        this.builder.setLocalizedAlertTitle("Test", null);
+    }
+
     @Test
-    public void testSetLocalizedAlertMessage() {
-        final String alertKey = "test.alert";
-        this.builder.setLocalizedAlertMessage(alertKey, null);
+    public void testSetAlertSubtitle() {
+        final String alertSubtitle = "This is a test alert message.";
+
+        this.builder.setAlertSubtitle(alertSubtitle);
 
         {
             final Map<String, Object> aps = this.extractApsObjectFromPayloadString(this.builder.buildWithDefaultMaximumLength());
+
+            @SuppressWarnings("unchecked")
             final Map<String, Object> alert = (Map<String, Object>) aps.get("alert");
 
-            assertEquals(alertKey, alert.get("loc-key"));
-            assertNull(alert.get("loc-args"));
+            assertEquals(alertSubtitle, alert.get("subtitle"));
         }
+    }
 
-        final String[] alertArgs = new String[] { "Moose", "helicopter" };
-        this.builder.setLocalizedAlertMessage(alertKey, alertArgs);
+    @Test
+    public void testSetLocalizedAlertSubitle() {
+        final String localizedAlertSubtitleKey = "alert.subtitle";
+
+        this.builder.setLocalizedAlertSubtitle(localizedAlertSubtitleKey);
 
         {
             final Map<String, Object> aps = this.extractApsObjectFromPayloadString(this.builder.buildWithDefaultMaximumLength());
-            final Map<String, Object> alert = (Map<String, Object>) aps.get("alert");
 
-            assertEquals(alertKey, alert.get("loc-key"));
+            @SuppressWarnings("unchecked")
+            final Map<String, Object> alert = (Map<String, Object>) aps.get("alert");
 
-            final List<Object> argsArray = (List<Object>) alert.get("loc-args");
-            assertEquals(alertArgs.length, argsArray.size());
-            assertTrue(argsArray.containsAll(java.util.Arrays.asList(alertArgs)));
+            assertEquals(localizedAlertSubtitleKey, alert.get("subtitle-loc-key"));
         }
     }
 
     @Test(expected = IllegalStateException.class)
-    public void testSetAlertBodyWithExistingLocalizedAlert() {
-        this.builder.setLocalizedAlertMessage("Test", null);
-        this.builder.setAlertBody("Test");
+    public void testSetAlertSubtitleWithExistingLocalizedAlertSubtitle() {
+        this.builder.setLocalizedAlertSubtitle("Test");
+        this.builder.setAlertSubtitle("Test");
     }
 
     @Test(expected = IllegalStateException.class)
-    public void testSetLocalizedAlertWithExistingAlertBody() {
-        this.builder.setAlertBody("Test");
-        this.builder.setLocalizedAlertMessage("Test", null);
+    public void testSetLocalizedAlertSubtitleWithExistingAlertSubtitle() {
+        this.builder.setAlertSubtitle("Test");
+        this.builder.setLocalizedAlertSubtitle("Test");
     }
 
-    @Test(expected = IllegalStateException.class)
-    public void testSetAlertTitleWithExistingLocalizedAlertTitle() {
-        this.builder.setLocalizedAlertTitle("Test", null);
-        this.builder.setAlertTitle("Test");
-    }
+    @Test
+    public void testSetAlertTitleAndBody() {
+        final String alertTitle = "This is a short alert title";
+        final String alertBody = "This is a longer alert body";
 
-    @Test(expected = IllegalStateException.class)
-    public void testSetLocalizedAlertTitleWithExistingAlertTitle() {
-        this.builder.setAlertTitle("Test");
-        this.builder.setLocalizedAlertTitle("Test", null);
+        this.builder.setAlertBody(alertBody);
+        this.builder.setAlertTitle(alertTitle);
+
+        {
+            final Map<String, Object> aps = this.extractApsObjectFromPayloadString(this.builder.buildWithDefaultMaximumLength());
+
+            @SuppressWarnings("unchecked")
+            final Map<String, Object> alert = (Map<String, Object>) aps.get("alert");
+
+            assertEquals(alertTitle, alert.get("title"));
+            assertEquals(alertBody, alert.get("body"));
+        }
     }
 
     @Test
@@ -283,6 +343,21 @@ public class ApnsPayloadBuilderTest {
         }
     }
 
+    @Test
+    public void testSetMutableContent() {
+        {
+            final Map<String, Object> aps = this.extractApsObjectFromPayloadString(this.builder.buildWithDefaultMaximumLength());
+            assertNull(aps.get("mutable-content"));
+        }
+
+        {
+            this.builder.setMutableContent(true);
+
+            final Map<String, Object> aps = this.extractApsObjectFromPayloadString(this.builder.buildWithDefaultMaximumLength());
+            assertEquals(1, ((Number) aps.get("mutable-content")).intValue());
+        }
+    }
+
     @Test
     public void testAddCustomProperty() {
         final String customKey = "string";