Initial WebSocket refactor.

This commit is contained in:
Cody Henthorne
2021-07-09 10:01:24 -04:00
committed by Greyson Parrelli
parent 916006e664
commit 5d6d78a51e
30 changed files with 1438 additions and 869 deletions

View File

@@ -1,409 +0,0 @@
/*
* Copyright (C) 2014-2016 Open Whisper Systems
*
* Licensed according to the LICENSE file in this repository.
*/
package org.whispersystems.signalservice.api;
import com.google.protobuf.ByteString;
import org.signal.zkgroup.profiles.ClientZkProfileOperations;
import org.signal.zkgroup.profiles.ProfileKey;
import org.signal.zkgroup.profiles.ProfileKeyCredential;
import org.signal.zkgroup.profiles.ProfileKeyCredentialRequest;
import org.signal.zkgroup.profiles.ProfileKeyCredentialRequestContext;
import org.signal.zkgroup.profiles.ProfileKeyVersion;
import org.whispersystems.libsignal.InvalidVersionException;
import org.whispersystems.libsignal.logging.Log;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException;
import org.whispersystems.signalservice.api.util.CredentialsProvider;
import org.whispersystems.signalservice.internal.push.AttachmentV2UploadAttributes;
import org.whispersystems.signalservice.internal.push.AttachmentV3UploadAttributes;
import org.whispersystems.signalservice.internal.push.SendGroupMessageResponse;
import org.whispersystems.signalservice.internal.push.OutgoingPushMessageList;
import org.whispersystems.signalservice.internal.push.ProofRequiredResponse;
import org.whispersystems.signalservice.internal.push.SendMessageResponse;
import org.whispersystems.signalservice.internal.util.Hex;
import org.whispersystems.signalservice.internal.util.JsonUtil;
import org.whispersystems.signalservice.internal.util.Util;
import org.whispersystems.signalservice.internal.util.concurrent.FutureTransformers;
import org.whispersystems.signalservice.internal.util.concurrent.ListenableFuture;
import org.whispersystems.signalservice.internal.websocket.WebSocketConnection;
import org.whispersystems.signalservice.internal.websocket.WebsocketResponse;
import org.whispersystems.util.Base64;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import static org.whispersystems.signalservice.internal.websocket.WebSocketProtos.WebSocketRequestMessage;
import static org.whispersystems.signalservice.internal.websocket.WebSocketProtos.WebSocketResponseMessage;
/**
* A SignalServiceMessagePipe represents a dedicated connection
* to the Signal Service, which the server can push messages
* down through.
*/
public class SignalServiceMessagePipe {
private static final String TAG = SignalServiceMessagePipe.class.getName();
private static final String SERVER_DELIVERED_TIMESTAMP_HEADER = "X-Signal-Timestamp";
private final WebSocketConnection websocket;
private final Optional<CredentialsProvider> credentialsProvider;
private final ClientZkProfileOperations clientZkProfile;
SignalServiceMessagePipe(WebSocketConnection websocket,
Optional<CredentialsProvider> credentialsProvider,
ClientZkProfileOperations clientZkProfile)
{
this.websocket = websocket;
this.credentialsProvider = credentialsProvider;
this.clientZkProfile = clientZkProfile;
this.websocket.connect();
}
/**
* A blocking call that reads a message off the pipe. When this
* call returns, the message has been acknowledged and will not
* be retransmitted.
*
* @param timeout The timeout to wait for.
* @param unit The timeout time unit.
* @return A new message.
*
* @throws InvalidVersionException
* @throws IOException
* @throws TimeoutException
*/
public SignalServiceEnvelope read(long timeout, TimeUnit unit)
throws InvalidVersionException, IOException, TimeoutException
{
return read(timeout, unit, new NullMessagePipeCallback());
}
/**
* A blocking call that reads a message off the pipe (see {@link #read(long, java.util.concurrent.TimeUnit)}
*
* Unlike {@link #read(long, java.util.concurrent.TimeUnit)}, this method allows you
* to specify a callback that will be called before the received message is acknowledged.
* This allows you to write the received message to durable storage before acknowledging
* receipt of it to the server.
*
* @param timeout The timeout to wait for.
* @param unit The timeout time unit.
* @param callback A callback that will be called before the message receipt is
* acknowledged to the server.
* @return The message read (same as the message sent through the callback).
* @throws TimeoutException
* @throws IOException
* @throws InvalidVersionException
*/
public SignalServiceEnvelope read(long timeout, TimeUnit unit, MessagePipeCallback callback)
throws TimeoutException, IOException, InvalidVersionException
{
while (true) {
Optional<SignalServiceEnvelope> envelope = readOrEmpty(timeout, unit, callback);
if (envelope.isPresent()) {
return envelope.get();
}
}
}
/**
* Similar to {@link #read(long, TimeUnit, MessagePipeCallback)}, except this will return
* {@link Optional#absent()} when an empty response is hit, which indicates the websocket is
* empty.
*
* Important: The empty response will only be hit once for each connection. That means if you get
* an empty response and call readOrEmpty() again on the same instance, you will not get an empty
* response, and instead will block until you get an actual message. This will, however, reset if
* connection breaks (if, for instance, you lose and regain network).
*/
public Optional<SignalServiceEnvelope> readOrEmpty(long timeout, TimeUnit unit, MessagePipeCallback callback)
throws TimeoutException, IOException
{
if (!credentialsProvider.isPresent()) {
throw new IllegalArgumentException("You can't read messages if you haven't specified credentials");
}
while (true) {
WebSocketRequestMessage request = websocket.readRequest(unit.toMillis(timeout));
WebSocketResponseMessage response = createWebSocketResponse(request);
try {
if (isSignalServiceEnvelope(request)) {
Optional<String> timestampHeader = findHeader(request, SERVER_DELIVERED_TIMESTAMP_HEADER);
long timestamp = 0;
if (timestampHeader.isPresent()) {
try {
timestamp = Long.parseLong(timestampHeader.get());
} catch (NumberFormatException e) {
Log.w(TAG, "Failed to parse " + SERVER_DELIVERED_TIMESTAMP_HEADER);
}
}
SignalServiceEnvelope envelope = new SignalServiceEnvelope(request.getBody().toByteArray(), timestamp);
callback.onMessage(envelope);
return Optional.of(envelope);
} else if (isSocketEmptyRequest(request)) {
return Optional.absent();
}
} finally {
websocket.sendResponse(response);
}
}
}
public Future<SendGroupMessageResponse> sendToGroup(byte[] body, byte[] joinedUnidentifiedAccess, long timestamp, boolean online) throws IOException {
List<String> headers = new LinkedList<String>() {{
add("content-type:application/vnd.signal-messenger.mrm");
add("Unidentified-Access-Key:" + Base64.encodeBytes(joinedUnidentifiedAccess));
}};
String path = String.format(Locale.US, "/v1/messages/multi_recipient?ts=%s&online=%s", timestamp, online);
WebSocketRequestMessage requestMessage = WebSocketRequestMessage.newBuilder()
.setId(new SecureRandom().nextLong())
.setVerb("PUT")
.setPath(path)
.addAllHeaders(headers)
.setBody(ByteString.copyFrom(body))
.build();
ListenableFuture<WebsocketResponse> response = websocket.sendRequest(requestMessage);
return FutureTransformers.map(response, value -> {
if (value.getStatus() == 200) {
return JsonUtil.fromJson(value.getBody(), SendGroupMessageResponse.class);
} else {
throw new NonSuccessfulResponseCodeException(value.getStatus());
}
});
}
public Future<SendMessageResponse> send(OutgoingPushMessageList list, Optional<UnidentifiedAccess> unidentifiedAccess) throws IOException {
List<String> headers = new LinkedList<String>() {{
add("content-type:application/json");
}};
if (unidentifiedAccess.isPresent()) {
headers.add("Unidentified-Access-Key:" + Base64.encodeBytes(unidentifiedAccess.get().getUnidentifiedAccessKey()));
}
WebSocketRequestMessage requestMessage = WebSocketRequestMessage.newBuilder()
.setId(new SecureRandom().nextLong())
.setVerb("PUT")
.setPath(String.format("/v1/messages/%s", list.getDestination()))
.addAllHeaders(headers)
.setBody(ByteString.copyFrom(JsonUtil.toJson(list).getBytes()))
.build();
ListenableFuture<WebsocketResponse> response = websocket.sendRequest(requestMessage);
return FutureTransformers.map(response, value -> {
if (value.getStatus() == 404) {
throw new UnregisteredUserException(list.getDestination(), new NotFoundException("not found"));
} else if (value.getStatus() == 428) {
ProofRequiredResponse proofResponse = JsonUtil.fromJson(value.getBody(), ProofRequiredResponse.class);
String retryAfterRaw = value.getHeader("Retry-After");
long retryAfter = Util.parseInt(retryAfterRaw, -1);
throw new ProofRequiredException(proofResponse, retryAfter);
} else if (value.getStatus() == 508) {
throw new ServerRejectedException();
} else if (value.getStatus() < 200 || value.getStatus() >= 300) {
throw new IOException("Non-successful response: " + value.getStatus());
}
if (Util.isEmpty(value.getBody())) {
return new SendMessageResponse(false);
} else {
return JsonUtil.fromJson(value.getBody(), SendMessageResponse.class);
}
});
}
public ListenableFuture<ProfileAndCredential> getProfile(SignalServiceAddress address,
Optional<ProfileKey> profileKey,
Optional<UnidentifiedAccess> unidentifiedAccess,
SignalServiceProfile.RequestType requestType)
throws IOException
{
List<String> headers = new LinkedList<>();
if (unidentifiedAccess.isPresent()) {
headers.add("Unidentified-Access-Key:" + Base64.encodeBytes(unidentifiedAccess.get().getUnidentifiedAccessKey()));
}
Optional<UUID> uuid = address.getUuid();
SecureRandom random = new SecureRandom();
ProfileKeyCredentialRequestContext requestContext = null;
WebSocketRequestMessage.Builder builder = WebSocketRequestMessage.newBuilder()
.setId(random.nextLong())
.setVerb("GET")
.addAllHeaders(headers);
if (uuid.isPresent() && profileKey.isPresent()) {
UUID target = uuid.get();
ProfileKeyVersion profileKeyIdentifier = profileKey.get().getProfileKeyVersion(target);
String version = profileKeyIdentifier.serialize();
if (requestType == SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL) {
requestContext = clientZkProfile.createProfileKeyCredentialRequestContext(random, target, profileKey.get());
ProfileKeyCredentialRequest request = requestContext.getRequest();
String credentialRequest = Hex.toStringCondensed(request.serialize());
builder.setPath(String.format("/v1/profile/%s/%s/%s", target, version, credentialRequest));
} else {
builder.setPath(String.format("/v1/profile/%s/%s", target, version));
}
} else {
builder.setPath(String.format("/v1/profile/%s", address.getIdentifier()));
}
final ProfileKeyCredentialRequestContext finalRequestContext = requestContext;
WebSocketRequestMessage requestMessage = builder.build();
return FutureTransformers.map(websocket.sendRequest(requestMessage), response -> {
if (response.getStatus() == 404) {
throw new NotFoundException("Not found");
} else if (response.getStatus() < 200 || response.getStatus() >= 300) {
throw new NonSuccessfulResponseCodeException(response.getStatus(), "Non-successful response: " + response.getStatus());
}
SignalServiceProfile signalServiceProfile = JsonUtil.fromJson(response.getBody(), SignalServiceProfile.class);
ProfileKeyCredential profileKeyCredential = finalRequestContext != null && signalServiceProfile.getProfileKeyCredentialResponse() != null
? clientZkProfile.receiveProfileKeyCredential(finalRequestContext, signalServiceProfile.getProfileKeyCredentialResponse())
: null;
return new ProfileAndCredential(signalServiceProfile, requestType, Optional.fromNullable(profileKeyCredential));
});
}
public AttachmentV2UploadAttributes getAttachmentV2UploadAttributes() throws IOException {
try {
WebSocketRequestMessage requestMessage = WebSocketRequestMessage.newBuilder()
.setId(new SecureRandom().nextLong())
.setVerb("GET")
.setPath("/v2/attachments/form/upload")
.build();
WebsocketResponse response = websocket.sendRequest(requestMessage).get(10, TimeUnit.SECONDS);
if (response.getStatus() < 200 || response.getStatus() >= 300) {
throw new IOException("Non-successful response: " + response.getStatus());
}
return JsonUtil.fromJson(response.getBody(), AttachmentV2UploadAttributes.class);
} catch (InterruptedException | ExecutionException | TimeoutException e) {
throw new IOException(e);
}
}
public AttachmentV3UploadAttributes getAttachmentV3UploadAttributes() throws IOException {
try {
WebSocketRequestMessage requestMessage = WebSocketRequestMessage.newBuilder()
.setId(new SecureRandom().nextLong())
.setVerb("GET")
.setPath("/v3/attachments/form/upload")
.build();
WebsocketResponse response = websocket.sendRequest(requestMessage).get(10, TimeUnit.SECONDS);
if (response.getStatus() < 200 || response.getStatus() >= 300) {
throw new IOException("Non-successful response: " + response.getStatus());
}
return JsonUtil.fromJson(response.getBody(), AttachmentV3UploadAttributes.class);
} catch (InterruptedException | ExecutionException | TimeoutException e) {
throw new IOException(e);
}
}
/**
* Close this connection to the server.
*/
public void shutdown() {
websocket.disconnect();
}
private boolean isSignalServiceEnvelope(WebSocketRequestMessage message) {
return "PUT".equals(message.getVerb()) && "/api/v1/message".equals(message.getPath());
}
private boolean isSocketEmptyRequest(WebSocketRequestMessage message) {
return "PUT".equals(message.getVerb()) && "/api/v1/queue/empty".equals(message.getPath());
}
private WebSocketResponseMessage createWebSocketResponse(WebSocketRequestMessage request) {
if (isSignalServiceEnvelope(request)) {
return WebSocketResponseMessage.newBuilder()
.setId(request.getId())
.setStatus(200)
.setMessage("OK")
.build();
} else {
return WebSocketResponseMessage.newBuilder()
.setId(request.getId())
.setStatus(400)
.setMessage("Unknown")
.build();
}
}
private static Optional<String> findHeader(WebSocketRequestMessage message, String targetHeader) {
if (message.getHeadersCount() == 0) {
return Optional.absent();
}
for (String header : message.getHeadersList()) {
if (header.startsWith(targetHeader)) {
String[] split = header.split(":");
if (split.length == 2 && split[0].trim().toLowerCase().equals(targetHeader.toLowerCase())) {
return Optional.of(split[1].trim());
}
}
}
return Optional.absent();
}
/**
* For receiving a callback when a new message has been
* received.
*/
public interface MessagePipeCallback {
void onMessage(SignalServiceEnvelope envelope);
}
private static class NullMessagePipeCallback implements MessagePipeCallback {
@Override
public void onMessage(SignalServiceEnvelope envelope) {}
}
}

View File

@@ -31,11 +31,9 @@ import org.whispersystems.signalservice.internal.push.PushServiceSocket;
import org.whispersystems.signalservice.internal.push.SignalServiceEnvelopeEntity;
import org.whispersystems.signalservice.internal.push.SignalServiceMessagesResult;
import org.whispersystems.signalservice.internal.sticker.StickerProtos;
import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider;
import org.whispersystems.signalservice.internal.util.Util;
import org.whispersystems.signalservice.internal.util.concurrent.FutureTransformers;
import org.whispersystems.signalservice.internal.util.concurrent.ListenableFuture;
import org.whispersystems.signalservice.internal.websocket.WebSocketConnection;
import java.io.ByteArrayOutputStream;
import java.io.File;
@@ -63,28 +61,6 @@ public class SignalServiceMessageReceiver {
private final SleepTimer sleepTimer;
private final ClientZkProfileOperations clientZkProfileOperations;
/**
* Construct a SignalServiceMessageReceiver.
*
* @param urls The URL of the Signal Service.
* @param uuid The Signal Service UUID.
* @param e164 The Signal Service phone number.
* @param password The Signal Service user password.
* @param signalingKey The 52 byte signaling key assigned to this user at registration.
*/
public SignalServiceMessageReceiver(SignalServiceConfiguration urls,
UUID uuid,
String e164,
String password,
String signalAgent,
ConnectivityListener listener,
SleepTimer timer,
ClientZkProfileOperations clientZkProfileOperations,
boolean automaticNetworkRetry)
{
this(urls, new StaticCredentialsProvider(uuid, e164, password), signalAgent, listener, timer, clientZkProfileOperations, automaticNetworkRetry);
}
/**
* Construct a SignalServiceMessageReceiver.
*
@@ -229,37 +205,6 @@ public class SignalServiceMessageReceiver {
return new SignalServiceStickerManifest(pack.getTitle(), pack.getAuthor(), cover, stickers);
}
/**
* Creates a pipe for receiving SignalService messages.
*
* Callers must call {@link SignalServiceMessagePipe#shutdown()} when finished with the pipe.
*
* @return A SignalServiceMessagePipe for receiving Signal Service messages.
*/
public SignalServiceMessagePipe createMessagePipe() {
WebSocketConnection webSocket = new WebSocketConnection(urls.getSignalServiceUrls()[0].getUrl(),
urls.getSignalServiceUrls()[0].getTrustStore(),
Optional.of(credentialsProvider), signalAgent, connectivityListener,
sleepTimer,
urls.getNetworkInterceptors(),
urls.getDns(),
urls.getSignalProxy());
return new SignalServiceMessagePipe(webSocket, Optional.of(credentialsProvider), clientZkProfileOperations);
}
public SignalServiceMessagePipe createUnidentifiedMessagePipe() {
WebSocketConnection webSocket = new WebSocketConnection(urls.getSignalServiceUrls()[0].getUrl(),
urls.getSignalServiceUrls()[0].getTrustStore(),
Optional.<CredentialsProvider>absent(), signalAgent, connectivityListener,
sleepTimer,
urls.getNetworkInterceptors(),
urls.getDns(),
urls.getSignalProxy());
return new SignalServiceMessagePipe(webSocket, Optional.of(credentialsProvider), clientZkProfileOperations);
}
public List<SignalServiceEnvelope> retrieveMessages() throws IOException {
return retrieveMessages(new NullMessageReceivedCallback());
}

View File

@@ -6,10 +6,8 @@
package org.whispersystems.signalservice.api;
import com.google.protobuf.ByteString;
import com.google.protobuf.InvalidProtocolBufferException;
import org.signal.libsignal.metadata.certificate.SenderCertificate;
import org.signal.libsignal.metadata.protocol.UnidentifiedSenderMessageContent;
import org.signal.zkgroup.profiles.ClientZkProfileOperations;
import org.whispersystems.libsignal.InvalidKeyException;
import org.whispersystems.libsignal.NoSessionException;
@@ -39,7 +37,6 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPoin
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
import org.whispersystems.signalservice.api.messages.SignalServiceGroup;
import org.whispersystems.signalservice.api.messages.SignalServiceGroupContext;
import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
@@ -72,23 +69,26 @@ import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredExcepti
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException;
import org.whispersystems.signalservice.api.services.AttachmentService;
import org.whispersystems.signalservice.api.services.MessagingService;
import org.whispersystems.signalservice.api.util.CredentialsProvider;
import org.whispersystems.signalservice.api.util.Uint64RangeException;
import org.whispersystems.signalservice.api.util.Uint64Util;
import org.whispersystems.signalservice.api.util.UuidUtil;
import org.whispersystems.signalservice.api.websocket.WebSocketUnavailableException;
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream;
import org.whispersystems.signalservice.internal.push.AttachmentV2UploadAttributes;
import org.whispersystems.signalservice.internal.push.AttachmentV3UploadAttributes;
import org.whispersystems.signalservice.internal.push.GroupMismatchedDevices;
import org.whispersystems.signalservice.internal.push.GroupStaleDevices;
import org.whispersystems.signalservice.internal.push.SendGroupMessageResponse;
import org.whispersystems.signalservice.internal.push.MismatchedDevices;
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage;
import org.whispersystems.signalservice.internal.push.OutgoingPushMessageList;
import org.whispersystems.signalservice.internal.push.ProvisioningProtos;
import org.whispersystems.signalservice.internal.push.PushAttachmentData;
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
import org.whispersystems.signalservice.internal.push.SendGroupMessageResponse;
import org.whispersystems.signalservice.internal.push.SendMessageResponse;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.AttachmentPointer;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.CallMessage;
@@ -110,7 +110,6 @@ import org.whispersystems.signalservice.internal.push.http.AttachmentCipherOutpu
import org.whispersystems.signalservice.internal.push.http.CancelationSignal;
import org.whispersystems.signalservice.internal.push.http.PartialSendCompleteListener;
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider;
import org.whispersystems.signalservice.internal.util.Util;
import org.whispersystems.util.Base64;
import org.whispersystems.util.ByteArrayUtil;
@@ -132,10 +131,7 @@ import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
/**
@@ -149,45 +145,18 @@ public class SignalServiceMessageSender {
private static final int RETRY_COUNT = 4;
private final PushServiceSocket socket;
private final SignalServiceProtocolStore store;
private final SignalSessionLock sessionLock;
private final SignalServiceAddress localAddress;
private final Optional<EventListener> eventListener;
private final PushServiceSocket socket;
private final SignalServiceProtocolStore store;
private final SignalSessionLock sessionLock;
private final SignalServiceAddress localAddress;
private final Optional<EventListener> eventListener;
private final AtomicReference<Optional<SignalServiceMessagePipe>> pipe;
private final AtomicReference<Optional<SignalServiceMessagePipe>> unidentifiedPipe;
private final AtomicBoolean isMultiDevice;
private final AttachmentService attachmentService;
private final MessagingService messagingService;
private final AtomicBoolean isMultiDevice;
private final ExecutorService executor;
private final long maxEnvelopeSize;
/**
* Construct a SignalServiceMessageSender.
*
* @param urls The URL of the Signal Service.
* @param uuid The Signal Service UUID.
* @param e164 The Signal Service phone number.
* @param password The Signal Service user password.
* @param store The SignalProtocolStore.
* @param eventListener An optional event listener, which fires whenever sessions are
* setup or torn down for a recipient.
*/
public SignalServiceMessageSender(SignalServiceConfiguration urls,
UUID uuid, String e164, String password,
SignalServiceProtocolStore store,
SignalSessionLock sessionLock,
String signalAgent,
boolean isMultiDevice,
Optional<SignalServiceMessagePipe> pipe,
Optional<SignalServiceMessagePipe> unidentifiedPipe,
Optional<EventListener> eventListener,
ClientZkProfileOperations clientZkProfileOperations,
ExecutorService executor,
boolean automaticNetworkRetry)
{
this(urls, new StaticCredentialsProvider(uuid, e164, password), store, sessionLock, signalAgent, isMultiDevice, pipe, unidentifiedPipe, eventListener, clientZkProfileOperations, executor, 0, automaticNetworkRetry);
}
private final ExecutorService executor;
private final long maxEnvelopeSize;
public SignalServiceMessageSender(SignalServiceConfiguration urls,
CredentialsProvider credentialsProvider,
@@ -195,24 +164,23 @@ public class SignalServiceMessageSender {
SignalSessionLock sessionLock,
String signalAgent,
boolean isMultiDevice,
Optional<SignalServiceMessagePipe> pipe,
Optional<SignalServiceMessagePipe> unidentifiedPipe,
SignalWebSocket signalWebSocket,
Optional<EventListener> eventListener,
ClientZkProfileOperations clientZkProfileOperations,
ExecutorService executor,
long maxEnvelopeSize,
boolean automaticNetworkRetry)
{
this.socket = new PushServiceSocket(urls, credentialsProvider, signalAgent, clientZkProfileOperations, automaticNetworkRetry);
this.store = store;
this.sessionLock = sessionLock;
this.localAddress = new SignalServiceAddress(credentialsProvider.getUuid(), credentialsProvider.getE164());
this.pipe = new AtomicReference<>(pipe);
this.unidentifiedPipe = new AtomicReference<>(unidentifiedPipe);
this.isMultiDevice = new AtomicBoolean(isMultiDevice);
this.eventListener = eventListener;
this.executor = executor != null ? executor : Executors.newSingleThreadExecutor();
this.maxEnvelopeSize = maxEnvelopeSize;
this.socket = new PushServiceSocket(urls, credentialsProvider, signalAgent, clientZkProfileOperations, automaticNetworkRetry);
this.store = store;
this.sessionLock = sessionLock;
this.localAddress = new SignalServiceAddress(credentialsProvider.getUuid(), credentialsProvider.getE164());
this.attachmentService = new AttachmentService(signalWebSocket);
this.messagingService = new MessagingService(signalWebSocket);
this.isMultiDevice = new AtomicBoolean(isMultiDevice);
this.eventListener = eventListener;
this.executor = executor != null ? executor : Executors.newSingleThreadExecutor();
this.maxEnvelopeSize = maxEnvelopeSize;
}
/**
@@ -538,9 +506,7 @@ public class SignalServiceMessageSender {
socket.cancelInFlightRequests();
}
public void update(SignalServiceMessagePipe pipe, SignalServiceMessagePipe unidentifiedPipe, boolean isMultiDevice) {
this.pipe.set(Optional.fromNullable(pipe));
this.unidentifiedPipe.set(Optional.fromNullable(unidentifiedPipe));
public void update(boolean isMultiDevice) {
this.isMultiDevice.set(isMultiDevice);
}
@@ -569,15 +535,14 @@ public class SignalServiceMessageSender {
throws NonSuccessfulResponseCodeException, PushNetworkException, MalformedResponseException
{
AttachmentV2UploadAttributes v2UploadAttributes = null;
Optional<SignalServiceMessagePipe> localPipe = pipe.get();
if (localPipe.isPresent()) {
Log.d(TAG, "Using pipe to retrieve attachment upload attributes...");
try {
v2UploadAttributes = localPipe.get().getAttachmentV2UploadAttributes();
} catch (IOException e) {
Log.w(TAG, "Failed to retrieve attachment upload attributes using pipe. Falling back...");
}
Log.d(TAG, "Using pipe to retrieve attachment upload attributes...");
try {
v2UploadAttributes = new AttachmentService.AttachmentAttributesResponseProcessor<>(attachmentService.getAttachmentV2UploadAttributes().blockingGet()).getResultOrThrow();
} catch (WebSocketUnavailableException e) {
Log.w(TAG, "[uploadAttachmentV2] Pipe unavailable, falling back... (" + e.getClass().getSimpleName() + ": " + e.getMessage() + ")");
} catch (IOException e) {
Log.w(TAG, "Failed to retrieve attachment upload attributes using pipe. Falling back...");
}
if (v2UploadAttributes == null) {
@@ -606,15 +571,14 @@ public class SignalServiceMessageSender {
public ResumableUploadSpec getResumableUploadSpec() throws IOException {
AttachmentV3UploadAttributes v3UploadAttributes = null;
Optional<SignalServiceMessagePipe> localPipe = pipe.get();
if (localPipe.isPresent()) {
Log.d(TAG, "Using pipe to retrieve attachment upload attributes...");
try {
v3UploadAttributes = localPipe.get().getAttachmentV3UploadAttributes();
} catch (IOException e) {
Log.w(TAG, "Failed to retrieve attachment upload attributes using pipe. Falling back...");
}
Log.d(TAG, "Using pipe to retrieve attachment upload attributes...");
try {
v3UploadAttributes = new AttachmentService.AttachmentAttributesResponseProcessor<>(attachmentService.getAttachmentV3UploadAttributes().blockingGet()).getResultOrThrow();
} catch (WebSocketUnavailableException e) {
Log.w(TAG, "[getResumableUploadSpec] Pipe unavailable, falling back... (" + e.getClass().getSimpleName() + ": " + e.getMessage() + ")");
} catch (IOException e) {
Log.w(TAG, "Failed to retrieve attachment upload attributes using pipe. Falling back...");
}
if (v3UploadAttributes == null) {
@@ -800,7 +764,7 @@ public class SignalServiceMessageSender {
.setLength(mention.getLength())
.setMentionUuid(mention.getUuid().toString()));
}
builder.setRequiredProtocolVersion(Math.max(DataMessage.ProtocolVersion.MENTIONS_VALUE, builder.getRequiredProtocolVersion()));
}
@@ -1631,22 +1595,23 @@ public class SignalServiceMessageSender {
throw new CancelationException();
}
Optional<SignalServiceMessagePipe> pipe = this.pipe.get();
Optional<SignalServiceMessagePipe> unidentifiedPipe = this.unidentifiedPipe.get();
if (pipe.isPresent() && !unidentifiedAccess.isPresent()) {
if (!unidentifiedAccess.isPresent()) {
try {
SendMessageResponse response = pipe.get().send(messages, Optional.absent()).get(10, TimeUnit.SECONDS);
SendMessageResponse response = new MessagingService.SendResponseProcessor<>(messagingService.send(messages, Optional.absent()).blockingGet()).getResultOrThrow();
return SendMessageResult.success(recipient, messages.getDevices(), false, response.getNeedsSync() || isMultiDevice.get(), System.currentTimeMillis() - startTime, content.getContent());
} catch (IOException | ExecutionException | InterruptedException | TimeoutException e) {
} catch (WebSocketUnavailableException e) {
Log.i(TAG, "[sendMessage] Pipe unavailable, falling back... (" + e.getClass().getSimpleName() + ": " + e.getMessage() + ")");
} catch (IOException e) {
Log.w(TAG, e);
Log.w(TAG, "[sendMessage] Pipe failed, falling back... (" + e.getClass().getSimpleName() + ": " + e.getMessage() + ")");
}
} else if (unidentifiedPipe.isPresent() && unidentifiedAccess.isPresent()) {
} else if (unidentifiedAccess.isPresent()) {
try {
SendMessageResponse response = unidentifiedPipe.get().send(messages, unidentifiedAccess).get(10, TimeUnit.SECONDS);
SendMessageResponse response = new MessagingService.SendResponseProcessor<>(messagingService.send(messages, unidentifiedAccess).blockingGet()).getResultOrThrow();
return SendMessageResult.success(recipient, messages.getDevices(), true, response.getNeedsSync() || isMultiDevice.get(), System.currentTimeMillis() - startTime, content.getContent());
} catch (IOException | ExecutionException | InterruptedException | TimeoutException e) {
} catch (WebSocketUnavailableException e) {
Log.i(TAG, "[sendMessage] Unidentified pipe unavailable, falling back... (" + e.getClass().getSimpleName() + ": " + e.getMessage() + ")");
} catch (IOException e) {
Log.w(TAG, e);
Log.w(TAG, "[sendMessage] Unidentified pipe failed, falling back...");
}
@@ -1808,17 +1773,13 @@ public class SignalServiceMessageSender {
joinedUnidentifiedAccess = ByteArrayUtil.xor(joinedUnidentifiedAccess, access.getUnidentifiedAccessKey());
}
Optional<SignalServiceMessagePipe> pipe = this.unidentifiedPipe.get();
if (pipe.isPresent()) {
try {
SendGroupMessageResponse response = pipe.get().sendToGroup(ciphertext, joinedUnidentifiedAccess, timestamp, online).get(10, TimeUnit.SECONDS);
return transformGroupResponseToMessageResults(recipientDevices, response, content);
} catch (IOException | ExecutionException | InterruptedException | TimeoutException e) {
Log.w(TAG, "[sendGroupMessage] Pipe failed, falling back... (" + e.getClass().getSimpleName() + ": " + e.getMessage() + ")");
}
} else {
Log.d(TAG, "[sendGroupMessage] No pipe available.");
try {
SendGroupMessageResponse response = new MessagingService.SendResponseProcessor<>(messagingService.sendToGroup(ciphertext, joinedUnidentifiedAccess, timestamp, online).blockingGet()).getResultOrThrow();
return transformGroupResponseToMessageResults(recipientDevices, response, content);
} catch (WebSocketUnavailableException e) {
Log.i(TAG, "[sendGroupMessage] Pipe unavailable, falling back... (" + e.getClass().getSimpleName() + ": " + e.getMessage() + ")");
} catch (IOException e) {
Log.w(TAG, "[sendGroupMessage] Pipe failed, falling back... (" + e.getClass().getSimpleName() + ": " + e.getMessage() + ")");
}
try {

View File

@@ -0,0 +1,220 @@
package org.whispersystems.signalservice.api;
import org.whispersystems.libsignal.logging.Log;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
import org.whispersystems.signalservice.api.websocket.WebSocketFactory;
import org.whispersystems.signalservice.api.websocket.WebSocketUnavailableException;
import org.whispersystems.signalservice.internal.websocket.WebSocketConnection;
import org.whispersystems.signalservice.internal.websocket.WebSocketProtos.WebSocketRequestMessage;
import org.whispersystems.signalservice.internal.websocket.WebSocketProtos.WebSocketResponseMessage;
import org.whispersystems.signalservice.internal.websocket.WebsocketResponse;
import org.whispersystems.util.Base64;
import java.io.IOException;
import java.util.concurrent.TimeoutException;
import io.reactivex.rxjava3.core.Single;
/**
* Provide a general interface to the WebSocket for making requests and reading messages sent by the server.
* Where appropriate, it will handle retrying failed unidentified requests on the regular WebSocket.
*/
public final class SignalWebSocket {
private static final String TAG = SignalWebSocket.class.getSimpleName();
private static final String SERVER_DELIVERED_TIMESTAMP_HEADER = "X-Signal-Timestamp";
private final WebSocketFactory webSocketFactory;
private WebSocketConnection webSocket;
private WebSocketConnection unidentifiedWebSocket;
private boolean canConnect;
public SignalWebSocket(WebSocketFactory webSocketFactory) {
this.webSocketFactory = webSocketFactory;
}
public synchronized void connect() {
canConnect = true;
try {
getWebSocket();
getUnidentifiedWebSocket();
} catch (WebSocketUnavailableException e) {
throw new AssertionError(e);
}
}
public synchronized void disconnect() {
canConnect = false;
if (webSocket != null) {
webSocket.disconnect();
webSocket = null;
}
if (unidentifiedWebSocket != null) {
unidentifiedWebSocket.disconnect();
unidentifiedWebSocket = null;
}
}
private synchronized WebSocketConnection getWebSocket() throws WebSocketUnavailableException {
if (!canConnect) {
throw new WebSocketUnavailableException();
}
if (webSocket == null || webSocket.isDead()) {
webSocket = webSocketFactory.createWebSocket();
webSocket.connect();
}
return webSocket;
}
private synchronized WebSocketConnection getUnidentifiedWebSocket() throws WebSocketUnavailableException {
if (!canConnect) {
throw new WebSocketUnavailableException();
}
if (unidentifiedWebSocket == null || unidentifiedWebSocket.isDead()) {
unidentifiedWebSocket = webSocketFactory.createUnidentifiedWebSocket();
unidentifiedWebSocket.connect();
}
return unidentifiedWebSocket;
}
public Single<WebsocketResponse> request(WebSocketRequestMessage requestMessage) {
try {
return getWebSocket().sendRequest(requestMessage);
} catch (IOException e) {
return Single.error(e);
}
}
public Single<WebsocketResponse> request(WebSocketRequestMessage requestMessage, Optional<UnidentifiedAccess> unidentifiedAccess) {
if (unidentifiedAccess.isPresent()) {
WebSocketRequestMessage message = WebSocketRequestMessage.newBuilder(requestMessage)
.addHeaders("Unidentified-Access-Key:" + Base64.encodeBytes(unidentifiedAccess.get().getUnidentifiedAccessKey()))
.build();
Single<WebsocketResponse> response;
try {
response = getUnidentifiedWebSocket().sendRequest(message);
} catch (IOException e) {
return Single.error(e);
}
return response.flatMap(r -> {
if (r.getStatus() == 401) {
return request(requestMessage);
}
return Single.just(r);
})
.onErrorResumeNext(t -> request(requestMessage));
} else {
return request(requestMessage);
}
}
/**
* <p>
* A blocking call that reads a message off the pipe. When this call returns, the message has been
* acknowledged and will not be retransmitted. This will return {@link Optional#absent()} when an
* empty response is hit, which indicates the WebSocket is empty.
* <p>
* You can specify a {@link MessageReceivedCallback} that will be called before the received message is acknowledged.
* This allows you to write the received message to durable storage before acknowledging receipt of it to the
* server.
* <p>
* Important: The empty response will only be hit once for each connection. That means if you get
* an empty response and call readOrEmpty() again on the same instance, you will not get an empty
* response, and instead will block until you get an actual message. This will, however, reset if
* connection breaks (if, for instance, you lose and regain network).
*
* @param timeout The timeout to wait for.
* @param callback A callback that will be called before the message receipt is acknowledged to the server.
* @return The message read (same as the message sent through the callback).
*/
@SuppressWarnings("DuplicateThrows")
public Optional<SignalServiceEnvelope> readOrEmpty(long timeout, MessageReceivedCallback callback)
throws TimeoutException, WebSocketUnavailableException, IOException
{
while (true) {
WebSocketRequestMessage request = getWebSocket().readRequest(timeout);
WebSocketResponseMessage response = createWebSocketResponse(request);
try {
if (isSignalServiceEnvelope(request)) {
Optional<String> timestampHeader = findHeader(request);
long timestamp = 0;
if (timestampHeader.isPresent()) {
try {
timestamp = Long.parseLong(timestampHeader.get());
} catch (NumberFormatException e) {
Log.w(TAG, "Failed to parse " + SERVER_DELIVERED_TIMESTAMP_HEADER);
}
}
SignalServiceEnvelope envelope = new SignalServiceEnvelope(request.getBody().toByteArray(), timestamp);
callback.onMessage(envelope);
return Optional.of(envelope);
} else if (isSocketEmptyRequest(request)) {
return Optional.absent();
}
} finally {
getWebSocket().sendResponse(response);
}
}
}
private static boolean isSignalServiceEnvelope(WebSocketRequestMessage message) {
return "PUT".equals(message.getVerb()) && "/api/v1/message".equals(message.getPath());
}
private static boolean isSocketEmptyRequest(WebSocketRequestMessage message) {
return "PUT".equals(message.getVerb()) && "/api/v1/queue/empty".equals(message.getPath());
}
private static WebSocketResponseMessage createWebSocketResponse(WebSocketRequestMessage request) {
if (isSignalServiceEnvelope(request)) {
return WebSocketResponseMessage.newBuilder()
.setId(request.getId())
.setStatus(200)
.setMessage("OK")
.build();
} else {
return WebSocketResponseMessage.newBuilder()
.setId(request.getId())
.setStatus(400)
.setMessage("Unknown")
.build();
}
}
private static Optional<String> findHeader(WebSocketRequestMessage message) {
if (message.getHeadersCount() == 0) {
return Optional.absent();
}
for (String header : message.getHeadersList()) {
if (header.startsWith(SERVER_DELIVERED_TIMESTAMP_HEADER)) {
String[] split = header.split(":");
if (split.length == 2 && split[0].trim().toLowerCase().equals(SERVER_DELIVERED_TIMESTAMP_HEADER.toLowerCase())) {
return Optional.of(split[1].trim());
}
}
}
return Optional.absent();
}
/**
* For receiving a callback when a new message has been
* received.
*/
public interface MessageReceivedCallback {
void onMessage(SignalServiceEnvelope envelope);
}
}

View File

@@ -0,0 +1,56 @@
package org.whispersystems.signalservice.api.services;
import org.whispersystems.signalservice.api.SignalWebSocket;
import org.whispersystems.signalservice.internal.ServiceResponse;
import org.whispersystems.signalservice.internal.ServiceResponseProcessor;
import org.whispersystems.signalservice.internal.push.AttachmentV2UploadAttributes;
import org.whispersystems.signalservice.internal.push.AttachmentV3UploadAttributes;
import org.whispersystems.signalservice.internal.websocket.DefaultResponseMapper;
import org.whispersystems.signalservice.internal.websocket.WebSocketProtos.WebSocketRequestMessage;
import java.security.SecureRandom;
import io.reactivex.rxjava3.core.Single;
/**
* Provide WebSocket based interface to attachment upload endpoints.
*
* Note: To be expanded to have REST fallback and other attachment related operations.
*/
public final class AttachmentService {
private final SignalWebSocket signalWebSocket;
public AttachmentService(SignalWebSocket signalWebSocket) {
this.signalWebSocket = signalWebSocket;
}
public Single<ServiceResponse<AttachmentV2UploadAttributes>> getAttachmentV2UploadAttributes() {
WebSocketRequestMessage requestMessage = WebSocketRequestMessage.newBuilder()
.setId(new SecureRandom().nextLong())
.setVerb("GET")
.setPath("/v2/attachments/form/upload")
.build();
return signalWebSocket.request(requestMessage)
.map(DefaultResponseMapper.getDefault(AttachmentV2UploadAttributes.class)::map)
.onErrorReturn(ServiceResponse::forUnknownError);
}
public Single<ServiceResponse<AttachmentV3UploadAttributes>> getAttachmentV3UploadAttributes() {
WebSocketRequestMessage requestMessage = WebSocketRequestMessage.newBuilder()
.setId(new SecureRandom().nextLong())
.setVerb("GET")
.setPath("/v3/attachments/form/upload")
.build();
return signalWebSocket.request(requestMessage)
.map(DefaultResponseMapper.getDefault(AttachmentV3UploadAttributes.class)::map)
.onErrorReturn(ServiceResponse::forUnknownError);
}
public static class AttachmentAttributesResponseProcessor<T> extends ServiceResponseProcessor<T> {
public AttachmentAttributesResponseProcessor(ServiceResponse<T> response) {
super(response);
}
}
}

View File

@@ -0,0 +1,94 @@
package org.whispersystems.signalservice.api.services;
import com.google.protobuf.ByteString;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.SignalWebSocket;
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException;
import org.whispersystems.signalservice.internal.ServiceResponse;
import org.whispersystems.signalservice.internal.ServiceResponseProcessor;
import org.whispersystems.signalservice.internal.push.OutgoingPushMessageList;
import org.whispersystems.signalservice.internal.push.SendGroupMessageResponse;
import org.whispersystems.signalservice.internal.push.SendMessageResponse;
import org.whispersystems.signalservice.internal.util.JsonUtil;
import org.whispersystems.signalservice.internal.util.Util;
import org.whispersystems.signalservice.internal.websocket.DefaultResponseMapper;
import org.whispersystems.signalservice.internal.websocket.ResponseMapper;
import org.whispersystems.signalservice.internal.websocket.WebSocketProtos.WebSocketRequestMessage;
import org.whispersystems.util.Base64;
import java.security.SecureRandom;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import io.reactivex.rxjava3.core.Single;
/**
* Provide WebSocket based interface to message sending endpoints.
* <p>
* Note: To be expanded to have REST fallback and other messaging related operations.
*/
public class MessagingService {
private final SignalWebSocket signalWebSocket;
public MessagingService(SignalWebSocket signalWebSocket) {
this.signalWebSocket = signalWebSocket;
}
public Single<ServiceResponse<SendMessageResponse>> send(OutgoingPushMessageList list, Optional<UnidentifiedAccess> unidentifiedAccess) {
List<String> headers = new LinkedList<String>() {{
add("content-type:application/json");
}};
WebSocketRequestMessage requestMessage = WebSocketRequestMessage.newBuilder()
.setId(new SecureRandom().nextLong())
.setVerb("PUT")
.setPath(String.format("/v1/messages/%s", list.getDestination()))
.addAllHeaders(headers)
.setBody(ByteString.copyFrom(JsonUtil.toJson(list).getBytes()))
.build();
ResponseMapper<SendMessageResponse> responseMapper = DefaultResponseMapper.extend(SendMessageResponse.class)
.withResponseMapper((status, body, getHeader) -> {
SendMessageResponse sendMessageResponse = Util.isEmpty(body) ? new SendMessageResponse(false)
: JsonUtil.fromJsonResponse(body, SendMessageResponse.class);
return ServiceResponse.forResult(sendMessageResponse, status, body);
})
.withCustomError(404, (status, body, getHeader) -> new UnregisteredUserException(list.getDestination(), new NotFoundException("not found")))
.build();
return signalWebSocket.request(requestMessage, unidentifiedAccess)
.map(responseMapper::map)
.onErrorReturn(ServiceResponse::forUnknownError);
}
public Single<ServiceResponse<SendGroupMessageResponse>> sendToGroup(byte[] body, byte[] joinedUnidentifiedAccess, long timestamp, boolean online) {
List<String> headers = new LinkedList<String>() {{
add("content-type:application/vnd.signal-messenger.mrm");
add("Unidentified-Access-Key:" + Base64.encodeBytes(joinedUnidentifiedAccess));
}};
String path = String.format(Locale.US, "/v1/messages/multi_recipient?ts=%s&online=%s", timestamp, online);
WebSocketRequestMessage requestMessage = WebSocketRequestMessage.newBuilder()
.setId(new SecureRandom().nextLong())
.setVerb("PUT")
.setPath(path)
.addAllHeaders(headers)
.setBody(ByteString.copyFrom(body))
.build();
return signalWebSocket.request(requestMessage)
.map(DefaultResponseMapper.getDefault(SendGroupMessageResponse.class)::map)
.onErrorReturn(ServiceResponse::forUnknownError);
}
public static class SendResponseProcessor<T> extends ServiceResponseProcessor<T> {
public SendResponseProcessor(ServiceResponse<T> response) {
super(response);
}
}
}

View File

@@ -0,0 +1,161 @@
package org.whispersystems.signalservice.api.services;
import org.signal.zkgroup.VerificationFailedException;
import org.signal.zkgroup.profiles.ClientZkProfileOperations;
import org.signal.zkgroup.profiles.ProfileKey;
import org.signal.zkgroup.profiles.ProfileKeyCredential;
import org.signal.zkgroup.profiles.ProfileKeyCredentialRequest;
import org.signal.zkgroup.profiles.ProfileKeyCredentialRequestContext;
import org.signal.zkgroup.profiles.ProfileKeyVersion;
import org.whispersystems.libsignal.util.Pair;
import org.whispersystems.libsignal.util.guava.Function;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
import org.whispersystems.signalservice.api.SignalWebSocket;
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.MalformedResponseException;
import org.whispersystems.signalservice.internal.ServiceResponse;
import org.whispersystems.signalservice.internal.ServiceResponseProcessor;
import org.whispersystems.signalservice.internal.util.Hex;
import org.whispersystems.signalservice.internal.util.JsonUtil;
import org.whispersystems.signalservice.internal.websocket.DefaultResponseMapper;
import org.whispersystems.signalservice.internal.websocket.ResponseMapper;
import org.whispersystems.signalservice.internal.websocket.WebSocketProtos;
import java.security.SecureRandom;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import io.reactivex.rxjava3.core.Single;
/**
* Provide Profile-related API services, encapsulating the logic to make the request, parse the response,
* and fallback to appropriate WebSocket or RESTful alternatives.
*/
public final class ProfileService {
private static final String TAG = ProfileService.class.getSimpleName();
private final ClientZkProfileOperations clientZkProfileOperations;
private final SignalServiceMessageReceiver receiver;
private final SignalWebSocket signalWebSocket;
public ProfileService(ClientZkProfileOperations clientZkProfileOperations,
SignalServiceMessageReceiver receiver,
SignalWebSocket signalWebSocket)
{
this.clientZkProfileOperations = clientZkProfileOperations;
this.receiver = receiver;
this.signalWebSocket = signalWebSocket;
}
public Single<ServiceResponse<ProfileAndCredential>> getProfile(SignalServiceAddress address,
Optional<ProfileKey> profileKey,
Optional<UnidentifiedAccess> unidentifiedAccess,
SignalServiceProfile.RequestType requestType)
{
Optional<UUID> uuid = address.getUuid();
SecureRandom random = new SecureRandom();
ProfileKeyCredentialRequestContext requestContext = null;
WebSocketProtos.WebSocketRequestMessage.Builder builder = WebSocketProtos.WebSocketRequestMessage.newBuilder()
.setId(random.nextLong())
.setVerb("GET");
if (uuid.isPresent() && profileKey.isPresent()) {
UUID target = uuid.get();
ProfileKeyVersion profileKeyIdentifier = profileKey.get().getProfileKeyVersion(target);
String version = profileKeyIdentifier.serialize();
if (requestType == SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL) {
requestContext = clientZkProfileOperations.createProfileKeyCredentialRequestContext(random, target, profileKey.get());
ProfileKeyCredentialRequest request = requestContext.getRequest();
String credentialRequest = Hex.toStringCondensed(request.serialize());
builder.setPath(String.format("/v1/profile/%s/%s/%s", target, version, credentialRequest));
} else {
builder.setPath(String.format("/v1/profile/%s/%s", target, version));
}
} else {
builder.setPath(String.format("/v1/profile/%s", address.getIdentifier()));
}
WebSocketProtos.WebSocketRequestMessage requestMessage = builder.build();
ResponseMapper<ProfileAndCredential> responseMapper = DefaultResponseMapper.extend(ProfileAndCredential.class)
.withResponseMapper(new ProfileResponseMapper(requestType, requestContext))
.build();
return signalWebSocket.request(requestMessage, unidentifiedAccess)
.map(responseMapper::map)
.onErrorResumeNext(t -> restFallback(address, profileKey, unidentifiedAccess, requestType))
.onErrorReturn(ServiceResponse::forUnknownError);
}
private Single<ServiceResponse<ProfileAndCredential>> restFallback(SignalServiceAddress address,
Optional<ProfileKey> profileKey,
Optional<UnidentifiedAccess> unidentifiedAccess,
SignalServiceProfile.RequestType requestType)
{
return Single.fromFuture(receiver.retrieveProfile(address, profileKey, unidentifiedAccess, requestType), 10, TimeUnit.SECONDS)
.onErrorResumeNext(t -> Single.fromFuture(receiver.retrieveProfile(address, profileKey, Optional.absent(), requestType), 10, TimeUnit.SECONDS))
.map(p -> ServiceResponse.forResult(p, 0, null));
}
/**
* Maps the API {@link SignalServiceProfile} model into the desired {@link ProfileAndCredential} domain model.
*/
private class ProfileResponseMapper implements DefaultResponseMapper.CustomResponseMapper<ProfileAndCredential> {
private final SignalServiceProfile.RequestType requestType;
private final ProfileKeyCredentialRequestContext requestContext;
public ProfileResponseMapper(SignalServiceProfile.RequestType requestType, ProfileKeyCredentialRequestContext requestContext) {
this.requestType = requestType;
this.requestContext = requestContext;
}
@Override
public ServiceResponse<ProfileAndCredential> map(int status, String body, Function<String, String> getHeader)
throws MalformedResponseException
{
try {
SignalServiceProfile signalServiceProfile = JsonUtil.fromJsonResponse(body, SignalServiceProfile.class);
ProfileKeyCredential profileKeyCredential = null;
if (requestContext != null && signalServiceProfile.getProfileKeyCredentialResponse() != null) {
profileKeyCredential = clientZkProfileOperations.receiveProfileKeyCredential(requestContext, signalServiceProfile.getProfileKeyCredentialResponse());
}
return ServiceResponse.forResult(new ProfileAndCredential(signalServiceProfile, requestType, Optional.fromNullable(profileKeyCredential)), status, body);
} catch (VerificationFailedException e) {
return ServiceResponse.forApplicationError(e, status, body);
}
}
}
/**
* Response processor for {@link ProfileAndCredential} service response.
*/
public static final class ProfileResponseProcessor extends ServiceResponseProcessor<ProfileAndCredential> {
public ProfileResponseProcessor(ServiceResponse<ProfileAndCredential> response) {
super(response);
}
public <T> Pair<T, ProfileAndCredential> getResult(T with) {
return new Pair<>(with, getResult());
}
@Override
public boolean notFound() {
return super.notFound();
}
@Override
public boolean genericIoError() {
return super.genericIoError();
}
}
}

View File

@@ -0,0 +1,8 @@
package org.whispersystems.signalservice.api.websocket;
import org.whispersystems.signalservice.internal.websocket.WebSocketConnection;
public interface WebSocketFactory {
WebSocketConnection createWebSocket();
WebSocketConnection createUnidentifiedWebSocket();
}

View File

@@ -0,0 +1,14 @@
package org.whispersystems.signalservice.api.websocket;
import java.io.IOException;
/**
* Thrown when the WebSocket is not available for use by runtime policy. Currently, the
* WebSocket is only available when the app is in the foreground and requested via IncomingMessageObserver.
* Or, when using WebSocket Strategy.
*/
public final class WebSocketUnavailableException extends IOException {
public WebSocketUnavailableException() {
super("WebSocket not currently available.");
}
}

View File

@@ -0,0 +1,99 @@
package org.whispersystems.signalservice.internal;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.libsignal.util.guava.Preconditions;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.internal.websocket.WebsocketResponse;
import java.util.concurrent.ExecutionException;
/**
* Encapsulates a parsed APi response regardless of where it came from (WebSocket or REST). Not only
* includes the success result but also any application errors encountered (404s, parsing, etc.) or
* execution errors encountered (IOException, etc.).
*/
public final class ServiceResponse<Result> {
private final int status;
private final Optional<String> body;
private final Optional<Result> result;
private final Optional<Throwable> applicationError;
private final Optional<Throwable> executionError;
private ServiceResponse(Result result, WebsocketResponse response) {
this(response.getStatus(), response.getBody(), result, null, null);
}
private ServiceResponse(Throwable applicationError, WebsocketResponse response) {
this(response.getStatus(), response.getBody(), null, applicationError, null);
}
public ServiceResponse(int status,
String body,
Result result,
Throwable applicationError,
Throwable executionError)
{
if (result != null) {
Preconditions.checkArgument(applicationError == null && executionError == null);
} else {
Preconditions.checkArgument(applicationError != null || executionError != null);
}
this.status = status;
this.body = Optional.fromNullable(body);
this.result = Optional.fromNullable(result);
this.applicationError = Optional.fromNullable(applicationError);
this.executionError = Optional.fromNullable(executionError);
}
public int getStatus() {
return status;
}
public Optional<String> getBody() {
return body;
}
public Optional<Result> getResult() {
return result;
}
public Optional<Throwable> getApplicationError() {
return applicationError;
}
public Optional<Throwable> getExecutionError() {
return executionError;
}
public static <T> ServiceResponse<T> forResult(T result, WebsocketResponse response) {
return new ServiceResponse<>(result, response);
}
public static <T> ServiceResponse<T> forResult(T result, int status, String body) {
return new ServiceResponse<>(status, body, result, null, null);
}
public static <T> ServiceResponse<T> forApplicationError(Throwable throwable, WebsocketResponse response) {
return new ServiceResponse<T>(throwable, response);
}
public static <T> ServiceResponse<T> forApplicationError(Throwable throwable, int status, String body) {
return new ServiceResponse<>(status, body, null, throwable, null);
}
public static <T> ServiceResponse<T> forExecutionError(Throwable throwable) {
return new ServiceResponse<>(0, null, null, null, throwable);
}
public static <T> ServiceResponse<T> forUnknownError(Throwable throwable) {
if (throwable instanceof ExecutionException) {
return forUnknownError(throwable.getCause());
} else if (throwable instanceof NonSuccessfulResponseCodeException) {
return forApplicationError(throwable, ((NonSuccessfulResponseCodeException) throwable).getCode(), null);
} else {
return forExecutionError(throwable);
}
}
}

View File

@@ -0,0 +1,123 @@
package org.whispersystems.signalservice.internal;
import org.whispersystems.libsignal.util.guava.Preconditions;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import java.io.IOException;
import java.util.concurrent.TimeoutException;
/**
* Provide the basis for processing a {@link ServiceResponse} in a sharable, quasi-enforceable
* ways. The goal is to balance the readability at the call sites where the various cases are handled
* and provide call specific information of what should be expected.
* <p>
* General premise is for subclasses to override and expose (via access modifier) the types of errors that
* should be handled when processing a response. For example, if {@link #notFound()} should be specifically
* handled then a subclass should override it, change the modifier to public, and then the caller knows it's
* a possible error case.
* <p>
* This doesn't exactly enforce the handling like a check exception would, but does hint
* to the caller what they should be aware of as possible outcomes of processing a response.
*/
public abstract class ServiceResponseProcessor<T> {
protected final ServiceResponse<T> response;
public ServiceResponseProcessor(ServiceResponse<T> response) {
this.response = response;
}
public ServiceResponse<T> getResponse() {
return response;
}
public T getResult() {
Preconditions.checkArgument(response.getResult().isPresent());
return response.getResult().get();
}
public T getResultOrThrow() throws IOException {
if (hasResult()) {
return getResult();
}
Throwable error = getError();
if (error instanceof IOException) {
throw (IOException) error;
} else if (error instanceof RuntimeException) {
throw (RuntimeException) error;
} else if (error instanceof InterruptedException || error instanceof TimeoutException) {
throw new IOException(error);
} else {
throw new IllegalStateException("Unexpected error type for response processor", error);
}
}
public boolean hasResult() {
return response.getResult().isPresent();
}
protected Throwable getError() {
return response.getApplicationError().or(response.getExecutionError()).orNull();
}
protected boolean authorizationFailed() {
return response.getStatus() == 401 || response.getStatus() == 403;
}
protected boolean notFound() {
return response.getStatus() == 404;
}
protected boolean mismatchedDevices() {
return response.getStatus() == 409;
}
protected boolean staleDevices() {
return response.getStatus() == 410;
}
protected boolean deviceLimitedExceeded() {
return response.getStatus() == 411;
}
protected boolean rateLimit() {
return response.getStatus() == 413;
}
protected boolean expectationFailed() {
return response.getStatus() == 417;
}
protected boolean registrationLock() {
return response.getStatus() == 423;
}
protected boolean proofRequired() {
return response.getStatus() == 428;
}
protected boolean deprecatedVersion() {
return response.getStatus() == 499;
}
protected boolean serverRejected() {
return response.getStatus() == 508;
}
protected boolean notSuccessful() {
return response.getStatus() != 200 && response.getStatus() != 202 && response.getStatus() != 204;
}
protected boolean genericIoError() {
Throwable error = getError();
if (error instanceof NonSuccessfulResponseCodeException) {
return false;
}
return error instanceof IOException ||
error instanceof TimeoutException ||
error instanceof InterruptedException;
}
}

View File

@@ -9,7 +9,7 @@ public final class LockedException extends NonSuccessfulResponseCodeException {
private final long timeRemaining;
private final String basicStorageCredentials;
LockedException(int length, long timeRemaining, String basicStorageCredentials) {
public LockedException(int length, long timeRemaining, String basicStorageCredentials) {
super(423);
this.length = length;
this.timeRemaining = timeRemaining;

View File

@@ -2043,15 +2043,15 @@ public class PushServiceSocket {
}
}
private static class RegistrationLockFailure {
public static class RegistrationLockFailure {
@JsonProperty
private int length;
public int length;
@JsonProperty
private long timeRemaining;
public long timeRemaining;
@JsonProperty
private AuthCredentials backupCredentials;
public AuthCredentials backupCredentials;
}
private static class ConnectionHolder {

View File

@@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.SerializerProvider;
import org.whispersystems.libsignal.IdentityKey;
import org.whispersystems.libsignal.InvalidKeyException;
import org.whispersystems.libsignal.logging.Log;
import org.whispersystems.signalservice.api.push.exceptions.MalformedResponseException;
import org.whispersystems.signalservice.api.util.UuidUtil;
import org.whispersystems.util.Base64;
@@ -50,6 +51,15 @@ public class JsonUtil {
{
return objectMapper.readValue(json, clazz);
}
public static <T> T fromJsonResponse(String body, Class<T> clazz)
throws MalformedResponseException {
try {
return JsonUtil.fromJson(body, clazz);
} catch (IOException e) {
throw new MalformedResponseException("Unable to parse entity", e);
}
}
public static class IdentityKeySerializer extends JsonSerializer<IdentityKey> {
@Override

View File

@@ -0,0 +1,149 @@
package org.whispersystems.signalservice.internal.websocket;
import org.whispersystems.libsignal.util.guava.Function;
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionException;
import org.whispersystems.signalservice.api.push.exceptions.ExpectationFailedException;
import org.whispersystems.signalservice.api.push.exceptions.MalformedResponseException;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
import org.whispersystems.signalservice.api.push.exceptions.RateLimitException;
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
import org.whispersystems.signalservice.internal.push.AuthCredentials;
import org.whispersystems.signalservice.internal.push.DeviceLimit;
import org.whispersystems.signalservice.internal.push.DeviceLimitExceededException;
import org.whispersystems.signalservice.internal.push.LockedException;
import org.whispersystems.signalservice.internal.push.MismatchedDevices;
import org.whispersystems.signalservice.internal.push.ProofRequiredResponse;
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
import org.whispersystems.signalservice.internal.push.StaleDevices;
import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevicesException;
import org.whispersystems.signalservice.internal.push.exceptions.StaleDevicesException;
import org.whispersystems.signalservice.internal.util.JsonUtil;
import org.whispersystems.signalservice.internal.util.Util;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
/**
* A default implementation of a {@link ErrorMapper} that can parse most known application
* errors.
* <p>
* Can be extended to add custom error mapping via {@link #extend()}.
* <p>
* While this call can be used directly, it is primarily intended to be used as part of
* {@link DefaultResponseMapper}.
*/
public final class DefaultErrorMapper implements ErrorMapper {
private static final DefaultErrorMapper INSTANCE = new DefaultErrorMapper();
private final Map<Integer, ErrorMapper> customErrorMappers;
public static DefaultErrorMapper getDefault() {
return INSTANCE;
}
public static DefaultErrorMapper.Builder extend() {
return new DefaultErrorMapper.Builder();
}
private DefaultErrorMapper() {
this(Collections.emptyMap());
}
private DefaultErrorMapper(Map<Integer, ErrorMapper> customErrorMappers) {
this.customErrorMappers = customErrorMappers;
}
public Throwable parseError(WebsocketResponse websocketResponse) {
return parseError(websocketResponse.getStatus(), websocketResponse.getBody(), websocketResponse::getHeader);
}
@Override
public Throwable parseError(int status, String body, Function<String, String> getHeader) {
if (customErrorMappers.containsKey(status)) {
return customErrorMappers.get(status).parseError(status, body, getHeader);
}
switch (status) {
case 401:
case 403:
return new AuthorizationFailedException(status, "Authorization failed!");
case 404:
return new NotFoundException("Not found");
case 409:
try {
return new MismatchedDevicesException(JsonUtil.fromJsonResponse(body, MismatchedDevices.class));
} catch (MalformedResponseException e) {
return e;
}
case 410:
try {
return new StaleDevicesException(JsonUtil.fromJsonResponse(body, StaleDevices.class));
} catch (MalformedResponseException e) {
return e;
}
case 411:
try {
return new DeviceLimitExceededException(JsonUtil.fromJsonResponse(body, DeviceLimit.class));
} catch (MalformedResponseException e) {
return e;
}
case 413:
return new RateLimitException("Rate limit exceeded: " + status);
case 417:
return new ExpectationFailedException();
case 423:
PushServiceSocket.RegistrationLockFailure accountLockFailure;
try {
accountLockFailure = JsonUtil.fromJsonResponse(body, PushServiceSocket.RegistrationLockFailure.class);
} catch (MalformedResponseException e) {
return e;
}
AuthCredentials credentials = accountLockFailure.backupCredentials;
String basicStorageCredentials = credentials != null ? credentials.asBasic() : null;
return new LockedException(accountLockFailure.length,
accountLockFailure.timeRemaining,
basicStorageCredentials);
case 428:
ProofRequiredResponse proofRequiredResponse;
try {
proofRequiredResponse = JsonUtil.fromJsonResponse(body, ProofRequiredResponse.class);
} catch (MalformedResponseException e) {
return e;
}
String retryAfterRaw = getHeader.apply("Retry-After");
long retryAfter = Util.parseInt(retryAfterRaw, -1);
return new ProofRequiredException(proofRequiredResponse, retryAfter);
case 499:
return new DeprecatedVersionException();
case 508:
return new ServerRejectedException();
}
if (status != 200 && status != 202 && status != 204) {
return new NonSuccessfulResponseCodeException(status, "Bad response: " + status);
}
return null;
}
public static class Builder {
private final Map<Integer, ErrorMapper> customErrorMappers = new HashMap<>();
public Builder withCustom(int status, ErrorMapper errorMapper) {
customErrorMappers.put(status, errorMapper);
return this;
}
public ErrorMapper build() {
return new DefaultErrorMapper(customErrorMappers);
}
}
}

View File

@@ -0,0 +1,86 @@
package org.whispersystems.signalservice.internal.websocket;
import org.whispersystems.libsignal.util.guava.Function;
import org.whispersystems.signalservice.api.push.exceptions.MalformedResponseException;
import org.whispersystems.signalservice.internal.ServiceResponse;
import org.whispersystems.signalservice.internal.util.JsonUtil;
import java.util.Objects;
/**
* A default implementation of a {@link ResponseMapper} that can parse most known
* application errors via {@link DefaultErrorMapper} and provides basic JSON parsing of the
* response model if possible.
* <p>
* Can be extended to add custom parsing for both the result type and the error cases.
* <p>
* See {@link #extend(Class)} and {@link DefaultErrorMapper#extend()}.
*/
public class DefaultResponseMapper<Response> implements ResponseMapper<Response> {
private final Class<Response> clazz;
private final ErrorMapper errorMapper;
private final CustomResponseMapper<Response> customResponseMapper;
public static <T> DefaultResponseMapper<T> getDefault(Class<T> clazz) {
return new DefaultResponseMapper<>(clazz);
}
public static <T> DefaultResponseMapper.Builder<T> extend(Class<T> clazz) {
return new DefaultResponseMapper.Builder<>(clazz);
}
private DefaultResponseMapper(Class<Response> clazz) {
this(clazz, null, DefaultErrorMapper.getDefault());
}
private DefaultResponseMapper(Class<Response> clazz, CustomResponseMapper<Response> customResponseMapper, ErrorMapper errorMapper) {
this.clazz = clazz;
this.customResponseMapper = customResponseMapper;
this.errorMapper = errorMapper;
}
@Override
public ServiceResponse<Response> map(int status, String body, Function<String, String> getHeader) {
Throwable applicationError = errorMapper.parseError(status, body, getHeader);
if (applicationError == null) {
try {
if (customResponseMapper != null) {
return Objects.requireNonNull(customResponseMapper.map(status, body, getHeader));
}
return ServiceResponse.forResult(JsonUtil.fromJsonResponse(body, clazz), status, body);
} catch (MalformedResponseException e) {
applicationError = e;
}
}
return ServiceResponse.forApplicationError(applicationError, status, body);
}
public static class Builder<Value> {
private final Class<Value> clazz;
private DefaultErrorMapper.Builder errorMapperBuilder = DefaultErrorMapper.extend();
private CustomResponseMapper<Value> customResponseMapper;
public Builder(Class<Value> clazz) {
this.clazz = clazz;
}
public Builder<Value> withResponseMapper(CustomResponseMapper<Value> responseMapper) {
this.customResponseMapper = responseMapper;
return this;
}
public Builder<Value> withCustomError(int status, ErrorMapper errorMapper) {
errorMapperBuilder.withCustom(status, errorMapper);
return this;
}
public ResponseMapper<Value> build() {
return new DefaultResponseMapper<>(clazz, customResponseMapper, errorMapperBuilder.build());
}
}
public interface CustomResponseMapper<T> {
ServiceResponse<T> map(int status, String body, Function<String, String> getHeader) throws MalformedResponseException;
}
}

View File

@@ -0,0 +1,13 @@
package org.whispersystems.signalservice.internal.websocket;
import org.whispersystems.libsignal.util.guava.Function;
/**
* Can map an API response to an appropriate {@link Throwable}.
* <p>
* Unless you need to do something really special, you should only be implementing this to customize
* {@link DefaultErrorMapper}.
*/
public interface ErrorMapper {
Throwable parseError(int status, String body, Function<String, String> getHeader);
}

View File

@@ -0,0 +1,23 @@
package org.whispersystems.signalservice.internal.websocket;
import org.whispersystems.libsignal.util.guava.Function;
import org.whispersystems.signalservice.internal.ServiceResponse;
/**
* Responsible for taking an API response and converting it to a {@link ServiceResponse}. This includes
* parsing for a success as well as any application errors. All errors (application or parsing related)
* are encapsulated in an error version of a {@link ServiceResponse}, hence why no method throws an
* exception.
* <p>
* Unless you need to do something really special, you should only be extending this to be provided to
* {@link DefaultResponseMapper}.
*
* @param <T> - The final type the API response will map into.
*/
public interface ResponseMapper<T> {
ServiceResponse<T> map(int status, String body, Function<String, String> getHeader);
default ServiceResponse<T> map(WebsocketResponse response) {
return map(response.getStatus(), response.getBody(), response::getHeader);
}
}

View File

@@ -12,10 +12,9 @@ import org.whispersystems.signalservice.api.util.Tls12SocketFactory;
import org.whispersystems.signalservice.api.util.TlsProxySocketFactory;
import org.whispersystems.signalservice.api.websocket.ConnectivityListener;
import org.whispersystems.signalservice.internal.configuration.SignalProxy;
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
import org.whispersystems.signalservice.internal.util.BlacklistingTrustManager;
import org.whispersystems.signalservice.internal.util.Util;
import org.whispersystems.signalservice.internal.util.concurrent.ListenableFuture;
import org.whispersystems.signalservice.internal.util.concurrent.SettableFuture;
import java.io.IOException;
import java.security.KeyManagementException;
@@ -25,7 +24,6 @@ import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -35,6 +33,9 @@ import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
import io.reactivex.rxjava3.subjects.SingleSubject;
import okhttp3.ConnectionSpec;
import okhttp3.Dns;
import okhttp3.Interceptor;
@@ -54,53 +55,55 @@ public class WebSocketConnection extends WebSocketListener {
private static final String TAG = WebSocketConnection.class.getSimpleName();
private static final int KEEPALIVE_TIMEOUT_SECONDS = 55;
private final LinkedList<WebSocketRequestMessage> incomingRequests = new LinkedList<>();
private final Map<Long, OutgoingRequest> outgoingRequests = new HashMap<>();
private final LinkedList<WebSocketRequestMessage> incomingRequests = new LinkedList<>();
private final Map<Long, OutgoingRequest> outgoingRequests = new HashMap<>();
private final String name;
private final String wsUri;
private final TrustStore trustStore;
private final Optional<CredentialsProvider> credentialsProvider;
private final String signalAgent;
private final ConnectivityListener listener;
private ConnectivityListener listener;
private final SleepTimer sleepTimer;
private final List<Interceptor> interceptors;
private final Optional<Dns> dns;
private final Optional<SignalProxy> signalProxy;
private WebSocket client;
private KeepAliveSender keepAliveSender;
private int attempts;
private boolean connected;
private WebSocket client;
private KeepAliveSender keepAliveSender;
private int attempts;
private boolean connected;
public WebSocketConnection(String httpUri,
TrustStore trustStore,
public WebSocketConnection(String name,
SignalServiceConfiguration serviceConfiguration,
Optional<CredentialsProvider> credentialsProvider,
String signalAgent,
ConnectivityListener listener,
SleepTimer timer,
List<Interceptor> interceptors,
Optional<Dns> dns,
Optional<SignalProxy> signalProxy)
SleepTimer timer)
{
this.trustStore = trustStore;
this.name = "[" + name + ":" + System.identityHashCode(this) + "]";
this.trustStore = serviceConfiguration.getSignalServiceUrls()[0].getTrustStore();
this.credentialsProvider = credentialsProvider;
this.signalAgent = signalAgent;
this.listener = listener;
this.sleepTimer = timer;
this.interceptors = interceptors;
this.dns = dns;
this.signalProxy = signalProxy;
this.interceptors = serviceConfiguration.getNetworkInterceptors();
this.dns = serviceConfiguration.getDns();
this.signalProxy = serviceConfiguration.getSignalProxy();
this.attempts = 0;
this.connected = false;
String uri = httpUri.replace("https://", "wss://").replace("http://", "ws://");
String uri = serviceConfiguration.getSignalServiceUrls()[0].getUrl().replace("https://", "wss://").replace("http://", "ws://");
if (credentialsProvider.isPresent()) this.wsUri = uri + "/v1/websocket/?login=%s&password=%s";
else this.wsUri = uri + "/v1/websocket/";
if (credentialsProvider.isPresent()) {
this.wsUri = uri + "/v1/websocket/?login=%s&password=%s";
} else {
this.wsUri = uri + "/v1/websocket/";
}
}
public synchronized void connect() {
Log.i(TAG, "connect()");
log("connect()");
if (client == null) {
String filledUri;
@@ -146,8 +149,12 @@ public class WebSocketConnection extends WebSocketListener {
}
}
public synchronized boolean isDead() {
return client == null;
}
public synchronized void disconnect() {
Log.i(TAG, "disconnect()");
log("disconnect()");
if (client != null) {
client.close(1000, "OK");
@@ -160,6 +167,11 @@ public class WebSocketConnection extends WebSocketListener {
keepAliveSender = null;
}
if (listener != null) {
listener.onDisconnected();
listener = null;
}
notifyAll();
}
@@ -176,27 +188,36 @@ public class WebSocketConnection extends WebSocketListener {
Util.wait(this, Math.max(1, timeoutMillis - elapsedTime(startTime)));
}
if (incomingRequests.isEmpty() && client == null) throw new IOException("Connection closed!");
else if (incomingRequests.isEmpty()) throw new TimeoutException("Timeout exceeded");
else return incomingRequests.removeFirst();
if (incomingRequests.isEmpty() && client == null) {
throw new IOException("Connection closed!");
} else if (incomingRequests.isEmpty()) {
throw new TimeoutException("Timeout exceeded");
} else {
return incomingRequests.removeFirst();
}
}
public synchronized ListenableFuture<WebsocketResponse> sendRequest(WebSocketRequestMessage request) throws IOException {
if (client == null || !connected) throw new IOException("No connection!");
public synchronized Single<WebsocketResponse> sendRequest(WebSocketRequestMessage request) throws IOException {
if (client == null || !connected) {
throw new IOException("No connection!");
}
WebSocketMessage message = WebSocketMessage.newBuilder()
.setType(WebSocketMessage.Type.REQUEST)
.setRequest(request)
.build();
SettableFuture<WebsocketResponse> future = new SettableFuture<>();
outgoingRequests.put(request.getId(), new OutgoingRequest(future, System.currentTimeMillis()));
SingleSubject<WebsocketResponse> single = SingleSubject.create();
outgoingRequests.put(request.getId(), new OutgoingRequest(single));
if (!client.send(ByteString.of(message.toByteArray()))) {
throw new IOException("Write failed!");
}
return future;
return single.subscribeOn(Schedulers.io())
.observeOn(Schedulers.io())
.timeout(10, TimeUnit.SECONDS);
}
public synchronized void sendResponse(WebSocketResponseMessage response) throws IOException {
@@ -234,13 +255,15 @@ public class WebSocketConnection extends WebSocketListener {
@Override
public synchronized void onOpen(WebSocket webSocket, Response response) {
if (client != null && keepAliveSender == null) {
Log.i(TAG, "onOpen() connected");
log("onOpen() connected");
attempts = 0;
connected = true;
keepAliveSender = new KeepAliveSender();
keepAliveSender.start();
if (listener != null) listener.onConnected();
if (listener != null) {
listener.onConnected();
}
}
}
@@ -249,33 +272,33 @@ public class WebSocketConnection extends WebSocketListener {
try {
WebSocketMessage message = WebSocketMessage.parseFrom(payload.toByteArray());
if (message.getType().getNumber() == WebSocketMessage.Type.REQUEST_VALUE) {
if (message.getType().getNumber() == WebSocketMessage.Type.REQUEST_VALUE) {
incomingRequests.add(message.getRequest());
} else if (message.getType().getNumber() == WebSocketMessage.Type.RESPONSE_VALUE) {
OutgoingRequest listener = outgoingRequests.get(message.getResponse().getId());
OutgoingRequest listener = outgoingRequests.remove(message.getResponse().getId());
if (listener != null) {
listener.getResponseFuture().set(new WebsocketResponse(message.getResponse().getStatus(),
new String(message.getResponse().getBody().toByteArray()),
message.getResponse().getHeadersList()));
listener.onSuccess(new WebsocketResponse(message.getResponse().getStatus(),
new String(message.getResponse().getBody().toByteArray()),
message.getResponse().getHeadersList()));
}
}
notifyAll();
} catch (InvalidProtocolBufferException e) {
Log.w(TAG, e);
warn(e);
}
}
@Override
public synchronized void onClosed(WebSocket webSocket, int code, String reason) {
Log.i(TAG, "onClose()");
log("onClose()");
this.connected = false;
Iterator<Map.Entry<Long, OutgoingRequest>> iterator = outgoingRequests.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<Long, OutgoingRequest> entry = iterator.next();
entry.getValue().getResponseFuture().setException(new IOException("Closed: " + code + ", " + reason));
entry.getValue().onError(new IOException("Closed: " + code + ", " + reason));
iterator.remove();
}
@@ -291,6 +314,7 @@ public class WebSocketConnection extends WebSocketListener {
Util.wait(this, Math.min(++attempts * 200, TimeUnit.SECONDS.toMillis(15)));
if (client != null) {
log("Client not null when closed, attempting to reconnect");
client.close(1000, "OK");
client = null;
connected = false;
@@ -302,7 +326,7 @@ public class WebSocketConnection extends WebSocketListener {
@Override
public synchronized void onFailure(WebSocket webSocket, Throwable t, Response response) {
Log.w(TAG, "onFailure()", t);
warn("onFailure()", t);
if (response != null && (response.code() == 401 || response.code() == 403)) {
if (listener != null) {
@@ -311,7 +335,7 @@ public class WebSocketConnection extends WebSocketListener {
} else if (listener != null) {
boolean shouldRetryConnection = listener.onGenericFailure(response, t);
if (!shouldRetryConnection) {
Log.w(TAG, "Experienced a failure, and the listener indicated we should not retry the connection. Disconnecting.");
warn("Experienced a failure, and the listener indicated we should not retry the connection. Disconnecting.");
disconnect();
}
}
@@ -328,7 +352,7 @@ public class WebSocketConnection extends WebSocketListener {
@Override
public synchronized void onClosing(WebSocket webSocket, int code, String reason) {
Log.i(TAG, "onClosing()");
log("onClosing()");
webSocket.close(1000, "OK");
}
@@ -342,25 +366,45 @@ public class WebSocketConnection extends WebSocketListener {
TrustManager[] trustManagers = BlacklistingTrustManager.createFor(trustStore);
context.init(null, trustManagers, null);
return new Pair<>(context.getSocketFactory(), (X509TrustManager)trustManagers[0]);
return new Pair<>(context.getSocketFactory(), (X509TrustManager) trustManagers[0]);
} catch (NoSuchAlgorithmException | KeyManagementException e) {
throw new AssertionError(e);
}
}
private void log(String message) {
Log.i(TAG, name + " " + message);
}
@SuppressWarnings("SameParameterValue")
private void warn(String message) {
Log.w(TAG, name + " " + message);
}
private void warn(Throwable e) {
Log.w(TAG, name, e);
}
@SuppressWarnings("SameParameterValue")
private void warn(String message, Throwable e) {
Log.w(TAG, name + " " + message, e);
}
private class KeepAliveSender extends Thread {
private AtomicBoolean stop = new AtomicBoolean(false);
private final AtomicBoolean stop = new AtomicBoolean(false);
public void run() {
while (!stop.get()) {
try {
sleepTimer.sleep(TimeUnit.SECONDS.toMillis(KEEPALIVE_TIMEOUT_SECONDS));
Log.d(TAG, "Sending keep alive...");
sendKeepAlive();
if (!stop.get()) {
log("Sending keep alive...");
sendKeepAlive();
}
} catch (Throwable e) {
Log.w(TAG, e);
warn(e);
}
}
}
@@ -371,20 +415,18 @@ public class WebSocketConnection extends WebSocketListener {
}
private static class OutgoingRequest {
private final SettableFuture<WebsocketResponse> responseFuture;
private final long startTimestamp;
private final SingleSubject<WebsocketResponse> responseSingle;
private OutgoingRequest(SettableFuture<WebsocketResponse> future, long startTimestamp) {
this.responseFuture = future;
this.startTimestamp = startTimestamp;
private OutgoingRequest(SingleSubject<WebsocketResponse> future) {
this.responseSingle = future;
}
SettableFuture<WebsocketResponse> getResponseFuture() {
return responseFuture;
public void onSuccess(WebsocketResponse response) {
responseSingle.onSuccess(response);
}
long getStartTimestamp() {
return startTimestamp;
public void onError(Throwable throwable) {
responseSingle.onError(throwable);
}
}
}

View File

@@ -1,9 +0,0 @@
package org.whispersystems.signalservice.internal.websocket;
public interface WebSocketEventListener {
public void onMessage(byte[] payload);
public void onClose();
public void onConnected();
}