diff --git a/gcm-sender-async/pom.xml b/gcm-sender-async/pom.xml
new file mode 100644
index 000000000..e9cb035b0
--- /dev/null
+++ b/gcm-sender-async/pom.xml
@@ -0,0 +1,44 @@
+
+
+
+ TextSecureServer
+ org.whispersystems.textsecure
+ 1.0
+
+ 4.0.0
+
+ gcm-sender-async
+ ${TextSecureServer.version}
+
+
+
+
+ org.apache.httpcomponents
+ httpasyncclient
+ 4.0.2
+
+
+ com.nurkiewicz.asyncretry
+ asyncretry-jdk7
+ 0.0.5
+
+
+
+ com.squareup.okhttp
+ mockwebserver
+ 2.1.0
+ test
+
+
+ com.github.tomakehurst
+ wiremock
+ 1.52
+ test
+
+
+
+
+
+
diff --git a/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/AuthenticationFailedException.java b/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/AuthenticationFailedException.java
new file mode 100644
index 000000000..a5f6aa61d
--- /dev/null
+++ b/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/AuthenticationFailedException.java
@@ -0,0 +1,21 @@
+/**
+ * Copyright (C) 2015 Open Whisper Systems
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.whispersystems.gcm.server;
+
+
+public class AuthenticationFailedException extends Exception {
+}
diff --git a/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/InvalidRequestException.java b/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/InvalidRequestException.java
new file mode 100644
index 000000000..ece46f86c
--- /dev/null
+++ b/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/InvalidRequestException.java
@@ -0,0 +1,21 @@
+/**
+ * Copyright (C) 2015 Open Whisper Systems
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.whispersystems.gcm.server;
+
+
+public class InvalidRequestException extends Exception {
+}
diff --git a/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/Message.java b/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/Message.java
new file mode 100644
index 000000000..f6e8c3ebf
--- /dev/null
+++ b/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/Message.java
@@ -0,0 +1,156 @@
+/**
+ * Copyright (C) 2015 Open Whisper Systems
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.whispersystems.gcm.server;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.whispersystems.gcm.server.internal.GcmRequestEntity;
+
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+
+public class Message {
+
+ private static final ObjectMapper objectMapper = new ObjectMapper();
+
+ private final String collapseKey;
+ private final Long ttl;
+ private final Boolean delayWhileIdle;
+ private final Map data;
+ private final List registrationIds;
+ private final String priority;
+
+ private Message(String collapseKey, Long ttl, Boolean delayWhileIdle,
+ Map data, List registrationIds,
+ String priority)
+ {
+ this.collapseKey = collapseKey;
+ this.ttl = ttl;
+ this.delayWhileIdle = delayWhileIdle;
+ this.data = data;
+ this.registrationIds = registrationIds;
+ this.priority = priority;
+ }
+
+ public String serialize() throws JsonProcessingException {
+ GcmRequestEntity requestEntity = new GcmRequestEntity(collapseKey, ttl, delayWhileIdle,
+ data, registrationIds, priority);
+
+ return objectMapper.writeValueAsString(requestEntity);
+ }
+
+ /**
+ * Construct a new Message using a Builder.
+ * @return A new Builder.
+ */
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+
+ private String collapseKey = null;
+ private Long ttl = null;
+ private Boolean delayWhileIdle = null;
+ private Map data = null;
+ private List registrationIds = new LinkedList<>();
+ private String priority = null;
+
+ private Builder() {}
+
+ /**
+ * @param collapseKey The GCM collapse key to use (optional).
+ * @return The Builder.
+ */
+ public Builder withCollapseKey(String collapseKey) {
+ this.collapseKey = collapseKey;
+ return this;
+ }
+
+ /**
+ * @param seconds The TTL (in seconds) for this message (optional).
+ * @return The Builder.
+ */
+ public Builder withTtl(long seconds) {
+ this.ttl = seconds;
+ return this;
+ }
+
+ /**
+ * @param delayWhileIdle Set GCM delay_while_idle (optional).
+ * @return The Builder.
+ */
+ public Builder withDelayWhileIdle(boolean delayWhileIdle) {
+ this.delayWhileIdle = delayWhileIdle;
+ return this;
+ }
+
+ /**
+ * Set a key in the GCM JSON payload delivered to the application (optional).
+ * @param key The key to set.
+ * @param value The value to set.
+ * @return The Builder.
+ */
+ public Builder withDataPart(String key, String value) {
+ if (data == null) {
+ data = new HashMap<>();
+ }
+ data.put(key, value);
+ return this;
+ }
+
+ /**
+ * Set the destination GCM registration ID (mandatory).
+ * @param registrationId The destination GCM registration ID.
+ * @return The Builder.
+ */
+ public Builder withDestination(String registrationId) {
+ this.registrationIds.clear();
+ this.registrationIds.add(registrationId);
+ return this;
+ }
+
+ /**
+ * Set the GCM message priority (optional).
+ *
+ * @param priority Valid values are "normal" and "high."
+ * On iOS, these correspond to APNs priority 5 and 10.
+ * @return The Builder.
+ */
+ public Builder withPriority(String priority) {
+ this.priority = priority;
+ return this;
+ }
+
+ /**
+ * Construct a message object.
+ *
+ * @return An immutable message object, as configured by this builder.
+ */
+ public Message build() {
+ if (registrationIds.isEmpty()) {
+ throw new IllegalArgumentException("You must specify a destination!");
+ }
+
+ return new Message(collapseKey, ttl, delayWhileIdle, data, registrationIds, priority);
+ }
+ }
+
+
+}
diff --git a/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/Result.java b/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/Result.java
new file mode 100644
index 000000000..e76a2ad4d
--- /dev/null
+++ b/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/Result.java
@@ -0,0 +1,100 @@
+/**
+ * Copyright (C) 2015 Open Whisper Systems
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.whispersystems.gcm.server;
+
+/**
+ * The result of a GCM send operation.
+ */
+public class Result {
+
+ private final Object context;
+ private final String canonicalRegistrationId;
+ private final String messageId;
+ private final String error;
+
+ Result(Object context, String canonicalRegistrationId, String messageId, String error) {
+ this.context = context;
+ this.canonicalRegistrationId = canonicalRegistrationId;
+ this.messageId = messageId;
+ this.error = error;
+ }
+
+ /**
+ * Returns the "canonical" GCM registration ID for this destination.
+ * See GCM documentation for details.
+ * @return The canonical GCM registration ID.
+ */
+ public String getCanonicalRegistrationId() {
+ return canonicalRegistrationId;
+ }
+
+ /**
+ * @return If a "canonical" GCM registration ID is present in the response.
+ */
+ public boolean hasCanonicalRegistrationId() {
+ return canonicalRegistrationId != null && !canonicalRegistrationId.isEmpty();
+ }
+
+ /**
+ * @return The assigned GCM message ID, if successful.
+ */
+ public String getMessageId() {
+ return messageId;
+ }
+
+ /**
+ * @return The raw error string, if present.
+ */
+ public String getError() {
+ return error;
+ }
+
+ /**
+ * @return If the send was a success.
+ */
+ public boolean isSuccess() {
+ return messageId != null && !messageId.isEmpty() && (error == null || error.isEmpty());
+ }
+
+ /**
+ * @return If the destination GCM registration ID is no longer registered.
+ */
+ public boolean isUnregistered() {
+ return "NotRegistered".equals(error);
+ }
+
+ /**
+ * @return If messages to this device are being throttled.
+ */
+ public boolean isThrottled() {
+ return "DeviceMessageRateExceeded".equals(error);
+ }
+
+ /**
+ * @return If the destination GCM registration ID is invalid.
+ */
+ public boolean isInvalidRegistrationId() {
+ return "InvalidRegistration".equals(error);
+ }
+
+ /**
+ * @return The context passed into Sender.send(), if any.
+ */
+ public Object getContext() {
+ return context;
+ }
+}
diff --git a/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/Sender.java b/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/Sender.java
new file mode 100644
index 000000000..0cb572960
--- /dev/null
+++ b/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/Sender.java
@@ -0,0 +1,204 @@
+/**
+ * Copyright (C) 2015 Open Whisper Systems
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.whispersystems.gcm.server;
+
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.SettableFuture;
+import com.nurkiewicz.asyncretry.AsyncRetryExecutor;
+import com.nurkiewicz.asyncretry.RetryContext;
+import com.nurkiewicz.asyncretry.RetryExecutor;
+import com.nurkiewicz.asyncretry.function.RetryCallable;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.concurrent.FutureCallback;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
+import org.apache.http.impl.nio.client.HttpAsyncClients;
+import org.apache.http.util.EntityUtils;
+import org.whispersystems.gcm.server.internal.GcmResponseEntity;
+import org.whispersystems.gcm.server.internal.GcmResponseListEntity;
+
+import javax.net.ssl.SSLContext;
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * The main interface to sending GCM messages. Thread safe.
+ *
+ * @author Moxie Marlinspike
+ */
+public class Sender {
+
+ private static final String PRODUCTION_URL = "https://fcm.googleapis.com/fcm/send";
+
+ private final CloseableHttpAsyncClient client;
+ private final String authorizationHeader;
+ private final RetryExecutor executor;
+ private final String url;
+
+ /**
+ * Construct a Sender instance.
+ *
+ * @param apiKey Your application's GCM API key.
+ */
+ public Sender(String apiKey) {
+ this(apiKey, 10);
+ }
+
+ /**
+ * Construct a Sender instance with a specified retry count.
+ *
+ * @param apiKey Your application's GCM API key.
+ * @param retryCount The number of retries to attempt on a network error or 500 response.
+ */
+ public Sender(String apiKey, int retryCount) {
+ this(apiKey, retryCount, PRODUCTION_URL);
+ }
+
+ @VisibleForTesting
+ public Sender(String apiKey, int retryCount, String url) {
+ ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+
+ this.url = url;
+ this.authorizationHeader = String.format("key=%s", apiKey);
+
+ this.client = HttpAsyncClients.custom()
+ .setMaxConnTotal(100)
+ .setMaxConnPerRoute(10)
+ .build();
+
+ this.executor = new AsyncRetryExecutor(scheduler).retryOn(ServerFailedException.class)
+ .retryOn(TimeoutException.class)
+ .retryOn(IOException.class)
+ .withExponentialBackoff(100, 2.0)
+ .withUniformJitter()
+ .withMaxDelay(4000)
+ .withMaxRetries(retryCount);
+
+ this.client.start();
+ }
+
+ /**
+ * Asynchronously send a message.
+ *
+ * @param message The message to send.
+ * @return A future.
+ */
+ public ListenableFuture send(Message message) {
+ return send(message, null);
+ }
+
+ /**
+ * Asynchronously send a message with a context to be passed in the future result.
+ *
+ * @param message The message to send.
+ * @param requestContext An opaque context to include the future result.
+ * @return The future.
+ */
+ public ListenableFuture send(final Message message, final Object requestContext) {
+ return executor.getFutureWithRetry(new RetryCallable>() {
+ @Override
+ public ListenableFuture call(RetryContext context) throws Exception {
+ SettableFuture future = SettableFuture.create();
+ HttpPost request = new HttpPost(url);
+
+ request.setHeader("Authorization", authorizationHeader);
+ request.setEntity(new StringEntity(message.serialize(),
+ ContentType.parse("application/json")));
+
+ client.execute(request, new ResponseHandler(future, requestContext));
+
+ return future;
+ }
+ });
+ }
+
+ /**
+ * Shut down all existing HTTP connections.
+ * @throws IOException
+ */
+ public void stop() throws IOException {
+ this.client.close();
+ }
+
+ private static final class ResponseHandler implements FutureCallback {
+
+ private static final ObjectMapper objectMapper = new ObjectMapper();
+
+ static {
+ objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+ }
+
+ private final SettableFuture future;
+ private final Object requestContext;
+
+ public ResponseHandler(SettableFuture future, Object requestContext) {
+ this.future = future;
+ this.requestContext = requestContext;
+ }
+
+ @Override
+ public void completed(HttpResponse result) {
+ try {
+ String responseBody = EntityUtils.toString(result.getEntity());
+
+ switch (result.getStatusLine().getStatusCode()) {
+ case 400: future.setException(new InvalidRequestException()); break;
+ case 401: future.setException(new AuthenticationFailedException()); break;
+ case 204:
+ case 200: future.set(parseResult(responseBody)); break;
+ default: future.setException(new ServerFailedException("Bad status: " + result.getStatusLine().getStatusCode()));
+ }
+ } catch (IOException e) {
+ future.setException(e);
+ }
+ }
+
+ @Override
+ public void failed(Exception ex) {
+ future.setException(ex);
+ }
+
+ @Override
+ public void cancelled() {
+ future.setException(new ServerFailedException("Canceled!"));
+ }
+
+ private Result parseResult(String body) throws IOException {
+ List responseList = objectMapper.readValue(body, GcmResponseListEntity.class)
+ .getResults();
+
+ if (responseList == null || responseList.size() == 0) {
+ throw new IOException("Empty response list!");
+ }
+
+ GcmResponseEntity responseEntity = responseList.get(0);
+
+ return new Result(this.requestContext,
+ responseEntity.getCanonicalRegistrationId(),
+ responseEntity.getMessageId(),
+ responseEntity.getError());
+ }
+ }
+}
\ No newline at end of file
diff --git a/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/ServerFailedException.java b/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/ServerFailedException.java
new file mode 100644
index 000000000..558e679c4
--- /dev/null
+++ b/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/ServerFailedException.java
@@ -0,0 +1,27 @@
+/**
+ * Copyright (C) 2015 Open Whisper Systems
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.whispersystems.gcm.server;
+
+public class ServerFailedException extends Exception {
+ public ServerFailedException(String message) {
+ super(message);
+ }
+
+ public ServerFailedException(Exception e) {
+ super(e);
+ }
+}
diff --git a/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/internal/GcmRequestEntity.java b/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/internal/GcmRequestEntity.java
new file mode 100644
index 000000000..9d002e330
--- /dev/null
+++ b/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/internal/GcmRequestEntity.java
@@ -0,0 +1,58 @@
+/**
+ * Copyright (C) 2015 Open Whisper Systems
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.whispersystems.gcm.server.internal;
+
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.List;
+import java.util.Map;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class GcmRequestEntity {
+
+ @JsonProperty(value = "collapse_key")
+ private String collapseKey;
+
+ @JsonProperty(value = "time_to_live")
+ private Long ttl;
+
+ @JsonProperty(value = "delay_while_idle")
+ private Boolean delayWhileIdle;
+
+ @JsonProperty(value = "data")
+ private Map data;
+
+ @JsonProperty(value = "registration_ids")
+ private List registrationIds;
+
+ @JsonProperty
+ private String priority;
+
+ public GcmRequestEntity(String collapseKey, Long ttl, Boolean delayWhileIdle,
+ Map data, List registrationIds,
+ String priority)
+ {
+ this.collapseKey = collapseKey;
+ this.ttl = ttl;
+ this.delayWhileIdle = delayWhileIdle;
+ this.data = data;
+ this.registrationIds = registrationIds;
+ this.priority = priority;
+ }
+}
diff --git a/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/internal/GcmResponseEntity.java b/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/internal/GcmResponseEntity.java
new file mode 100644
index 000000000..698452706
--- /dev/null
+++ b/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/internal/GcmResponseEntity.java
@@ -0,0 +1,43 @@
+/**
+ * Copyright (C) 2015 Open Whisper Systems
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.whispersystems.gcm.server.internal;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public class GcmResponseEntity {
+
+ @JsonProperty(value = "message_id")
+ private String messageId;
+
+ @JsonProperty(value = "registration_id")
+ private String canonicalRegistrationId;
+
+ @JsonProperty
+ private String error;
+
+ public String getMessageId() {
+ return messageId;
+ }
+
+ public String getCanonicalRegistrationId() {
+ return canonicalRegistrationId;
+ }
+
+ public String getError() {
+ return error;
+ }
+}
diff --git a/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/internal/GcmResponseListEntity.java b/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/internal/GcmResponseListEntity.java
new file mode 100644
index 000000000..59c3b11aa
--- /dev/null
+++ b/gcm-sender-async/src/main/java/org/whispersystems/gcm/server/internal/GcmResponseListEntity.java
@@ -0,0 +1,31 @@
+/**
+ * Copyright (C) 2015 Open Whisper Systems
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.whispersystems.gcm.server.internal;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.List;
+
+public class GcmResponseListEntity {
+
+ @JsonProperty
+ private List results;
+
+ public List getResults() {
+ return results;
+ }
+}
diff --git a/gcm-sender-async/src/test/java/org/whispersystems/gcm/server/MessageTest.java b/gcm-sender-async/src/test/java/org/whispersystems/gcm/server/MessageTest.java
new file mode 100644
index 000000000..56a7becc0
--- /dev/null
+++ b/gcm-sender-async/src/test/java/org/whispersystems/gcm/server/MessageTest.java
@@ -0,0 +1,45 @@
+package org.whispersystems.gcm.server;
+
+import org.junit.Test;
+
+import java.io.IOException;
+
+import static org.junit.Assert.assertEquals;
+import static org.whispersystems.gcm.server.util.JsonHelpers.jsonFixture;
+
+public class MessageTest {
+
+ @Test
+ public void testMinimal() throws IOException {
+ Message message = Message.newBuilder()
+ .withDestination("1")
+ .build();
+
+ assertEquals(message.serialize(), jsonFixture("fixtures/message-minimal.json"));
+ }
+
+ @Test
+ public void testComplete() throws IOException {
+ Message message = Message.newBuilder()
+ .withDestination("1")
+ .withCollapseKey("collapse")
+ .withDelayWhileIdle(true)
+ .withTtl(10)
+ .withPriority("high")
+ .build();
+
+ assertEquals(message.serialize(), jsonFixture("fixtures/message-complete.json"));
+ }
+
+ @Test
+ public void testWithData() throws IOException {
+ Message message = Message.newBuilder()
+ .withDestination("2")
+ .withDataPart("key1", "value1")
+ .withDataPart("key2", "value2")
+ .build();
+
+ assertEquals(message.serialize(), jsonFixture("fixtures/message-data.json"));
+ }
+
+}
diff --git a/gcm-sender-async/src/test/java/org/whispersystems/gcm/server/SenderTest.java b/gcm-sender-async/src/test/java/org/whispersystems/gcm/server/SenderTest.java
new file mode 100644
index 000000000..c3c7b199f
--- /dev/null
+++ b/gcm-sender-async/src/test/java/org/whispersystems/gcm/server/SenderTest.java
@@ -0,0 +1,177 @@
+package org.whispersystems.gcm.server;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import com.squareup.okhttp.mockwebserver.MockResponse;
+import com.squareup.okhttp.mockwebserver.RecordedRequest;
+import com.squareup.okhttp.mockwebserver.rule.MockWebServerRule;
+import org.junit.Rule;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import static junit.framework.TestCase.assertTrue;
+import static org.junit.Assert.*;
+import static org.whispersystems.gcm.server.util.FixtureHelpers.fixture;
+import static org.whispersystems.gcm.server.util.JsonHelpers.jsonFixture;
+
+public class SenderTest {
+
+ @Rule
+ public MockWebServerRule server = new MockWebServerRule();
+
+ @Test
+ public void testSuccess() throws InterruptedException, ExecutionException, TimeoutException, IOException {
+ MockResponse successResponse = new MockResponse().setResponseCode(200)
+ .setBody(fixture("fixtures/response-success.json"));
+ server.enqueue(successResponse);
+
+ String context = "my context";
+ Sender sender = new Sender("foobarbaz", 10, server.getUrl("/gcm/send").toExternalForm());
+ ListenableFuture future = sender.send(Message.newBuilder().withDestination("1").build(), context);
+
+ Result result = future.get(10, TimeUnit.SECONDS);
+
+ assertEquals(result.isSuccess(), true);
+ assertEquals(result.isThrottled(), false);
+ assertEquals(result.isUnregistered(), false);
+ assertEquals(result.getMessageId(), "1:08");
+ assertNull(result.getError());
+ assertNull(result.getCanonicalRegistrationId());
+ assertEquals(context, result.getContext());
+
+ RecordedRequest request = server.takeRequest();
+ assertEquals(request.getPath(), "/gcm/send");
+ assertEquals(new String(request.getBody()), jsonFixture("fixtures/message-minimal.json"));
+ assertEquals(request.getHeader("Authorization"), "key=foobarbaz");
+ assertEquals(request.getHeader("Content-Type"), "application/json");
+ assertEquals(server.getRequestCount(), 1);
+ }
+
+ @Test
+ public void testBadApiKey() throws ExecutionException, InterruptedException, TimeoutException {
+ MockResponse unauthorizedResponse = new MockResponse().setResponseCode(401);
+ server.enqueue(unauthorizedResponse);
+
+ Sender sender = new Sender("foobar", 10, server.getUrl("/gcm/send").toExternalForm());
+ ListenableFuture future = sender.send(Message.newBuilder().withDestination("1").build());
+
+ try {
+ future.get(10, TimeUnit.SECONDS);
+ throw new AssertionError();
+ } catch (ExecutionException ee) {
+ assertTrue(ee.getCause() instanceof AuthenticationFailedException);
+ }
+
+ assertEquals(server.getRequestCount(), 1);
+ }
+
+ @Test
+ public void testBadRequest() throws TimeoutException, InterruptedException {
+ MockResponse malformed = new MockResponse().setResponseCode(400);
+ server.enqueue(malformed);
+
+ Sender sender = new Sender("foobarbaz", 10, server.getUrl("/gcm/send").toExternalForm());
+ ListenableFuture future = sender.send(Message.newBuilder().withDestination("1").build());
+
+ try {
+ future.get(10, TimeUnit.SECONDS);
+ throw new AssertionError();
+ } catch (ExecutionException e) {
+ assertTrue(e.getCause() instanceof InvalidRequestException);
+ }
+
+ assertEquals(server.getRequestCount(), 1);
+ }
+
+ @Test
+ public void testServerError() throws TimeoutException, InterruptedException {
+ MockResponse error = new MockResponse().setResponseCode(503);
+ server.enqueue(error);
+ server.enqueue(error);
+ server.enqueue(error);
+
+ Sender sender = new Sender("foobarbaz", 2, server.getUrl("/gcm/send").toExternalForm());
+ ListenableFuture future = sender.send(Message.newBuilder().withDestination("1").build());
+
+ try {
+ future.get(10, TimeUnit.SECONDS);
+ throw new AssertionError();
+ } catch (ExecutionException ee) {
+ assertTrue(ee.getCause() instanceof ServerFailedException);
+ }
+
+ assertEquals(server.getRequestCount(), 3);
+ }
+
+ @Test
+ public void testServerErrorRecovery() throws InterruptedException, ExecutionException, TimeoutException {
+ MockResponse success = new MockResponse().setResponseCode(200)
+ .setBody(fixture("fixtures/response-success.json"));
+
+ MockResponse error = new MockResponse().setResponseCode(503);
+
+ server.enqueue(error);
+ server.enqueue(error);
+ server.enqueue(error);
+ server.enqueue(success);
+
+ Sender sender = new Sender("foobarbaz", 3, server.getUrl("/gcm/send").toExternalForm());
+ ListenableFuture future = sender.send(Message.newBuilder().withDestination("1").build());
+
+ Result result = future.get(10, TimeUnit.SECONDS);
+
+ assertEquals(server.getRequestCount(), 4);
+ assertEquals(result.isSuccess(), true);
+ assertEquals(result.isThrottled(), false);
+ assertEquals(result.isUnregistered(), false);
+ assertEquals(result.getMessageId(), "1:08");
+ assertNull(result.getError());
+ assertNull(result.getCanonicalRegistrationId());
+ }
+
+ @Test
+ public void testNetworkError() throws TimeoutException, InterruptedException, IOException {
+ MockResponse response = new MockResponse().setResponseCode(200)
+ .setBody(fixture("fixtures/response-success.json"));
+
+ server.enqueue(response);
+ server.enqueue(response);
+ server.enqueue(response);
+
+ Sender sender = new Sender("foobarbaz", 2, server.getUrl("/gcm/send").toExternalForm());
+
+ server.get().shutdown();
+
+ ListenableFuture future = sender.send(Message.newBuilder().withDestination("1").build());
+
+ try {
+ future.get(10, TimeUnit.SECONDS);
+ } catch (ExecutionException e) {
+ assertTrue(e.getCause() instanceof IOException);
+ }
+ }
+
+ @Test
+ public void testNotRegistered() throws InterruptedException, ExecutionException, TimeoutException {
+ MockResponse response = new MockResponse().setResponseCode(200)
+ .setBody(fixture("fixtures/response-not-registered.json"));
+
+ server.enqueue(response);
+
+ Sender sender = new Sender("foobarbaz", 2, server.getUrl("/gcm/send").toExternalForm());
+ ListenableFuture future = sender.send(Message.newBuilder()
+ .withDestination("2")
+ .withDataPart("message", "new message!")
+ .build());
+
+ Result result = future.get(10, TimeUnit.SECONDS);
+
+ assertFalse(result.isSuccess());
+ assertTrue(result.isUnregistered());
+ assertFalse(result.isThrottled());
+ assertEquals(result.getError(), "NotRegistered");
+ }
+}
diff --git a/gcm-sender-async/src/test/java/org/whispersystems/gcm/server/SimultaneousSenderTest.java b/gcm-sender-async/src/test/java/org/whispersystems/gcm/server/SimultaneousSenderTest.java
new file mode 100644
index 000000000..f7fb184fa
--- /dev/null
+++ b/gcm-sender-async/src/test/java/org/whispersystems/gcm/server/SimultaneousSenderTest.java
@@ -0,0 +1,73 @@
+package org.whispersystems.gcm.server;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.github.tomakehurst.wiremock.junit.WireMockRule;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.squareup.okhttp.mockwebserver.MockResponse;
+import org.junit.Rule;
+import org.junit.Test;
+
+import java.util.LinkedList;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.*;
+import static junit.framework.TestCase.assertEquals;
+import static junit.framework.TestCase.assertTrue;
+import static org.whispersystems.gcm.server.util.FixtureHelpers.fixture;
+
+public class SimultaneousSenderTest {
+
+ @Rule
+ public WireMockRule wireMock = new WireMockRule(8089);
+
+ @Test
+ public void testSimultaneousSuccess() throws TimeoutException, InterruptedException, ExecutionException, JsonProcessingException {
+ stubFor(post(urlPathEqualTo("/gcm/send"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withBody(fixture("fixtures/response-success.json"))));
+
+ Sender sender = new Sender("foobarbaz", 2, "http://localhost:8089/gcm/send");
+ List> results = new LinkedList<>();
+
+ for (int i=0;i<1000;i++) {
+ results.add(sender.send(Message.newBuilder().withDestination("1").build()));
+ }
+
+ int i=0;
+ for (ListenableFuture future : results) {
+ Result result = future.get(60, TimeUnit.SECONDS);
+ System.out.println("Got " + (i++));
+
+ if (!result.isSuccess()) {
+ throw new AssertionError(result.getError());
+ }
+ }
+ }
+
+ @Test
+ public void testSimultaneousFailure() throws TimeoutException, InterruptedException {
+ stubFor(post(urlPathEqualTo("/gcm/send"))
+ .willReturn(aResponse()
+ .withStatus(503)));
+
+ Sender sender = new Sender("foobarbaz", 2, "http://localhost:8089/gcm/send");
+ List> futures = new LinkedList<>();
+
+ for (int i=0;i<1000;i++) {
+ futures.add(sender.send(Message.newBuilder().withDestination("1").build()));
+ }
+
+ for (ListenableFuture future : futures) {
+ try {
+ Result result = future.get(60, TimeUnit.SECONDS);
+ } catch (ExecutionException e) {
+ assertTrue(e.getCause() instanceof ServerFailedException);
+ }
+ }
+ }
+}
diff --git a/gcm-sender-async/src/test/java/org/whispersystems/gcm/server/util/FixtureHelpers.java b/gcm-sender-async/src/test/java/org/whispersystems/gcm/server/util/FixtureHelpers.java
new file mode 100644
index 000000000..6759ef909
--- /dev/null
+++ b/gcm-sender-async/src/test/java/org/whispersystems/gcm/server/util/FixtureHelpers.java
@@ -0,0 +1,43 @@
+package org.whispersystems.gcm.server.util;
+
+import com.google.common.base.Charsets;
+import com.google.common.io.Resources;
+
+import java.io.IOException;
+import java.nio.charset.Charset;
+
+/**
+ * A set of helper method for fixture files.
+ */
+public class FixtureHelpers {
+ private FixtureHelpers() { /* singleton */ }
+
+ /**
+ * Reads the given fixture file from the classpath (e. g. {@code src/test/resources})
+ * and returns its contents as a UTF-8 string.
+ *
+ * @param filename the filename of the fixture file
+ * @return the contents of {@code src/test/resources/{filename}}
+ * @throws IllegalArgumentException if an I/O error occurs.
+ */
+ public static String fixture(String filename) {
+ return fixture(filename, Charsets.UTF_8);
+ }
+
+ /**
+ * Reads the given fixture file from the classpath (e. g. {@code src/test/resources})
+ * and returns its contents as a string.
+ *
+ * @param filename the filename of the fixture file
+ * @param charset the character set of {@code filename}
+ * @return the contents of {@code src/test/resources/{filename}}
+ * @throws IllegalArgumentException if an I/O error occurs.
+ */
+ private static String fixture(String filename, Charset charset) {
+ try {
+ return Resources.toString(Resources.getResource(filename), charset).trim();
+ } catch (IOException e) {
+ throw new IllegalArgumentException(e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/gcm-sender-async/src/test/java/org/whispersystems/gcm/server/util/JsonHelpers.java b/gcm-sender-async/src/test/java/org/whispersystems/gcm/server/util/JsonHelpers.java
new file mode 100644
index 000000000..812d45221
--- /dev/null
+++ b/gcm-sender-async/src/test/java/org/whispersystems/gcm/server/util/JsonHelpers.java
@@ -0,0 +1,26 @@
+package org.whispersystems.gcm.server.util;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+
+import static org.whispersystems.gcm.server.util.FixtureHelpers.fixture;
+
+public class JsonHelpers {
+
+ private static final ObjectMapper objectMapper = new ObjectMapper();
+
+ public static String asJson(Object object) throws JsonProcessingException {
+ return objectMapper.writeValueAsString(object);
+ }
+
+ public static T fromJson(String value, Class clazz) throws IOException {
+ return objectMapper.readValue(value, clazz);
+ }
+
+ public static String jsonFixture(String filename) throws IOException {
+ return objectMapper.writeValueAsString(objectMapper.readValue(fixture(filename), JsonNode.class));
+ }
+}
diff --git a/gcm-sender-async/src/test/resources/fixtures/message-complete.json b/gcm-sender-async/src/test/resources/fixtures/message-complete.json
new file mode 100644
index 000000000..43a4dcf06
--- /dev/null
+++ b/gcm-sender-async/src/test/resources/fixtures/message-complete.json
@@ -0,0 +1,7 @@
+{
+ "priority" : "high",
+ "collapse_key" : "collapse",
+ "time_to_live" : 10,
+ "delay_while_idle" : true,
+ "registration_ids" : ["1"]
+}
\ No newline at end of file
diff --git a/gcm-sender-async/src/test/resources/fixtures/message-data.json b/gcm-sender-async/src/test/resources/fixtures/message-data.json
new file mode 100644
index 000000000..4993e04e4
--- /dev/null
+++ b/gcm-sender-async/src/test/resources/fixtures/message-data.json
@@ -0,0 +1,7 @@
+{
+ "data" : {
+ "key1" : "value1",
+ "key2" : "value2"
+ },
+ "registration_ids" : ["2"]
+}
\ No newline at end of file
diff --git a/gcm-sender-async/src/test/resources/fixtures/message-minimal.json b/gcm-sender-async/src/test/resources/fixtures/message-minimal.json
new file mode 100644
index 000000000..2aab43b01
--- /dev/null
+++ b/gcm-sender-async/src/test/resources/fixtures/message-minimal.json
@@ -0,0 +1,3 @@
+{
+ "registration_ids" : ["1"]
+}
\ No newline at end of file
diff --git a/gcm-sender-async/src/test/resources/fixtures/response-not-registered.json b/gcm-sender-async/src/test/resources/fixtures/response-not-registered.json
new file mode 100644
index 000000000..9363c9d72
--- /dev/null
+++ b/gcm-sender-async/src/test/resources/fixtures/response-not-registered.json
@@ -0,0 +1,8 @@
+{ "multicast_id": 216,
+ "success": 0,
+ "failure": 1,
+ "canonical_ids": 0,
+ "results": [
+ { "error": "NotRegistered"}
+ ]
+}
\ No newline at end of file
diff --git a/gcm-sender-async/src/test/resources/fixtures/response-success.json b/gcm-sender-async/src/test/resources/fixtures/response-success.json
new file mode 100644
index 000000000..7ae2b3d96
--- /dev/null
+++ b/gcm-sender-async/src/test/resources/fixtures/response-success.json
@@ -0,0 +1,8 @@
+{ "multicast_id": 108,
+ "success": 1,
+ "failure": 0,
+ "canonical_ids": 0,
+ "results": [
+ { "message_id": "1:08" }
+ ]
+}
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 1a30e5289..d1ee288ac 100644
--- a/pom.xml
+++ b/pom.xml
@@ -11,6 +11,7 @@
redis-dispatch
websocket-resources
+ gcm-sender-async
service
diff --git a/service/pom.xml b/service/pom.xml
index c7d77330f..8ab88cca2 100644
--- a/service/pom.xml
+++ b/service/pom.xml
@@ -28,6 +28,17 @@
websocket-resources
${TextSecureServer.version}
+
+ org.whispersystems.textsecure
+ gcm-sender-async
+ ${TextSecureServer.version}
+
+
+ com.google.guava
+ guava
+
+
+
@@ -115,18 +126,6 @@
-
- org.whispersystems
- gcm-sender-async
- 0.1.6
-
-
- com.google.guava
- guava
-
-
-
-
com.googlecode.libphonenumber
libphonenumber
diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/GCMSender.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/GCMSender.java
index 12de116aa..25e73d21a 100644
--- a/service/src/main/java/org/whispersystems/textsecuregcm/push/GCMSender.java
+++ b/service/src/main/java/org/whispersystems/textsecuregcm/push/GCMSender.java
@@ -40,7 +40,7 @@ public class GCMSender implements Managed {
private final Meter unregistered = metricRegistry.meter(name(getClass(), "sent", "unregistered"));
private final Meter canonical = metricRegistry.meter(name(getClass(), "sent", "canonical"));
- private final Map outboundMeters = new HashMap() {{
+ private final Map outboundMeters = new HashMap<>() {{
put("receipt", metricRegistry.meter(name(getClass(), "outbound", "receipt")));
put("notification", metricRegistry.meter(name(getClass(), "outbound", "notification")));
}};