Implement delivery receipts.

1) Support a "receipt" push message type.

2) Identify messages by timestamp.

3) Introduce a JobManager to handle the queue for network
   dependent jobs.
This commit is contained in:
Moxie Marlinspike
2014-07-25 15:14:29 -07:00
parent 8d6b9ae43e
commit 36ec1d84a1
48 changed files with 739 additions and 271 deletions

View File

@@ -19,7 +19,7 @@ repositories {
}
dependencies {
compile 'com.google.protobuf:protobuf-java:2.4.1'
compile 'com.google.protobuf:protobuf-java:2.5.0'
compile 'com.madgag:sc-light-jdk15on:1.47.0.2'
compile 'com.googlecode.libphonenumber:libphonenumber:6.1'
compile 'org.whispersystems:gson:2.2.4'

View File

@@ -10,6 +10,7 @@ message IncomingPushMessageSignal {
KEY_EXCHANGE = 2;
PREKEY_BUNDLE = 3;
PLAINTEXT = 4;
RECEIPT = 5;
}
optional Type type = 1;
optional string source = 2;

View File

@@ -1,9 +0,0 @@
package org.whispersystems.textsecure.push;
import java.io.IOException;
public class AuthorizationFailedException extends IOException {
public AuthorizationFailedException(String s) {
super(s);
}
}

View File

@@ -1,6 +0,0 @@
package org.whispersystems.textsecure.push;
import java.io.IOException;
public class ExpectationFailedException extends IOException {
}

View File

@@ -138,4 +138,12 @@ public class IncomingPushMessage implements Parcelable {
public boolean isPreKeyBundle() {
return getType() == IncomingPushMessageSignal.Type.PREKEY_BUNDLE_VALUE;
}
public boolean isReceipt() {
return getType() == IncomingPushMessageSignal.Type.RECEIPT_VALUE;
}
public boolean isPlaintext() {
return getType() == IncomingPushMessageSignal.Type.PLAINTEXT_VALUE;
}
}

View File

@@ -1,9 +0,0 @@
package org.whispersystems.textsecure.push;
import java.io.IOException;
public class NotFoundException extends IOException {
public NotFoundException(String s) {
super(s);
}
}

View File

@@ -1,6 +1,5 @@
package org.whispersystems.textsecure.push;
import java.util.LinkedList;
import java.util.List;
public class OutgoingPushMessageList {
@@ -9,9 +8,14 @@ public class OutgoingPushMessageList {
private String relay;
private long timestamp;
private List<OutgoingPushMessage> messages;
public OutgoingPushMessageList(String destination, String relay, List<OutgoingPushMessage> messages) {
public OutgoingPushMessageList(String destination, long timestamp, String relay,
List<OutgoingPushMessage> messages)
{
this.timestamp = timestamp;
this.destination = destination;
this.relay = relay;
this.messages = messages;
@@ -28,4 +32,8 @@ public class OutgoingPushMessageList {
public String getRelay() {
return relay;
}
public long getTimestamp() {
return timestamp;
}
}

View File

@@ -240,6 +240,10 @@ public final class PushMessageProtos {
* <code>PLAINTEXT = 4;</code>
*/
PLAINTEXT(4, 4),
/**
* <code>RECEIPT = 5;</code>
*/
RECEIPT(5, 5),
;
/**
@@ -262,6 +266,10 @@ public final class PushMessageProtos {
* <code>PLAINTEXT = 4;</code>
*/
public static final int PLAINTEXT_VALUE = 4;
/**
* <code>RECEIPT = 5;</code>
*/
public static final int RECEIPT_VALUE = 5;
public final int getNumber() { return value; }
@@ -273,6 +281,7 @@ public final class PushMessageProtos {
case 2: return KEY_EXCHANGE;
case 3: return PREKEY_BUNDLE;
case 4: return PLAINTEXT;
case 5: return RECEIPT;
default: return null;
}
}
@@ -4079,28 +4088,28 @@ public final class PushMessageProtos {
static {
java.lang.String[] descriptorData = {
"\n\037IncomingPushMessageSignal.proto\022\ntexts" +
"ecure\"\207\002\n\031IncomingPushMessageSignal\0228\n\004t" +
"ecure\"\224\002\n\031IncomingPushMessageSignal\0228\n\004t" +
"ype\030\001 \001(\0162*.textsecure.IncomingPushMessa" +
"geSignal.Type\022\016\n\006source\030\002 \001(\t\022\024\n\014sourceD" +
"evice\030\007 \001(\r\022\r\n\005relay\030\003 \001(\t\022\021\n\ttimestamp\030" +
"\005 \001(\004\022\017\n\007message\030\006 \001(\014\"W\n\004Type\022\013\n\007UNKNOW" +
"\005 \001(\004\022\017\n\007message\030\006 \001(\014\"d\n\004Type\022\013\n\007UNKNOW" +
"N\020\000\022\016\n\nCIPHERTEXT\020\001\022\020\n\014KEY_EXCHANGE\020\002\022\021\n" +
"\rPREKEY_BUNDLE\020\003\022\r\n\tPLAINTEXT\020\004\"\207\004\n\022Push" +
"MessageContent\022\014\n\004body\030\001 \001(\t\022E\n\013attachme" +
"nts\030\002 \003(\01320.textsecure.PushMessageConten",
"t.AttachmentPointer\022:\n\005group\030\003 \001(\0132+.tex" +
"tsecure.PushMessageContent.GroupContext\022" +
"\r\n\005flags\030\004 \001(\r\032A\n\021AttachmentPointer\022\n\n\002i" +
"d\030\001 \001(\006\022\023\n\013contentType\030\002 \001(\t\022\013\n\003key\030\003 \001(" +
"\014\032\363\001\n\014GroupContext\022\n\n\002id\030\001 \001(\014\022>\n\004type\030\002" +
" \001(\01620.textsecure.PushMessageContent.Gro" +
"upContext.Type\022\014\n\004name\030\003 \001(\t\022\017\n\007members\030" +
"\004 \003(\t\022@\n\006avatar\030\005 \001(\01320.textsecure.PushM" +
"essageContent.AttachmentPointer\"6\n\004Type\022" +
"\013\n\007UNKNOWN\020\000\022\n\n\006UPDATE\020\001\022\013\n\007DELIVER\020\002\022\010\n",
"\004QUIT\020\003\"\030\n\005Flags\022\017\n\013END_SESSION\020\001B7\n\"org" +
".whispersystems.textsecure.pushB\021PushMes" +
"sageProtos"
"\rPREKEY_BUNDLE\020\003\022\r\n\tPLAINTEXT\020\004\022\013\n\007RECEI" +
"PT\020\005\"\207\004\n\022PushMessageContent\022\014\n\004body\030\001 \001(" +
"\t\022E\n\013attachments\030\002 \003(\01320.textsecure.Push",
"MessageContent.AttachmentPointer\022:\n\005grou" +
"p\030\003 \001(\0132+.textsecure.PushMessageContent." +
"GroupContext\022\r\n\005flags\030\004 \001(\r\032A\n\021Attachmen" +
"tPointer\022\n\n\002id\030\001 \001(\006\022\023\n\013contentType\030\002 \001(" +
"\t\022\013\n\003key\030\003 \001(\014\032\363\001\n\014GroupContext\022\n\n\002id\030\001 " +
"\001(\014\022>\n\004type\030\002 \001(\01620.textsecure.PushMessa" +
"geContent.GroupContext.Type\022\014\n\004name\030\003 \001(" +
"\t\022\017\n\007members\030\004 \003(\t\022@\n\006avatar\030\005 \001(\01320.tex" +
"tsecure.PushMessageContent.AttachmentPoi" +
"nter\"6\n\004Type\022\013\n\007UNKNOWN\020\000\022\n\n\006UPDATE\020\001\022\013\n",
"\007DELIVER\020\002\022\010\n\004QUIT\020\003\"\030\n\005Flags\022\017\n\013END_SES" +
"SION\020\001B7\n\"org.whispersystems.textsecure." +
"pushB\021PushMessageProtos"
};
com.google.protobuf.Descriptors.FileDescriptor.InternalDescriptorAssigner assigner =
new com.google.protobuf.Descriptors.FileDescriptor.InternalDescriptorAssigner() {

View File

@@ -25,9 +25,17 @@ import com.google.thoughtcrimegson.JsonParseException;
import org.apache.http.conn.ssl.StrictHostnameVerifier;
import org.whispersystems.libaxolotl.IdentityKey;
import org.whispersystems.libaxolotl.ecc.ECPublicKey;
import org.whispersystems.libaxolotl.state.SignedPreKeyRecord;
import org.whispersystems.libaxolotl.state.PreKeyBundle;
import org.whispersystems.libaxolotl.state.PreKeyRecord;
import org.whispersystems.libaxolotl.state.SignedPreKeyRecord;
import org.whispersystems.textsecure.push.exceptions.AuthorizationFailedException;
import org.whispersystems.textsecure.push.exceptions.ExpectationFailedException;
import org.whispersystems.textsecure.push.exceptions.MismatchedDevicesException;
import org.whispersystems.textsecure.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.textsecure.push.exceptions.NotFoundException;
import org.whispersystems.textsecure.push.exceptions.PushNetworkException;
import org.whispersystems.textsecure.push.exceptions.RateLimitException;
import org.whispersystems.textsecure.push.exceptions.StaleDevicesException;
import org.whispersystems.textsecure.util.Base64;
import org.whispersystems.textsecure.util.BlacklistingTrustManager;
import org.whispersystems.textsecure.util.Util;
@@ -75,6 +83,7 @@ public class PushServiceSocket {
private static final String DIRECTORY_TOKENS_PATH = "/v1/directory/tokens";
private static final String DIRECTORY_VERIFY_PATH = "/v1/directory/%s";
private static final String MESSAGE_PATH = "/v1/messages/%s";
private static final String RECEIPT_PATH = "/v1/receipt/%s/%d";
private static final String ATTACHMENT_PATH = "/v1/attachments/%s";
private static final boolean ENFORCE_SSL = true;
@@ -109,6 +118,16 @@ public class PushServiceSocket {
"PUT", new Gson().toJson(signalingKeyEntity));
}
public void sendReceipt(String destination, long messageId, String relay) throws IOException {
String path = String.format(RECEIPT_PATH, destination, messageId);
if (!Util.isEmpty(relay)) {
path += "?relay=" + relay;
}
makeRequest(path, "PUT", null);
}
public void registerGcmId(String gcmRegistrationId) throws IOException {
GcmRegistrationId registration = new GcmRegistrationId(gcmRegistrationId);
makeRequest(REGISTER_GCM_PATH, "PUT", new Gson().toJson(registration));
@@ -380,68 +399,77 @@ public class PushServiceSocket {
}
private String makeRequest(String urlFragment, String method, String body)
throws IOException
throws NonSuccessfulResponseCodeException, PushNetworkException
{
HttpURLConnection connection = makeBaseRequest(urlFragment, method, body);
String response = Util.readFully(connection.getInputStream());
connection.disconnect();
try {
String response = Util.readFully(connection.getInputStream());
connection.disconnect();
return response;
return response;
} catch (IOException ioe) {
throw new PushNetworkException(ioe);
}
}
private HttpURLConnection makeBaseRequest(String urlFragment, String method, String body)
throws IOException
throws NonSuccessfulResponseCodeException, PushNetworkException
{
HttpURLConnection connection = getConnection(urlFragment, method);
try {
HttpURLConnection connection = getConnection(urlFragment, method);
if (body != null) {
connection.setDoOutput(true);
if (body != null) {
connection.setDoOutput(true);
}
connection.connect();
if (body != null) {
Log.w("PushServiceSocket", method + " -- " + body);
OutputStream out = connection.getOutputStream();
out.write(body.getBytes());
out.close();
}
if (connection.getResponseCode() == 413) {
connection.disconnect();
throw new RateLimitException("Rate limit exceeded: " + connection.getResponseCode());
}
if (connection.getResponseCode() == 401 || connection.getResponseCode() == 403) {
connection.disconnect();
throw new AuthorizationFailedException("Authorization failed!");
}
if (connection.getResponseCode() == 404) {
connection.disconnect();
throw new NotFoundException("Not found");
}
if (connection.getResponseCode() == 409) {
String response = Util.readFully(connection.getErrorStream());
throw new MismatchedDevicesException(new Gson().fromJson(response, MismatchedDevices.class));
}
if (connection.getResponseCode() == 410) {
String response = Util.readFully(connection.getErrorStream());
throw new StaleDevicesException(new Gson().fromJson(response, StaleDevices.class));
}
if (connection.getResponseCode() == 417) {
throw new ExpectationFailedException();
}
if (connection.getResponseCode() != 200 && connection.getResponseCode() != 204) {
throw new NonSuccessfulResponseCodeException("Bad response: " + connection.getResponseCode() +
" " + connection.getResponseMessage());
}
return connection;
} catch (IOException e) {
throw new PushNetworkException(e);
}
connection.connect();
if (body != null) {
Log.w("PushServiceSocket", method + " -- " + body);
OutputStream out = connection.getOutputStream();
out.write(body.getBytes());
out.close();
}
if (connection.getResponseCode() == 413) {
connection.disconnect();
throw new RateLimitException("Rate limit exceeded: " + connection.getResponseCode());
}
if (connection.getResponseCode() == 401 || connection.getResponseCode() == 403) {
connection.disconnect();
throw new AuthorizationFailedException("Authorization failed!");
}
if (connection.getResponseCode() == 404) {
connection.disconnect();
throw new NotFoundException("Not found");
}
if (connection.getResponseCode() == 409) {
String response = Util.readFully(connection.getErrorStream());
throw new MismatchedDevicesException(new Gson().fromJson(response, MismatchedDevices.class));
}
if (connection.getResponseCode() == 410) {
String response = Util.readFully(connection.getErrorStream());
throw new StaleDevicesException(new Gson().fromJson(response, StaleDevices.class));
}
if (connection.getResponseCode() == 417) {
throw new ExpectationFailedException();
}
if (connection.getResponseCode() != 200 && connection.getResponseCode() != 204) {
throw new IOException("Bad response: " + connection.getResponseCode() + " " + connection.getResponseMessage());
}
return connection;
}
private HttpURLConnection getConnection(String urlFragment, String method) throws IOException {

View File

@@ -1,10 +0,0 @@
package org.whispersystems.textsecure.push;
import java.io.IOException;
public class RateLimitException extends IOException {
public RateLimitException(String s) {
super(s);
}
}

View File

@@ -1,16 +0,0 @@
package org.whispersystems.textsecure.push;
import java.io.IOException;
public class StaleDevicesException extends IOException {
private final StaleDevices staleDevices;
public StaleDevicesException(StaleDevices staleDevices) {
this.staleDevices = staleDevices;
}
public StaleDevices getStaleDevices() {
return staleDevices;
}
}

View File

@@ -0,0 +1,7 @@
package org.whispersystems.textsecure.push.exceptions;
public class AuthorizationFailedException extends NonSuccessfulResponseCodeException {
public AuthorizationFailedException(String s) {
super(s);
}
}

View File

@@ -0,0 +1,4 @@
package org.whispersystems.textsecure.push.exceptions;
public class ExpectationFailedException extends NonSuccessfulResponseCodeException {
}

View File

@@ -1,8 +1,8 @@
package org.whispersystems.textsecure.push;
package org.whispersystems.textsecure.push.exceptions;
import java.io.IOException;
import org.whispersystems.textsecure.push.MismatchedDevices;
public class MismatchedDevicesException extends IOException {
public class MismatchedDevicesException extends NonSuccessfulResponseCodeException {
private final MismatchedDevices mismatchedDevices;

View File

@@ -0,0 +1,14 @@
package org.whispersystems.textsecure.push.exceptions;
import java.io.IOException;
public class NonSuccessfulResponseCodeException extends IOException {
public NonSuccessfulResponseCodeException() {
super();
}
public NonSuccessfulResponseCodeException(String s) {
super(s);
}
}

View File

@@ -0,0 +1,9 @@
package org.whispersystems.textsecure.push.exceptions;
import org.whispersystems.textsecure.push.exceptions.NonSuccessfulResponseCodeException;
public class NotFoundException extends NonSuccessfulResponseCodeException {
public NotFoundException(String s) {
super(s);
}
}

View File

@@ -0,0 +1,9 @@
package org.whispersystems.textsecure.push.exceptions;
import java.io.IOException;
public class PushNetworkException extends IOException {
public PushNetworkException(Exception exception) {
super(exception);
}
}

View File

@@ -0,0 +1,10 @@
package org.whispersystems.textsecure.push.exceptions;
import org.whispersystems.textsecure.push.exceptions.NonSuccessfulResponseCodeException;
public class RateLimitException extends NonSuccessfulResponseCodeException {
public RateLimitException(String s) {
super(s);
}
}

View File

@@ -0,0 +1,17 @@
package org.whispersystems.textsecure.push.exceptions;
import org.whispersystems.textsecure.push.StaleDevices;
import org.whispersystems.textsecure.push.exceptions.NonSuccessfulResponseCodeException;
public class StaleDevicesException extends NonSuccessfulResponseCodeException {
private final StaleDevices staleDevices;
public StaleDevicesException(StaleDevices staleDevices) {
this.staleDevices = staleDevices;
}
public StaleDevices getStaleDevices() {
return staleDevices;
}
}