com.google.android.gcm.server.AsyncSender.java Source code

Java tutorial

Introduction

Here is the source code for com.google.android.gcm.server.AsyncSender.java

Source

/*
 * Copyright 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.android.gcm.server;

import static com.google.android.gcm.server.Constants.GCM_SEND_ENDPOINT;
import static com.google.android.gcm.server.Constants.JSON_CANONICAL_IDS;
import static com.google.android.gcm.server.Constants.JSON_ERROR;
import static com.google.android.gcm.server.Constants.JSON_FAILURE;
import static com.google.android.gcm.server.Constants.JSON_MESSAGE_ID;
import static com.google.android.gcm.server.Constants.JSON_MULTICAST_ID;
import static com.google.android.gcm.server.Constants.JSON_NOTIFICATION;
import static com.google.android.gcm.server.Constants.JSON_NOTIFICATION_BADGE;
import static com.google.android.gcm.server.Constants.JSON_NOTIFICATION_BODY;
import static com.google.android.gcm.server.Constants.JSON_NOTIFICATION_BODY_LOC_ARGS;
import static com.google.android.gcm.server.Constants.JSON_NOTIFICATION_BODY_LOC_KEY;
import static com.google.android.gcm.server.Constants.JSON_NOTIFICATION_CLICK_ACTION;
import static com.google.android.gcm.server.Constants.JSON_NOTIFICATION_COLOR;
import static com.google.android.gcm.server.Constants.JSON_NOTIFICATION_ICON;
import static com.google.android.gcm.server.Constants.JSON_NOTIFICATION_SOUND;
import static com.google.android.gcm.server.Constants.JSON_NOTIFICATION_TAG;
import static com.google.android.gcm.server.Constants.JSON_NOTIFICATION_TITLE;
import static com.google.android.gcm.server.Constants.JSON_NOTIFICATION_TITLE_LOC_ARGS;
import static com.google.android.gcm.server.Constants.JSON_NOTIFICATION_TITLE_LOC_KEY;
import static com.google.android.gcm.server.Constants.JSON_PAYLOAD;
import static com.google.android.gcm.server.Constants.JSON_REGISTRATION_IDS;
import static com.google.android.gcm.server.Constants.JSON_RESULTS;
import static com.google.android.gcm.server.Constants.JSON_SUCCESS;
import static com.google.android.gcm.server.Constants.JSON_TO;
import static com.google.android.gcm.server.Constants.PARAM_COLLAPSE_KEY;
import static com.google.android.gcm.server.Constants.PARAM_CONTENT_AVAILABLE;
import static com.google.android.gcm.server.Constants.PARAM_DELAY_WHILE_IDLE;
import static com.google.android.gcm.server.Constants.PARAM_DRY_RUN;
import static com.google.android.gcm.server.Constants.PARAM_PRIORITY;
import static com.google.android.gcm.server.Constants.PARAM_RESTRICTED_PACKAGE_NAME;
import static com.google.android.gcm.server.Constants.PARAM_TIME_TO_LIVE;
import static com.google.android.gcm.server.Constants.TOKEN_CANONICAL_REG_ID;
import static com.google.android.gcm.server.Constants.TOPIC_PREFIX;

import java.io.IOException;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

import org.apache.http.HttpResponse;
import org.apache.http.client.methods.RequestBuilder;

import com.nhb.common.BaseLoggable;
import com.nhb.common.async.Callback;
import com.nhb.common.data.PuArray;
import com.nhb.common.data.PuElement;
import com.nhb.common.data.PuObject;
import com.nhb.messaging.http.HttpAsyncFuture;
import com.nhb.messaging.http.HttpClientHelper;

/**
 * Helper class to send messages to the GCM service using an API Key.
 */
public class AsyncSender extends BaseLoggable {

    protected static final String UTF8 = "UTF-8";

    /**
     * Initial delay before first retry, without jitter.
     */
    protected static final int BACKOFF_INITIAL_DELAY = 1000;
    /**
     * Maximum delay before a retry.
     */
    protected static final int MAX_BACKOFF_DELAY = 1024000;

    protected final Random random = new Random();

    private final String key;
    private HttpClientHelper http;
    private ExecutorService callbackExecutor;

    /**
     * Default constructor.
     *
     * @param key
     *            API key obtained through the Google API Console.
     */
    public AsyncSender(String key) {
        this.key = nonNull(key);
        this.callbackExecutor = Executors.newCachedThreadPool();
    }

    /**
     * Sends a message to one device, retrying in case of unavailability.
     *
     * <p>
     * <strong>Note: </strong> this method uses exponential back-off to retry in
     * case of service unavailability and hence could block the calling thread
     * for many seconds.
     *
     * @param message
     *            message to be sent, including the device's registration id.
     * @param to
     *            registration token, notification key, or topic where the
     *            message will be sent.
     * @param retries
     *            number of retries in case of service unavailability errors.
     *
     * @return result of the request (see its javadoc for more details).
     *
     * @throws IllegalArgumentException
     *             if to is {@literal null}.
     * @throws InvalidRequestException
     *             if GCM didn't returned a 200 or 5xx status.
     * @throws IOException
     *             if message could not be sent.
     */
    public void send(Message message, String to, int retries, Callback<Result> callback) throws IOException {
        int attempt = 0;
        int backoff = BACKOFF_INITIAL_DELAY;

        // the wrapper callback for retrying scenario
        Callback<Result> sentCallBack = new Callback<Result>() {
            final AtomicInteger retryCounter = new AtomicInteger(0);

            @Override
            public void apply(Result result) {
                if (null == result) {
                    if (retryCounter.incrementAndGet() <= retries) {
                        getLogger()
                                .debug("Attempt #" + attempt + " to send message " + message + " to regIds " + to);
                        try {
                            sleep(backoff / 2 + random.nextInt(backoff));
                            sendNoRetry(message, to, this);
                        } catch (IOException e) {
                            getLogger().error("Error while retrying to send message", e);
                            callback.apply(null);
                        }
                    } else {
                        getLogger().warn("Max retrying times exceeded", new Exception());
                        callback.apply(null);
                    }
                } else {
                    callback.apply(result);
                }
            }
        };

        // try to send message for the first times...
        sendNoRetry(message, to, sentCallBack);
    }

    /**
     * Sends a message without retrying in case of service unavailability. See
     * {@link #send(Message, String, int)} for more info.
     *
     * @return result of the post, or {@literal null} if the GCM service was
     *         unavailable or any network exception caused the request to fail,
     *         or if the response contains more than one result.
     *
     * @throws InvalidRequestException
     *             if GCM didn't returned a 200 status.
     * @throws IllegalArgumentException
     *             if to is {@literal null}.
     */
    public void sendNoRetry(Message message, String to, Callback<Result> callback) throws IOException {
        nonNull(to);
        PuObject jsonRequest = new PuObject();
        messageToPuObject(message, jsonRequest);
        jsonRequest.set(JSON_TO, to);
        Callback<PuObject> postCallback = new Callback<PuObject>() {

            @Override
            public void apply(PuObject data) {

                Result.Builder resultBuilder = new Result.Builder();

                if (data.variableExists("results")) {
                    PuArray arr = data.getPuArray("results");
                    if (arr.size() >= 1) {
                        PuObject puo = arr.remove(0).getPuObject();
                        String messageId = puo.getString(JSON_MESSAGE_ID, "");
                        String canonicalRegId = puo.getString(TOKEN_CANONICAL_REG_ID, "");
                        String error = puo.getString(JSON_ERROR, "");
                        if (messageId.length() > 0) {
                            resultBuilder.messageId(messageId);
                        }
                        if (canonicalRegId.length() > 0) {
                            resultBuilder.canonicalRegistrationId(canonicalRegId);
                        }
                        if (error.length() > 0) {
                            resultBuilder.errorCode(error);
                        }
                        callback.apply(resultBuilder.build());
                    } else {
                        getLogger().warn("Found null or " + arr.size() + " results, expected one");
                        callback.apply(null);
                        return;
                    }
                } else if (to.startsWith(TOPIC_PREFIX)) {
                    if (data.variableExists(JSON_MESSAGE_ID)) {
                        // message_id is expected when this is the
                        // response from a
                        // topic message.
                        String messageId = data.getString(JSON_MESSAGE_ID, "");
                        resultBuilder.messageId(messageId);
                        callback.apply(resultBuilder.build());
                    } else if (data.variableExists(JSON_ERROR)) {
                        String error = data.get(JSON_ERROR);
                        resultBuilder.errorCode(error);
                        callback.apply(resultBuilder.build());
                    } else {
                        getLogger().warn(
                                "Expected " + JSON_MESSAGE_ID + " or " + JSON_ERROR + " found: " + data.toJSON());
                        callback.apply(null);
                        return;
                    }
                } else if (data.variableExists(JSON_SUCCESS) && data.variableExists(JSON_FAILURE)) {
                    // success and failure are expected when response is
                    // from group
                    // message.
                    int success = data.getInteger(JSON_SUCCESS);
                    int failure = data.getInteger(JSON_FAILURE);
                    List<String> failedIds = null;
                    if (data.variableExists("failed_registration_ids")) {
                        PuArray jFailedIds = data.getPuArray("failed_registration_ids");
                        failedIds = new ArrayList<String>();
                        while (jFailedIds.size() > 0) {
                            failedIds.add(jFailedIds.remove(0).getString());

                        }
                    }
                    resultBuilder.success(success).failure(failure).failedRegistrationIds(failedIds);
                    callback.apply(resultBuilder.build());
                } else {
                    getLogger().warn("Unrecognized response: " + data.toJSON());
                    // throw new IOException(data.toJSON());
                    callback.apply(null);
                }
            }
        };
        try {
            post(GCM_SEND_ENDPOINT, "application/json", jsonRequest.toJSON(), postCallback);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    /**
     * Sends a message to many devices, retrying in case of unavailability.
     *
     * <p>
     * <strong>Note: </strong> this method uses exponential back-off to retry in
     * case of service unavailability and hence could block the calling thread
     * for many seconds.
     *
     * @param message
     *            message to be sent.
     * @param regIds
     *            registration id of the devices that will receive the message.
     * @param retries
     *            number of retries in case of service unavailability errors.
     *
     * @return combined result of all requests made.
     *
     * @throws IllegalArgumentException
     *             if registrationIds is {@literal null} or empty.
     * @throws InvalidRequestException
     *             if GCM didn't returned a 200 or 503 status.
     * @throws IOException
     *             if message could not be sent.
     */
    public void send(Message message, List<String> regIds, int retries, Callback<MulticastResult> callback)
            throws IOException {

        final int backoff = BACKOFF_INITIAL_DELAY;
        // Map of results by registration id, it will be updated after each
        // attempt to send the messages
        final Map<String, Result> results = new HashMap<String, Result>();

        sendNoRetry(message, regIds, new Callback<MulticastResult>() {

            private final AtomicInteger retryCounter = new AtomicInteger(0);

            @Override
            public void apply(MulticastResult multicastResult) {

                boolean isRetry = false;
                List<String> unsentRegIds = new ArrayList<String>(regIds);
                long multicastId = 0;
                List<Long> multicastIds = new ArrayList<Long>();
                if (null == multicastResult) {
                    if (retryCounter.incrementAndGet() <= retries) {
                        isRetry = true;
                    }
                } else {
                    multicastId = multicastResult.getMulticastId();
                    getLogger().debug("Multicast_id on attempt # " + retryCounter.get() + ": " + multicastId);
                    multicastIds.add(multicastId);
                    unsentRegIds = updateStatus(unsentRegIds, results, multicastResult);
                    isRetry = !unsentRegIds.isEmpty() && retryCounter.get() <= retries;
                }
                if (isRetry) {
                    getLogger().debug("Attempt #" + retryCounter.get() + " to send message " + message
                            + " to regIds " + regIds.size());
                    try {
                        sendNoRetry(message, regIds, this);
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                    int sleepTime = backoff / 2 + random.nextInt(backoff);
                    sleep(sleepTime);
                    // if (2 * backoff < MAX_BACKOFF_DELAY) {
                    // backoff *= 2;
                    // }
                } else {
                    if (multicastIds.isEmpty()) {
                        // all JSON posts failed due to GCM
                        // unavailability
                        callback.apply(null);
                        // getLogger().debug("AsyncSender apply callback
                        // with null value");
                        return;
                    }
                    // calculate summary
                    int success = 0, failure = 0, canonicalIds = 0;
                    for (Result re : results.values()) {
                        if (re.getMessageId() != null) {
                            success++;
                            if (re.getCanonicalRegistrationId() != null) {
                                canonicalIds++;
                            }
                        } else {
                            failure++;
                        }
                    }
                    // build a new object with the overall result
                    multicastId = multicastIds.remove(0);
                    MulticastResult.Builder builder = new MulticastResult.Builder(success, failure, canonicalIds,
                            multicastId).retryMulticastIds(multicastIds);
                    // add results, in the same order as the input
                    for (String regId : regIds) {
                        Result re = results.get(regId);
                        builder.addResult(re);
                    }
                    callback.apply(builder.build());
                }
            }
        });
    }

    /**
     * Updates the status of the messages sent to devices and the list of
     * devices that should be retried.
     *
     * @param unsentRegIds
     *            list of devices that are still pending an update.
     * @param allResults
     *            map of status that will be updated.
     * @param multicastResult
     *            result of the last multicast sent.
     *
     * @return updated version of devices that should be retried.
     */
    private List<String> updateStatus(List<String> unsentRegIds, Map<String, Result> allResults,
            MulticastResult multicastResult) {
        List<Result> results = multicastResult.getResults();
        if (results.size() != unsentRegIds.size()) {
            // should never happen, unless there is a flaw in the algorithm
            throw new RuntimeException("Internal error: sizes do not match. " + "currentResults: " + results
                    + "; unsentRegIds: " + unsentRegIds);
        }
        List<String> newUnsentRegIds = new ArrayList<String>();
        for (int i = 0; i < unsentRegIds.size(); i++) {
            String regId = unsentRegIds.get(i);
            Result result = results.get(i);
            allResults.put(regId, result);
            String error = result.getErrorCodeName();
            if (error != null && (error.equals(Constants.ERROR_UNAVAILABLE)
                    || error.equals(Constants.ERROR_INTERNAL_SERVER_ERROR))) {
                newUnsentRegIds.add(regId);
            }
        }
        return newUnsentRegIds;
    }

    /**
     * Sends a message without retrying in case of service unavailability. See
     * {@link #send(Message, List, int)} for more info.
     *
     * @return multicast results if the message was sent successfully,
     *         {@literal null} if it failed but could be retried.
     *
     * @throws IOException
     *             if there was a JSON parsing error
     */
    public void sendNoRetry(Message message, List<String> registrationIds, Callback<MulticastResult> sendCallback)
            throws IOException {
        if (nonNull(registrationIds).isEmpty()) {
            throw new IllegalArgumentException("registrationIds cannot be empty");
        }
        PuObject jsonRequest = new PuObject();
        messageToPuObject(message, jsonRequest);
        jsonRequest.set(JSON_REGISTRATION_IDS, registrationIds);
        Callback<PuObject> callback = new Callback<PuObject>() {

            @Override
            public void apply(PuObject result) {
                try {
                    // System.out.println("data:" +result.toJSON());
                    int success = result.getInteger(JSON_SUCCESS);
                    int failure = result.getInteger(JSON_FAILURE);
                    int canonicalIds = result.getInteger(JSON_CANONICAL_IDS);
                    long multicastId = result.getLong(JSON_MULTICAST_ID);
                    MulticastResult.Builder builder = new MulticastResult.Builder(success, failure, canonicalIds,
                            multicastId);
                    PuArray results = result.getPuArray(JSON_RESULTS);
                    if (results != null) {
                        while (results.size() > 0) {
                            Result.Builder resultObj = new Result.Builder();
                            PuObject puo = results.remove(0).getPuObject();
                            String messageId = puo.getString(JSON_MESSAGE_ID, "");
                            String canonicalRegId = puo.getString(TOKEN_CANONICAL_REG_ID, "");
                            String error = puo.getString(JSON_ERROR, "");
                            if (messageId.length() > 0) {
                                resultObj.messageId(messageId);
                            }
                            if (canonicalRegId.length() > 0) {
                                resultObj.canonicalRegistrationId(canonicalRegId);
                            }
                            if (error.length() > 0) {
                                resultObj.errorCode(error);
                            }

                            builder.addResult(resultObj.build());
                        }
                    }
                    sendCallback.apply(builder.build());
                } catch (Exception e) {
                    sendCallback.apply(null);
                }

            }
        };
        String json = jsonRequest.toJSON();
        // getLogger().debug("Sending payload: " + json);
        post(GCM_SEND_ENDPOINT, "application/json", json, callback);
    }

    /**
     * Populate Map with message.
     *
     * @param message
     *            Message used to populate Map.
     * @param mapRequest
     *            Map populated by Message.
     */
    private void messageToPuObject(Message message, PuObject request) {
        if (message == null || request == null) {
            return;
        }
        setJsonField(request, PARAM_PRIORITY, message.getPriority());
        setJsonField(request, PARAM_CONTENT_AVAILABLE, message.getContentAvailable());
        setJsonField(request, PARAM_TIME_TO_LIVE, message.getTimeToLive());
        setJsonField(request, PARAM_COLLAPSE_KEY, message.getCollapseKey());
        setJsonField(request, PARAM_RESTRICTED_PACKAGE_NAME, message.getRestrictedPackageName());
        setJsonField(request, PARAM_DELAY_WHILE_IDLE, message.isDelayWhileIdle());
        setJsonField(request, PARAM_DRY_RUN, message.isDryRun());
        Map<String, String> payload = message.getData();
        if (!payload.isEmpty()) {
            request.set(JSON_PAYLOAD, payload);
        }
        if (message.getNotification() != null) {
            Notification notification = message.getNotification();
            PuObject nMap = new PuObject();
            if (notification.getBadge() != null) {
                setJsonField(nMap, JSON_NOTIFICATION_BADGE, notification.getBadge().toString());
            }
            setJsonField(nMap, JSON_NOTIFICATION_BODY, notification.getBody());
            setJsonField(nMap, JSON_NOTIFICATION_BODY_LOC_ARGS, notification.getBodyLocArgs());
            setJsonField(nMap, JSON_NOTIFICATION_BODY_LOC_KEY, notification.getBodyLocKey());
            setJsonField(nMap, JSON_NOTIFICATION_CLICK_ACTION, notification.getClickAction());
            setJsonField(nMap, JSON_NOTIFICATION_COLOR, notification.getColor());
            setJsonField(nMap, JSON_NOTIFICATION_ICON, notification.getIcon());
            setJsonField(nMap, JSON_NOTIFICATION_SOUND, notification.getSound());
            setJsonField(nMap, JSON_NOTIFICATION_TAG, notification.getTag());
            setJsonField(nMap, JSON_NOTIFICATION_TITLE, notification.getTitle());
            setJsonField(nMap, JSON_NOTIFICATION_TITLE_LOC_ARGS, notification.getTitleLocArgs());
            setJsonField(nMap, JSON_NOTIFICATION_TITLE_LOC_KEY, notification.getTitleLocKey());
            request.set(JSON_NOTIFICATION, nMap);
        }
    }

    /**
     * Sets a JSON field, but only if the value is not {@literal null}.
     */
    private void setJsonField(PuObject json, String field, Object value) {
        if (value != null) {
            json.set(field, value);
        }
    }

    /**
     * Makes an HTTP POST request to a given endpoint.
     *
     * <p>
     * <strong>Note: </strong> the returned connected should not be
     * disconnected, otherwise it would kill persistent connections made using
     * Keep-Alive.
     *
     * @param url
     *            endpoint to post the request. @param contentType type of
     *            request. @param body body of the request.
     *
     * @return the underlying connection.
     *
     * @throws IOException
     *             propagated from underlying methods. @throws
     */
    public void post(String url, String contentType, String body, Callback<PuObject> callback) throws IOException {
        if (url == null || contentType == null || body == null) {
            throw new IllegalArgumentException("arguments cannot be null");
        }
        if (!url.startsWith("https://")) {
            getLogger().warn("URL does not use https: " + url);
        }
        PuObject data = PuObject.fromJSON(body);
        http = new HttpClientHelper();
        http.setUsingMultipath(false);
        RequestBuilder builder = null;
        builder = RequestBuilder.post(GCM_SEND_ENDPOINT).addHeader("Content-Type", contentType)
                .addHeader("Authorization", "key=" + key).setCharset(Charset.forName("UTF-8"));

        HttpAsyncFuture future = http.executeAsync(builder, data);
        future.setCallback(new Callback<HttpResponse>() {
            @Override
            public void apply(HttpResponse result) {
                final PuObject puo = new PuObject();
                PuElement element = null;
                try {
                    element = HttpClientHelper.handleResponse(result);
                    puo.addAll((PuObject) element);
                } catch (Exception e) {
                    puo.set("error", element.toString());
                }
                puo.set("status", result.getStatusLine());

                callbackExecutor.submit(new Runnable() {

                    @Override
                    public void run() {
                        callback.apply(puo);
                    }
                });
            }
        });
        return;
    }

    static <T> T nonNull(T argument) {
        if (argument == null) {
            throw new IllegalArgumentException("argument cannot be null");
        }
        return argument;
    }

    void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}