diff --git a/CHANGELOG.md b/CHANGELOG.md index afc4ad9dc..fb55a1953 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 000000000..065376a79 --- /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 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 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 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 000000000..27f315591 --- /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 bodyLocArgs; + + @Key("title_loc_key") + private final String titleLocKey; + + @Key("title_loc_args") + private final List 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 bodyLocArgs = new ArrayList<>(); + private String titleLocKey; + private List 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 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 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 000000000..40d76d6b2 --- /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 + * + * Apple documentation for various headers and payload fields supported by APNS. + */ +public class ApnsConfig { + + @Key("headers") + private final Map headers; + + @Key("payload") + private final Map 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.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 headers = new HashMap<>(); + private final Map 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 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 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 000000000..c35d550d4 --- /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 + * aps dictionary 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 000000000..6f7e249f3 --- /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 + * alert property 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 locArgs; + + @Key("title-loc-key") + private final String titleLocKey; + + @Key("title-loc-args") + private final List 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 locArgs = new ArrayList<>(); + private String titleLocKey; + private List 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 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 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 000000000..00b68c681 --- /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. + * + *

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 FCM_ERROR_CODES = + ImmutableMap.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 IID_ERROR_CODES = + ImmutableMap.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 sendAsync(@NonNull Message message) { + return sendAsync(message, false); + } + + /** + * Sends the given {@link Message} via Firebase Cloud Messaging. + * + *

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 sendAsync(@NonNull Message message, boolean dryRun) { + return new TaskToApiFuture<>(send(message, dryRun)); + } + + private Task send(final Message message, final boolean dryRun) { + checkNotNull(message, "message must not be null"); + return ImplFirebaseTrampolines.submitCallable(app, new Callable() { + @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 subscribeToTopicAsync( + @NonNull List registrationTokens, @NonNull String topic) { + return new TaskToApiFuture<>(subscribeToTopic(registrationTokens, topic)); + } + + private Task subscribeToTopic( + final List registrationTokens, final String topic) { + checkRegistrationTokens(registrationTokens); + checkTopic(topic); + + return ImplFirebaseTrampolines.submitCallable(app, new Callable() { + @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 unsubscribeFromTopicAsync( + @NonNull List registrationTokens, @NonNull String topic) { + return new TaskToApiFuture<>(unsubscribeFromTopic(registrationTokens, topic)); + } + + private Task unsubscribeFromTopic( + final List registrationTokens, final String topic) { + checkRegistrationTokens(registrationTokens); + checkTopic(topic); + + return ImplFirebaseTrampolines.submitCallable(app, new Callable() { + @Override + public TopicManagementResponse call() throws FirebaseMessagingException { + return makeTopicManagementRequest(registrationTokens, topic, IID_UNSUBSCRIBE_PATH); + } + }); + } + + private String makeSendRequest(Message message, + boolean dryRun) throws FirebaseMessagingException { + ImmutableMap.Builder payload = ImmutableMap.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 registrationTokens, + String topic, String path) throws FirebaseMessagingException { + if (!topic.startsWith("/topics/")) { + topic = "/topics/" + topic; + } + Map 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 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 { + + 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 error; + + + String getString(String key) { + if (error != null) { + return (String) error.get(key); + } + return null; + } + } + + private static class InstanceIdServiceResponse { + @Key("results") + private List> 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 000000000..5f57474ea --- /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 000000000..564c8896e --- /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 + * FCM message + * format + */ +public class Message { + + @Key("data") + private final Map 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 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 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 000000000..eaa1e1443 --- /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 000000000..230c64df2 --- /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 ERROR_CODES = ImmutableMap.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 errors; + + TopicManagementResponse(List> results) { + int successCount = 0; + ImmutableList.Builder 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 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 000000000..6b5ac4ac2 --- /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 headers; + + @Key("data") + private final Map 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 headers = new HashMap<>(); + private final Map data = new HashMap<>(); + private WebpushNotification notification; + + private Builder() {} + + /** + * Adds the given key-value pair as a Webpush HTTP header. Refer to + * Webpush specification + * 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 + * Webpush specification + * 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 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 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 000000000..92876c562 --- /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; + } +} diff --git a/src/test/java/com/google/firebase/auth/FirebaseAuthTest.java b/src/test/java/com/google/firebase/auth/FirebaseAuthTest.java index 383aa2cd9..e2c709ccd 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.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.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 static Collection data() throws Exception { }); } - 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 environmentVariables = - ImmutableMap.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 000000000..f309889e8 --- /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 000000000..ac55255b9 --- /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 HTTP_ERRORS = ImmutableList.of(401, 404, 500); + private static final String MOCK_RESPONSE = "{\"name\": \"mock-name\"}"; + + private static final ImmutableList.Builder tooManyIds = ImmutableList.builder(); + + static { + for (int i = 0; i < 1001; i++) { + tooManyIds.add("id" + i); + } + } + + private static final List INVALID_TOPIC_MGT_ARGS = ImmutableList.of( + new TopicMgtArgs(null, null), + new TopicMgtArgs(null, "test-topic"), + new TopicMgtArgs(ImmutableList.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> testMessages = buildTestMessages(); + + for (Map.Entry> 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.of("message", entry.getValue())); + } + } + + @Test + public void testSendDryRun() throws Exception { + MockLowLevelHttpResponse response = new MockLowLevelHttpResponse() + .setContent(MOCK_RESPONSE); + FirebaseMessaging messaging = initMessaging(response); + Map> testMessages = buildTestMessages(); + + for (Map.Entry> 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 expected) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + request.getContent().writeTo(out); + JsonParser parser = Utils.getDefaultJsonFactory().createJsonParser(out.toString()); + Map 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 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 registrationTokens; + private final String topic; + + TopicMgtArgs(List registrationTokens, String topic) { + this.registrationTokens = registrationTokens; + this.topic = topic; + } + } + + private static Map> buildTestMessages() { + ImmutableMap.Builder> builder = ImmutableMap.builder(); + + // Empty message + builder.put( + Message.builder().setTopic("test-topic").build(), + ImmutableMap.of("topic", "test-topic")); + + // Notification message + builder.put( + Message.builder() + .setNotification(new Notification("test title", "test body")) + .setTopic("test-topic") + .build(), + ImmutableMap.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.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.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.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.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.of("badge", new BigDecimal(42), + "alert", ImmutableMap.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.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 000000000..21caeeb18 --- /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 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 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 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 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 data = ImmutableMap.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 notification = ImmutableMap.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 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 notification = ImmutableMap.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 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 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 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 data = ImmutableMap.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 notification = ImmutableMap.builder() + .put("title", "webpush-title") + .put("body", "webpush-body") + .put("icon", "webpush-icon") + .build(); + Map data = ImmutableMap.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.builder() + .put("title", "webpush-title") + .put("body", "webpush-body") + .build(); + data = ImmutableMap.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 data = ImmutableMap.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.of("cd2", "cd-v2", "cd3", true)) + .setAps(Aps.builder().build()) + .build()) + .setTopic("test-topic") + .build(); + + Map payload = ImmutableMap.builder() + .put("cd1", "cd-v1") + .put("cd2", "cd-v2") + .put("cd3", true) + .put("aps", ImmutableMap.of()) + .build(); + Map data = ImmutableMap.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 payload = ImmutableMap.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.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.of( + "cd1", "cd-v1", + "aps", ImmutableMap.builder() + .put("alert", ImmutableMap.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.of("payload", payload)), + message); + } + + @Test + public void testInvalidApnsConfig() { + List 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 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 toMap(Object object) throws IOException { + JsonFactory jsonFactory = Utils.getDefaultJsonFactory(); + String json = jsonFactory.toString(object); + JsonParser parser = jsonFactory.createJsonParser(json); + Map map = new HashMap<>(); + parser.parse(map); + return map; + } + +}