Bring gcm-sender-async in as a module
This commit is contained in:
parent
6e0b956e61
commit
e6f25b9c5e
|
@ -0,0 +1,44 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<parent>
|
||||
<artifactId>TextSecureServer</artifactId>
|
||||
<groupId>org.whispersystems.textsecure</groupId>
|
||||
<version>1.0</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<artifactId>gcm-sender-async</artifactId>
|
||||
<version>${TextSecureServer.version}</version>
|
||||
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.apache.httpcomponents</groupId>
|
||||
<artifactId>httpasyncclient</artifactId>
|
||||
<version>4.0.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.nurkiewicz.asyncretry</groupId>
|
||||
<artifactId>asyncretry-jdk7</artifactId>
|
||||
<version>0.0.5</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp</groupId>
|
||||
<artifactId>mockwebserver</artifactId>
|
||||
<version>2.1.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.tomakehurst</groupId>
|
||||
<artifactId>wiremock</artifactId>
|
||||
<version>1.52</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
|
||||
</project>
|
||||
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.whispersystems.gcm.server;
|
||||
|
||||
|
||||
public class AuthenticationFailedException extends Exception {
|
||||
}
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.whispersystems.gcm.server;
|
||||
|
||||
|
||||
public class InvalidRequestException extends Exception {
|
||||
}
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<String, String> data;
|
||||
private final List<String> registrationIds;
|
||||
private final String priority;
|
||||
|
||||
private Message(String collapseKey, Long ttl, Boolean delayWhileIdle,
|
||||
Map<String, String> data, List<String> 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<String, String> data = null;
|
||||
private List<String> 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<Result> 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<Result> send(final Message message, final Object requestContext) {
|
||||
return executor.getFutureWithRetry(new RetryCallable<ListenableFuture<Result>>() {
|
||||
@Override
|
||||
public ListenableFuture<Result> call(RetryContext context) throws Exception {
|
||||
SettableFuture<Result> 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<HttpResponse> {
|
||||
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
static {
|
||||
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
}
|
||||
|
||||
private final SettableFuture<Result> future;
|
||||
private final Object requestContext;
|
||||
|
||||
public ResponseHandler(SettableFuture<Result> 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<GcmResponseEntity> 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());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.whispersystems.gcm.server;
|
||||
|
||||
public class ServerFailedException extends Exception {
|
||||
public ServerFailedException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ServerFailedException(Exception e) {
|
||||
super(e);
|
||||
}
|
||||
}
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<String, String> data;
|
||||
|
||||
@JsonProperty(value = "registration_ids")
|
||||
private List<String> registrationIds;
|
||||
|
||||
@JsonProperty
|
||||
private String priority;
|
||||
|
||||
public GcmRequestEntity(String collapseKey, Long ttl, Boolean delayWhileIdle,
|
||||
Map<String, String> data, List<String> registrationIds,
|
||||
String priority)
|
||||
{
|
||||
this.collapseKey = collapseKey;
|
||||
this.ttl = ttl;
|
||||
this.delayWhileIdle = delayWhileIdle;
|
||||
this.data = data;
|
||||
this.registrationIds = registrationIds;
|
||||
this.priority = priority;
|
||||
}
|
||||
}
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.whispersystems.gcm.server.internal;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class GcmResponseListEntity {
|
||||
|
||||
@JsonProperty
|
||||
private List<GcmResponseEntity> results;
|
||||
|
||||
public List<GcmResponseEntity> getResults() {
|
||||
return results;
|
||||
}
|
||||
}
|
|
@ -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"));
|
||||
}
|
||||
|
||||
}
|
|
@ -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<Result> 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<Result> 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<Result> 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<Result> 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<Result> 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<Result> 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<Result> 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");
|
||||
}
|
||||
}
|
|
@ -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<ListenableFuture<Result>> results = new LinkedList<>();
|
||||
|
||||
for (int i=0;i<1000;i++) {
|
||||
results.add(sender.send(Message.newBuilder().withDestination("1").build()));
|
||||
}
|
||||
|
||||
int i=0;
|
||||
for (ListenableFuture<Result> 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<ListenableFuture<Result>> futures = new LinkedList<>();
|
||||
|
||||
for (int i=0;i<1000;i++) {
|
||||
futures.add(sender.send(Message.newBuilder().withDestination("1").build()));
|
||||
}
|
||||
|
||||
for (ListenableFuture<Result> future : futures) {
|
||||
try {
|
||||
Result result = future.get(60, TimeUnit.SECONDS);
|
||||
} catch (ExecutionException e) {
|
||||
assertTrue(e.getCause() instanceof ServerFailedException);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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> T fromJson(String value, Class<T> 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));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"priority" : "high",
|
||||
"collapse_key" : "collapse",
|
||||
"time_to_live" : 10,
|
||||
"delay_while_idle" : true,
|
||||
"registration_ids" : ["1"]
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"data" : {
|
||||
"key1" : "value1",
|
||||
"key2" : "value2"
|
||||
},
|
||||
"registration_ids" : ["2"]
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"registration_ids" : ["1"]
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
{ "multicast_id": 216,
|
||||
"success": 0,
|
||||
"failure": 1,
|
||||
"canonical_ids": 0,
|
||||
"results": [
|
||||
{ "error": "NotRegistered"}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
{ "multicast_id": 108,
|
||||
"success": 1,
|
||||
"failure": 0,
|
||||
"canonical_ids": 0,
|
||||
"results": [
|
||||
{ "message_id": "1:08" }
|
||||
]
|
||||
}
|
1
pom.xml
1
pom.xml
|
@ -11,6 +11,7 @@
|
|||
<modules>
|
||||
<module>redis-dispatch</module>
|
||||
<module>websocket-resources</module>
|
||||
<module>gcm-sender-async</module>
|
||||
<module>service</module>
|
||||
</modules>
|
||||
|
||||
|
|
|
@ -28,6 +28,17 @@
|
|||
<artifactId>websocket-resources</artifactId>
|
||||
<version>${TextSecureServer.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.whispersystems.textsecure</groupId>
|
||||
<artifactId>gcm-sender-async</artifactId>
|
||||
<version>${TextSecureServer.version}</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>guava</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
|
||||
<dependency>
|
||||
|
@ -115,18 +126,6 @@
|
|||
</dependency>
|
||||
|
||||
|
||||
<dependency>
|
||||
<groupId>org.whispersystems</groupId>
|
||||
<artifactId>gcm-sender-async</artifactId>
|
||||
<version>0.1.6</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>guava</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.googlecode.libphonenumber</groupId>
|
||||
<artifactId>libphonenumber</artifactId>
|
||||
|
|
|
@ -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<String, Meter> outboundMeters = new HashMap<String, Meter>() {{
|
||||
private final Map<String, Meter> outboundMeters = new HashMap<>() {{
|
||||
put("receipt", metricRegistry.meter(name(getClass(), "outbound", "receipt")));
|
||||
put("notification", metricRegistry.meter(name(getClass(), "outbound", "notification")));
|
||||
}};
|
||||
|
|
Loading…
Reference in New Issue