Repository: firebase/firebase-admin-java (java)

Issue:
FR: Expose mutable_content property in iOS Notifications API
  * Operating System version: Any
  * Firebase SDK version: 5.9.0
  * Library version: Latest
  * Firebase Product: notifications

Please provide an API for setting the `mutable_content` property for iOS in Firebase Notifications.
`com.google.firebase.messaging.Aps` class exposes some some properties like `content-available` but not `mutable_content`. 

`mutable_content` property is important for processing notifications in an `UNNotificationServiceExtension`. 

[Apple docs](https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/ModifyingNotifications.html)

Pull request:
Implementing support for APNS content-mutable field
Implements new setter in the `Aps.Builder` class:

```
Builder setContentMutable(boolean)
Builder putCustomField(String, Object)
Builder putAllCustomFields(Map<String,Object>)
```

Resolves #155 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6898334..e080075 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,7 @@
 # Unreleased
 
+- [added] Added new `setMutableContent()`, `putCustomData()` and
+  `putAllCustomData()` methods to the `Aps.Builder` API.
 - [fixed] Improved error handling in FCM by mapping more server-side
   errors to client-side error codes.
 
diff --git a/src/main/java/com/google/firebase/database/connection/NettyWebSocketClient.java b/src/main/java/com/google/firebase/database/connection/NettyWebSocketClient.java
index d71a1ee..40d1727 100644
--- a/src/main/java/com/google/firebase/database/connection/NettyWebSocketClient.java
+++ b/src/main/java/com/google/firebase/database/connection/NettyWebSocketClient.java
@@ -33,6 +33,7 @@ import io.netty.handler.codec.http.websocketx.WebSocketVersion;
 import io.netty.handler.ssl.SslContext;
 import io.netty.handler.ssl.SslContextBuilder;
 
+import java.io.EOFException;
 import java.net.URI;
 import java.security.KeyStore;
 import java.util.concurrent.ExecutorService;
@@ -130,8 +131,12 @@ class NettyWebSocketClient implements WebsocketConnection.WSClient {
 
   @Override
   public void send(String msg) {
-    checkState(channel != null && channel.isActive(), "channel not connected for sending");
-    channel.writeAndFlush(new TextWebSocketFrame(msg));
+    checkState(channel != null, "Channel not initialized");
+    if (!channel.isActive()) {
+      eventHandler.onError(new EOFException("WebSocket channel became inactive"));
+    } else {
+      channel.writeAndFlush(new TextWebSocketFrame(msg));
+    }
   }
 
   /**
diff --git a/src/main/java/com/google/firebase/database/connection/WebsocketConnection.java b/src/main/java/com/google/firebase/database/connection/WebsocketConnection.java
index d58a1af..9b28a19 100644
--- a/src/main/java/com/google/firebase/database/connection/WebsocketConnection.java
+++ b/src/main/java/com/google/firebase/database/connection/WebsocketConnection.java
@@ -324,8 +324,8 @@ class WebsocketConnection {
 
     @Override
     public void onError(final Throwable e) {
-      if (e.getCause() != null && e.getCause() instanceof EOFException) {
-        logger.error("WebSocket reached EOF", e);
+      if (e instanceof EOFException || e.getCause() instanceof EOFException) {
+        logger.debug("WebSocket reached EOF", e);
       } else {
         logger.error("WebSocket error", e);
       }
diff --git a/src/main/java/com/google/firebase/messaging/ApnsConfig.java b/src/main/java/com/google/firebase/messaging/ApnsConfig.java
index cc486c2..5e503c0 100644
--- a/src/main/java/com/google/firebase/messaging/ApnsConfig.java
+++ b/src/main/java/com/google/firebase/messaging/ApnsConfig.java
@@ -45,7 +45,7 @@ public class ApnsConfig {
     this.headers = builder.headers.isEmpty() ? null : ImmutableMap.copyOf(builder.headers);
     this.payload = ImmutableMap.<String, Object>builder()
         .putAll(builder.customData)
-        .put("aps", builder.aps)
+        .put("aps", builder.aps.getFields())
         .build();
   }
 
diff --git a/src/main/java/com/google/firebase/messaging/Aps.java b/src/main/java/com/google/firebase/messaging/Aps.java
index c35d550..52922c7 100644
--- a/src/main/java/com/google/firebase/messaging/Aps.java
+++ b/src/main/java/com/google/firebase/messaging/Aps.java
@@ -18,8 +18,11 @@ package com.google.firebase.messaging;
 
 import static com.google.common.base.Preconditions.checkArgument;
 
-import com.google.api.client.util.Key;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.firebase.internal.NonNull;
+import java.util.HashMap;
+import java.util.Map;
 
 /**
  * Represents the <a href="https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/PayloadKeyReference.html">
@@ -27,37 +30,41 @@ import com.google.common.base.Strings;
  */
 public class Aps {
 
-  @Key("alert")
-  private final Object alert;
-
-  @Key("badge")
-  private final Integer badge;
-
-  @Key("sound")
-  private final String sound;
-
-  @Key("content-available")
-  private final Integer contentAvailable;
-
-  @Key("category")
-  private final String category;
-
-  @Key("thread-id")
-  private final String threadId;
+  private final Map<String, Object> fields;
 
   private Aps(Builder builder) {
     checkArgument(Strings.isNullOrEmpty(builder.alertString) || (builder.alert == null),
         "Multiple alert specifications (string and ApsAlert) found.");
+    ImmutableMap.Builder<String, Object> fields = ImmutableMap.builder();
     if (builder.alert != null) {
-      this.alert = builder.alert;
-    } else {
-      this.alert = builder.alertString;
-    }
-    this.badge = builder.badge;
-    this.sound = builder.sound;
-    this.contentAvailable = builder.contentAvailable ? 1 : null;
-    this.category = builder.category;
-    this.threadId = builder.threadId;
+      fields.put("alert", builder.alert);
+    } else if (builder.alertString != null) {
+      fields.put("alert", builder.alertString);
+    }
+    if (builder.badge != null) {
+      fields.put("badge", builder.badge);
+    }
+    if (builder.sound != null) {
+      fields.put("sound", builder.sound);
+    }
+    if (builder.contentAvailable) {
+      fields.put("content-available", 1);
+    }
+    if (builder.mutableContent) {
+      fields.put("mutable-content", 1);
+    }
+    if (builder.category != null) {
+      fields.put("category", builder.category);
+    }
+    if (builder.threadId != null) {
+      fields.put("thread-id", builder.threadId);
+    }
+    fields.putAll(builder.customData);
+    this.fields = fields.build();
+  }
+
+  Map<String, Object> getFields() {
+    return this.fields;
   }
 
   /**
@@ -76,8 +83,10 @@ public class Aps {
     private Integer badge;
     private String sound;
     private boolean contentAvailable;
+    private boolean mutableContent;
     private String category;
     private String threadId;
+    private final Map<String, Object> customData = new HashMap<>();
 
     private Builder() {}
 
@@ -137,6 +146,41 @@ public class Aps {
       return this;
     }
 
+    /**
+     * Specifies whether to set the {@code mutable-content} property on the message, so the
+     * clients can modify the notification via app extensions.
+     *
+     * @param mutableContent True to make the content mutable via app extensions.
+     * @return This builder.
+     */
+    public Builder setMutableContent(boolean mutableContent) {
+      this.mutableContent = mutableContent;
+      return this;
+    }
+
+    /**
+     * Puts a custom key-value pair to the aps dictionary.
+     *
+     * @param key A non-null key.
+     * @param value A non-null, json-serializable value.
+     * @return This builder.
+     */
+    public Builder putCustomData(@NonNull String key, @NonNull Object value) {
+      this.customData.put(key, value);
+      return this;
+    }
+
+    /**
+     * Puts all the key-value pairs in the specified map to the aps dictionary.
+     *
+     * @param fields A non-null map. Map must not contain null keys or values.
+     * @return This builder.
+     */
+    public Builder putAllCustomData(@NonNull Map<String, Object> fields) {
+      this.customData.putAll(fields);
+      return this;
+    }
+
     /**
      * Sets the notification type.
      *
@@ -159,6 +203,13 @@ public class Aps {
       return this;
     }
 
+    /**
+     * Builds a new {@link Aps} instance from the fields set on this builder.
+     *
+     * @return A non-null {@link Aps}.
+     * @throws IllegalArgumentException If the alert is specified both as an object and a string.
+     *     Or if the same field is set both using a setter method, and as a custom field.
+     */
     public Aps build() {
       return new Aps(this);
     }
diff --git a/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java b/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java
index c068004..21ab389 100644
--- a/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java
+++ b/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java
@@ -58,6 +58,8 @@ import java.util.concurrent.Callable;
 public class FirebaseMessaging {
 
   private static final String FCM_URL = "https://fcm.googleapis.com/v1/projects/%s/messages:send";
+  private static final String FCM_ERROR_TYPE =
+      "type.googleapis.com/google.firebase.fcm.v1.FcmErrorCode";
 
   private static final String INTERNAL_ERROR = "internal-error";
   private static final String UNKNOWN_ERROR = "unknown-error";
@@ -261,11 +263,11 @@ public class FirebaseMessaging {
       // ignored
     }
 
-    String code = FCM_ERROR_CODES.get(response.getString("status"));
+    String code = FCM_ERROR_CODES.get(response.getErrorCode());
     if (code == null) {
       code = UNKNOWN_ERROR;
     }
-    String msg = response.getString("message");
+    String msg = response.getErrorMessage();
     if (Strings.isNullOrEmpty(msg)) {
       msg = String.format("Unexpected HTTP response with status: %d; body: %s",
           e.getStatusCode(), e.getContent());
@@ -388,9 +390,27 @@ public class FirebaseMessaging {
     private Map<String, Object> error;
 
 
-    String getString(String key) {
+    String getErrorCode() {
+      if (error == null) {
+        return null;
+      }
+      Object details = error.get("details");
+      if (details != null && details instanceof List) {
+        for (Object detail : (List) details) {
+          if (detail instanceof Map) {
+            Map detailMap = (Map) detail;
+            if (FCM_ERROR_TYPE.equals(detailMap.get("@type"))) {
+              return (String) detailMap.get("errorCode");
+            }
+          }
+        }
+      }
+      return (String) error.get("status");
+    }
+
+    String getErrorMessage() {
       if (error != null) {
-        return (String) error.get(key);
+        return (String) error.get("message");
       }
       return null;
     }


Test patch:
diff --git a/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java b/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java
index 3eb1545..2872d8a 100644
--- a/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java
+++ b/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java
@@ -97,7 +97,7 @@ public class FirebaseMessagingTest {
   }
 
   @Test
-  public void testNullMessage() throws Exception {
+  public void testNullMessage() {
     FirebaseMessaging messaging = initDefaultMessaging();
     TestResponseInterceptor interceptor = new TestResponseInterceptor();
     messaging.setInterceptor(interceptor);
@@ -218,6 +218,31 @@ public class FirebaseMessagingTest {
     }
   }
 
+  @Test
+  public void testSendErrorWithFcmErrorCode() throws Exception {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+    FirebaseMessaging messaging = initMessaging(response);
+    for (int code : HTTP_ERRORS) {
+      response.setStatusCode(code).setContent(
+          "{\"error\": {\"status\": \"INVALID_ARGUMENT\", \"message\": \"test error\", "
+              + "\"details\":[{\"@type\": \"type.googleapis.com/google.firebase.fcm"
+              + ".v1.FcmErrorCode\", \"errorCode\": \"UNREGISTERED\"}]}}");
+      TestResponseInterceptor interceptor = new TestResponseInterceptor();
+      messaging.setInterceptor(interceptor);
+      try {
+        messaging.sendAsync(Message.builder().setTopic("test-topic").build()).get();
+        fail("No error thrown for HTTP error");
+      } catch (ExecutionException e) {
+        assertTrue(e.getCause() instanceof FirebaseMessagingException);
+        FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
+        assertEquals("registration-token-not-registered", error.getErrorCode());
+        assertEquals("test error", error.getMessage());
+        assertTrue(error.getCause() instanceof HttpResponseException);
+      }
+      checkRequestHeader(interceptor);
+    }
+  }
+
   @Test
   public void testInvalidSubscribe() {
     FirebaseMessaging messaging = initDefaultMessaging();
diff --git a/src/test/java/com/google/firebase/messaging/MessageTest.java b/src/test/java/com/google/firebase/messaging/MessageTest.java
index 21caeeb..3313f2b 100644
--- a/src/test/java/com/google/firebase/messaging/MessageTest.java
+++ b/src/test/java/com/google/firebase/messaging/MessageTest.java
@@ -36,7 +36,7 @@ import org.junit.Test;
 public class MessageTest {
 
   @Test(expected = IllegalArgumentException.class)
-  public void testMessageWithoutTarget() throws IOException {
+  public void testMessageWithoutTarget() {
     Message.builder().build();
   }
 
@@ -214,7 +214,7 @@ public class MessageTest {
   }
 
   @Test
-  public void testInvalidAndroidConfig() throws IOException {
+  public void testInvalidAndroidConfig() {
     try {
       AndroidConfig.builder().setTtl(-1).build();
       fail("No error thrown for invalid ttl");
@@ -363,6 +363,7 @@ public class MessageTest {
                 .setBadge(42)
                 .setCategory("test-category")
                 .setContentAvailable(true)
+                .setMutableContent(true)
                 .setSound("test-sound")
                 .setThreadId("test-thread-id")
                 .build())
@@ -376,6 +377,7 @@ public class MessageTest {
             .put("badge", new BigDecimal(42))
             .put("category", "test-category")
             .put("content-available", new BigDecimal(1))
+            .put("mutable-content", new BigDecimal(1))
             .put("sound", "test-sound")
             .put("thread-id", "test-thread-id")
             .build());
@@ -404,6 +406,8 @@ public class MessageTest {
                 .setCategory("test-category")
                 .setSound("test-sound")
                 .setThreadId("test-thread-id")
+                .putCustomData("ck1", "cv1")
+                .putAllCustomData(ImmutableMap.<String, Object>of("ck2", "cv2", "ck3", 1))
                 .build())
             .build())
         .setTopic("test-topic")
@@ -424,6 +428,9 @@ public class MessageTest {
             .put("category", "test-category")
             .put("sound", "test-sound")
             .put("thread-id", "test-thread-id")
+            .put("ck1", "cv1")
+            .put("ck2", "cv2")
+            .put("ck3", new BigDecimal(1))
             .build());
     assertJsonEquals(
         ImmutableMap.of(
@@ -456,6 +463,15 @@ public class MessageTest {
       // expected
     }
 
+    builder = Aps.builder().setMutableContent(true).putCustomData("mutable-content", 1);
+    try {
+      builder.build();
+      fail("No error thrown for invalid aps");
+    } catch (IllegalArgumentException expected) {
+      // expected
+    }
+
+
     List<ApsAlert.Builder> notificationBuilders = ImmutableList.of(
         ApsAlert.builder().addLocalizationArg("foo"),
         ApsAlert.builder().addTitleLocalizationArg("foo")