From e6f25b9c5e557027c081b7791160505f73052f7e Mon Sep 17 00:00:00 2001 From: Moxie Marlinspike Date: Wed, 29 May 2019 11:03:33 -0700 Subject: [PATCH] Bring gcm-sender-async in as a module --- gcm-sender-async/pom.xml | 44 ++++ .../server/AuthenticationFailedException.java | 21 ++ .../gcm/server/InvalidRequestException.java | 21 ++ .../whispersystems/gcm/server/Message.java | 156 ++++++++++++++ .../org/whispersystems/gcm/server/Result.java | 100 +++++++++ .../org/whispersystems/gcm/server/Sender.java | 204 ++++++++++++++++++ .../gcm/server/ServerFailedException.java | 27 +++ .../gcm/server/internal/GcmRequestEntity.java | 58 +++++ .../server/internal/GcmResponseEntity.java | 43 ++++ .../internal/GcmResponseListEntity.java | 31 +++ .../gcm/server/MessageTest.java | 45 ++++ .../whispersystems/gcm/server/SenderTest.java | 177 +++++++++++++++ .../gcm/server/SimultaneousSenderTest.java | 73 +++++++ .../gcm/server/util/FixtureHelpers.java | 43 ++++ .../gcm/server/util/JsonHelpers.java | 26 +++ .../resources/fixtures/message-complete.json | 7 + .../test/resources/fixtures/message-data.json | 7 + .../resources/fixtures/message-minimal.json | 3 + .../fixtures/response-not-registered.json | 8 + .../resources/fixtures/response-success.json | 8 + pom.xml | 1 + service/pom.xml | 23 +- .../textsecuregcm/push/GCMSender.java | 2 +- 23 files changed, 1115 insertions(+), 13 deletions(-) create mode 100644 gcm-sender-async/pom.xml create mode 100644 gcm-sender-async/src/main/java/org/whispersystems/gcm/server/AuthenticationFailedException.java create mode 100644 gcm-sender-async/src/main/java/org/whispersystems/gcm/server/InvalidRequestException.java create mode 100644 gcm-sender-async/src/main/java/org/whispersystems/gcm/server/Message.java create mode 100644 gcm-sender-async/src/main/java/org/whispersystems/gcm/server/Result.java create mode 100644 gcm-sender-async/src/main/java/org/whispersystems/gcm/server/Sender.java create mode 100644 gcm-sender-async/src/main/java/org/whispersystems/gcm/server/ServerFailedException.java create mode 100644 gcm-sender-async/src/main/java/org/whispersystems/gcm/server/internal/GcmRequestEntity.java create mode 100644 gcm-sender-async/src/main/java/org/whispersystems/gcm/server/internal/GcmResponseEntity.java create mode 100644 gcm-sender-async/src/main/java/org/whispersystems/gcm/server/internal/GcmResponseListEntity.java create mode 100644 gcm-sender-async/src/test/java/org/whispersystems/gcm/server/MessageTest.java create mode 100644 gcm-sender-async/src/test/java/org/whispersystems/gcm/server/SenderTest.java create mode 100644 gcm-sender-async/src/test/java/org/whispersystems/gcm/server/SimultaneousSenderTest.java create mode 100644 gcm-sender-async/src/test/java/org/whispersystems/gcm/server/util/FixtureHelpers.java create mode 100644 gcm-sender-async/src/test/java/org/whispersystems/gcm/server/util/JsonHelpers.java create mode 100644 gcm-sender-async/src/test/resources/fixtures/message-complete.json create mode 100644 gcm-sender-async/src/test/resources/fixtures/message-data.json create mode 100644 gcm-sender-async/src/test/resources/fixtures/message-minimal.json create mode 100644 gcm-sender-async/src/test/resources/fixtures/response-not-registered.json create mode 100644 gcm-sender-async/src/test/resources/fixtures/response-success.json diff --git a/gcm-sender-async/pom.xml b/gcm-sender-async/pom.xml new file mode 100644 index 00000000..e9cb035b --- /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 00000000..a5f6aa61 --- /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 00000000..ece46f86 --- /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 00000000..f6e8c3eb --- /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 00000000..e76a2ad4 --- /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 00000000..0cb57296 --- /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 00000000..558e679c --- /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 00000000..9d002e33 --- /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 00000000..69845270 --- /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 00000000..59c3b11a --- /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 00000000..56a7becc --- /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 00000000..c3c7b199 --- /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 00000000..f7fb184f --- /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 00000000..6759ef90 --- /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 00000000..812d4522 --- /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 00000000..43a4dcf0 --- /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 00000000..4993e04e --- /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 00000000..2aab43b0 --- /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 00000000..9363c9d7 --- /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 00000000..7ae2b3d9 --- /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 1a30e528..d1ee288a 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 c7d77330..8ab88cca 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 12de116a..25e73d21 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"))); }};