diff --git a/core-android/src/main/java/io/ably/pubsub/push/FcmTopic.java b/core-android/src/main/java/io/ably/pubsub/push/FcmTopic.java new file mode 100644 index 000000000..acc7539fb --- /dev/null +++ b/core-android/src/main/java/io/ably/pubsub/push/FcmTopic.java @@ -0,0 +1,77 @@ +package io.ably.pubsub.push; + +import io.ably.pubsub.http.HttpRequester; +import io.ably.pubsub.http.PubSubBase; +import io.ably.pubsub.realtime.CompletionListener; +import io.ably.pubsub.types.AblyException; + +/** + * A single FCM topic that this device can subscribe to or unsubscribe from. + * Obtained from {@link FcmTopics#get(String)}. + */ +public class FcmTopic { + /** + * The FCM topic name. + */ + public final String name; + + private final PubSubBase rest; + private final Push push; + + FcmTopic(PubSubBase rest, Push push, String name) { + this.rest = rest; + this.push = push; + this.name = name; + } + + /** + * Subscribes this device to the topic. Subscribing when already subscribed succeeds. + *

+ * If FCM rejects the device's registration token, Ably marks the device registration failed + * and the request fails; the device must then activate again. + * + * @throws AblyException + */ + public void subscribe() throws AblyException { + subscribeImpl().sync(); + } + + /** + * Asynchronously subscribes this device to the topic. See {@link #subscribe()}. + * + * @param listener A listener to be notified of success or failure. + *

+ * This listener is invoked on a background thread. + */ + public void subscribeAsync(CompletionListener listener) { + subscribeImpl().async(new CompletionListener.ToCallback<>(listener)); + } + + /** + * Unsubscribes this device from the topic. Unsubscribing when not subscribed succeeds. + * + * @throws AblyException + */ + public void unsubscribe() throws AblyException { + unsubscribeImpl().sync(); + } + + /** + * Asynchronously unsubscribes this device from the topic. See {@link #unsubscribe()}. + * + * @param listener A listener to be notified of success or failure. + *

+ * This listener is invoked on a background thread. + */ + public void unsubscribeAsync(CompletionListener listener) { + unsubscribeImpl().async(new CompletionListener.ToCallback<>(listener)); + } + + private HttpRequester.Request subscribeImpl() { + return push.localDeviceRequest((deviceId, headers) -> AdminFcmTopics.subscribeDeviceImpl(rest, headers, name, deviceId)); + } + + private HttpRequester.Request unsubscribeImpl() { + return push.localDeviceRequest((deviceId, headers) -> AdminFcmTopics.unsubscribeDeviceImpl(rest, headers, name, deviceId)); + } +} diff --git a/core-android/src/main/java/io/ably/pubsub/push/FcmTopics.java b/core-android/src/main/java/io/ably/pubsub/push/FcmTopics.java new file mode 100644 index 000000000..00dc439f0 --- /dev/null +++ b/core-android/src/main/java/io/ably/pubsub/push/FcmTopics.java @@ -0,0 +1,65 @@ +package io.ably.pubsub.push; + +import io.ably.pubsub.http.HttpRequester; +import io.ably.pubsub.http.PubSubBase; +import io.ably.pubsub.types.AblyException; +import io.ably.pubsub.types.Callback; + +/** + * Manages this device's FCM topic subscriptions. + *

+ * FCM topics let FCM fan a single send out to every device subscribed to the topic. A topic exists + * once its first device subscribes; there is no create or delete call. Topic names must match + * {@code [a-zA-Z0-9-_.~%]{1,900}}. FCM allows at most 2,000 topic subscriptions per device and never + * removes them automatically, so unsubscribe when the device no longer needs a topic. + *

+ * The device must be activated for push with FCM ({@link Push#activate()} must have completed) before + * using these methods. Requests authenticate as the device, so no push-admin capability is needed. + */ +public class FcmTopics { + private final PubSubBase rest; + private final Push push; + + FcmTopics(PubSubBase rest, Push push) { + this.rest = rest; + this.push = push; + } + + /** + * Returns an {@link FcmTopic} for subscribing this device to, or unsubscribing it from, the named topic. + * An empty topic name is reported as an {@link AblyException} by the operations on the returned object. + * + * @param topic The FCM topic name. + * @return An {@link FcmTopic} object. + */ + public FcmTopic get(String topic) { + return new FcmTopic(rest, push, topic); + } + + /** + * Retrieves the names of the FCM topics this device is subscribed to, + * including any it subscribed to other than through Ably. + * + * @return The topic names. + * @throws AblyException + */ + public String[] list() throws AblyException { + return listImpl().sync(); + } + + /** + * Asynchronously retrieves the names of the FCM topics this device is subscribed to, + * including any it subscribed to other than through Ably. + * + * @param callback A callback returning the topic names. + *

+ * This callback is invoked on a background thread. + */ + public void listAsync(Callback callback) { + listImpl().async(callback); + } + + private HttpRequester.Request listImpl() { + return push.localDeviceRequest((deviceId, headers) -> AdminFcmTopics.listImpl(rest, headers, deviceId)); + } +} diff --git a/core-android/src/main/java/io/ably/pubsub/push/Push.java b/core-android/src/main/java/io/ably/pubsub/push/Push.java index 05be1ee29..2ce66e5ab 100644 --- a/core-android/src/main/java/io/ably/pubsub/push/Push.java +++ b/core-android/src/main/java/io/ably/pubsub/push/Push.java @@ -3,6 +3,7 @@ import android.content.Context; import android.content.SharedPreferences; import android.preference.PreferenceManager; +import io.ably.pubsub.http.HttpRequester; import io.ably.pubsub.http.HttpUtils; import io.ably.pubsub.http.PubSubBase; import io.ably.pubsub.http.PubSubHttpClient; @@ -18,8 +19,14 @@ */ public class Push extends PushBase { + /** + * An {@link FcmTopics} object, for managing this device's FCM topic subscriptions. + */ + public final FcmTopics fcmTopics; + public Push(PubSubBase rest) { super(rest); + this.fcmTopics = new FcmTopics(rest, this); } /** @@ -122,6 +129,47 @@ public LocalDevice getLocalDevice() throws AblyException { return getActivationContext().getLocalDevice(); } + /** + * Returns the local device, failing if push activation has not completed, since the device has + * no ID or identity token to authenticate push requests with until then. + */ + LocalDevice requireActivatedDevice() throws AblyException { + LocalDevice localDevice = getLocalDevice(); + if (localDevice == null || localDevice.deviceIdentityToken == null) { + // Alternatively, we could store a queue of pending subscriptions in the + // device storage. But then, in order to know if this subscription operation + // succeeded, you would have to add a BroadcastReceiver in AndroidManifest.xml. + // Arguably that encourages just ignoring any errors, and forcing you to listen + // to the broadcast after push.activate has finished before subscribing is + // more robust. + throw AblyException.fromThrowable(new Exception("cannot use device before PubSubHttpClient.push.activate has finished")); + } + return localDevice; + } + + /** + * Builds a request for the local device, authenticated as it. + */ + interface LocalDeviceRequest { + HttpRequester.Request build(String deviceId, Param[] headers); + } + + /** + * Builds a request authenticated as the local device, or a failed request if push activation has + * not completed. The device ID and identity headers come from the same device read, so they + * cannot disagree if the device is reset concurrently. + */ + HttpRequester.Request localDeviceRequest(LocalDeviceRequest request) { + LocalDevice device; + try { + device = requireActivatedDevice(); + } catch (AblyException e) { + return rest.http.failedRequest(e); + } + Param[] headers = HttpUtils.mergeHeaders(super.pushRequestHeaders(false), device.deviceIdentityHeaders()); + return request.build(device.id, headers); + } + @Override Param[] pushRequestHeaders(boolean forLocalDevice) { Param[] headers = super.pushRequestHeaders(forLocalDevice); diff --git a/core-android/src/main/java/io/ably/pubsub/push/PushChannel.java b/core-android/src/main/java/io/ably/pubsub/push/PushChannel.java index 550ec6261..1c560559d 100644 --- a/core-android/src/main/java/io/ably/pubsub/push/PushChannel.java +++ b/core-android/src/main/java/io/ably/pubsub/push/PushChannel.java @@ -249,16 +249,6 @@ protected String getClientId() throws AblyException { } protected DeviceDetails getDevice() throws AblyException { - LocalDevice localDevice = rest.push.getActivationContext().getLocalDevice(); - if (localDevice == null || localDevice.deviceIdentityToken == null) { - // Alternatively, we could store a queue of pending subscriptions in the - // device storage. But then, in order to know if this subscription operation - // succeeded, you would have to add a BroadcastReceiver in AndroidManifest.xml. - // Arguably that encourages just ignoring any errors, and forcing you to listen - // to the broadcast after push.activate has finished before subscribing is - // more robust. - throw AblyException.fromThrowable(new Exception("cannot use device before PubSubHttpClient.push.activate has finished")); - } - return localDevice; + return rest.push.requireActivatedDevice(); } } diff --git a/lib/src/main/java/io/ably/pubsub/push/AdminFcmTopic.java b/lib/src/main/java/io/ably/pubsub/push/AdminFcmTopic.java new file mode 100644 index 000000000..3a9b77a44 --- /dev/null +++ b/lib/src/main/java/io/ably/pubsub/push/AdminFcmTopic.java @@ -0,0 +1,287 @@ +package io.ably.pubsub.push; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import io.ably.pubsub.http.HttpCore; +import io.ably.pubsub.http.HttpRequester; +import io.ably.pubsub.http.HttpScheduler; +import io.ably.pubsub.http.HttpUtils; +import io.ably.pubsub.http.PubSubBase; +import io.ably.pubsub.realtime.CompletionListener; +import io.ably.pubsub.types.AblyException; +import io.ably.pubsub.types.Callback; +import io.ably.pubsub.types.ErrorInfo; +import io.ably.pubsub.types.Param; +import io.ably.pubsub.util.Log; +import io.ably.pubsub.util.ParamsUtils; + +import java.util.Arrays; + +/** + * A single FCM topic, managed with push-admin privileges. Obtained from {@link AdminFcmTopics#get(String)}. + */ +public class AdminFcmTopic { + private static final String TAG = AdminFcmTopic.class.getName(); + + /** + * The FCM topic name. + */ + public final String name; + + private final PubSubBase rest; + private final PushBase push; + + AdminFcmTopic(PubSubBase rest, PushBase push, String name) { + this.rest = rest; + this.push = push; + this.name = name; + } + + /** + * Subscribes a device to the topic. Subscribing a device that is already subscribed succeeds. + *

+ * The device must be registered with Ably for FCM. + * + * @param deviceId The unique ID of the device. + * @throws AblyException + */ + public void subscribe(String deviceId) throws AblyException { + AdminFcmTopics.subscribeDeviceImpl(rest, push.pushRequestHeaders(deviceId), name, deviceId).sync(); + } + + /** + * Asynchronously subscribes a device to the topic. See {@link #subscribe(String)}. + * + * @param deviceId The unique ID of the device. + * @param listener A listener to be notified of success or failure. + *

+ * This listener is invoked on a background thread. + */ + public void subscribeAsync(String deviceId, CompletionListener listener) { + AdminFcmTopics.subscribeDeviceImpl(rest, push.pushRequestHeaders(deviceId), name, deviceId) + .async(new CompletionListener.ToCallback<>(listener)); + } + + /** + * Unsubscribes a device from the topic. Unsubscribing a device that is not subscribed succeeds. + * + * @param deviceId The unique ID of the device. + * @throws AblyException + */ + public void unsubscribe(String deviceId) throws AblyException { + AdminFcmTopics.unsubscribeDeviceImpl(rest, push.pushRequestHeaders(deviceId), name, deviceId).sync(); + } + + /** + * Asynchronously unsubscribes a device from the topic. See {@link #unsubscribe(String)}. + * + * @param deviceId The unique ID of the device. + * @param listener A listener to be notified of success or failure. + *

+ * This listener is invoked on a background thread. + */ + public void unsubscribeAsync(String deviceId, CompletionListener listener) { + AdminFcmTopics.unsubscribeDeviceImpl(rest, push.pushRequestHeaders(deviceId), name, deviceId) + .async(new CompletionListener.ToCallback<>(listener)); + } + + /** + * Requests that every FCM device registered for a clientId is subscribed to the topic. + *

+ * Ably accepts the request and subscribes the devices in the background, so success means the + * request was accepted, not that every device is subscribed. Devices registered for the clientId + * later are not subscribed. + * + * @param clientId The ID of the client whose devices to subscribe. + * @throws AblyException + */ + public void subscribeClient(String clientId) throws AblyException { + subscribeClientImpl(clientId).sync(); + } + + /** + * Asynchronously requests that every FCM device registered for a clientId is subscribed to the topic. + * See {@link #subscribeClient(String)}. + * + * @param clientId The ID of the client whose devices to subscribe. + * @param listener A listener to be notified of success or failure. + *

+ * This listener is invoked on a background thread. + */ + public void subscribeClientAsync(String clientId, CompletionListener listener) { + subscribeClientImpl(clientId).async(new CompletionListener.ToCallback<>(listener)); + } + + /** + * Requests that every FCM device subscribed to push notifications on any of the given channels + * is subscribed to the topic. + *

+ * Ably accepts the request and subscribes the devices in the background, so success means the + * request was accepted, not that every device is subscribed. This is a one-off batch, not a + * standing link: devices that subscribe to the channels later are not subscribed to the topic. + * + * @param channels The names of the channels whose subscribed devices to subscribe. + * @throws AblyException + */ + public void subscribeChannels(String[] channels) throws AblyException { + subscribeChannelsImpl(channels).sync(); + } + + /** + * Asynchronously requests that every FCM device subscribed to push notifications on any of the + * given channels is subscribed to the topic. See {@link #subscribeChannels(String[])}. + * + * @param channels The names of the channels whose subscribed devices to subscribe. + * @param listener A listener to be notified of success or failure. + *

+ * This listener is invoked on a background thread. + */ + public void subscribeChannelsAsync(String[] channels, CompletionListener listener) { + subscribeChannelsImpl(channels).async(new CompletionListener.ToCallback<>(listener)); + } + + /** + * Requests that every FCM device registered for a clientId is unsubscribed from the topic. + *

+ * Ably accepts the request and unsubscribes the devices in the background, so success means the + * request was accepted, not that every device is unsubscribed. + * + * @param clientId The ID of the client whose devices to unsubscribe. + * @throws AblyException + */ + public void unsubscribeClient(String clientId) throws AblyException { + unsubscribeClientImpl(clientId).sync(); + } + + /** + * Asynchronously requests that every FCM device registered for a clientId is unsubscribed from the topic. + * See {@link #unsubscribeClient(String)}. + * + * @param clientId The ID of the client whose devices to unsubscribe. + * @param listener A listener to be notified of success or failure. + *

+ * This listener is invoked on a background thread. + */ + public void unsubscribeClientAsync(String clientId, CompletionListener listener) { + unsubscribeClientImpl(clientId).async(new CompletionListener.ToCallback<>(listener)); + } + + /** + * Sends a push notification to every device subscribed to the topic. + *

+ * The payload carries FCM message fields ({@code data}, {@code notification}, {@code android}), + * which are passed through to FCM; Ably sets the topic itself. {@code android.priority} defaults + * to {@code high}. FCM requires every value in {@code data} to be a string. + * + * @param payload A JSON object containing the FCM message fields. + * @return The FCM message ID. + * @throws AblyException + */ + public String publish(JsonObject payload) throws AblyException { + return publishImpl(payload).sync(); + } + + /** + * Asynchronously sends a push notification to every device subscribed to the topic. + * See {@link #publish(JsonObject)}. + * + * @param payload A JSON object containing the FCM message fields. + * @param callback A callback returning the FCM message ID. + *

+ * This callback is invoked on a background thread. + */ + public void publishAsync(JsonObject payload, Callback callback) { + publishImpl(payload).async(callback); + } + + private HttpRequester.Request subscribeClientImpl(String clientId) { + Log.v(TAG, "subscribeClientImpl(): topic=" + name + ", clientId=" + clientId); + AblyException invalid = AdminFcmTopics.emptyArgument("topic", name, "clientId", clientId); + if (invalid != null) { + return rest.http.failedRequest(invalid); + } + JsonObject bodyJson = new JsonObject(); + bodyJson.addProperty("clientId", clientId); + return postSubscriptions(bodyJson); + } + + private HttpRequester.Request subscribeChannelsImpl(String[] channels) { + Log.v(TAG, "subscribeChannelsImpl(): topic=" + name + ", channels=" + Arrays.toString(channels)); + AblyException invalid = AdminFcmTopics.emptyArgument("topic", name); + if (invalid != null) { + return rest.http.failedRequest(invalid); + } + if (channels == null || channels.length == 0) { + return rest.http.failedRequest(AdminFcmTopics.missingArgument("channels")); + } + JsonArray channelsJson = new JsonArray(); + for (String channel : channels) { + invalid = AdminFcmTopics.emptyArgument("channel name", channel); + if (invalid != null) { + return rest.http.failedRequest(invalid); + } + channelsJson.add(channel); + } + JsonObject bodyJson = new JsonObject(); + bodyJson.add("channels", channelsJson); + return postSubscriptions(bodyJson); + } + + private HttpRequester.Request postSubscriptions(JsonObject bodyJson) { + final HttpCore.RequestBody body = HttpUtils.requestBodyFromGson(bodyJson, rest.options.useBinaryProtocol); + return rest.http.request(new HttpRequester.Execute() { + @Override + public void execute(HttpScheduler http, Callback callback) throws AblyException { + Param[] params = ParamsUtils.enrichParams(null, rest.options); + http.post(AdminFcmTopics.topicPath(name) + "/subscriptions", HttpUtils.defaultAcceptHeaders(rest.options.useBinaryProtocol), params, body, null, true, callback); + } + }); + } + + private HttpRequester.Request unsubscribeClientImpl(final String clientId) { + Log.v(TAG, "unsubscribeClientImpl(): topic=" + name + ", clientId=" + clientId); + AblyException invalid = AdminFcmTopics.emptyArgument("topic", name, "clientId", clientId); + if (invalid != null) { + return rest.http.failedRequest(invalid); + } + return rest.http.request(new HttpRequester.Execute() { + @Override + public void execute(HttpScheduler http, Callback callback) throws AblyException { + Param[] params = ParamsUtils.enrichParams(new Param[] { new Param("clientId", clientId) }, rest.options); + http.del(AdminFcmTopics.topicPath(name) + "/subscriptions", HttpUtils.defaultAcceptHeaders(rest.options.useBinaryProtocol), params, null, true, callback); + } + }); + } + + private HttpRequester.Request publishImpl(JsonObject payload) { + Log.v(TAG, "publishImpl(): topic=" + name + ", payload=" + payload); + AblyException invalid = AdminFcmTopics.emptyArgument("topic", name); + if (invalid != null) { + return rest.http.failedRequest(invalid); + } + if (payload == null || payload.entrySet().isEmpty()) { + return rest.http.failedRequest(AdminFcmTopics.missingArgument("payload")); + } + final HttpCore.RequestBody body = HttpUtils.requestBodyFromGson(payload, rest.options.useBinaryProtocol); + return rest.http.request(new HttpRequester.Execute() { + @Override + public void execute(HttpScheduler http, Callback callback) throws AblyException { + Param[] params = ParamsUtils.enrichParams(null, rest.options); + http.post(AdminFcmTopics.topicPath(name) + "/messages", HttpUtils.defaultAcceptHeaders(rest.options.useBinaryProtocol), params, body, messageIdResponseHandler, true, callback); + } + }); + } + + /** Reads the FCM message ID from the publish response, {@code {"topic": ..., "messageId": ...}}. */ + private static final HttpCore.ResponseHandler messageIdResponseHandler = new HttpCore.ResponseHandler() { + @Override + public String handleResponse(HttpCore.Response response, ErrorInfo error) throws AblyException { + // The publish has succeeded once the server accepts it, so a missing or malformed + // message ID is reported as null rather than as a failure. + JsonElement body = AdminFcmTopics.responseJson(response, error); + JsonElement messageId = body != null && body.isJsonObject() ? body.getAsJsonObject().get("messageId") : null; + return messageId != null && messageId.isJsonPrimitive() ? messageId.getAsString() : null; + } + }; +} diff --git a/lib/src/main/java/io/ably/pubsub/push/AdminFcmTopics.java b/lib/src/main/java/io/ably/pubsub/push/AdminFcmTopics.java new file mode 100644 index 000000000..0c63a1e09 --- /dev/null +++ b/lib/src/main/java/io/ably/pubsub/push/AdminFcmTopics.java @@ -0,0 +1,217 @@ +package io.ably.pubsub.push; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import io.ably.pubsub.http.HttpCore; +import io.ably.pubsub.http.HttpRequester; +import io.ably.pubsub.http.HttpScheduler; +import io.ably.pubsub.http.HttpUtils; +import io.ably.pubsub.http.PubSubBase; +import io.ably.pubsub.types.AblyException; +import io.ably.pubsub.types.Callback; +import io.ably.pubsub.types.ErrorInfo; +import io.ably.pubsub.types.Param; +import io.ably.pubsub.util.Log; +import io.ably.pubsub.util.ParamsUtils; +import io.ably.pubsub.util.Serialisation; +import io.ably.pubsub.util.StringUtils; + +/** + * Manages FCM topics with push-admin privileges: subscribing any device to a topic, + * listing a device's topics, and publishing to a topic. + *

+ * FCM topics let FCM fan a single send out to every Android device subscribed to the topic, + * instead of Ably sending one FCM request per device. Ably resolves devices to their FCM + * registration tokens and calls FCM with the app's service account, so callers never handle + * FCM credentials or tokens. + *

+ * A topic exists once its first device subscribes; there is no create or delete call. Topic names + * must match {@code [a-zA-Z0-9-_.~%]{1,900}}. FCM topics are open: any instance of the app can + * subscribe to any topic directly through the Firebase SDK, so never put user-specific or + * sensitive data in a topic message. + */ +public class AdminFcmTopics { + private static final String TAG = AdminFcmTopics.class.getName(); + + private final PubSubBase rest; + private final PushBase push; + + AdminFcmTopics(PubSubBase rest, PushBase push) { + this.rest = rest; + this.push = push; + } + + /** + * Returns an {@link AdminFcmTopic} for managing subscriptions to, and publishing to, the named topic. + * An empty topic name is reported as an {@link AblyException} by the operations on the returned object. + * + * @param topic The FCM topic name. + * @return An {@link AdminFcmTopic} object. + */ + public AdminFcmTopic get(String topic) { + return new AdminFcmTopic(rest, push, topic); + } + + /** + * Retrieves the names of the FCM topics a device is subscribed to, + * including any it subscribed to other than through Ably. + * + * @param deviceId The unique ID of the device. + * @return The topic names. + * @throws AblyException + */ + public String[] list(String deviceId) throws AblyException { + return listImpl(rest, push.pushRequestHeaders(deviceId), deviceId).sync(); + } + + /** + * Asynchronously retrieves the names of the FCM topics a device is subscribed to, + * including any it subscribed to other than through Ably. + * + * @param deviceId The unique ID of the device. + * @param callback A callback returning the topic names. + *

+ * This callback is invoked on a background thread. + */ + public void listAsync(String deviceId, Callback callback) { + listImpl(rest, push.pushRequestHeaders(deviceId), deviceId).async(callback); + } + + /** + * Sends a push notification to every device subscribed to an FCM topic. + *

+ * The payload carries FCM message fields ({@code data}, {@code notification}, {@code android}), + * which are passed through to FCM; Ably sets the topic itself. {@code android.priority} defaults + * to {@code high}. FCM requires every value in {@code data} to be a string. + * + * @param topic The FCM topic name. + * @param payload A JSON object containing the FCM message fields. + * @return The FCM message ID. + * @throws AblyException + */ + public String publish(String topic, JsonObject payload) throws AblyException { + return get(topic).publish(payload); + } + + /** + * Asynchronously sends a push notification to every device subscribed to an FCM topic. + * See {@link #publish(String, JsonObject)}. + * + * @param topic The FCM topic name. + * @param payload A JSON object containing the FCM message fields. + * @param callback A callback returning the FCM message ID. + *

+ * This callback is invoked on a background thread. + */ + public void publishAsync(String topic, JsonObject payload, Callback callback) { + get(topic).publishAsync(payload, callback); + } + + static String topicPath(String topic) { + return "/push/fcmTopics/" + HttpUtils.encodeURIComponent(topic); + } + + static String deviceSubscriptionPath(String topic, String deviceId) { + return topicPath(topic) + "/subscriptions/" + HttpUtils.encodeURIComponent(deviceId); + } + + static HttpRequester.Request subscribeDeviceImpl(final PubSubBase rest, final Param[] headers, final String topic, final String deviceId) { + Log.v(TAG, "subscribeDeviceImpl(): topic=" + topic + ", deviceId=" + deviceId); + AblyException invalid = emptyArgument("topic", topic, "deviceId", deviceId); + if (invalid != null) { + return rest.http.failedRequest(invalid); + } + // The endpoint reads no body, but HTTP clients such as OkHttp reject a POST without one. + final HttpCore.RequestBody body = HttpUtils.requestBodyFromGson(new JsonObject(), rest.options.useBinaryProtocol); + return rest.http.request(new HttpRequester.Execute() { + @Override + public void execute(HttpScheduler http, Callback callback) throws AblyException { + Param[] params = ParamsUtils.enrichParams(null, rest.options); + http.post(deviceSubscriptionPath(topic, deviceId), headers, params, body, null, true, callback); + } + }); + } + + static HttpRequester.Request unsubscribeDeviceImpl(final PubSubBase rest, final Param[] headers, final String topic, final String deviceId) { + Log.v(TAG, "unsubscribeDeviceImpl(): topic=" + topic + ", deviceId=" + deviceId); + AblyException invalid = emptyArgument("topic", topic, "deviceId", deviceId); + if (invalid != null) { + return rest.http.failedRequest(invalid); + } + return rest.http.request(new HttpRequester.Execute() { + @Override + public void execute(HttpScheduler http, Callback callback) throws AblyException { + Param[] params = ParamsUtils.enrichParams(null, rest.options); + http.del(deviceSubscriptionPath(topic, deviceId), headers, params, null, true, callback); + } + }); + } + + static HttpRequester.Request listImpl(final PubSubBase rest, final Param[] headers, final String deviceId) { + Log.v(TAG, "listImpl(): deviceId=" + deviceId); + AblyException invalid = emptyArgument("deviceId", deviceId); + if (invalid != null) { + return rest.http.failedRequest(invalid); + } + return rest.http.request(new HttpRequester.Execute() { + @Override + public void execute(HttpScheduler http, Callback callback) throws AblyException { + Param[] params = ParamsUtils.enrichParams(new Param[] { new Param("deviceId", deviceId) }, rest.options); + http.get("/push/fcmTopics", headers, params, topicsResponseHandler, true, callback); + } + }); + } + + /** + * Checks arguments given as alternating names and values. + * + * @return An error naming the first null or empty argument, or null if there is none. + */ + static AblyException emptyArgument(String... namesAndValues) { + for (int i = 0; i < namesAndValues.length; i += 2) { + if (StringUtils.isNullOrEmpty(namesAndValues[i + 1])) { + return missingArgument(namesAndValues[i]); + } + } + return null; + } + + static AblyException missingArgument(String name) { + return AblyException.fromErrorInfo(new ErrorInfo(name + " cannot be empty", 400, 40000)); + } + + /** + * Decodes a response body as JSON, throwing the request's error if it failed. + * + * @return The decoded body, or null if the response has no body. + */ + static JsonElement responseJson(HttpCore.Response response, ErrorInfo error) throws AblyException { + if (error != null) { + throw AblyException.fromErrorInfo(error); + } + if (response == null || response.body == null || response.body.length == 0) { + return null; + } + return Serialisation.httpResponseHandler.handleResponse(response, null); + } + + /** Reads the list response, a plain (unpaginated) array of topic names. */ + private static final HttpCore.ResponseHandler topicsResponseHandler = new HttpCore.ResponseHandler() { + @Override + public String[] handleResponse(HttpCore.Response response, ErrorInfo error) throws AblyException { + JsonElement body = responseJson(response, error); + if (body == null || body.isJsonNull()) { + return new String[0]; + } + try { + if (!body.isJsonArray()) { + throw new JsonParseException("expected an array of topic names, got " + body); + } + return Serialisation.gson.fromJson(body, String[].class); + } catch (JsonParseException e) { + throw AblyException.fromErrorInfo(e, new ErrorInfo("Unexpected response listing FCM topics: " + e.getMessage(), 500, 50000)); + } + } + }; +} diff --git a/lib/src/main/java/io/ably/pubsub/push/PushBase.java b/lib/src/main/java/io/ably/pubsub/push/PushBase.java index 421959284..236bec7c3 100644 --- a/lib/src/main/java/io/ably/pubsub/push/PushBase.java +++ b/lib/src/main/java/io/ably/pubsub/push/PushBase.java @@ -29,7 +29,7 @@ public class PushBase { public PushBase(PubSubBase rest) { this.rest = rest; - this.admin = new Admin(rest); + this.admin = new Admin(rest, this); } public static class Admin { @@ -47,11 +47,16 @@ public static class Admin { * Spec: RSH1c */ public final ChannelSubscriptions channelSubscriptions; + /** + * An {@link AdminFcmTopics} object, for managing and publishing to FCM topics. + */ + public final AdminFcmTopics fcmTopics; - Admin(PubSubBase rest) { + Admin(PubSubBase rest, PushBase push) { this.rest = rest; this.deviceRegistrations = new DeviceRegistrations(rest); this.channelSubscriptions = new ChannelSubscriptions(rest); + this.fcmTopics = new AdminFcmTopics(rest, push); } /** diff --git a/lib/src/test/kotlin/io/ably/pubsub/push/AdminFcmTopicsTest.kt b/lib/src/test/kotlin/io/ably/pubsub/push/AdminFcmTopicsTest.kt new file mode 100644 index 000000000..ddfcb33d5 --- /dev/null +++ b/lib/src/test/kotlin/io/ably/pubsub/push/AdminFcmTopicsTest.kt @@ -0,0 +1,176 @@ +package io.ably.pubsub.push + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import io.ably.pubsub.http.PubSubHttpClient +import io.ably.pubsub.types.AblyException +import io.ably.pubsub.uts.infra.unit.MockHttpClient +import io.ably.pubsub.uts.infra.unit.PendingRequest +import io.ably.pubsub.uts.infra.unit.TestHttpClient +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.CopyOnWriteArrayList + +/** + * Checks the requests the push-admin FCM topics API sends, against a mock HTTP engine. + */ +class AdminFcmTopicsTest { + + private val requests = CopyOnWriteArrayList() + + /** A client whose every request is recorded and answered with [status] and [body]. */ + private fun client(status: Int = 200, body: Any = emptyMap()): PubSubHttpClient { + val mock = MockHttpClient { + onConnectionAttempt = { it.respondWithSuccess() } + onRequest = { request -> + requests.add(request) + request.respondWith(status, body) + } + } + return TestHttpClient { install(mock) } + } + + private val request get() = requests.single() + + private fun bodyJson(): JsonObject = JsonParser.parseString(String(request.body)).asJsonObject + + @Test + fun `subscribe posts to the device subscription path`() { + client(body = mapOf("topic" to "team-manutd", "deviceId" to "device-1")) + .push.admin.fcmTopics.get("team-manutd").subscribe("device-1") + + assertEquals("POST", request.method) + assertEquals("/push/fcmTopics/team-manutd/subscriptions/device-1", request.url.path) + } + + @Test + fun `unsubscribe deletes the device subscription path`() { + client(status = 204, body = "").push.admin.fcmTopics.get("team-manutd").unsubscribe("device-1") + + assertEquals("DELETE", request.method) + assertEquals("/push/fcmTopics/team-manutd/subscriptions/device-1", request.url.path) + } + + @Test + fun `topic and device id are percent-encoded in the path`() { + // FCM topic names may contain %, which the server requires sent as %25. + client().push.admin.fcmTopics.get("50%off").subscribe("device/1") + + // java.net.URL.path is the raw, still-encoded path. + assertEquals("/push/fcmTopics/50%25off/subscriptions/device%2F1", request.url.path) + } + + @Test + fun `subscribeClient posts the clientId to the topic subscriptions`() { + client(status = 202, body = mapOf("topic" to "team-manutd")) + .push.admin.fcmTopics.get("team-manutd").subscribeClient("client-1") + + assertEquals("POST", request.method) + assertEquals("/push/fcmTopics/team-manutd/subscriptions", request.url.path) + assertEquals("client-1", bodyJson().get("clientId").asString) + assertTrue(!bodyJson().has("channels")) + } + + @Test + fun `subscribeChannels posts the channels to the topic subscriptions`() { + client(status = 202, body = mapOf("topic" to "team-manutd")) + .push.admin.fcmTopics.get("team-manutd").subscribeChannels(arrayOf("a", "b")) + + assertEquals("/push/fcmTopics/team-manutd/subscriptions", request.url.path) + val channels = bodyJson().getAsJsonArray("channels").map { it.asString } + assertEquals(listOf("a", "b"), channels) + } + + @Test + fun `unsubscribeClient deletes the topic subscriptions with a clientId param`() { + client(status = 202, body = mapOf("topic" to "team-manutd")) + .push.admin.fcmTopics.get("team-manutd").unsubscribeClient("client-1") + + assertEquals("DELETE", request.method) + assertEquals("/push/fcmTopics/team-manutd/subscriptions", request.url.path) + assertTrue(request.url.query.split("&").contains("clientId=client-1")) + } + + @Test + fun `list gets the device's topics`() { + val topics = client(body = listOf("team-manutd", "alerts-goals")).push.admin.fcmTopics.list("device-1") + + assertEquals("GET", request.method) + assertEquals("/push/fcmTopics", request.url.path) + assertTrue(request.url.query.split("&").contains("deviceId=device-1")) + assertArrayEquals(arrayOf("team-manutd", "alerts-goals"), topics) + } + + @Test + fun `publish posts the payload to the topic messages and returns the message id`() { + val payload = JsonObject().apply { + add("data", JsonObject().apply { addProperty("homeScore", "2") }) + add("android", JsonObject().apply { addProperty("ttl", "120s") }) + } + + val messageId = client(body = mapOf("topic" to "team-manutd", "messageId" to "projects/p/messages/1")) + .push.admin.fcmTopics.publish("team-manutd", payload) + + assertEquals("POST", request.method) + assertEquals("/push/fcmTopics/team-manutd/messages", request.url.path) + assertEquals(payload, bodyJson()) + assertEquals("projects/p/messages/1", messageId) + } + + @Test + fun `publish returns null when the response has no message id`() { + assertNull(client().push.admin.fcmTopics.get("team-manutd").publish(JsonObject().apply { + add("data", JsonObject()) + })) + } + + @Test + fun `publish returns null when the response has no body`() { + assertNull(client(status = 204, body = "").push.admin.fcmTopics.publish("team-manutd", JsonObject().apply { + add("data", JsonObject()) + })) + } + + @Test + fun `list with an unexpected response shape raises AblyException`() { + val e = assertThrows(AblyException::class.java) { + client(body = mapOf("items" to listOf("team-manutd"))).push.admin.fcmTopics.list("device-1") + } + assertEquals(50000, e.errorInfo.code) + } + + @Test + fun `list returns an empty array when the response has no body`() { + assertArrayEquals(arrayOf(), client(status = 204, body = "").push.admin.fcmTopics.list("device-1")) + } + + @Test + fun `server errors are raised as AblyException`() { + val error = mapOf("error" to mapOf("code" to 40183, "statusCode" to 400, "message" to "needs a service account")) + val e = assertThrows(AblyException::class.java) { + client(status = 400, body = error).push.admin.fcmTopics.get("team-manutd").subscribe("device-1") + } + assertEquals(40183, e.errorInfo.code) + } + + @Test + fun `empty arguments fail without a request`() { + val fcmTopics = client().push.admin.fcmTopics + for (call in listOf<() -> Unit>( + { fcmTopics.get("").subscribe("device-1") }, + { fcmTopics.get(null).unsubscribeClient("client-1") }, + { fcmTopics.publish("", JsonObject().apply { add("data", JsonObject()) }) }, + )) { + assertEquals(40000, assertThrows(AblyException::class.java) { call() }.errorInfo.code) + } + assertThrows(AblyException::class.java) { fcmTopics.get("t").subscribe("") } + assertThrows(AblyException::class.java) { fcmTopics.get("t").subscribeChannels(arrayOf()) } + assertThrows(AblyException::class.java) { fcmTopics.publish("t", JsonObject()) } + assertThrows(AblyException::class.java) { fcmTopics.list(null) } + assertTrue(requests.isEmpty()) + } +}