0
0
mirror of https://github.com/signalapp/Signal-Server.git synced 2024-09-20 12:02:18 +02:00

Bring gcm-sender-async in as a module

This commit is contained in:
Moxie Marlinspike 2019-05-29 11:03:33 -07:00
parent 6e0b956e61
commit e6f25b9c5e
23 changed files with 1115 additions and 13 deletions

44
gcm-sender-async/pom.xml Normal file
View File

@ -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>

View File

@ -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 {
}

View File

@ -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 {
}

View File

@ -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);
}
}
}

View File

@ -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;
}
}

View File

@ -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());
}
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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"));
}
}

View File

@ -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");
}
}

View File

@ -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);
}
}
}
}

View File

@ -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);
}
}
}

View File

@ -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));
}
}

View File

@ -0,0 +1,7 @@
{
"priority" : "high",
"collapse_key" : "collapse",
"time_to_live" : 10,
"delay_while_idle" : true,
"registration_ids" : ["1"]
}

View File

@ -0,0 +1,7 @@
{
"data" : {
"key1" : "value1",
"key2" : "value2"
},
"registration_ids" : ["2"]
}

View File

@ -0,0 +1,3 @@
{
"registration_ids" : ["1"]
}

View File

@ -0,0 +1,8 @@
{ "multicast_id": 216,
"success": 0,
"failure": 1,
"canonical_ids": 0,
"results": [
{ "error": "NotRegistered"}
]
}

View File

@ -0,0 +1,8 @@
{ "multicast_id": 108,
"success": 1,
"failure": 0,
"canonical_ids": 0,
"results": [
{ "message_id": "1:08" }
]
}

View File

@ -11,6 +11,7 @@
<modules>
<module>redis-dispatch</module>
<module>websocket-resources</module>
<module>gcm-sender-async</module>
<module>service</module>
</modules>

View File

@ -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>

View File

@ -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")));
}};