Repository: firebase/firebase-admin-java
Pull request #127 (2018-01-17 19:14:20)

Firebase Cloud Messaging API
Implements the messaging related types and the following entry points:

```
FirebaseMessaging.getInstance()
FirebaseMessaging.getInstance(FirebaseApp app)
FirebaseMessaging.sendAsync(Message msg)
FirebaseMessaging.sendAsync(Message msg, boolean dryRun)
FirebaseMessaging.subscribeToTopicAsync(List<String> tokens, String topic)
FirebaseMessaging.unsubscribeFromTopicAsync(List<String> tokens, String topic)
```

go/firebase-admin-fcm-api

jcvillalta03: @hiranya911 I am wondering if there is a ticket some where of features desired in this PR. I have been working on an implementation myself in a personal project and would love to contribute if there is an actual list of requirements for the API
hiranya911: @jcvillalta03 The initial offering is going to include the methods described above. If you have ideas for more features or further expansions, please feel free to create a new issue in this repo.
jcvillalta03: Thanks @hiranya911. So at this point, this is initial offering is considered “code complete”?
hiranya911: @jcvillalta03 for the moment yes. Equivalent set of operations are being implemented in other Admin SDK variants (Node, Python and Go) as well. But you should feel encouraged to propose changes/additions on top of what I have done here. If a certain use case cannot be handled properly with the initial set of APIs, we'd like to know, and see if we can do something about it.
jcvillalta03: Ok, thanks for the explanation. Sorry to keeping conversation going in comments, I just wanted to make sure that I understand the Contribution guidelines. 
#101  (which I believe this implements) was a high-level request, but now I see that the requirements are to match functionality of other Admin SDKs.  👍  on using HTTP v1.

I look forward to this release! 
hiranya911: Made the suggested changes.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index afc4ad9..fb55a19 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,24 +1,39 @@
 # Unreleased
 
-### Token revocation
-- [added] The [`verifyIdTokenAsync(...)`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseAuth.html#verifyIdTokenAsync)
-  method has an added signature that accepts a boolean `checkRevoked` parameter. When `true`, an 
-  additional check is performed to see whether the token has been revoked. 
-- [added] A new method [`FirebaseAuth.revokeRefreshTokensAsync(uid)`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseAuth.html#revokeRefreshTokens)
-  has been added to invalidate all tokens issued to a user before the current second.
-- [added] A new getter `getTokensValidAfterTimestamp()` has been added to the   
-  [`UserRecord`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/UserRecord),
-  which denotes the time in epoch milliseconds before which tokens are not valid. This is truncated to 1000 milliseconds.
+### Cloud Messaging
+
+- [feature] Added the `FirebaseCloudMessaging` API for sending
+  Firebase notifications and managing topic subscriptions.
+
+### Authentication
+
+- [added] The [`verifyIdTokenAsync()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseAuth.html#verifyIdTokenAsync)
+  method has an overload that accepts a boolean `checkRevoked` parameter.
+  When `true`, an additional check is performed to see whether the token
+  has been revoked.
+- [added] A new [`revokeRefreshTokensAsync()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseAuth.html#revokeRefreshTokens)
+  method has been added to invalidate all tokens issued to a user.
+- [added] A new getter `getTokensValidAfterTimestamp()` has been added
+  to the [`UserRecord`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/UserRecord)
+  class, which denotes the time before which tokens are not valid.
+
+### Realtime Database
+
+- [fixed] Exceptions thrown by database event handlers are now logged.
 
 ### Initialization
 
 - [fixed] The [`FirebaseOptions.Builder.setStorageBucket()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/FirebaseOptions.Builder.html#setStorageBucket(java.lang.String))
   method now throws a clear exception when invoked with a bucket URL
   instead of the name.
+- [fixed] Implemented a fix for a potential Guava version conflict which
+  was causing an `IllegalStateException` (precondition failure) in some
+  environments.
 
-### Realtime Database
+### Cloud Firestore
 
-- [fixed] Exceptions thrown by database event handlers are now logged.
+- [fixed] Upgraded the Cloud Firestore client to the latest available
+  version.
 
 # v5.8.0
 
diff --git a/src/main/java/com/google/firebase/messaging/AndroidConfig.java b/src/main/java/com/google/firebase/messaging/AndroidConfig.java
new file mode 100644
index 0000000..065376a
--- /dev/null
+++ b/src/main/java/com/google/firebase/messaging/AndroidConfig.java
@@ -0,0 +1,200 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.messaging;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.api.client.util.Key;
+import com.google.common.collect.ImmutableMap;
+import com.google.firebase.internal.NonNull;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Represents the Android-specific options that can be included in a {@link Message}.
+ * Instances of this class are thread-safe and immutable.
+ */
+public class AndroidConfig {
+
+  @Key("collapse_key")
+  private final String collapseKey;
+
+  @Key("priority")
+  private final String priority;
+
+  @Key("ttl")
+  private final String ttl;
+
+  @Key("restricted_package_name")
+  private final String restrictedPackageName;
+
+  @Key("data")
+  private final Map<String, String> data;
+
+  @Key("notification")
+  private final AndroidNotification notification;
+
+  private AndroidConfig(Builder builder) {
+    this.collapseKey = builder.collapseKey;
+    if (builder.priority != null) {
+      this.priority = builder.priority.name().toLowerCase();
+    } else {
+      this.priority = null;
+    }
+    if (builder.ttl != null) {
+      checkArgument(builder.ttl >= 0, "ttl must not be negative");
+      long seconds = TimeUnit.MILLISECONDS.toSeconds(builder.ttl);
+      long subsecondNanos = TimeUnit.MILLISECONDS.toNanos(builder.ttl - seconds * 1000L);
+      if (subsecondNanos > 0) {
+        this.ttl = String.format("%d.%09ds", seconds, subsecondNanos);
+      } else {
+        this.ttl = String.format("%ds", seconds);
+      }
+    } else {
+      this.ttl = null;
+    }
+    this.restrictedPackageName = builder.restrictedPackageName;
+    this.data = builder.data.isEmpty() ? null : ImmutableMap.copyOf(builder.data);
+    this.notification = builder.notification;
+  }
+
+  /**
+   * Priority levels that can be set on an {@link AndroidConfig}.
+   */
+  public enum Priority {
+    HIGH,
+    NORMAL,
+  }
+
+  /**
+   * Creates a new {@link AndroidConfig.Builder}.
+   *
+   * @return A {@link AndroidConfig.Builder} instance.
+   */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  public static class Builder {
+
+    private String collapseKey;
+    private Priority priority;
+    private Long ttl;
+    private String restrictedPackageName;
+    private final Map<String, String> data = new HashMap<>();
+    private AndroidNotification notification;
+
+    private Builder() {}
+
+    /**
+     * Sets a collapse key for the message. Collapse key serves as an identifier for a group of
+     * messages that can be collapsed, so that only the last message gets sent when delivery can be
+     * resumed. A maximum of 4 different collapse keys may be active at any given time.
+     *
+     * @param collapseKey A collapse key string.
+     * @return This builder.
+     */
+    public Builder setCollapseKey(String collapseKey) {
+      this.collapseKey = collapseKey;
+      return this;
+    }
+
+    /**
+     * Sets the priority of the message.
+     *
+     * @param priority A value from the {@link Priority} enum.
+     * @return This builder.
+     */
+    public Builder setPriority(Priority priority) {
+      this.priority = priority;
+      return this;
+    }
+
+    /**
+     * Sets the time-to-live duration of the message in milliseconds.
+     *
+     * @param ttl Time-to-live duration in milliseconds.
+     * @return This builder.
+     */
+    public Builder setTtl(long ttl) {
+      this.ttl = ttl;
+      return this;
+    }
+
+    /**
+     * Sets the package name of the application where the registration tokens must match in order
+     * to receive the message.
+     *
+     * @param restrictedPackageName A package name string.
+     * @return This builder.
+     */
+    public Builder setRestrictedPackageName(String restrictedPackageName) {
+      this.restrictedPackageName = restrictedPackageName;
+      return this;
+    }
+
+    /**
+     * Adds the given key-value pair to the message as a data field. Key and the value may not be
+     * null. When set, overrides any data fields set on the top-level {@link Message} via
+     * {@link Message.Builder#putData(String, String)} and {@link Message.Builder#putAllData(Map)}.
+     *
+     * @param key Name of the data field. Must not be null.
+     * @param value Value of the data field. Must not be null.
+     * @return This builder.
+     */
+    public Builder putData(@NonNull String key, @NonNull String value) {
+      this.data.put(key, value);
+      return this;
+    }
+
+    /**
+     * Adds all the key-value pairs in the given map to the message as data fields. None of the
+     * keys and values may be null. When set, overrides any data fields set on the top-level
+     * {@link Message} via {@link Message.Builder#putData(String, String)} and
+     * {@link Message.Builder#putAllData(Map)}.
+     *
+     * @param map A non-null map of data fields. Map must not contain null keys or values.
+     * @return This builder.
+     */
+    public Builder putAllData(@NonNull Map<String, String> map) {
+      this.data.putAll(map);
+      return this;
+    }
+
+    /**
+     * Sets the Android notification to be included in the message.
+     *
+     * @param notification An {@link AndroidNotification} instance.
+     * @return This builder.
+     */
+    public Builder setNotification(AndroidNotification notification) {
+      this.notification = notification;
+      return this;
+    }
+
+    /**
+     * Creates a new {@link AndroidConfig} instance from the parameters set on this builder.
+     *
+     * @return A new {@link AndroidConfig} instance.
+     * @throws IllegalArgumentException If any of the parameters set on the builder are invalid.
+     */
+    public AndroidConfig build() {
+      return new AndroidConfig(this);
+    }
+  }
+}
diff --git a/src/main/java/com/google/firebase/messaging/AndroidNotification.java b/src/main/java/com/google/firebase/messaging/AndroidNotification.java
new file mode 100644
index 0000000..27f3155
--- /dev/null
+++ b/src/main/java/com/google/firebase/messaging/AndroidNotification.java
@@ -0,0 +1,286 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+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.ImmutableList;
+import com.google.firebase.internal.NonNull;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Represents the Android-specific notification options that can be included in a {@link Message}.
+ * Instances of this class are thread-safe and immutable.
+ */
+public class AndroidNotification {
+
+  @Key("title")
+  private final String title;
+
+  @Key("body")
+  private final String body;
+
+  @Key("icon")
+  private final String icon;
+
+  @Key("color")
+  private final String color;
+
+  @Key("sound")
+  private final String sound;
+
+  @Key("tag")
+  private final String tag;
+
+  @Key("click_action")
+  private final String clickAction;
+
+  @Key("body_loc_key")
+  private final String bodyLocKey;
+
+  @Key("body_loc_args")
+  private final List<String> bodyLocArgs;
+
+  @Key("title_loc_key")
+  private final String titleLocKey;
+
+  @Key("title_loc_args")
+  private final List<String> titleLocArgs;
+
+  private AndroidNotification(Builder builder) {
+    this.title = builder.title;
+    this.body = builder.body;
+    this.icon = builder.icon;
+    if (builder.color != null) {
+      checkArgument(builder.color.matches("^#[0-9a-fA-F]{6}$"),
+          "color must be in the form #RRGGBB");
+    }
+    this.color = builder.color;
+    this.sound = builder.sound;
+    this.tag = builder.tag;
+    this.clickAction = builder.clickAction;
+    this.bodyLocKey = builder.bodyLocKey;
+    if (!builder.bodyLocArgs.isEmpty()) {
+      checkArgument(!Strings.isNullOrEmpty(builder.bodyLocKey),
+          "bodyLocKey is required when specifying bodyLocArgs");
+      this.bodyLocArgs = ImmutableList.copyOf(builder.bodyLocArgs);
+    } else {
+      this.bodyLocArgs = null;
+    }
+
+    this.titleLocKey = builder.titleLocKey;
+    if (!builder.titleLocArgs.isEmpty()) {
+      checkArgument(!Strings.isNullOrEmpty(builder.titleLocKey),
+          "titleLocKey is required when specifying titleLocArgs");
+      this.titleLocArgs = ImmutableList.copyOf(builder.titleLocArgs);
+    } else {
+      this.titleLocArgs = null;
+    }
+  }
+
+  /**
+   * Creates a new {@link AndroidNotification.Builder}.
+   *
+   * @return A {@link AndroidNotification.Builder} instance.
+   */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  public static class Builder {
+
+    private String title;
+    private String body;
+    private String icon;
+    private String color;
+    private String sound;
+    private String tag;
+    private String clickAction;
+    private String bodyLocKey;
+    private List<String> bodyLocArgs = new ArrayList<>();
+    private String titleLocKey;
+    private List<String> titleLocArgs = new ArrayList<>();
+
+    private Builder() {}
+
+    /**
+     * Sets the title of the Android notification. When provided, overrides the title set
+     * via {@link Notification}.
+     *
+     * @param title Title of the notification.
+     * @return This builder.
+     */
+    public Builder setTitle(String title) {
+      this.title = title;
+      return this;
+    }
+
+    /**
+     * Sets the body of the Android notification. When provided, overrides the body sent
+     * via {@link Notification}.
+     *
+     * @param body Body of the notification.
+     * @return This builder.
+     */
+    public Builder setBody(String body) {
+      this.body = body;
+      return this;
+    }
+
+    /**
+     * Sets the icon of the Android notification.
+     *
+     * @param icon Icon resource for the notification.
+     * @return This builder.
+     */
+    public Builder setIcon(String icon) {
+      this.icon = icon;
+      return this;
+    }
+
+    /**
+     * Sets the notification icon color.
+     *
+     * @param color Color specified in the {@code #rrggbb} format.
+     * @return This builder.
+     */
+    public Builder setColor(String color) {
+      this.color = color;
+      return this;
+    }
+
+    /**
+     * Sets the sound to be played when the device receives the notification.
+     *
+     * @param sound File name of the sound resource or "default".
+     * @return This builder.
+     */
+    public Builder setSound(String sound) {
+      this.sound = sound;
+      return this;
+    }
+
+    /**
+     * Sets the notification tag. This is an identifier used to replace existing notifications in
+     * the notification drawer. If not specified, each request creates a new notification.
+     *
+     * @param tag Notification tag.
+     * @return This builder.
+     */
+    public Builder setTag(String tag) {
+      this.tag = tag;
+      return this;
+    }
+
+    /**
+     * Sets the action associated with a user click on the notification. If specified, an activity
+     * with a matching Intent Filter is launched when a user clicks on the notification.
+     *
+     * @param clickAction Click action name.
+     * @return This builder.
+     */
+    public Builder setClickAction(String clickAction) {
+      this.clickAction = clickAction;
+      return this;
+    }
+
+    /**
+     * Sets the key of the body string in the app's string resources to use to localize the body
+     * text.
+     *
+     * @param bodyLocKey Resource key string.
+     * @return This builder.
+     */
+    public Builder setBodyLocalizationKey(String bodyLocKey) {
+      this.bodyLocKey = bodyLocKey;
+      return this;
+    }
+
+    /**
+     * Adds a resource key string that will be used in place of the format specifiers in
+     * {@code bodyLocKey}.
+     *
+     * @param arg Resource key string.
+     * @return This builder.
+     */
+    public Builder addBodyLocalizationArg(@NonNull String arg) {
+      this.bodyLocArgs.add(arg);
+      return this;
+    }
+
+    /**
+     * Adds a list of resource keys that will be used in place of the format specifiers in
+     * {@code bodyLocKey}.
+     *
+     * @param args List of resource key strings.
+     * @return This builder.
+     */
+    public Builder addAllBodyLocalizationArgs(@NonNull List<String> args) {
+      this.bodyLocArgs.addAll(args);
+      return this;
+    }
+
+    /**
+     * Sets the key of the title string in the app's string resources to use to localize the title
+     * text.
+     *
+     * @param titleLocKey Resource key string.
+     * @return This builder.
+     */
+    public Builder setTitleLocalizationKey(String titleLocKey) {
+      this.titleLocKey = titleLocKey;
+      return this;
+    }
+
+    /**
+     * Adds a resource key string that will be used in place of the format specifiers in
+     * {@code titleLocKey}.
+     *
+     * @param arg Resource key string.
+     * @return This builder.
+     */
+    public Builder addTitleLocalizationArg(@NonNull String arg) {
+      this.titleLocArgs.add(arg);
+      return this;
+    }
+
+    /**
+     * Adds a list of resource keys that will be used in place of the format specifiers in
+     * {@code titleLocKey}.
+     *
+     * @param args List of resource key strings.
+     * @return This builder.
+     */
+    public Builder addAllTitleLocalizationArgs(@NonNull List<String> args) {
+      this.titleLocArgs.addAll(args);
+      return this;
+    }
+
+    /**
+     * Creates a new {@link AndroidNotification} instance from the parameters set on this builder.
+     *
+     * @return A new {@link AndroidNotification} instance.
+     * @throws IllegalArgumentException If any of the parameters set on the builder are invalid.
+     */
+    public AndroidNotification build() {
+      return new AndroidNotification(this);
+    }
+  }
+}
diff --git a/src/main/java/com/google/firebase/messaging/ApnsConfig.java b/src/main/java/com/google/firebase/messaging/ApnsConfig.java
new file mode 100644
index 0000000..40d76d6
--- /dev/null
+++ b/src/main/java/com/google/firebase/messaging/ApnsConfig.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.messaging;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.api.client.util.Key;
+import com.google.common.collect.ImmutableMap;
+import com.google.firebase.internal.NonNull;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Represents the APNS-specific options that can be included in a {@link Message}.
+ * Instances of this class are thread-safe and immutable. Refer to
+ * <a href="https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html">
+ * Apple documentation</a> for various headers and payload fields supported by APNS.
+ */
+public class ApnsConfig {
+
+  @Key("headers")
+  private final Map<String, String> headers;
+
+  @Key("payload")
+  private final Map<String, Object> payload;
+
+  private ApnsConfig(Builder builder) {
+    checkArgument(builder.aps != null, "aps must be specified");
+    checkArgument(!builder.customData.containsKey("aps"),
+        "aps cannot be specified as part of custom data");
+    this.headers = builder.headers.isEmpty() ? null : ImmutableMap.copyOf(builder.headers);
+    this.payload = ImmutableMap.<String, Object>builder()
+        .putAll(builder.customData)
+        .put("aps", builder.aps)
+        .build();
+  }
+
+  /**
+   * Creates a new {@link ApnsConfig.Builder}.
+   *
+   * @return A {@link ApnsConfig.Builder} instance.
+   */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  public static class Builder {
+
+    private final Map<String, String> headers = new HashMap<>();
+    private final Map<String, Object> customData = new HashMap<>();
+    private Aps aps;
+
+    private Builder() {}
+
+    /**
+     * Adds the given key-value pair as an APNS header.
+     *
+     * @param key Name of the header field. Must not be null.
+     * @param value Value of the header field. Must not be null.
+     * @return This builder.
+     */
+    public Builder putHeader(@NonNull String key, @NonNull String value) {
+      headers.put(key, value);
+      return this;
+    }
+
+    /**
+     * Adds all the key-value pairs in the given map as APNS headers.
+     *
+     * @param map A non-null map of headers. Map must not contain null keys or values.
+     * @return This builder.
+     */
+    public Builder putAllHeaders(@NonNull Map<String, String> map) {
+      headers.putAll(map);
+      return this;
+    }
+
+    /**
+     * Sets the aps dictionary of the APNS message.
+     *
+     * @param aps A non-null instance of {@link Aps}.
+     * @return This builder.
+     */
+    public Builder setAps(@NonNull Aps aps) {
+      this.aps = aps;
+      return this;
+    }
+
+    /**
+     * Adds the given key-value pair as an APNS custom data field.
+     *
+     * @param key Name of the data field. Must not be null.
+     * @param value Value of the data field. Must not be null.
+     * @return This builder.
+     */
+    public Builder putCustomData(@NonNull String key, @NonNull Object value) {
+      this.customData.put(key, value);
+      return this;
+    }
+
+    /**
+     * Adds all the key-value pairs in the given map as APNS custom data fields.
+     *
+     * @param map A non-null map. Map must not contain null keys or values.
+     * @return This builder.
+     */
+    public Builder putAllCustomData(@NonNull Map<String, Object> map) {
+      this.customData.putAll(map);
+      return this;
+    }
+
+    /**
+     * Creates a new {@link ApnsConfig} instance from the parameters set on this builder.
+     *
+     * @return A new {@link ApnsConfig} instance.
+     * @throws IllegalArgumentException If any of the parameters set on the builder are invalid.
+     */
+    public ApnsConfig build() {
+      return new ApnsConfig(this);
+    }
+  }
+}
diff --git a/src/main/java/com/google/firebase/messaging/Aps.java b/src/main/java/com/google/firebase/messaging/Aps.java
new file mode 100644
index 0000000..c35d550
--- /dev/null
+++ b/src/main/java/com/google/firebase/messaging/Aps.java
@@ -0,0 +1,166 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+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;
+
+/**
+ * Represents the <a href="https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/PayloadKeyReference.html">
+ * aps dictionary</a> that is part of every APNS message.
+ */
+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 Aps(Builder builder) {
+    checkArgument(Strings.isNullOrEmpty(builder.alertString) || (builder.alert == null),
+        "Multiple alert specifications (string and ApsAlert) found.");
+    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;
+  }
+
+  /**
+   * Creates a new {@link Aps.Builder}.
+   *
+   * @return A {@link Aps.Builder} instance.
+   */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  public static class Builder {
+
+    private String alertString;
+    private ApsAlert alert;
+    private Integer badge;
+    private String sound;
+    private boolean contentAvailable;
+    private String category;
+    private String threadId;
+
+    private Builder() {}
+
+    /**
+     * Sets the alert field as a string.
+     *
+     * @param alert A string alert.
+     * @return This builder.
+     */
+    public Builder setAlert(String alert) {
+      this.alertString = alert;
+      return this;
+    }
+
+    /**
+     * Sets the alert as a dictionary.
+     *
+     * @param alert An instance of {@link ApsAlert}.
+     * @return This builder.
+     */
+    public Builder setAlert(ApsAlert alert) {
+      this.alert = alert;
+      return this;
+    }
+
+    /**
+     * Sets the badge to be displayed with the message. Set to 0 to remove the badge. When not
+     * invoked, the badge will remain unchanged.
+     *
+     * @param badge An integer representing the badge.
+     * @return This builder.
+     */
+    public Builder setBadge(int badge) {
+      this.badge = badge;
+      return this;
+    }
+
+    /**
+     * Sets the sound to be played with the message.
+     *
+     * @param sound Sound file name or {@code "default"}.
+     * @return This builder.
+     */
+    public Builder setSound(String sound) {
+      this.sound = sound;
+      return this;
+    }
+
+    /**
+     * Specifies whether to configure a background update notification.
+     *
+     * @param contentAvailable True to perform a background update.
+     * @return This builder.
+     */
+    public Builder setContentAvailable(boolean contentAvailable) {
+      this.contentAvailable = contentAvailable;
+      return this;
+    }
+
+    /**
+     * Sets the notification type.
+     *
+     * @param category A string identifier.
+     * @return This builder.
+     */
+    public Builder setCategory(String category) {
+      this.category = category;
+      return this;
+    }
+
+    /**
+     * Sets an app-specific identifier for grouping notifications.
+     *
+     * @param threadId A string identifier.
+     * @return This builder.
+     */
+    public Builder setThreadId(String threadId) {
+      this.threadId = threadId;
+      return this;
+    }
+
+    public Aps build() {
+      return new Aps(this);
+    }
+  }
+}
diff --git a/src/main/java/com/google/firebase/messaging/ApsAlert.java b/src/main/java/com/google/firebase/messaging/ApsAlert.java
new file mode 100644
index 0000000..6f7e249
--- /dev/null
+++ b/src/main/java/com/google/firebase/messaging/ApsAlert.java
@@ -0,0 +1,234 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+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.ImmutableList;
+import com.google.firebase.internal.NonNull;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Represents the <a href="https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/PayloadKeyReference.html#//apple_ref/doc/uid/TP40008194-CH17-SW5">
+ * alert property</a> that can be included in the aps dictionary of an APNS payload.
+ */
+public class ApsAlert {
+
+  @Key("title")
+  private final String title;
+
+  @Key("body")
+  private final String body;
+
+  @Key("loc-key")
+  private final String locKey;
+
+  @Key("loc-args")
+  private final List<String> locArgs;
+
+  @Key("title-loc-key")
+  private final String titleLocKey;
+
+  @Key("title-loc-args")
+  private final List<String> titleLocArgs;
+
+  @Key("action-loc-key")
+  private final String actionLocKey;
+
+  @Key("launch-image")
+  private final String launchImage;
+
+  private ApsAlert(Builder builder) {
+    this.title = builder.title;
+    this.body = builder.body;
+    this.actionLocKey = builder.actionLocKey;
+    this.locKey = builder.locKey;
+    if (!builder.locArgs.isEmpty()) {
+      checkArgument(!Strings.isNullOrEmpty(builder.locKey),
+          "locKey is required when specifying locArgs");
+      this.locArgs = ImmutableList.copyOf(builder.locArgs);
+    } else {
+      this.locArgs = null;
+    }
+
+    this.titleLocKey = builder.titleLocKey;
+    if (!builder.titleLocArgs.isEmpty()) {
+      checkArgument(!Strings.isNullOrEmpty(builder.titleLocKey),
+          "titleLocKey is required when specifying titleLocArgs");
+      this.titleLocArgs = ImmutableList.copyOf(builder.titleLocArgs);
+    } else {
+      this.titleLocArgs = null;
+    }
+    this.launchImage = builder.launchImage;
+  }
+
+  /**
+   * Creates a new {@link ApsAlert.Builder}.
+   *
+   * @return A {@link ApsAlert.Builder} instance.
+   */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  public static class Builder {
+
+    private String title;
+    private String body;
+    private String locKey;
+    private List<String> locArgs = new ArrayList<>();
+    private String titleLocKey;
+    private List<String> titleLocArgs = new ArrayList<>();
+    private String actionLocKey;
+    private String launchImage;
+
+    private Builder() {}
+
+    /**
+     * Sets the title of the alert. When provided, overrides the title sent
+     * via {@link Notification}.
+     *
+     * @param title Title of the notification.
+     * @return This builder.
+     */
+    public Builder setTitle(String title) {
+      this.title = title;
+      return this;
+    }
+
+    /**
+     * Sets the body of the alert. When provided, overrides the body sent
+     * via {@link Notification}.
+     *
+     * @param body Body of the notification.
+     * @return This builder.
+     */
+    public Builder setBody(String body) {
+      this.body = body;
+      return this;
+    }
+
+    /**
+     * Sets the key of the text in the app's string resources to use to localize the action button
+     * text.
+     *
+     * @param actionLocKey Resource key string.
+     * @return This builder.
+     */
+    public Builder setActionLocalizationKey(String actionLocKey) {
+      this.actionLocKey = actionLocKey;
+      return this;
+    }
+
+    /**
+     * Sets the key of the body string in the app's string resources to use to localize the body
+     * text.
+     *
+     * @param locKey Resource key string.
+     * @return This builder.
+     */
+    public Builder setLocalizationKey(String locKey) {
+      this.locKey = locKey;
+      return this;
+    }
+
+    /**
+     * Adds a resource key string that will be used in place of the format specifiers in
+     * {@code bodyLocKey}.
+     *
+     * @param arg Resource key string.
+     * @return This builder.
+     */
+    public Builder addLocalizationArg(@NonNull String arg) {
+      this.locArgs.add(arg);
+      return this;
+    }
+
+    /**
+     * Adds a list of resource keys that will be used in place of the format specifiers in
+     * {@code bodyLocKey}.
+     *
+     * @param args List of resource key strings.
+     * @return This builder.
+     */
+    public Builder addAllLocalizationArgs(@NonNull List<String> args) {
+      this.locArgs.addAll(args);
+      return this;
+    }
+
+    /**
+     * Sets the key of the title string in the app's string resources to use to localize the title
+     * text.
+     *
+     * @param titleLocKey Resource key string.
+     * @return This builder.
+     */
+    public Builder setTitleLocalizationKey(String titleLocKey) {
+      this.titleLocKey = titleLocKey;
+      return this;
+    }
+
+    /**
+     * Adds a resource key string that will be used in place of the format specifiers in
+     * {@code titleLocKey}.
+     *
+     * @param arg Resource key string.
+     * @return This builder.
+     */
+    public Builder addTitleLocalizationArg(@NonNull String arg) {
+      this.titleLocArgs.add(arg);
+      return this;
+    }
+
+    /**
+     * Adds a list of resource keys that will be used in place of the format specifiers in
+     * {@code titleLocKey}.
+     *
+     * @param args List of resource key strings.
+     * @return This builder.
+     */
+    public Builder addAllTitleLocArgs(@NonNull List<String> args) {
+      this.titleLocArgs.addAll(args);
+      return this;
+    }
+
+    /**
+     * Sets the launch image for the notification action.
+     *
+     * @param launchImage An image file name.
+     * @return This builder.
+     */
+    public Builder setLaunchImage(String launchImage) {
+      this.launchImage = launchImage;
+      return this;
+    }
+
+    /**
+     * Creates a new {@link ApsAlert} instance from the parameters set on this builder.
+     *
+     * @return A new {@link ApsAlert} instance.
+     * @throws IllegalArgumentException If any of the parameters set on the builder are invalid.
+     */
+    public ApsAlert build() {
+      return new ApsAlert(this);
+    }
+  }
+
+}
diff --git a/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java b/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java
new file mode 100644
index 0000000..00b68c6
--- /dev/null
+++ b/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java
@@ -0,0 +1,401 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.messaging;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.api.client.http.GenericUrl;
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpRequestFactory;
+import com.google.api.client.http.HttpResponse;
+import com.google.api.client.http.HttpResponseException;
+import com.google.api.client.http.HttpResponseInterceptor;
+import com.google.api.client.http.HttpTransport;
+import com.google.api.client.http.json.JsonHttpContent;
+import com.google.api.client.json.JsonFactory;
+import com.google.api.client.json.JsonObjectParser;
+import com.google.api.client.json.JsonParser;
+import com.google.api.client.util.Key;
+import com.google.api.core.ApiFuture;
+import com.google.auth.http.HttpCredentialsAdapter;
+import com.google.auth.oauth2.GoogleCredentials;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.ImplFirebaseTrampolines;
+import com.google.firebase.internal.FirebaseService;
+import com.google.firebase.internal.NonNull;
+import com.google.firebase.internal.TaskToApiFuture;
+import com.google.firebase.tasks.Task;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Callable;
+
+/**
+ * This class is the entry point for all server-side Firebase Cloud Messaging actions.
+ *
+ * <p>You can get an instance of FirebaseMessaging via {@link #getInstance(FirebaseApp)}, and
+ * then use it to send messages or manage FCM topic subscriptions.
+ */
+public class FirebaseMessaging {
+
+  private static final String FCM_URL = "https://fcm.googleapis.com/v1/projects/%s/messages:send";
+
+  private static final String INTERNAL_ERROR = "internal-error";
+  private static final String UNKNOWN_ERROR = "unknown-error";
+  private static final Map<String, String> FCM_ERROR_CODES =
+      ImmutableMap.<String, String>builder()
+        .put("APNS_AUTH_ERROR", "authentication-error")
+        .put("INTERNAL", INTERNAL_ERROR)
+        .put("INVALID_ARGUMENT", "invalid-argument")
+        .put("UNREGISTERED", "registration-token-not-registered")
+        .put("QUOTA_EXCEEDED", "message-rate-exceeded")
+        .put("SENDER_ID_MISMATCH", "authentication-error")
+        .put("UNAVAILABLE", "server-unavailable")
+        .build();
+  static final Map<Integer, String> IID_ERROR_CODES =
+      ImmutableMap.<Integer, String>builder()
+        .put(400, "invalid-argument")
+        .put(401, "authentication-error")
+        .put(403, "authentication-error")
+        .put(500, INTERNAL_ERROR)
+        .put(503, "server-unavailable")
+        .build();
+
+  private static final String IID_HOST = "https://iid.googleapis.com";
+  private static final String IID_SUBSCRIBE_PATH = "iid/v1:batchAdd";
+  private static final String IID_UNSUBSCRIBE_PATH = "iid/v1:batchRemove";
+
+  private final FirebaseApp app;
+  private final HttpRequestFactory requestFactory;
+  private final JsonFactory jsonFactory;
+  private final String url;
+
+  private HttpResponseInterceptor interceptor;
+
+  private FirebaseMessaging(FirebaseApp app) {
+    HttpTransport httpTransport = app.getOptions().getHttpTransport();
+    GoogleCredentials credentials = ImplFirebaseTrampolines.getCredentials(app);
+    this.app = app;
+    this.requestFactory = httpTransport.createRequestFactory(
+        new HttpCredentialsAdapter(credentials));
+    this.jsonFactory = app.getOptions().getJsonFactory();
+    String projectId = ImplFirebaseTrampolines.getProjectId(app);
+    checkArgument(!Strings.isNullOrEmpty(projectId),
+        "Project ID is required to access messaging service. Use a service account credential or "
+            + "set the project ID explicitly via FirebaseOptions. Alternatively you can also "
+            + "set the project ID via the GCLOUD_PROJECT environment variable.");
+    this.url = String.format(FCM_URL, projectId);
+  }
+
+  /**
+   * Gets the {@link FirebaseMessaging} instance for the default {@link FirebaseApp}.
+   *
+   * @return The {@link FirebaseMessaging} instance for the default {@link FirebaseApp}.
+   */
+  public static FirebaseMessaging getInstance() {
+    return getInstance(FirebaseApp.getInstance());
+  }
+
+  /**
+   * Gets the {@link FirebaseMessaging} instance for the specified {@link FirebaseApp}.
+   *
+   * @return The {@link FirebaseMessaging} instance for the specified {@link FirebaseApp}.
+   */
+  public static synchronized FirebaseMessaging getInstance(FirebaseApp app) {
+    FirebaseMessagingService service = ImplFirebaseTrampolines.getService(app, SERVICE_ID,
+        FirebaseMessagingService.class);
+    if (service == null) {
+      service = ImplFirebaseTrampolines.addService(app, new FirebaseMessagingService(app));
+    }
+    return service.getInstance();
+  }
+
+  /**
+   * Sends the given {@link Message} via Firebase Cloud Messaging.
+   *
+   * @param message A non-null {@link Message} to be sent.
+   * @return An {@code ApiFuture} that will complete with a message ID string when the message
+   *     has been sent.
+   */
+  public ApiFuture<String> sendAsync(@NonNull Message message) {
+    return sendAsync(message, false);
+  }
+
+  /**
+   * Sends the given {@link Message} via Firebase Cloud Messaging.
+   *
+   * <p>If the {@code dryRun} option is set to true, the message will not be actually sent. Instead
+   * FCM performs all the necessary validations, and emulates the send operation.
+   *
+   * @param message A non-null {@link Message} to be sent.
+   * @param dryRun a boolean indicating whether to perform a dry run (validation only) of the send.
+   * @return An {@code ApiFuture} that will complete with a message ID string when the message
+   *     has been sent, or when the emulation has finished.
+   */
+  public ApiFuture<String> sendAsync(@NonNull Message message, boolean dryRun) {
+    return new TaskToApiFuture<>(send(message, dryRun));
+  }
+
+  private Task<String> send(final Message message, final boolean dryRun) {
+    checkNotNull(message, "message must not be null");
+    return ImplFirebaseTrampolines.submitCallable(app, new Callable<String>() {
+      @Override
+      public String call() throws FirebaseMessagingException {
+        return makeSendRequest(message, dryRun);
+      }
+    });
+  }
+
+  /**
+   * Subscribes a list of registration tokens to a topic.
+   *
+   * @param registrationTokens A non-null, non-empty list of device registration tokens, with at
+   *     most 1000 entries.
+   * @param topic Name of the topic to subscribe to. May contain the {@code /topics/} prefix.
+   * @return An {@code ApiFuture} that will complete with a {@link TopicManagementResponse}.
+   */
+  public ApiFuture<TopicManagementResponse> subscribeToTopicAsync(
+      @NonNull List<String> registrationTokens, @NonNull String topic) {
+    return new TaskToApiFuture<>(subscribeToTopic(registrationTokens, topic));
+  }
+
+  private Task<TopicManagementResponse> subscribeToTopic(
+      final List<String> registrationTokens, final String topic) {
+    checkRegistrationTokens(registrationTokens);
+    checkTopic(topic);
+
+    return ImplFirebaseTrampolines.submitCallable(app, new Callable<TopicManagementResponse>() {
+      @Override
+      public TopicManagementResponse call() throws FirebaseMessagingException {
+        return makeTopicManagementRequest(registrationTokens, topic, IID_SUBSCRIBE_PATH);
+      }
+    });
+  }
+
+  /**
+   * Unubscribes a list of registration tokens from a topic.
+   *
+   * @param registrationTokens A non-null, non-empty list of device registration tokens, with at
+   *     most 1000 entries.
+   * @param topic Name of the topic to unsubscribe from. May contain the {@code /topics/} prefix.
+   * @return An {@code ApiFuture} that will complete with a {@link TopicManagementResponse}.
+   */
+  public ApiFuture<TopicManagementResponse> unsubscribeFromTopicAsync(
+      @NonNull List<String> registrationTokens, @NonNull String topic) {
+    return new TaskToApiFuture<>(unsubscribeFromTopic(registrationTokens, topic));
+  }
+
+  private Task<TopicManagementResponse> unsubscribeFromTopic(
+      final List<String> registrationTokens, final String topic) {
+    checkRegistrationTokens(registrationTokens);
+    checkTopic(topic);
+
+    return ImplFirebaseTrampolines.submitCallable(app, new Callable<TopicManagementResponse>() {
+      @Override
+      public TopicManagementResponse call() throws FirebaseMessagingException {
+        return makeTopicManagementRequest(registrationTokens, topic, IID_UNSUBSCRIBE_PATH);
+      }
+    });
+  }
+
+  private String makeSendRequest(Message message,
+      boolean dryRun) throws FirebaseMessagingException {
+    ImmutableMap.Builder<String, Object> payload = ImmutableMap.<String, Object>builder()
+        .put("message", message);
+    if (dryRun) {
+      payload.put("validate_only", true);
+    }
+    HttpResponse response = null;
+    try {
+      HttpRequest request = requestFactory.buildPostRequest(
+          new GenericUrl(url), new JsonHttpContent(jsonFactory, payload.build()));
+      request.setParser(new JsonObjectParser(jsonFactory));
+      request.setResponseInterceptor(interceptor);
+      response = request.execute();
+      MessagingServiceResponse parsed = new MessagingServiceResponse();
+      jsonFactory.createJsonParser(response.getContent()).parseAndClose(parsed);
+      return parsed.name;
+    } catch (HttpResponseException e) {
+      handleSendHttpError(e);
+      return null;
+    } catch (IOException e) {
+      throw new FirebaseMessagingException(
+          INTERNAL_ERROR, "Error while calling FCM backend service", e);
+    } finally {
+      disconnectQuietly(response);
+    }
+  }
+
+  private void handleSendHttpError(HttpResponseException e) throws FirebaseMessagingException {
+    MessagingServiceErrorResponse response = new MessagingServiceErrorResponse();
+    try {
+      JsonParser parser = jsonFactory.createJsonParser(e.getContent());
+      parser.parseAndClose(response);
+    } catch (IOException ignored) {
+      // ignored
+    }
+
+    String code = FCM_ERROR_CODES.get(response.getString("status"));
+    if (code == null) {
+      code = UNKNOWN_ERROR;
+    }
+    String msg = response.getString("message");
+    if (Strings.isNullOrEmpty(msg)) {
+      msg = String.format("Unexpected HTTP response with status: %d; body: %s",
+          e.getStatusCode(), e.getContent());
+    }
+    throw new FirebaseMessagingException(code, msg, e);
+  }
+
+  private TopicManagementResponse makeTopicManagementRequest(List<String> registrationTokens,
+      String topic, String path) throws FirebaseMessagingException {
+    if (!topic.startsWith("/topics/")) {
+      topic = "/topics/" + topic;
+    }
+    Map<String, Object> payload = ImmutableMap.of(
+        "to", topic,
+        "registration_tokens", registrationTokens
+    );
+
+    final String url = String.format("%s/%s", IID_HOST, path);
+    HttpResponse response = null;
+    try {
+      HttpRequest request = requestFactory.buildPostRequest(
+          new GenericUrl(url), new JsonHttpContent(jsonFactory, payload));
+      request.getHeaders().set("access_token_auth", "true");
+      request.setParser(new JsonObjectParser(jsonFactory));
+      request.setResponseInterceptor(interceptor);
+      response = request.execute();
+      InstanceIdServiceResponse parsed = new InstanceIdServiceResponse();
+      jsonFactory.createJsonParser(response.getContent()).parseAndClose(parsed);
+      checkState(parsed.results != null && !parsed.results.isEmpty(),
+          "unexpected response from topic management service");
+      return new TopicManagementResponse(parsed.results);
+    } catch (HttpResponseException e) {
+      handleTopicManagementHttpError(e);
+      return null;
+    } catch (IOException e) {
+      throw new FirebaseMessagingException(
+          INTERNAL_ERROR, "Error while calling IID backend service", e);
+    } finally {
+      disconnectQuietly(response);
+    }
+  }
+
+  private void handleTopicManagementHttpError(
+      HttpResponseException e) throws FirebaseMessagingException {
+    InstanceIdServiceErrorResponse response = new InstanceIdServiceErrorResponse();
+    try {
+      JsonParser parser = jsonFactory.createJsonParser(e.getContent());
+      parser.parseAndClose(response);
+    } catch (IOException ignored) {
+      // ignored
+    }
+
+    // Infer error code from HTTP status
+    String code = IID_ERROR_CODES.get(e.getStatusCode());
+    if (code == null) {
+      code = UNKNOWN_ERROR;
+    }
+    String msg = response.error;
+    if (Strings.isNullOrEmpty(msg)) {
+      msg = String.format("Unexpected HTTP response with status: %d; body: %s",
+          e.getStatusCode(), e.getContent());
+    }
+    throw new FirebaseMessagingException(code, msg, e);
+  }
+
+  private static void disconnectQuietly(HttpResponse response) {
+    if (response != null) {
+      try {
+        response.disconnect();
+      } catch (IOException ignored) {
+        // ignored
+      }
+    }
+  }
+
+  private static void checkRegistrationTokens(List<String> registrationTokens) {
+    checkArgument(registrationTokens != null && !registrationTokens.isEmpty(),
+        "registrationTokens list must not be null or empty");
+    checkArgument(registrationTokens.size() <= 1000,
+        "registrationTokens list must not contain more than 1000 elements");
+    for (String token : registrationTokens) {
+      checkArgument(!Strings.isNullOrEmpty(token),
+          "registration tokens list must not contain null or empty strings");
+    }
+  }
+
+  private static void checkTopic(String topic) {
+    checkArgument(!Strings.isNullOrEmpty(topic), "topic must not be null or empty");
+    checkArgument(topic.matches("^(/topics/)?(private/)?[a-zA-Z0-9-_.~%]+$"), "invalid topic name");
+  }
+
+  @VisibleForTesting
+  void setInterceptor(HttpResponseInterceptor interceptor) {
+    this.interceptor = interceptor;
+  }
+
+  private static final String SERVICE_ID = FirebaseMessaging.class.getName();
+
+  private static class FirebaseMessagingService extends FirebaseService<FirebaseMessaging> {
+
+    FirebaseMessagingService(FirebaseApp app) {
+      super(SERVICE_ID, new FirebaseMessaging(app));
+    }
+
+    @Override
+    public void destroy() {
+      // NOTE: We don't explicitly tear down anything here, but public methods of FirebaseMessaging
+      // will now fail because calls to getOptions() and getToken() will hit FirebaseApp,
+      // which will throw once the app is deleted.
+    }
+  }
+
+  private static class MessagingServiceResponse {
+    @Key("name")
+    private String name;
+  }
+
+  private static class MessagingServiceErrorResponse {
+    @Key("error")
+    private Map<String, Object> error;
+
+
+    String getString(String key) {
+      if (error != null) {
+        return (String) error.get(key);
+      }
+      return null;
+    }
+  }
+
+  private static class InstanceIdServiceResponse {
+    @Key("results")
+    private List<Map<String, Object>> results;
+  }
+
+  private static class InstanceIdServiceErrorResponse {
+    @Key("error")
+    private String error;
+  }
+}
diff --git a/src/main/java/com/google/firebase/messaging/FirebaseMessagingException.java b/src/main/java/com/google/firebase/messaging/FirebaseMessagingException.java
new file mode 100644
index 0000000..5f57474
--- /dev/null
+++ b/src/main/java/com/google/firebase/messaging/FirebaseMessagingException.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.messaging;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Strings;
+import com.google.firebase.FirebaseException;
+import com.google.firebase.internal.NonNull;
+
+public class FirebaseMessagingException extends FirebaseException {
+
+  private final String errorCode;
+
+  FirebaseMessagingException(String errorCode, String message, Throwable cause) {
+    super(message, cause);
+    checkArgument(!Strings.isNullOrEmpty(errorCode));
+    this.errorCode = errorCode;
+  }
+
+
+  /** Returns an error code that may provide more information about the error. */
+  @NonNull
+  public String getErrorCode() {
+    return errorCode;
+  }
+}
diff --git a/src/main/java/com/google/firebase/messaging/Message.java b/src/main/java/com/google/firebase/messaging/Message.java
new file mode 100644
index 0000000..564c889
--- /dev/null
+++ b/src/main/java/com/google/firebase/messaging/Message.java
@@ -0,0 +1,230 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+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.common.primitives.Booleans;
+import com.google.firebase.internal.NonNull;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Represents a message that can be sent via Firebase Cloud Messaging (FCM). Contains payload
+ * information as well as the recipient information. In particular, the message must contain
+ * exactly one token, topic or condition parameter. Instances of this class are thread-safe
+ * and immutable. Use {@link Message.Builder} to create new instances.
+ *
+ * @see
+ * <a href="https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages">FCM message
+ * format</a>
+ */
+public class Message {
+
+  @Key("data")
+  private final Map<String, String> data;
+
+  @Key("notification")
+  private final Notification notification;
+
+  @Key("android")
+  private final AndroidConfig androidConfig;
+
+  @Key("webpush")
+  private final WebpushConfig webpushConfig;
+
+  @Key("apns")
+  private final ApnsConfig apnsConfig;
+
+  @Key("token")
+  private final String token;
+
+  @Key("topic")
+  private final String topic;
+
+  @Key("condition")
+  private final String condition;
+
+  private Message(Builder builder) {
+    this.data = builder.data.isEmpty() ? null : ImmutableMap.copyOf(builder.data);
+    this.notification = builder.notification;
+    this.androidConfig = builder.androidConfig;
+    this.webpushConfig = builder.webpushConfig;
+    this.apnsConfig = builder.apnsConfig;
+    int count = Booleans.countTrue(
+        !Strings.isNullOrEmpty(builder.token),
+        !Strings.isNullOrEmpty(builder.topic),
+        !Strings.isNullOrEmpty(builder.condition)
+    );
+    checkArgument(count == 1, "Exactly one of token, topic or condition must be specified");
+    this.token = builder.token;
+    this.topic = stripPrefix(builder.topic);
+    this.condition = builder.condition;
+  }
+
+  private static String stripPrefix(String topic) {
+    if (Strings.isNullOrEmpty(topic)) {
+      return null;
+    }
+    if (topic.startsWith("/topics/")) {
+      topic = topic.replaceFirst("^/topics/", "");
+    }
+    // Checks for illegal characters and empty string.
+    checkArgument(topic.matches("[a-zA-Z0-9-_.~%]+"), "Malformed topic name");
+    return topic;
+  }
+
+  /**
+   * Creates a new {@link Message.Builder}.
+   *
+   * @return A {@link Message.Builder} instance.
+   */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  public static class  Builder {
+
+    private final Map<String, String> data = new HashMap<>();
+    private Notification notification;
+    private AndroidConfig androidConfig;
+    private WebpushConfig webpushConfig;
+    private ApnsConfig apnsConfig;
+    private String token;
+    private String topic;
+    private String condition;
+
+    private Builder() {}
+
+    /**
+     * Sets the notification information to be included in the message.
+     *
+     * @param notification A {@link Notification} instance.
+     * @return This builder.
+     */
+    public Builder setNotification(Notification notification) {
+      this.notification = notification;
+      return this;
+    }
+
+    /**
+     * Sets the Android-specific information to be included in the message.
+     *
+     * @param androidConfig An {@link AndroidConfig} instance.
+     * @return This builder.
+     */
+    public Builder setAndroidConfig(AndroidConfig androidConfig) {
+      this.androidConfig = androidConfig;
+      return this;
+    }
+
+    /**
+     * Sets the Webpush-specific information to be included in the message.
+     *
+     * @param webpushConfig A {@link WebpushConfig} instance.
+     * @return This builder.
+     */
+    public Builder setWebpushConfig(WebpushConfig webpushConfig) {
+      this.webpushConfig = webpushConfig;
+      return this;
+    }
+
+    /**
+     * Sets the information specific to APNS (Apple Push Notification Service).
+     *
+     * @param apnsConfig An {@link ApnsConfig} instance.
+     * @return This builder.
+     */
+    public Builder setApnsConfig(ApnsConfig apnsConfig) {
+      this.apnsConfig = apnsConfig;
+      return this;
+    }
+
+    /**
+     * Sets the registration token of the device to which the message should be sent.
+     *
+     * @param token A valid device registration token.
+     * @return This builder.
+     */
+    public Builder setToken(String token) {
+      this.token = token;
+      return this;
+    }
+
+    /**
+     * Sets the name of the FCM topic to which the message should be sent. Topic names may
+     * contain the {@code /topics/} prefix.
+     *
+     * @param topic A valid topic name.
+     * @return This builder.
+     */
+    public Builder setTopic(String topic) {
+      this.topic = topic;
+      return this;
+    }
+
+    /**
+     * Sets the FCM condition to which the message should be sent.
+     *
+     * @param condition A valid condition string (e.g. {@code "'foo' in topics"}).
+     * @return This builder.
+     */
+    public Builder setCondition(String condition) {
+      this.condition = condition;
+      return this;
+    }
+
+    /**
+     * Adds the given key-value pair to the message as a data field. Key or the value may not be
+     * null.
+     *
+     * @param key Name of the data field. Must not be null.
+     * @param value Value of the data field. Must not be null.
+     * @return This builder.
+     */
+    public Builder putData(@NonNull String key, @NonNull String value) {
+      this.data.put(key, value);
+      return this;
+    }
+
+    /**
+     * Adds all the key-value pairs in the given map to the message as data fields. None of the
+     * keys or values may be null.
+     *
+     * @param map A non-null map of data fields. Map must not contain null keys or values.
+     * @return This builder.
+     */
+    public Builder putAllData(@NonNull Map<String, String> map) {
+      this.data.putAll(map);
+      return this;
+    }
+
+    /**
+     * Creates a new {@link Message} instance from the parameters set on this builder.
+     *
+     * @return A new {@link Message} instance.
+     * @throws IllegalArgumentException If any of the parameters set on the builder are invalid.
+     */
+    public Message build() {
+      return new Message(this);
+    }
+  }
+
+}
diff --git a/src/main/java/com/google/firebase/messaging/Notification.java b/src/main/java/com/google/firebase/messaging/Notification.java
new file mode 100644
index 0000000..eaa1e14
--- /dev/null
+++ b/src/main/java/com/google/firebase/messaging/Notification.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.messaging;
+
+import com.google.api.client.util.Key;
+
+/**
+ * Represents the notification parameters that can be included in a {@link Message}. Instances
+ * of this class are thread-safe and immutable.
+ */
+public class Notification {
+
+  @Key("title")
+  private final String title;
+
+  @Key("body")
+  private final String body;
+
+  /**
+   * Creates a new {@code Notification} using the given title and body.
+   *
+   * @param title Title of the notification.
+   * @param body Body of the notification.
+   */
+  public Notification(String title, String body) {
+    this.title = title;
+    this.body = body;
+  }
+
+}
diff --git a/src/main/java/com/google/firebase/messaging/TopicManagementResponse.java b/src/main/java/com/google/firebase/messaging/TopicManagementResponse.java
new file mode 100644
index 0000000..230c64d
--- /dev/null
+++ b/src/main/java/com/google/firebase/messaging/TopicManagementResponse.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.messaging;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.firebase.internal.NonNull;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The response produced by FCM topic management operations.
+ */
+public class TopicManagementResponse {
+
+  private static final String UNKNOWN_ERROR = "unknown-error";
+
+  // Server error codes as defined in https://developers.google.com/instance-id/reference/server
+  // TODO: Should we handle other error codes here (e.g. PERMISSION_DENIED)?
+  private static final Map<String, String> ERROR_CODES = ImmutableMap.<String, String>builder()
+      .put("INVALID_ARGUMENT", "invalid-argument")
+      .put("NOT_FOUND", "registration-token-not-registered")
+      .put("INTERNAL", "internal-error")
+      .put("TOO_MANY_TOPICS", "too-many-topics")
+      .build();
+
+  private final int successCount;
+  private final List<Error> errors;
+
+  TopicManagementResponse(List<Map<String, Object>> results) {
+    int successCount = 0;
+    ImmutableList.Builder<Error> errors = ImmutableList.builder();
+    for (int i = 0; i < results.size(); i++) {
+      Map result = results.get(i);
+      if (result.isEmpty()) {
+        successCount++;
+      } else {
+        errors.add(new Error(i, (String) result.get("error")));
+      }
+    }
+    this.successCount = successCount;
+    this.errors = errors.build();
+  }
+
+  /**
+   * Gets the number of registration tokens that were successfully subscribed or unsubscribed.
+   *
+   * @return The number of successes.
+   */
+  public int getSuccessCount() {
+    return successCount;
+  }
+
+  /**
+   * Gets the number of registration tokens that could not be subscribed or unsubscribed, and
+   * resulted in an error.
+   *
+   * @return The number of failures.
+   */
+  public int getFailureCount() {
+    return errors.size();
+  }
+
+  /**
+   * Gets a list of errors encountered while executing the topic management operation.
+   *
+   * @return A non-null list.
+   */
+  @NonNull
+  public List<Error> getErrors() {
+    return errors;
+  }
+
+  /**
+   * A topic management error.
+   */
+  public static class Error {
+    private final int index;
+    private final String reason;
+
+    private Error(int index, String reason) {
+      this.index = index;
+      this.reason = ERROR_CODES.containsKey(reason)
+        ? ERROR_CODES.get(reason) : UNKNOWN_ERROR;
+    }
+
+    /**
+     * Index of the registration token to which this error is related to.
+     *
+     * @return An index into the original registration token list.
+     */
+    public int getIndex() {
+      return index;
+    }
+
+    /**
+     * String describing the nature of the error.
+     *
+     * @return A non-null, non-empty error message.
+     */
+    @NonNull
+    public String getReason() {
+      return reason;
+    }
+  }
+}
diff --git a/src/main/java/com/google/firebase/messaging/WebpushConfig.java b/src/main/java/com/google/firebase/messaging/WebpushConfig.java
new file mode 100644
index 0000000..6b5ac4a
--- /dev/null
+++ b/src/main/java/com/google/firebase/messaging/WebpushConfig.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.messaging;
+
+import com.google.api.client.util.Key;
+import com.google.common.collect.ImmutableMap;
+import com.google.firebase.internal.NonNull;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Represents the Webpush protocol options that can be included in a {@link Message}. Instances
+ * of this class are thread-safe and immutable.
+ */
+public class WebpushConfig {
+
+  @Key("headers")
+  private final Map<String, String> headers;
+
+  @Key("data")
+  private final Map<String, String> data;
+
+  @Key("notification")
+  private final WebpushNotification notification;
+
+  private WebpushConfig(Builder builder) {
+    this.headers = builder.headers.isEmpty() ? null : ImmutableMap.copyOf(builder.headers);
+    this.data = builder.data.isEmpty() ? null : ImmutableMap.copyOf(builder.data);
+    this.notification = builder.notification;
+  }
+
+  /**
+   * Creates a new {@link WebpushConfig.Builder}.
+   *
+   * @return A {@link WebpushConfig.Builder} instance.
+   */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  public static class Builder {
+
+    private final Map<String, String> headers = new HashMap<>();
+    private final Map<String, String> data = new HashMap<>();
+    private WebpushNotification notification;
+
+    private Builder() {}
+
+    /**
+     * Adds the given key-value pair as a Webpush HTTP header. Refer to
+     * <a href="https://tools.ietf.org/html/rfc8030#section-5">Webpush specification</a>
+     * for supported headers.
+     *
+     * @param key Name of the header. Must not be null.
+     * @param value Value of the header. Must not be null.
+     * @return This builder.
+     */
+    public Builder putHeader(@NonNull String key, @NonNull String value) {
+      headers.put(key, value);
+      return this;
+    }
+
+    /**
+     * Adds all the key-value pairs in the given map as Webpush headers. Refer to
+     * <a href="https://tools.ietf.org/html/rfc8030#section-5">Webpush specification</a>
+     * for supported headers.
+     *
+     * @param map A non-null map of header values. Map must not contain null keys or values.
+     * @return This builder.
+     */
+    public Builder putAllHeaders(@NonNull Map<String, String> map) {
+      headers.putAll(map);
+      return this;
+    }
+
+    /**
+     * Sets the given key-value pair as a Webpush data field. When set, overrides any data fields
+     * set using the methods {@link Message.Builder#putData(String, String)} and
+     * {@link Message.Builder#putAllData(Map)}.
+     *
+     * @param key Name of the data field. Must not be null.
+     * @param value Value of the data field. Must not be null.
+     * @return This builder.
+     */
+    public Builder putData(String key, String value) {
+      data.put(key, value);
+      return this;
+    }
+
+    /**
+     * Adds all the key-value pairs in the given map as Webpush data fields. When set, overrides any
+     * data fields set using the methods {@link Message.Builder#putData(String, String)} and
+     * {@link Message.Builder#putAllData(Map)}.
+     *
+     * @param map A non-null map of data values. Map must not contain null keys or values.
+     * @return This builder.
+     */
+    public Builder putAllData(Map<String, String> map) {
+      data.putAll(map);
+      return this;
+    }
+
+    /**
+     * Sets the Webpush notification to be included in the message.
+     *
+     * @param notification A {@link WebpushNotification} instance.
+     * @return This builder.
+     */
+    public Builder setNotification(WebpushNotification notification) {
+      this.notification = notification;
+      return this;
+    }
+
+    /**
+     * Creates a new {@link WebpushConfig} instance from the parameters set on this builder.
+     *
+     * @return A new {@link WebpushConfig} instance.
+     * @throws IllegalArgumentException If any of the parameters set on the builder are invalid.
+     */
+    public WebpushConfig build() {
+      return new WebpushConfig(this);
+    }
+  }
+}
diff --git a/src/main/java/com/google/firebase/messaging/WebpushNotification.java b/src/main/java/com/google/firebase/messaging/WebpushNotification.java
new file mode 100644
index 0000000..92876c5
--- /dev/null
+++ b/src/main/java/com/google/firebase/messaging/WebpushNotification.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.messaging;
+
+import com.google.api.client.util.Key;
+import com.google.firebase.internal.Nullable;
+
+/**
+ * Represents the Webpush-specific notification options that can be included in a {@link Message}.
+ * Instances of this class are thread-safe and immutable.
+ */
+public class WebpushNotification {
+
+  @Key("title")
+  private final String title;
+
+  @Key("body")
+  private final String body;
+
+  @Key("icon")
+  private final String icon;
+
+  /**
+   * Creates a new notification with the given title and body. Overrides the options set via
+   * {@link Notification}.
+   *
+   * @param title Title of the notification.
+   * @param body Body of the notification.
+   */
+  public WebpushNotification(String title, String body) {
+    this(title, body, null);
+  }
+
+  /**
+   * Creates a new notification with the given title, body and icon. Overrides the options set via
+   * {@link Notification}.
+   *
+   * @param title Title of the notification.
+   * @param body Body of the notification.
+   * @param icon URL to the notifications icon.
+   */
+  public WebpushNotification(String title, String body, @Nullable String icon) {
+    this.title = title;
+    this.body = body;
+    this.icon = icon;
+  }
+}


Test patch:
diff --git a/src/test/java/com/google/firebase/auth/FirebaseAuthTest.java b/src/test/java/com/google/firebase/auth/FirebaseAuthTest.java
index 383aa2c..e2c709c 100644
--- a/src/test/java/com/google/firebase/auth/FirebaseAuthTest.java
+++ b/src/test/java/com/google/firebase/auth/FirebaseAuthTest.java
@@ -16,7 +16,6 @@
 
 package com.google.firebase.auth;
 
-import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNotSame;
@@ -37,7 +36,6 @@ import com.google.auth.oauth2.ServiceAccountCredentials;
 import com.google.auth.oauth2.UserCredentials;
 import com.google.common.base.Defaults;
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableMap;
 import com.google.firebase.FirebaseApp;
 import com.google.firebase.FirebaseOptions;
 import com.google.firebase.ImplFirebaseTrampolines;
@@ -47,14 +45,11 @@ import com.google.firebase.database.MapBuilder;
 import com.google.firebase.testing.ServiceAccount;
 import com.google.firebase.testing.TestUtils;
 import java.io.ByteArrayInputStream;
-import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
-import java.io.PrintWriter;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 import java.lang.reflect.Modifier;
-import java.nio.file.Files;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -124,32 +119,6 @@ public class FirebaseAuthTest {
         });
   }
 
-  private static GoogleCredentials createApplicationDefaultCredential() throws IOException {
-    final MockTokenServerTransport transport = new MockTokenServerTransport();
-    transport.addServiceAccount(ServiceAccount.EDITOR.getEmail(), ACCESS_TOKEN);
-
-    // Set the GOOGLE_APPLICATION_CREDENTIALS environment variable for application-default
-    // credentials. This requires us to write the credentials to the location specified by the
-    // environment variable.
-    File credentialsFile = File.createTempFile("google-test-credentials", "json");
-    PrintWriter writer = new PrintWriter(Files.newBufferedWriter(credentialsFile.toPath(), UTF_8));
-    writer.print(ServiceAccount.EDITOR.asString());
-    writer.close();
-    Map<String, String> environmentVariables =
-        ImmutableMap.<String, String>builder()
-            .put("GOOGLE_APPLICATION_CREDENTIALS", credentialsFile.getAbsolutePath())
-            .build();
-    TestUtils.setEnvironmentVariables(environmentVariables);
-    credentialsFile.deleteOnExit();
-
-    return GoogleCredentials.getApplicationDefault(new HttpTransportFactory() {
-      @Override
-      public HttpTransport create() {
-        return transport;
-      }
-    });
-  }
-
   private static GoogleCredentials createRefreshTokenCredential() throws IOException {
 
     final MockTokenServerTransport transport = new MockTokenServerTransport();
diff --git a/src/test/java/com/google/firebase/messaging/FirebaseMessagingIT.java b/src/test/java/com/google/firebase/messaging/FirebaseMessagingIT.java
new file mode 100644
index 0000000..f309889
--- /dev/null
+++ b/src/test/java/com/google/firebase/messaging/FirebaseMessagingIT.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.messaging;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import com.google.common.collect.ImmutableList;
+import com.google.firebase.testing.IntegrationTestUtils;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class FirebaseMessagingIT {
+
+  private static final String TEST_REGISTRATION_TOKEN =
+      "fGw0qy4TGgk:APA91bGtWGjuhp4WRhHXgbabIYp1jxEKI08ofj_v1bKhWAGJQ4e3arRCWzeTfHaLz83mBnDh0a"
+          + "PWB1AykXAVUUGl2h1wT4XI6XazWpvY7RBUSYfoxtqSWGIm2nvWh2BOP1YG501SsRoE";
+
+  @BeforeClass
+  public static void setUpClass() throws Exception {
+    IntegrationTestUtils.ensureDefaultApp();
+  }
+
+  @Test
+  public void testSend() throws Exception {
+    FirebaseMessaging messaging = FirebaseMessaging.getInstance();
+    Message message = Message.builder()
+        .setNotification(new Notification("Title", "Body"))
+        .setAndroidConfig(AndroidConfig.builder()
+            .setRestrictedPackageName("com.google.firebase.testing")
+            .build())
+        .setApnsConfig(ApnsConfig.builder()
+            .setAps(Aps.builder()
+                .setAlert(ApsAlert.builder()
+                    .setTitle("Title")
+                    .setBody("Body")
+                    .build())
+                .build())
+            .build())
+        .setWebpushConfig(WebpushConfig.builder()
+            .putHeader("X-Custom-Val", "Foo")
+            .setNotification(new WebpushNotification("Title", "Body"))
+            .build())
+        .setTopic("foo-bar")
+        .build();
+    String id = messaging.sendAsync(message, true).get();
+    assertTrue(id != null && id.matches("^projects/.*/messages/.*$"));
+  }
+
+  @Test
+  public void testSubscribe() throws Exception {
+    FirebaseMessaging messaging = FirebaseMessaging.getInstance();
+    TopicManagementResponse results = messaging.subscribeToTopicAsync(
+        ImmutableList.of(TEST_REGISTRATION_TOKEN), "mock-topic").get();
+    assertEquals(1, results.getSuccessCount() + results.getFailureCount());
+  }
+
+  @Test
+  public void testUnsubscribe() throws Exception {
+    FirebaseMessaging messaging = FirebaseMessaging.getInstance();
+    TopicManagementResponse results = messaging.unsubscribeFromTopicAsync(
+        ImmutableList.of(TEST_REGISTRATION_TOKEN), "mock-topic").get();
+    assertEquals(1, results.getSuccessCount() + results.getFailureCount());
+  }
+}
diff --git a/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java b/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java
new file mode 100644
index 0000000..ac55255
--- /dev/null
+++ b/src/test/java/com/google/firebase/messaging/FirebaseMessagingTest.java
@@ -0,0 +1,527 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.messaging;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.api.client.googleapis.util.Utils;
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpResponseException;
+import com.google.api.client.json.JsonParser;
+import com.google.api.client.testing.http.MockHttpTransport;
+import com.google.api.client.testing.http.MockLowLevelHttpResponse;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.FirebaseOptions;
+import com.google.firebase.TestOnlyImplFirebaseTrampolines;
+import com.google.firebase.auth.MockGoogleCredentials;
+import com.google.firebase.testing.TestResponseInterceptor;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import org.junit.After;
+import org.junit.Test;
+
+public class FirebaseMessagingTest {
+
+  private static final String TEST_FCM_URL =
+      "https://fcm.googleapis.com/v1/projects/test-project/messages:send";
+  private static final String TEST_IID_SUBSCRIBE_URL =
+      "https://iid.googleapis.com/iid/v1:batchAdd";
+  private static final String TEST_IID_UNSUBSCRIBE_URL =
+      "https://iid.googleapis.com/iid/v1:batchRemove";
+  private static final List<Integer> HTTP_ERRORS = ImmutableList.of(401, 404, 500);
+  private static final String MOCK_RESPONSE = "{\"name\": \"mock-name\"}";
+
+  private static final ImmutableList.Builder<String> tooManyIds = ImmutableList.builder();
+
+  static {
+    for (int i = 0; i < 1001; i++) {
+      tooManyIds.add("id" + i);
+    }
+  }
+
+  private static final List<TopicMgtArgs> INVALID_TOPIC_MGT_ARGS = ImmutableList.of(
+      new TopicMgtArgs(null, null),
+      new TopicMgtArgs(null, "test-topic"),
+      new TopicMgtArgs(ImmutableList.<String>of(), "test-topic"),
+      new TopicMgtArgs(ImmutableList.of(""), "test-topic"),
+      new TopicMgtArgs(tooManyIds.build(), "test-topic"),
+      new TopicMgtArgs(ImmutableList.of(""), null),
+      new TopicMgtArgs(ImmutableList.of("id"), ""),
+      new TopicMgtArgs(ImmutableList.of("id"), "foo*")
+  );
+
+  @After
+  public void tearDown() {
+    TestOnlyImplFirebaseTrampolines.clearInstancesForTest();
+  }
+
+  @Test
+  public void testNoProjectId() {
+    FirebaseOptions options = new FirebaseOptions.Builder()
+        .setCredentials(new MockGoogleCredentials("test-token"))
+        .build();
+    FirebaseApp.initializeApp(options);
+    try {
+      FirebaseMessaging.getInstance();
+      fail("No error thrown for missing project ID");
+    } catch (IllegalArgumentException expected) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testNullMessage() throws Exception {
+    FirebaseMessaging messaging = initDefaultMessaging();
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    messaging.setInterceptor(interceptor);
+    try {
+      messaging.sendAsync(null);
+      fail("No error thrown for null message");
+    } catch (NullPointerException expected) {
+      // expected
+    }
+
+    assertNull(interceptor.getResponse());
+  }
+
+  @Test
+  public void testSend() throws Exception {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+        .setContent(MOCK_RESPONSE);
+    FirebaseMessaging messaging = initMessaging(response);
+    Map<Message, Map<String, Object>> testMessages = buildTestMessages();
+
+    for (Map.Entry<Message, Map<String, Object>> entry : testMessages.entrySet()) {
+      response.setContent(MOCK_RESPONSE);
+      TestResponseInterceptor interceptor = new TestResponseInterceptor();
+      messaging.setInterceptor(interceptor);
+      String resp = messaging.sendAsync(entry.getKey()).get();
+      assertEquals("mock-name", resp);
+
+      HttpRequest request = checkRequestHeader(interceptor);
+      checkRequest(request, ImmutableMap.<String, Object>of("message", entry.getValue()));
+    }
+  }
+
+  @Test
+  public void testSendDryRun() throws Exception {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+        .setContent(MOCK_RESPONSE);
+    FirebaseMessaging messaging = initMessaging(response);
+    Map<Message, Map<String, Object>> testMessages = buildTestMessages();
+
+    for (Map.Entry<Message, Map<String, Object>> entry : testMessages.entrySet()) {
+      response.setContent(MOCK_RESPONSE);
+      TestResponseInterceptor interceptor = new TestResponseInterceptor();
+      messaging.setInterceptor(interceptor);
+      String resp = messaging.sendAsync(entry.getKey(), true).get();
+      assertEquals("mock-name", resp);
+
+      HttpRequest request = checkRequestHeader(interceptor);
+      checkRequest(request, ImmutableMap.of("message", entry.getValue(), "validate_only", true));
+    }
+  }
+
+  @Test
+  public void testSendError() throws Exception {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+    FirebaseMessaging messaging = initMessaging(response);
+    for (int code : HTTP_ERRORS) {
+      response.setStatusCode(code).setContent("{}");
+      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("unknown-error", error.getErrorCode());
+        assertEquals("Unexpected HTTP response with status: " + code + "; body: {}",
+            error.getMessage());
+        assertTrue(error.getCause() instanceof HttpResponseException);
+      }
+      checkRequestHeader(interceptor);
+    }
+  }
+
+  @Test
+  public void testSendErrorWithDetails() 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\"}}");
+      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("invalid-argument", error.getErrorCode());
+        assertEquals("test error", error.getMessage());
+        assertTrue(error.getCause() instanceof HttpResponseException);
+      }
+      checkRequestHeader(interceptor);
+    }
+  }
+
+  @Test
+  public void testInvalidSubscribe() {
+    FirebaseMessaging messaging = initDefaultMessaging();
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    messaging.setInterceptor(interceptor);
+
+    for (TopicMgtArgs args : INVALID_TOPIC_MGT_ARGS) {
+      try {
+        messaging.subscribeToTopicAsync(args.registrationTokens, args.topic);
+        fail("No error thrown for invalid args");
+      } catch (IllegalArgumentException expected) {
+        // expected
+      }
+    }
+
+    assertNull(interceptor.getResponse());
+  }
+
+  @Test
+  public void testSubscribe() throws Exception {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+        .setContent("{\"results\": [{}, {\"error\": \"error_reason\"}]}");
+    FirebaseMessaging messaging = initMessaging(response);
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    messaging.setInterceptor(interceptor);
+
+    TopicManagementResponse result = messaging.subscribeToTopicAsync(
+        ImmutableList.of("id1", "id2"), "test-topic").get();
+    HttpRequest request = checkTopicManagementRequestHeader(interceptor, TEST_IID_SUBSCRIBE_URL);
+    checkTopicManagementRequest(request, result);
+  }
+
+  @Test
+  public void testSubscribeError() throws Exception {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+    FirebaseMessaging messaging = initMessaging(response);
+    for (int statusCode : HTTP_ERRORS) {
+      response.setStatusCode(statusCode).setContent("{\"error\": \"test error\"}");
+      TestResponseInterceptor interceptor = new TestResponseInterceptor();
+      messaging.setInterceptor(interceptor);
+      try {
+        messaging.subscribeToTopicAsync(ImmutableList.of("id1", "id2"), "test-topic").get();
+        fail("No error thrown for HTTP error");
+      } catch (ExecutionException e) {
+        assertTrue(e.getCause() instanceof FirebaseMessagingException);
+        FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
+        assertEquals(getTopicManagementErrorCode(statusCode), error.getErrorCode());
+        assertEquals("test error", error.getMessage());
+        assertTrue(error.getCause() instanceof HttpResponseException);
+      }
+
+      checkTopicManagementRequestHeader(interceptor, TEST_IID_SUBSCRIBE_URL);
+    }
+  }
+
+  @Test
+  public void testInvalidUnsubscribe() {
+    FirebaseMessaging messaging = initDefaultMessaging();
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    messaging.setInterceptor(interceptor);
+
+    for (TopicMgtArgs args : INVALID_TOPIC_MGT_ARGS) {
+      try {
+        messaging.unsubscribeFromTopicAsync(args.registrationTokens, args.topic);
+        fail("No error thrown for invalid args");
+      } catch (IllegalArgumentException expected) {
+        // expected
+      }
+    }
+
+    assertNull(interceptor.getResponse());
+  }
+
+  @Test
+  public void testUnsubscribe() throws Exception {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+        .setContent("{\"results\": [{}, {\"error\": \"error_reason\"}]}");
+    FirebaseMessaging messaging = initMessaging(response);
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    messaging.setInterceptor(interceptor);
+
+    TopicManagementResponse result = messaging.unsubscribeFromTopicAsync(
+        ImmutableList.of("id1", "id2"), "test-topic").get();
+    HttpRequest request = checkTopicManagementRequestHeader(interceptor, TEST_IID_UNSUBSCRIBE_URL);
+    checkTopicManagementRequest(request, result);
+  }
+
+  @Test
+  public void testUnsubscribeError() throws Exception {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+    FirebaseMessaging messaging = initMessaging(response);
+    for (int statusCode : HTTP_ERRORS) {
+      response.setStatusCode(statusCode).setContent("{\"error\": \"test error\"}");
+      TestResponseInterceptor interceptor = new TestResponseInterceptor();
+      messaging.setInterceptor(interceptor);
+      try {
+        messaging.unsubscribeFromTopicAsync(ImmutableList.of("id1", "id2"), "test-topic").get();
+        fail("No error thrown for HTTP error");
+      } catch (ExecutionException e) {
+        assertTrue(e.getCause() instanceof FirebaseMessagingException);
+        FirebaseMessagingException error = (FirebaseMessagingException) e.getCause();
+        assertEquals(getTopicManagementErrorCode(statusCode), error.getErrorCode());
+        assertEquals("test error", error.getMessage());
+        assertTrue(error.getCause() instanceof HttpResponseException);
+      }
+
+      checkTopicManagementRequestHeader(interceptor, TEST_IID_UNSUBSCRIBE_URL);
+    }
+  }
+
+  private static String getTopicManagementErrorCode(int statusCode) {
+    String code = FirebaseMessaging.IID_ERROR_CODES.get(statusCode);
+    if (code == null) {
+      code = "unknown-error";
+    }
+    return code;
+  }
+
+  private static FirebaseMessaging initMessaging(MockLowLevelHttpResponse mockResponse) {
+    MockHttpTransport transport = new MockHttpTransport.Builder()
+        .setLowLevelHttpResponse(mockResponse)
+        .build();
+    FirebaseOptions options = new FirebaseOptions.Builder()
+        .setCredentials(new MockGoogleCredentials("test-token"))
+        .setProjectId("test-project")
+        .setHttpTransport(transport)
+        .build();
+    FirebaseApp app = FirebaseApp.initializeApp(options);
+
+    FirebaseMessaging messaging = FirebaseMessaging.getInstance();
+    assertSame(messaging, FirebaseMessaging.getInstance(app));
+    return messaging;
+  }
+
+  private static FirebaseMessaging initDefaultMessaging() {
+    FirebaseOptions options = new FirebaseOptions.Builder()
+        .setCredentials(new MockGoogleCredentials("test-token"))
+        .setProjectId("test-project")
+        .build();
+    FirebaseApp app = FirebaseApp.initializeApp(options);
+    return FirebaseMessaging.getInstance(app);
+  }
+
+  private static void checkRequest(
+      HttpRequest request, Map<String, Object> expected) throws IOException {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    request.getContent().writeTo(out);
+    JsonParser parser = Utils.getDefaultJsonFactory().createJsonParser(out.toString());
+    Map<String, Object> parsed = new HashMap<>();
+    parser.parseAndClose(parsed);
+    assertEquals(expected, parsed);
+  }
+
+  private static HttpRequest checkRequestHeader(TestResponseInterceptor interceptor) {
+    assertNotNull(interceptor.getResponse());
+    HttpRequest request = interceptor.getResponse().getRequest();
+    assertEquals("POST", request.getRequestMethod());
+    assertEquals(TEST_FCM_URL, request.getUrl().toString());
+    assertEquals("Bearer test-token", request.getHeaders().getAuthorization());
+    return request;
+  }
+
+  private static void checkTopicManagementRequest(
+      HttpRequest request, TopicManagementResponse result) throws IOException {
+    assertEquals(1, result.getSuccessCount());
+    assertEquals(1, result.getFailureCount());
+    assertEquals(1, result.getErrors().size());
+    assertEquals(1, result.getErrors().get(0).getIndex());
+    assertEquals("unknown-error", result.getErrors().get(0).getReason());
+
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    request.getContent().writeTo(out);
+    Map<String, Object> parsed = new HashMap<>();
+    JsonParser parser = Utils.getDefaultJsonFactory().createJsonParser(out.toString());
+    parser.parseAndClose(parsed);
+    assertEquals(2, parsed.size());
+    assertEquals("/topics/test-topic", parsed.get("to"));
+    assertEquals(ImmutableList.of("id1", "id2"), parsed.get("registration_tokens"));
+  }
+
+  private static HttpRequest checkTopicManagementRequestHeader(
+      TestResponseInterceptor interceptor, String expectedUrl) {
+    assertNotNull(interceptor.getResponse());
+    HttpRequest request = interceptor.getResponse().getRequest();
+    assertEquals("POST", request.getRequestMethod());
+    assertEquals(expectedUrl, request.getUrl().toString());
+    assertEquals("Bearer test-token", request.getHeaders().getAuthorization());
+    return request;
+  }
+
+  private static class TopicMgtArgs {
+    private final List<String> registrationTokens;
+    private final String topic;
+
+    TopicMgtArgs(List<String> registrationTokens, String topic) {
+      this.registrationTokens = registrationTokens;
+      this.topic = topic;
+    }
+  }
+
+  private static Map<Message, Map<String, Object>> buildTestMessages() {
+    ImmutableMap.Builder<Message, Map<String, Object>> builder = ImmutableMap.builder();
+
+    // Empty message
+    builder.put(
+        Message.builder().setTopic("test-topic").build(),
+        ImmutableMap.<String, Object>of("topic", "test-topic"));
+
+    // Notification message
+    builder.put(
+        Message.builder()
+            .setNotification(new Notification("test title", "test body"))
+            .setTopic("test-topic")
+            .build(),
+        ImmutableMap.<String, Object>of(
+            "topic", "test-topic",
+            "notification", ImmutableMap.of("title", "test title", "body", "test body")));
+
+    // Data message
+    builder.put(
+        Message.builder()
+            .putData("k1", "v1")
+            .putData("k2", "v2")
+            .putAllData(ImmutableMap.of("k3", "v3", "k4", "v4"))
+            .setTopic("test-topic")
+            .build(),
+        ImmutableMap.<String, Object>of(
+            "topic", "test-topic",
+            "data", ImmutableMap.of("k1", "v1", "k2", "v2", "k3", "v3", "k4", "v4")));
+
+    // Android message
+    builder.put(
+        Message.builder()
+            .setAndroidConfig(AndroidConfig.builder()
+                .setPriority(AndroidConfig.Priority.HIGH)
+                .setTtl(TimeUnit.SECONDS.toMillis(123))
+                .setRestrictedPackageName("test-package")
+                .setCollapseKey("test-key")
+                .setNotification(AndroidNotification.builder()
+                    .setClickAction("test-action")
+                    .setTitle("test-title")
+                    .setBody("test-body")
+                    .setIcon("test-icon")
+                    .setColor("#112233")
+                    .setTag("test-tag")
+                    .setSound("test-sound")
+                    .setTitleLocalizationKey("test-title-key")
+                    .setBodyLocalizationKey("test-body-key")
+                    .addTitleLocalizationArg("t-arg1")
+                    .addAllTitleLocalizationArgs(ImmutableList.of("t-arg2", "t-arg3"))
+                    .addBodyLocalizationArg("b-arg1")
+                    .addAllBodyLocalizationArgs(ImmutableList.of("b-arg2", "b-arg3"))
+                    .build())
+                .build())
+            .setTopic("test-topic")
+            .build(),
+        ImmutableMap.<String, Object>of(
+            "topic", "test-topic",
+            "android", ImmutableMap.of(
+                "priority", "high",
+                "collapse_key", "test-key",
+                "ttl", "123s",
+                "restricted_package_name", "test-package",
+                "notification", ImmutableMap.builder()
+                    .put("click_action", "test-action")
+                    .put("title", "test-title")
+                    .put("body", "test-body")
+                    .put("icon", "test-icon")
+                    .put("color", "#112233")
+                    .put("tag", "test-tag")
+                    .put("sound", "test-sound")
+                    .put("title_loc_key", "test-title-key")
+                    .put("title_loc_args", ImmutableList.of("t-arg1", "t-arg2", "t-arg3"))
+                    .put("body_loc_key", "test-body-key")
+                    .put("body_loc_args", ImmutableList.of("b-arg1", "b-arg2", "b-arg3"))
+                    .build()
+            )
+        ));
+
+    // APNS message
+    builder.put(
+        Message.builder()
+            .setApnsConfig(ApnsConfig.builder()
+                .putHeader("h1", "v1")
+                .putAllHeaders(ImmutableMap.of("h2", "v2", "h3", "v3"))
+                .putAllCustomData(ImmutableMap.<String, Object>of("k1", "v1", "k2", true))
+                .setAps(Aps.builder()
+                    .setBadge(42)
+                    .setAlert(ApsAlert.builder()
+                        .setTitle("test-title")
+                        .setBody("test-body")
+                        .build())
+                    .build())
+                .build())
+            .setTopic("test-topic")
+            .build(),
+        ImmutableMap.<String, Object>of(
+            "topic", "test-topic",
+            "apns", ImmutableMap.of(
+                "headers", ImmutableMap.of("h1", "v1", "h2", "v2", "h3", "v3"),
+                "payload", ImmutableMap.of("k1", "v1", "k2", true,
+                    "aps", ImmutableMap.<String, Object>of("badge", new BigDecimal(42),
+                        "alert", ImmutableMap.<String, Object>of(
+                            "title", "test-title", "body", "test-body"))))
+        ));
+
+    // Webpush message
+    builder.put(
+        Message.builder()
+            .setWebpushConfig(WebpushConfig.builder()
+                .putHeader("h1", "v1")
+                .putAllHeaders(ImmutableMap.of("h2", "v2", "h3", "v3"))
+                .putData("k1", "v1")
+                .putAllData(ImmutableMap.of("k2", "v2", "k3", "v3"))
+                .setNotification(new WebpushNotification("test-title", "test-body", "test-icon"))
+                .build())
+            .setTopic("test-topic")
+            .build(),
+        ImmutableMap.<String, Object>of(
+            "topic", "test-topic",
+            "webpush", ImmutableMap.of(
+                "headers", ImmutableMap.of("h1", "v1", "h2", "v2", "h3", "v3"),
+                "data", ImmutableMap.of("k1", "v1", "k2", "v2", "k3", "v3"),
+                "notification", ImmutableMap.of(
+                    "title", "test-title", "body", "test-body", "icon", "test-icon"))
+        ));
+
+    return builder.build();
+  }
+}
diff --git a/src/test/java/com/google/firebase/messaging/MessageTest.java b/src/test/java/com/google/firebase/messaging/MessageTest.java
new file mode 100644
index 0000000..21caeeb
--- /dev/null
+++ b/src/test/java/com/google/firebase/messaging/MessageTest.java
@@ -0,0 +1,487 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.messaging;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+import com.google.api.client.googleapis.util.Utils;
+import com.google.api.client.json.JsonFactory;
+import com.google.api.client.json.JsonParser;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.firebase.messaging.AndroidConfig.Priority;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import org.junit.Test;
+
+public class MessageTest {
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testMessageWithoutTarget() throws IOException {
+    Message.builder().build();
+  }
+
+  @Test
+  public void testEmptyMessage() throws IOException {
+    assertJsonEquals(ImmutableMap.of("topic", "test-topic"),
+        Message.builder().setTopic("test-topic").build());
+    assertJsonEquals(ImmutableMap.of("condition", "'foo' in topics"),
+        Message.builder().setCondition("'foo' in topics").build());
+    assertJsonEquals(ImmutableMap.of("token", "test-token"),
+        Message.builder().setToken("test-token").build());
+  }
+
+  @Test
+  public void testDataMessage() throws IOException {
+    Message message = Message.builder()
+        .putData("k1", "v1")
+        .putData("k2", "v2")
+        .putAllData(ImmutableMap.of("k3", "v3", "k4", "v4"))
+        .setTopic("test-topic")
+        .build();
+    Map<String, String> data = ImmutableMap.of("k1", "v1", "k2", "v2", "k3", "v3", "k4", "v4");
+    assertJsonEquals(ImmutableMap.of("topic", "test-topic", "data", data), message);
+  }
+
+  @Test
+  public void testInvalidTopicNames() {
+    List<String> invalidTopicNames = ImmutableList.of("/topics/", "/foo/bar", "foo bar");
+    for (String topicName : invalidTopicNames) {
+      try {
+        Message.builder().setTopic(topicName).build();
+      } catch (IllegalArgumentException expected) {
+        // expected
+      }
+    }
+  }
+
+  @Test
+  public void testPrefixedTopicName() throws IOException {
+    Message message = Message.builder()
+        .setTopic("/topics/test-topic")
+        .build();
+    assertJsonEquals(ImmutableMap.of("topic", "test-topic"), message);
+  }
+
+  @Test
+  public void testNotificationMessage() throws IOException {
+    Message message = Message.builder()
+        .setNotification(new Notification("title", "body"))
+        .setTopic("test-topic")
+        .build();
+    Map<String, String> data = ImmutableMap.of("title", "title", "body", "body");
+    assertJsonEquals(ImmutableMap.of("topic", "test-topic", "notification", data), message);
+  }
+
+  @Test
+  public void testEmptyAndroidMessage() throws IOException {
+    Message message = Message.builder()
+        .setAndroidConfig(AndroidConfig.builder().build())
+        .setTopic("test-topic")
+        .build();
+    Map<String, Object> data = ImmutableMap.of();
+    assertJsonEquals(ImmutableMap.of("topic", "test-topic", "android", data), message);
+  }
+
+  @Test
+  public void testAndroidMessageWithoutNotification() throws IOException {
+    Message message = Message.builder()
+        .setAndroidConfig(AndroidConfig.builder()
+            .setCollapseKey("test-key")
+            .setPriority(Priority.HIGH)
+            .setTtl(10)
+            .setRestrictedPackageName("test-pkg-name")
+            .putData("k1", "v1")
+            .putAllData(ImmutableMap.of("k2", "v2", "k3", "v3"))
+            .build())
+        .setTopic("test-topic")
+        .build();
+    Map<String, Object> data = ImmutableMap.<String, Object>of(
+        "collapse_key", "test-key",
+        "priority", "high",
+        "ttl", "0.010000000s", // 10 ms = 10,000,000 ns
+        "restricted_package_name", "test-pkg-name",
+        "data", ImmutableMap.of("k1", "v1", "k2", "v2", "k3", "v3")
+    );
+    assertJsonEquals(ImmutableMap.of("topic", "test-topic", "android", data), message);
+  }
+
+  @Test
+  public void testAndroidMessageWithNotification() throws IOException {
+    Message message = Message.builder()
+        .setAndroidConfig(AndroidConfig.builder()
+            .setCollapseKey("test-key")
+            .setPriority(Priority.HIGH)
+            .setTtl(TimeUnit.DAYS.toMillis(30))
+            .setRestrictedPackageName("test-pkg-name")
+            .setNotification(AndroidNotification.builder()
+                .setTitle("android-title")
+                .setBody("android-body")
+                .setIcon("android-icon")
+                .setSound("android-sound")
+                .setColor("#112233")
+                .setTag("android-tag")
+                .setClickAction("android-click")
+                .setTitleLocalizationKey("title-loc")
+                .addTitleLocalizationArg("title-arg1")
+                .addAllTitleLocalizationArgs(ImmutableList.of("title-arg2", "title-arg3"))
+                .setBodyLocalizationKey("body-loc")
+                .addBodyLocalizationArg("body-arg1")
+                .addAllBodyLocalizationArgs(ImmutableList.of("body-arg2", "body-arg3"))
+                .build())
+            .build())
+        .setTopic("test-topic")
+        .build();
+    Map<String, Object> notification = ImmutableMap.<String, Object>builder()
+        .put("title", "android-title")
+        .put("body", "android-body")
+        .put("icon", "android-icon")
+        .put("sound", "android-sound")
+        .put("color", "#112233")
+        .put("tag", "android-tag")
+        .put("click_action", "android-click")
+        .put("title_loc_key", "title-loc")
+        .put("title_loc_args", ImmutableList.of("title-arg1", "title-arg2", "title-arg3"))
+        .put("body_loc_key", "body-loc")
+        .put("body_loc_args", ImmutableList.of("body-arg1", "body-arg2", "body-arg3"))
+        .build();
+    Map<String, Object> data = ImmutableMap.of(
+        "collapse_key", "test-key",
+        "priority", "high",
+        "ttl", "2592000s",
+        "restricted_package_name", "test-pkg-name",
+        "notification", notification
+    );
+    assertJsonEquals(ImmutableMap.of("topic", "test-topic", "android", data), message);
+  }
+
+  @Test
+  public void testAndroidMessageWithoutLocalization() throws IOException {
+    Message message = Message.builder()
+        .setAndroidConfig(AndroidConfig.builder()
+            .setCollapseKey("test-key")
+            .setPriority(Priority.NORMAL)
+            .setTtl(TimeUnit.SECONDS.toMillis(10))
+            .setRestrictedPackageName("test-pkg-name")
+            .setNotification(AndroidNotification.builder()
+                .setTitle("android-title")
+                .setBody("android-body")
+                .setIcon("android-icon")
+                .setSound("android-sound")
+                .setColor("#112233")
+                .setTag("android-tag")
+                .setClickAction("android-click")
+                .build())
+            .build())
+        .setTopic("test-topic")
+        .build();
+    Map<String, Object> notification = ImmutableMap.<String, Object>builder()
+        .put("title", "android-title")
+        .put("body", "android-body")
+        .put("icon", "android-icon")
+        .put("sound", "android-sound")
+        .put("color", "#112233")
+        .put("tag", "android-tag")
+        .put("click_action", "android-click")
+        .build();
+    Map<String, Object> data = ImmutableMap.of(
+        "collapse_key", "test-key",
+        "priority", "normal",
+        "ttl", "10s",
+        "restricted_package_name", "test-pkg-name",
+        "notification", notification
+    );
+    assertJsonEquals(ImmutableMap.of("topic", "test-topic", "android", data), message);
+  }
+
+  @Test
+  public void testInvalidAndroidConfig() throws IOException {
+    try {
+      AndroidConfig.builder().setTtl(-1).build();
+      fail("No error thrown for invalid ttl");
+    } catch (IllegalArgumentException expected) {
+      // expected
+    }
+
+    List<AndroidNotification.Builder> notificationBuilders = ImmutableList.of(
+        AndroidNotification.builder().setColor(""),
+        AndroidNotification.builder().setColor("foo"),
+        AndroidNotification.builder().setColor("123"),
+        AndroidNotification.builder().setColor("#AABBCK"),
+        AndroidNotification.builder().addBodyLocalizationArg("foo"),
+        AndroidNotification.builder().addTitleLocalizationArg("foo")
+    );
+    for (int i = 0; i < notificationBuilders.size(); i++) {
+      try {
+        notificationBuilders.get(i).build();
+        fail("No error thrown for invalid notification: " + i);
+      } catch (IllegalArgumentException expected) {
+        // expected
+      }
+    }
+  }
+
+  @Test
+  public void testEmptyWebpushMessage() throws IOException {
+    Message message = Message.builder()
+        .setWebpushConfig(WebpushConfig.builder().build())
+        .setTopic("test-topic")
+        .build();
+    Map<String, Object> data = ImmutableMap.of();
+    assertJsonEquals(ImmutableMap.of("topic", "test-topic", "webpush", data), message);
+  }
+
+  @Test
+  public void testWebpushMessageWithoutNotification() throws IOException {
+    Message message = Message.builder()
+        .setWebpushConfig(WebpushConfig.builder()
+            .putHeader("k1", "v1")
+            .putAllHeaders(ImmutableMap.of("k2", "v2", "k3", "v3"))
+            .putData("k1", "v1")
+            .putAllData(ImmutableMap.of("k2", "v2", "k3", "v3"))
+            .build())
+        .setTopic("test-topic")
+        .build();
+    Map<String, Object> data = ImmutableMap.<String, Object>of(
+        "headers", ImmutableMap.of("k1", "v1", "k2", "v2", "k3", "v3"),
+        "data", ImmutableMap.of("k1", "v1", "k2", "v2", "k3", "v3")
+    );
+    assertJsonEquals(ImmutableMap.of("topic", "test-topic", "webpush", data), message);
+  }
+
+  @Test
+  public void testWebpushMessageWithNotification() throws IOException {
+    Message message = Message.builder()
+        .setWebpushConfig(WebpushConfig.builder()
+            .putHeader("k1", "v1")
+            .putAllHeaders(ImmutableMap.of("k2", "v2", "k3", "v3"))
+            .putData("k1", "v1")
+            .putAllData(ImmutableMap.of("k2", "v2", "k3", "v3"))
+            .setNotification(new WebpushNotification(
+                "webpush-title", "webpush-body", "webpush-icon"))
+            .build())
+        .setTopic("test-topic")
+        .build();
+    Map<String, Object> notification = ImmutableMap.<String, Object>builder()
+        .put("title", "webpush-title")
+        .put("body", "webpush-body")
+        .put("icon", "webpush-icon")
+        .build();
+    Map<String, Object> data = ImmutableMap.<String, Object>of(
+        "headers", ImmutableMap.of("k1", "v1", "k2", "v2", "k3", "v3"),
+        "data", ImmutableMap.of("k1", "v1", "k2", "v2", "k3", "v3"),
+        "notification", notification
+    );
+    assertJsonEquals(ImmutableMap.of("topic", "test-topic", "webpush", data), message);
+
+    // Test notification without icon
+    message = Message.builder()
+        .setWebpushConfig(WebpushConfig.builder()
+            .putHeader("k1", "v1")
+            .putAllHeaders(ImmutableMap.of("k2", "v2", "k3", "v3"))
+            .putData("k1", "v1")
+            .putAllData(ImmutableMap.of("k2", "v2", "k3", "v3"))
+            .setNotification(new WebpushNotification("webpush-title", "webpush-body"))
+            .build())
+        .setTopic("test-topic")
+        .build();
+    notification = ImmutableMap.<String, Object>builder()
+        .put("title", "webpush-title")
+        .put("body", "webpush-body")
+        .build();
+    data = ImmutableMap.<String, Object>of(
+        "headers", ImmutableMap.of("k1", "v1", "k2", "v2", "k3", "v3"),
+        "data", ImmutableMap.of("k1", "v1", "k2", "v2", "k3", "v3"),
+        "notification", notification
+    );
+    assertJsonEquals(ImmutableMap.of("topic", "test-topic", "webpush", data), message);
+  }
+
+  @Test
+  public void testEmptyApnsMessage() throws IOException {
+    Message message = Message.builder()
+        .setApnsConfig(ApnsConfig.builder().setAps(Aps.builder().build()).build())
+        .setTopic("test-topic")
+        .build();
+    Map<String, Object> data = ImmutableMap.<String, Object>of("payload",
+        ImmutableMap.of("aps", ImmutableMap.of()));
+    assertJsonEquals(ImmutableMap.of("topic", "test-topic", "apns", data), message);
+  }
+
+  @Test
+  public void testApnsMessageWithPayload() throws IOException {
+    Message message = Message.builder()
+        .setApnsConfig(ApnsConfig.builder()
+            .putHeader("k1", "v1")
+            .putAllHeaders(ImmutableMap.of("k2", "v2", "k3", "v3"))
+            .putCustomData("cd1", "cd-v1")
+            .putAllCustomData(ImmutableMap.<String, Object>of("cd2", "cd-v2", "cd3", true))
+            .setAps(Aps.builder().build())
+            .build())
+        .setTopic("test-topic")
+        .build();
+
+    Map<String, Object> payload = ImmutableMap.<String, Object>builder()
+        .put("cd1", "cd-v1")
+        .put("cd2", "cd-v2")
+        .put("cd3", true)
+        .put("aps", ImmutableMap.of())
+        .build();
+    Map<String, Object> data = ImmutableMap.<String, Object>of(
+        "headers", ImmutableMap.of("k1", "v1", "k2", "v2", "k3", "v3"),
+        "payload", payload
+    );
+    assertJsonEquals(ImmutableMap.of("topic", "test-topic", "apns", data), message);
+  }
+
+  @Test
+  public void testApnsMessageWithPayloadAndAps() throws IOException {
+    Message message = Message.builder()
+        .setApnsConfig(ApnsConfig.builder()
+            .putCustomData("cd1", "cd-v1")
+            .setAps(Aps.builder()
+                .setAlert("alert string")
+                .setBadge(42)
+                .setCategory("test-category")
+                .setContentAvailable(true)
+                .setSound("test-sound")
+                .setThreadId("test-thread-id")
+                .build())
+            .build())
+        .setTopic("test-topic")
+        .build();
+    Map<String, Object> payload = ImmutableMap.<String, Object>of(
+        "cd1", "cd-v1",
+        "aps", ImmutableMap.builder()
+            .put("alert", "alert string")
+            .put("badge", new BigDecimal(42))
+            .put("category", "test-category")
+            .put("content-available", new BigDecimal(1))
+            .put("sound", "test-sound")
+            .put("thread-id", "test-thread-id")
+            .build());
+    assertJsonEquals(
+        ImmutableMap.of(
+            "topic", "test-topic",
+            "apns", ImmutableMap.<String, Object>of("payload", payload)),
+        message);
+
+    message = Message.builder()
+        .setApnsConfig(ApnsConfig.builder()
+            .putCustomData("cd1", "cd-v1")
+            .setAps(Aps.builder()
+                .setAlert(ApsAlert.builder()
+                    .setTitle("test-title")
+                    .setBody("test-body")
+                    .setLocalizationKey("test-loc-key")
+                    .setActionLocalizationKey("test-action-loc-key")
+                    .setTitleLocalizationKey("test-title-loc-key")
+                    .addLocalizationArg("arg1")
+                    .addAllLocalizationArgs(ImmutableList.of("arg2", "arg3"))
+                    .addTitleLocalizationArg("arg4")
+                    .addAllTitleLocArgs(ImmutableList.of("arg5", "arg6"))
+                    .setLaunchImage("test-image")
+                    .build())
+                .setCategory("test-category")
+                .setSound("test-sound")
+                .setThreadId("test-thread-id")
+                .build())
+            .build())
+        .setTopic("test-topic")
+        .build();
+    payload = ImmutableMap.<String, Object>of(
+        "cd1", "cd-v1",
+        "aps", ImmutableMap.<String, Object>builder()
+            .put("alert", ImmutableMap.<String, Object>builder()
+                .put("title", "test-title")
+                .put("body", "test-body")
+                .put("loc-key", "test-loc-key")
+                .put("action-loc-key", "test-action-loc-key")
+                .put("title-loc-key", "test-title-loc-key")
+                .put("loc-args", ImmutableList.of("arg1", "arg2", "arg3"))
+                .put("title-loc-args", ImmutableList.of("arg4", "arg5", "arg6"))
+                .put("launch-image", "test-image")
+                .build())
+            .put("category", "test-category")
+            .put("sound", "test-sound")
+            .put("thread-id", "test-thread-id")
+            .build());
+    assertJsonEquals(
+        ImmutableMap.of(
+            "topic", "test-topic",
+            "apns", ImmutableMap.<String, Object>of("payload", payload)),
+        message);
+  }
+
+  @Test
+  public void testInvalidApnsConfig() {
+    List<ApnsConfig.Builder> configBuilders = ImmutableList.of(
+        ApnsConfig.builder(),
+        ApnsConfig.builder().putCustomData("aps", "foo"),
+        ApnsConfig.builder().putCustomData("aps", "foo").setAps(Aps.builder().build())
+    );
+    for (int i = 0; i < configBuilders.size(); i++) {
+      try {
+        configBuilders.get(i).build();
+        fail("No error thrown for invalid config: " + i);
+      } catch (IllegalArgumentException expected) {
+        // expected
+      }
+    }
+
+    Aps.Builder builder = Aps.builder().setAlert("string").setAlert(ApsAlert.builder().build());
+    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")
+    );
+    for (int i = 0; i < notificationBuilders.size(); i++) {
+      try {
+        notificationBuilders.get(i).build();
+        fail("No error thrown for invalid alert: " + i);
+      } catch (IllegalArgumentException expected) {
+        // expected
+      }
+    }
+  }
+
+  private static void assertJsonEquals(
+      Map expected, Object actual) throws IOException {
+    assertEquals(expected, toMap(actual));
+  }
+
+  private static Map<String, Object> toMap(Object object) throws IOException {
+    JsonFactory jsonFactory = Utils.getDefaultJsonFactory();
+    String json = jsonFactory.toString(object);
+    JsonParser parser = jsonFactory.createJsonParser(json);
+    Map<String, Object> map = new HashMap<>();
+    parser.parse(map);
+    return map;
+  }
+
+}