Switch to GCM CCS and add support for APN feedback processing.

This commit is contained in:
Moxie Marlinspike
2014-07-23 18:00:49 -07:00
parent 362abd618f
commit 4cd098af1d
14 changed files with 735 additions and 186 deletions

View File

@@ -1,69 +1,415 @@
/**
* Copyright (C) 2013 Open WhisperSystems
*
* 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.textsecuregcm.push;
import com.codahale.metrics.Meter;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.SharedMetricRegistries;
import com.google.android.gcm.server.Constants;
import com.google.android.gcm.server.Message;
import com.google.android.gcm.server.Result;
import com.google.android.gcm.server.Sender;
import org.whispersystems.textsecuregcm.entities.EncryptedOutgoingMessage;
import com.google.common.base.Optional;
import org.jivesoftware.smack.ConnectionConfiguration;
import org.jivesoftware.smack.ConnectionListener;
import org.jivesoftware.smack.PacketListener;
import org.jivesoftware.smack.SmackException;
import org.jivesoftware.smack.XMPPConnection;
import org.jivesoftware.smack.XMPPException;
import org.jivesoftware.smack.filter.PacketTypeFilter;
import org.jivesoftware.smack.packet.DefaultPacketExtension;
import org.jivesoftware.smack.packet.Message;
import org.jivesoftware.smack.packet.Packet;
import org.jivesoftware.smack.packet.PacketExtension;
import org.jivesoftware.smack.provider.PacketExtensionProvider;
import org.jivesoftware.smack.provider.ProviderManager;
import org.jivesoftware.smack.tcp.XMPPTCPConnection;
import org.jivesoftware.smack.util.StringUtils;
import org.json.simple.JSONObject;
import org.json.simple.JSONValue;
import org.json.simple.parser.ParseException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.entities.PendingMessage;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.util.Util;
import org.xmlpull.v1.XmlPullParser;
import javax.net.ssl.SSLSocketFactory;
import java.io.IOException;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import static com.codahale.metrics.MetricRegistry.name;
import io.dropwizard.lifecycle.Managed;
public class GCMSender {
public class GCMSender implements Managed, PacketListener {
private final Logger logger = LoggerFactory.getLogger(GCMSender.class);
private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(org.whispersystems.textsecuregcm.util.Constants.METRICS_NAME);
private final Meter success = metricRegistry.meter(name(getClass(), "sent", "success"));
private final Meter failure = metricRegistry.meter(name(getClass(), "sent", "failure"));
private final Meter unregistered = metricRegistry.meter(name(getClass(), "sent", "unregistered"));
private final Sender sender;
private static final String GCM_SERVER = "gcm.googleapis.com";
private static final int GCM_PORT = 5235;
public GCMSender(String apiKey) {
this.sender = new Sender(apiKey);
private static final String GCM_ELEMENT_NAME = "gcm";
private static final String GCM_NAMESPACE = "google:mobile:data";
private final Map<String, UnacknowledgedMessage> pendingMessages = new ConcurrentHashMap<>();
private final long senderId;
private final String apiKey;
private final AccountsManager accounts;
private XMPPTCPConnection connection;
public GCMSender(AccountsManager accounts, long senderId, String apiKey) {
this.accounts = accounts;
this.senderId = senderId;
this.apiKey = apiKey;
ProviderManager.addExtensionProvider(GCM_ELEMENT_NAME, GCM_NAMESPACE,
new GcmPacketExtensionProvider());
}
public String sendMessage(String gcmRegistrationId, EncryptedOutgoingMessage outgoingMessage)
throws NotPushRegisteredException, TransientPushFailureException
public void sendMessage(String destinationNumber, long destinationDeviceId,
String registrationId, PendingMessage message)
{
String messageId = "m-" + UUID.randomUUID().toString();
UnacknowledgedMessage unacknowledgedMessage = new UnacknowledgedMessage(destinationNumber,
destinationDeviceId,
registrationId, message);
sendMessage(messageId, unacknowledgedMessage);
}
public void sendMessage(String messageId, UnacknowledgedMessage message) {
try {
Message gcmMessage = new Message.Builder().addData("type", "message")
.addData("message", outgoingMessage.serialize())
.build();
Map<String, String> dataObject = new HashMap<>();
dataObject.put("type", "message");
dataObject.put("message", message.getPendingMessage().getEncryptedOutgoingMessage());
Result result = sender.send(gcmMessage, gcmRegistrationId, 5);
Map<String, Object> messageObject = new HashMap<>();
messageObject.put("to", message.getRegistrationId());
messageObject.put("message_id", messageId);
messageObject.put("data", dataObject);
if (result.getMessageId() != null) {
success.mark();
return result.getCanonicalRegistrationId();
} else {
failure.mark();
if (result.getErrorCodeName().equals(Constants.ERROR_NOT_REGISTERED)) {
throw new NotPushRegisteredException("Device no longer registered with GCM.");
} else {
throw new TransientPushFailureException("GCM Failed: " + result.getErrorCodeName());
}
String json = JSONObject.toJSONString(messageObject);
pendingMessages.put(messageId, message);
connection.sendPacket(new GcmPacketExtension(json).toPacket());
} catch (SmackException.NotConnectedException e) {
logger.warn("GCMClient", "No connection", e);
}
}
@Override
public void start() throws Exception {
this.connection = connect(senderId, apiKey);
}
@Override
public void stop() throws Exception {
this.connection.disconnect();
}
@Override
public void processPacket(Packet packet) throws SmackException.NotConnectedException {
Message incomingMessage = (Message) packet;
GcmPacketExtension gcmPacket = (GcmPacketExtension) incomingMessage.getExtension(GCM_NAMESPACE);
String json = gcmPacket.getJson();
try {
Map<String, Object> jsonObject = (Map<String, Object>) JSONValue.parseWithException(json);
Object messageType = jsonObject.get("message_type");
if (messageType == null) {
handleUpstreamMessage(jsonObject);
return;
}
} catch (IOException e) {
throw new TransientPushFailureException(e);
switch (messageType.toString()) {
case "ack" : handleAckReceipt(jsonObject); break;
case "nack" : handleNackReceipt(jsonObject); break;
case "receipt" : handleDeliveryReceipt(jsonObject); break;
case "control" : handleControlMessage(jsonObject); break;
default:
logger.warn("Received unknown GCM message: " + messageType.toString());
}
} catch (ParseException e) {
logger.warn("GCMClient", "Received unparsable message", e);
} catch (Exception e) {
logger.warn("GCMClient", "Failed to process packet", e);
}
}
private void handleControlMessage(Map<String, Object> message) {
String controlType = (String) message.get("control_type");
if ("CONNECTION_DRAINING".equals(controlType)) {
logger.warn("GCM Connection is draining! Initiating reconnect...");
reconnect();
} else {
logger.warn("Received unknown GCM control message: " + controlType);
}
}
private void handleDeliveryReceipt(Map<String, Object> message) {
logger.warn("Got delivery receipt!");
}
private void handleNackReceipt(Map<String, Object> message) {
String messageId = (String) message.get("message_id");
String errorCode = (String) message.get("error");
if (errorCode == null) {
logger.warn("Null GCM error code!");
if (messageId != null) {
pendingMessages.remove(messageId);
}
return;
}
switch (errorCode) {
case "BAD_REGISTRATION" : handleBadRegistration(message); break;
case "DEVICE_UNREGISTERED" : handleBadRegistration(message); break;
case "INTERNAL_SERVER_ERROR" : handleServerFailure(message); break;
case "INVALID_JSON" : handleClientFailure(message); break;
case "QUOTA_EXCEEDED" : handleClientFailure(message); break;
case "SERVICE_UNAVAILABLE" : handleServerFailure(message); break;
}
}
private void handleAckReceipt(Map<String, Object> message) {
success.mark();
String messageId = (String) message.get("message_id");
if (messageId != null) {
pendingMessages.remove(messageId);
}
}
private void handleUpstreamMessage(Map<String, Object> message)
throws SmackException.NotConnectedException
{
logger.warn("Got upstream message from GCM Server!");
Map<String, Object> ack = new HashMap<>();
message.put("message_type", "ack");
message.put("to", message.get("from"));
message.put("message_id", message.get("message_id"));
String json = JSONValue.toJSONString(ack);
Packet request = new GcmPacketExtension(json).toPacket();
connection.sendPacket(request);
}
private void handleBadRegistration(Map<String, Object> message) {
unregistered.mark();
String messageId = (String) message.get("message_id");
if (messageId != null) {
UnacknowledgedMessage unacknowledgedMessage = pendingMessages.remove(messageId);
if (unacknowledgedMessage != null) {
Optional<Account> account = accounts.get(unacknowledgedMessage.getDestinationNumber());
if (account.isPresent()) {
Optional<Device> device = account.get().getDevice(unacknowledgedMessage.getDestinationDeviceId());
if (device.isPresent()) {
device.get().setGcmId(null);
accounts.update(account.get());
}
}
}
}
}
private void handleServerFailure(Map<String, Object> message) {
failure.mark();
String messageId = (String)message.get("message_id");
if (messageId != null) {
UnacknowledgedMessage unacknowledgedMessage = pendingMessages.remove(messageId);
if (unacknowledgedMessage != null) {
sendMessage(messageId, unacknowledgedMessage);
}
}
}
private void handleClientFailure(Map<String, Object> message) {
failure.mark();
logger.warn("Unrecoverable error: " + message.get("error"));
String messageId = (String)message.get("message_id");
if (messageId != null) {
pendingMessages.remove(messageId);
}
}
private void reconnect() {
try {
this.connection.disconnect();
} catch (SmackException.NotConnectedException e) {
logger.warn("GCMClient", "Disconnect attempt", e);
}
while (true) {
try {
this.connection = connect(senderId, apiKey);
return;
} catch (XMPPException | IOException | SmackException e) {
logger.warn("GCMClient", "Reconnecting", e);
Util.sleep(1000);
}
}
}
private XMPPTCPConnection connect(long senderId, String apiKey)
throws XMPPException, IOException, SmackException
{
ConnectionConfiguration config = new ConnectionConfiguration(GCM_SERVER, GCM_PORT);
config.setSecurityMode(ConnectionConfiguration.SecurityMode.enabled);
config.setReconnectionAllowed(true);
config.setRosterLoadedAtLogin(false);
config.setSendPresence(false);
config.setSocketFactory(SSLSocketFactory.getDefault());
XMPPTCPConnection connection = new XMPPTCPConnection(config);
connection.connect();
connection.addConnectionListener(new LoggingConnectionListener());
connection.addPacketListener(this, new PacketTypeFilter(Message.class));
connection.login(senderId + "@gcm.googleapis.com", apiKey);
return connection;
}
private static class GcmPacketExtensionProvider implements PacketExtensionProvider {
@Override
public PacketExtension parseExtension(XmlPullParser xmlPullParser) throws Exception {
String json = xmlPullParser.nextText();
return new GcmPacketExtension(json);
}
}
private static final class GcmPacketExtension extends DefaultPacketExtension {
private final String json;
public GcmPacketExtension(String json) {
super(GCM_ELEMENT_NAME, GCM_NAMESPACE);
this.json = json;
}
public String getJson() {
return json;
}
@Override
public String toXML() {
return String.format("<%s xmlns=\"%s\">%s</%s>", GCM_ELEMENT_NAME, GCM_NAMESPACE,
StringUtils.escapeForXML(json), GCM_ELEMENT_NAME);
}
public Packet toPacket() {
Message message = new Message();
message.addExtension(this);
return message;
}
}
private class LoggingConnectionListener implements ConnectionListener {
@Override
public void connected(XMPPConnection xmppConnection) {
logger.warn("GCM XMPP Connected.");
}
@Override
public void authenticated(XMPPConnection xmppConnection) {
logger.warn("GCM XMPP Authenticated.");
}
@Override
public void reconnectionSuccessful() {
logger.warn("GCM XMPP Reconnecting..");
Iterator<Map.Entry<String, UnacknowledgedMessage>> iterator =
pendingMessages.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, UnacknowledgedMessage> entry = iterator.next();
iterator.remove();
sendMessage(entry.getKey(), entry.getValue());
}
}
@Override
public void reconnectionFailed(Exception e) {
logger.warn("GCM XMPP Reconnection failed!", e);
}
@Override
public void reconnectingIn(int seconds) {
logger.warn(String.format("GCM XMPP Reconnecting in %d secs", seconds));
}
@Override
public void connectionClosedOnError(Exception e) {
logger.warn("GCM XMPP Connection closed on error.");
}
@Override
public void connectionClosed() {
logger.warn("GCM XMPP Connection closed.");
}
}
private static class UnacknowledgedMessage {
private final String destinationNumber;
private final long destinationDeviceId;
private final String registrationId;
private final PendingMessage pendingMessage;
private UnacknowledgedMessage(String destinationNumber,
long destinationDeviceId,
String registrationId,
PendingMessage pendingMessage)
{
this.destinationNumber = destinationNumber;
this.destinationDeviceId = destinationDeviceId;
this.registrationId = registrationId;
this.pendingMessage = pendingMessage;
}
private String getRegistrationId() {
return registrationId;
}
private PendingMessage getPendingMessage() {
return pendingMessage;
}
public String getDestinationNumber() {
return destinationNumber;
}
public long getDestinationDeviceId() {
return destinationDeviceId;
}
}
}