diff --git a/res/layout/activity_seed.xml b/res/layout/activity_seed.xml index cd79c01125..1ac6b7e57a 100644 --- a/res/layout/activity_seed.xml +++ b/res/layout/activity_seed.xml @@ -123,8 +123,7 @@ android:layout_height="50dp" android:background="@color/transparent" android:textColor="@color/signal_primary" - android:text="Link Device (Coming Soon)" - android:alpha="0.24" + android:text="Link Device" android:elevation="0dp" android:stateListAnimator="@null" /> diff --git a/res/layout/profile_preference_view.xml b/res/layout/profile_preference_view.xml index 6df515f480..620b984f88 100644 --- a/res/layout/profile_preference_view.xml +++ b/res/layout/profile_preference_view.xml @@ -30,6 +30,14 @@ android:layout_height="wrap_content" tools:text="+14151231234"/> + + \ No newline at end of file diff --git a/res/values/strings.xml b/res/values/strings.xml index 51065f8cc1..307c000c62 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -1572,6 +1572,7 @@ Looks like you don\'t have any conversations yet. Get started by messaging a friend. + Secondary device Copied to clipboard Share Public Key Show QR Code diff --git a/res/xml/preferences.xml b/res/xml/preferences.xml index 666ee32098..270c9254d3 100644 --- a/res/xml/preferences.xml +++ b/res/xml/preferences.xml @@ -42,7 +42,7 @@ android:icon="@drawable/icon_qr_code"/> MultiDeviceUtilities.signAndSendPairingAuthorisationMessage(context, pairingAuthorisation)); + } + @Override public void handleDeviceLinkAuthorized(@NotNull PairingAuthorisation pairingAuthorisation) {} + @Override public void handleDeviceLinkingDialogDismissed() {} } private class ProfileClickListener implements Preference.OnPreferenceClickListener { diff --git a/src/org/thoughtcrime/securesms/ConversationListActivity.java b/src/org/thoughtcrime/securesms/ConversationListActivity.java index 3858149e94..57fc652324 100644 --- a/src/org/thoughtcrime/securesms/ConversationListActivity.java +++ b/src/org/thoughtcrime/securesms/ConversationListActivity.java @@ -41,6 +41,7 @@ import android.widget.Toast; import org.thoughtcrime.securesms.components.RatingManager; import org.thoughtcrime.securesms.components.SearchToolbar; import org.thoughtcrime.securesms.conversation.ConversationActivity; +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil; import org.thoughtcrime.securesms.database.Address; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; @@ -58,6 +59,7 @@ import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; import org.thoughtcrime.securesms.util.DynamicTheme; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.whispersystems.signalservice.loki.api.LokiStorageAPI; import java.util.List; @@ -195,6 +197,13 @@ public class ConversationListActivity extends PassphraseRequiredActionBarActivit outline.setOval(0, 0, view.getWidth(), view.getHeight()); } }); + + // Display the correct identicon if we're a secondary device + String currentUser = TextSecurePreferences.getLocalNumber(this); + String recipientAddress = recipient.getAddress().serialize(); + String primaryAddress = TextSecurePreferences.getMasterHexEncodedPublicKey(this); + String profileAddress = (recipientAddress.equalsIgnoreCase(currentUser) && primaryAddress != null) ? primaryAddress : recipientAddress; + profilePictureImageView.setClipToOutline(true); profilePictureImageView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { @@ -204,7 +213,7 @@ public class ConversationListActivity extends PassphraseRequiredActionBarActivit int height = profilePictureImageView.getHeight(); if (width == 0 || height == 0) return true; profilePictureImageView.getViewTreeObserver().removeOnPreDrawListener(this); - JazzIdenticonDrawable identicon = new JazzIdenticonDrawable(width, height, recipient.getAddress().serialize().toLowerCase()); + JazzIdenticonDrawable identicon = new JazzIdenticonDrawable(width, height, profileAddress.toLowerCase()); profilePictureImageView.setImageDrawable(identicon); return true; } diff --git a/src/org/thoughtcrime/securesms/components/AvatarImageView.java b/src/org/thoughtcrime/securesms/components/AvatarImageView.java index 9158ab0689..6eae6b42aa 100644 --- a/src/org/thoughtcrime/securesms/components/AvatarImageView.java +++ b/src/org/thoughtcrime/securesms/components/AvatarImageView.java @@ -107,8 +107,9 @@ public class AvatarImageView extends AppCompatImageView { if (w == 0 || h == 0 || recipient == null) { return; } Drawable image; + Context context = this.getContext(); if (recipient.isGroupRecipient()) { - Context context = this.getContext(); + String name = Optional.fromNullable(recipient.getName()).or(Optional.fromNullable(TextSecurePreferences.getProfileName(context))).or(""); MaterialColor fallbackColor = recipient.getColor(); @@ -119,7 +120,12 @@ public class AvatarImageView extends AppCompatImageView { image = new GeneratedContactPhoto(name, R.drawable.ic_profile_default).asDrawable(context, fallbackColor.toAvatarColor(context)); } else { - image = new JazzIdenticonDrawable(w, h, recipient.getAddress().serialize().toLowerCase()); + // Default to primary device image + String ourPublicKey = TextSecurePreferences.getLocalNumber(context); + String ourPrimaryDevice = TextSecurePreferences.getMasterHexEncodedPublicKey(context); + String recipientAddress = recipient.getAddress().serialize(); + String profileAddress = (ourPrimaryDevice != null && ourPublicKey.equals(recipientAddress)) ? ourPrimaryDevice : recipientAddress; + image = new JazzIdenticonDrawable(w, h, profileAddress.toLowerCase()); } setImageDrawable(image); } diff --git a/src/org/thoughtcrime/securesms/components/TypingStatusSender.java b/src/org/thoughtcrime/securesms/components/TypingStatusSender.java index b7526f1065..927b158e42 100644 --- a/src/org/thoughtcrime/securesms/components/TypingStatusSender.java +++ b/src/org/thoughtcrime/securesms/components/TypingStatusSender.java @@ -9,13 +9,14 @@ import org.thoughtcrime.securesms.database.Address; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.jobs.TypingSendJob; -import org.thoughtcrime.securesms.loki.MultiDeviceUtilitiesKt; +import org.thoughtcrime.securesms.loki.MultiDeviceUtilities; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.signalservice.loki.api.LokiStorageAPI; import java.util.HashMap; import java.util.Map; +import java.util.Set; import java.util.concurrent.TimeUnit; import kotlin.Unit; @@ -90,12 +91,13 @@ public class TypingStatusSender { ApplicationContext.getInstance(context).getJobManager().add(new TypingSendJob(threadId, typingStarted)); return; } - - MultiDeviceUtilitiesKt.getAllDevicePublicKeys(context, recipient.getAddress().serialize(), storageAPI, (devicePublicKey, isFriend, friendCount) -> { - Recipient device = Recipient.from(context, Address.fromSerialized(devicePublicKey), false); - long deviceThreadID = threadDatabase.getThreadIdIfExistsFor(device); - if (deviceThreadID > -1) { - ApplicationContext.getInstance(context).getJobManager().add(new TypingSendJob(deviceThreadID, typingStarted)); + LokiStorageAPI.shared.getAllDevicePublicKeys(recipient.getAddress().serialize()).success(devices -> { + for (String device : devices) { + Recipient deviceRecipient = Recipient.from(context, Address.fromSerialized(device), false); + long deviceThreadID = threadDatabase.getThreadIdIfExistsFor(deviceRecipient); + if (deviceThreadID > -1) { + ApplicationContext.getInstance(context).getJobManager().add(new TypingSendJob(deviceThreadID, typingStarted)); + } } return Unit.INSTANCE; }); diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 25ee20998d..6e761f90bc 100644 --- a/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -38,7 +38,6 @@ import android.net.Uri; import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; -import android.os.Handler; import android.os.Vibrator; import android.provider.Browser; import android.provider.ContactsContract; @@ -158,9 +157,11 @@ import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.loki.FriendRequestViewDelegate; import org.thoughtcrime.securesms.loki.LokiAPIUtilities; import org.thoughtcrime.securesms.loki.LokiThreadDatabase; +import org.thoughtcrime.securesms.loki.LokiMessageDatabase; import org.thoughtcrime.securesms.loki.LokiThreadDatabaseDelegate; import org.thoughtcrime.securesms.loki.LokiUserDatabase; import org.thoughtcrime.securesms.loki.MentionCandidateSelectionView; +import org.thoughtcrime.securesms.loki.MultiDeviceUtilities; import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.mediasend.MediaSendActivity; import org.thoughtcrime.securesms.mms.AttachmentManager; @@ -225,10 +226,8 @@ import org.thoughtcrime.securesms.util.concurrent.SettableFuture; import org.thoughtcrime.securesms.util.views.Stub; import org.whispersystems.libsignal.InvalidMessageException; import org.whispersystems.libsignal.util.guava.Optional; -import org.whispersystems.signalservice.api.SignalServiceMessageSender; -import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; -import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.loki.api.LokiAPI; +import org.whispersystems.signalservice.loki.api.LokiStorageAPI; import org.whispersystems.signalservice.loki.messaging.LokiMessageFriendRequestStatus; import org.whispersystems.signalservice.loki.messaging.LokiThreadFriendRequestStatus; import org.whispersystems.signalservice.loki.messaging.Mention; @@ -249,6 +248,7 @@ import java.util.concurrent.atomic.AtomicInteger; import kotlin.Unit; import network.loki.messenger.R; +import static nl.komponents.kovenant.KovenantApi.task; import static org.thoughtcrime.securesms.TransportOption.Type; import static org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord; import static org.whispersystems.libsignal.SessionCipher.SESSION_LOCK; @@ -353,6 +353,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity private ArrayList mentions = new ArrayList<>(); private String oldText = ""; + // Multi Device + private boolean isFriendsWithAnyDevice = false; @Override protected void onPreCreate() { @@ -719,7 +721,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity if (isSingleConversation() && getRecipient().getContactUri() == null) { inflater.inflate(R.menu.conversation_add_to_contacts, menu); } - */ + if (recipient != null && recipient.isLocalNumber()) { if (isSecureText) menu.findItem(R.id.menu_call_secure).setVisible(false); @@ -731,6 +733,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity muteItem.setVisible(false); } } + */ searchViewItem = menu.findItem(R.id.menu_search); @@ -2183,21 +2186,74 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity @Override public void handleThreadFriendRequestStatusChanged(long threadID) { - if (threadID != this.threadId) { return; } - new Handler(getMainLooper()).post(this::updateInputPanel); + if (threadID != this.threadId) { + Recipient threadRecipient = DatabaseFactory.getThreadDatabase(this).getRecipientForThreadId(threadID); + if (threadRecipient != null && !threadRecipient.isGroupRecipient()) { + LokiStorageAPI.shared.getAllDevicePublicKeys(threadRecipient.getAddress().serialize()).success(devices -> { + // We should update our input if this thread is a part of the other threads device + if (devices.contains(recipient.getAddress().serialize())) { + this.updateInputPanel(); + } + return Unit.INSTANCE; + }); + } + return; + } + + this.updateInputPanel(); } private void updateInputPanel() { - boolean hasPendingFriendRequest = !recipient.isGroupRecipient() && DatabaseFactory.getLokiThreadDatabase(this).hasPendingFriendRequest(threadId); - updateToggleButtonState(); - inputPanel.setEnabled(!hasPendingFriendRequest); - int hintID = hasPendingFriendRequest ? R.string.activity_conversation_pending_friend_request_hint : R.string.activity_conversation_default_hint; - inputPanel.setHint(getResources().getString(hintID)); - if (!hasPendingFriendRequest) { - inputPanel.composeText.requestFocus(); - InputMethodManager inputMethodManager = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE); - inputMethodManager.showSoftInput(inputPanel.composeText, 0); + /* + isFriendsWithAnyDevice caches whether we are friends with any of the other users device. + + This stops the case where the input panel disables and enables rapidly. + - This can occur when we are not friends with the current thread BUT multi-device tells us that we are friends with another one of their devices. + */ + if (recipient.isGroupRecipient() || isNoteToSelf() || isFriendsWithAnyDevice) { + setInputPanelEnabled(true); + return; } + + // It could take a while before our promise resolves, so we assume the best case + LokiThreadFriendRequestStatus friendRequestStatus = DatabaseFactory.getLokiThreadDatabase(this).getFriendRequestStatus(threadId); + boolean isPending = friendRequestStatus == LokiThreadFriendRequestStatus.REQUEST_SENDING || friendRequestStatus == LokiThreadFriendRequestStatus.REQUEST_SENT || friendRequestStatus == LokiThreadFriendRequestStatus.REQUEST_RECEIVED; + setInputPanelEnabled(!isPending); + + // We should always have the input panel enabled if we are friends with the current user + isFriendsWithAnyDevice = friendRequestStatus == LokiThreadFriendRequestStatus.FRIENDS; + + // Multi-device input logic + if (!isFriendsWithAnyDevice) { + // We should enable the input if we don't have any pending friend requests OR we are friends with a linked device + MultiDeviceUtilities.hasPendingFriendRequestWithAnyLinkedDevice(this, recipient).success(hasPendingRequests -> { + if (!hasPendingRequests) { + setInputPanelEnabled(true); + } else { + MultiDeviceUtilities.isFriendsWithAnyLinkedDevice(this, recipient).success(isFriends -> { + // If we are friend with any of the other devices then we want to make sure the input panel is always enabled for the duration of this conversation + isFriendsWithAnyDevice = isFriends; + setInputPanelEnabled(isFriends); + return Unit.INSTANCE; + }); + } + return Unit.INSTANCE; + }); + } + } + + private void setInputPanelEnabled(boolean enabled) { + Util.runOnMain(() -> { + updateToggleButtonState(); + int hintID = enabled ? R.string.activity_conversation_default_hint : R.string.activity_conversation_pending_friend_request_hint; + inputPanel.setHint(getResources().getString(hintID)); + inputPanel.setEnabled(enabled); + if (enabled) { + inputPanel.composeText.requestFocus(); + InputMethodManager inputMethodManager = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE); + inputMethodManager.showSoftInput(inputPanel.composeText, 0); + } + }); } private void sendMessage() { @@ -2401,9 +2457,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } private void updateToggleButtonState() { - // Don't allow attachments if we're not friends - LokiThreadFriendRequestStatus friendRequestStatus = DatabaseFactory.getLokiThreadDatabase(this).getFriendRequestStatus(threadId); - if (!recipient.isGroupRecipient() && friendRequestStatus != LokiThreadFriendRequestStatus.FRIENDS) { + // Don't allow attachments if we're not friends with any device + if (!isNoteToSelf() && !recipient.isGroupRecipient() && !isFriendsWithAnyDevice) { buttonToggle.display(sendButton); quickAttachmentToggle.hide(); inlineAttachmentToggle.hide(); @@ -2988,27 +3043,34 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity // region Loki @Override public void acceptFriendRequest(@NotNull MessageRecord friendRequest) { - String contactID = DatabaseFactory.getThreadDatabase(this).getRecipientForThreadId(this.threadId).getAddress().toString(); - SignalServiceMessageSender messageSender = ApplicationContext.getInstance(this).communicationModule.provideSignalMessageSender(); - SignalServiceAddress address = new SignalServiceAddress(contactID); - SignalServiceDataMessage message = new SignalServiceDataMessage(System.currentTimeMillis(), ""); - Context context = this; - AsyncTask.execute(() -> { - try { - messageSender.sendMessage(0, address, Optional.absent(), message); // The message ID doesn't matter - DatabaseFactory.getLokiThreadDatabase(context).setFriendRequestStatus(this.threadId, LokiThreadFriendRequestStatus.FRIENDS); - DatabaseFactory.getLokiMessageDatabase(context).setFriendRequestStatus(friendRequest.id, LokiMessageFriendRequestStatus.REQUEST_ACCEPTED); - } catch (Exception e) { - Log.d("Loki", "Failed to send background message to: " + contactID + "."); - } - }); + // Send the accept to the original friend request thread id + LokiMessageDatabase lokiMessageDatabase = DatabaseFactory.getLokiMessageDatabase(this); + long originalThreadID = lokiMessageDatabase.getOriginalThreadID(friendRequest.id); + long threadId = originalThreadID < 0 ? this.threadId : originalThreadID; + + Address contact = DatabaseFactory.getThreadDatabase(this).getRecipientForThreadId(threadId).getAddress(); + String contactPubKey = contact.toString(); + DatabaseFactory.getLokiThreadDatabase(this).setFriendRequestStatus(threadId, LokiThreadFriendRequestStatus.FRIENDS); + lokiMessageDatabase.setFriendRequestStatus(friendRequest.id, LokiMessageFriendRequestStatus.REQUEST_ACCEPTED); + MessageSender.sendBackgroundMessageToAllDevices(this, contactPubKey); + MessageSender.syncContact(this, contact); + updateInputPanel(); } @Override public void rejectFriendRequest(@NotNull MessageRecord friendRequest) { - DatabaseFactory.getLokiThreadDatabase(this).setFriendRequestStatus(this.threadId, LokiThreadFriendRequestStatus.NONE); - String contactID = DatabaseFactory.getThreadDatabase(this).getRecipientForThreadId(this.threadId).getAddress().toString(); + LokiMessageDatabase lokiMessageDatabase = DatabaseFactory.getLokiMessageDatabase(this); + long originalThreadID = lokiMessageDatabase.getOriginalThreadID(friendRequest.id); + long threadId = originalThreadID < 0 ? this.threadId : originalThreadID; + + DatabaseFactory.getLokiThreadDatabase(this).setFriendRequestStatus(threadId, LokiThreadFriendRequestStatus.NONE); + String contactID = DatabaseFactory.getThreadDatabase(this).getRecipientForThreadId(threadId).getAddress().toString(); DatabaseFactory.getLokiPreKeyBundleDatabase(this).removePreKeyBundle(contactID); + updateInputPanel(); + } + + public boolean isNoteToSelf() { + return TextSecurePreferences.getLocalNumber(this).equals(recipient.getAddress().serialize()); } // endregion } diff --git a/src/org/thoughtcrime/securesms/database/Address.java b/src/org/thoughtcrime/securesms/database/Address.java index ed20c6fbbb..d063025cfa 100644 --- a/src/org/thoughtcrime/securesms/database/Address.java +++ b/src/org/thoughtcrime/securesms/database/Address.java @@ -61,7 +61,7 @@ public class Address implements Parcelable, Comparable
{ private Address(@NonNull String address, Boolean isPublicChat) { if (address == null) throw new AssertionError(address); - this.address = address; + this.address = address.toLowerCase(); this.isPublicChat = isPublicChat; } diff --git a/src/org/thoughtcrime/securesms/database/SmsDatabase.java b/src/org/thoughtcrime/securesms/database/SmsDatabase.java index 4fdcfdcc6a..112ddc463b 100644 --- a/src/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/src/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -47,6 +47,7 @@ import org.whispersystems.libsignal.util.guava.Optional; import java.io.IOException; import java.security.SecureRandom; +import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -193,6 +194,23 @@ public class SmsDatabase extends MessagingDatabase { return -1; } + public Set getAllMessageIDs(long threadID) { + SQLiteDatabase database = databaseHelper.getReadableDatabase(); + Cursor cursor = null; + Set messageIDs = new HashSet<>(); + try { + cursor = database.query(TABLE_NAME, null, THREAD_ID + " = ?", new String[] { threadID + "" }, null, null, null); + while (cursor != null && cursor.moveToNext()) { + messageIDs.add(cursor.getLong(0)); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return messageIDs; + } + public void markAsEndSession(long id) { updateTypeBitmask(id, Types.KEY_EXCHANGE_MASK, Types.END_SESSION_BIT); } diff --git a/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 9216a2314b..115cd1090d 100644 --- a/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -70,6 +70,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int lokiV1 = 22; private static final int lokiV2 = 23; private static final int lokiV3 = 24; + private static final int lokiV4 = 25; private static final int DATABASE_VERSION = lokiV3; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes private static final String DATABASE_NAME = "signal.db"; @@ -128,7 +129,8 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(LokiAPIDatabase.getCreatePairingAuthorisationTableCommand()); db.execSQL(LokiPreKeyBundleDatabase.getCreateTableCommand()); db.execSQL(LokiPreKeyRecordDatabase.getCreateTableCommand()); - db.execSQL(LokiMessageDatabase.getCreateTableCommand()); + db.execSQL(LokiMessageDatabase.getCreateMessageFriendRequestTableCommand()); + db.execSQL(LokiMessageDatabase.getCreateMessageToThreadMappingTableCommand()); db.execSQL(LokiThreadDatabase.getCreateFriendRequestTableCommand()); db.execSQL(LokiThreadDatabase.getCreateSessionResetTableCommand()); db.execSQL(LokiThreadDatabase.getCreatePublicChatTableCommand()); @@ -504,6 +506,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL("ALTER TABLE part ADD COLUMN url TEXT"); } + if (oldVersion < lokiV4) { + db.execSQL(LokiMessageDatabase.getCreateMessageToThreadMappingTableCommand()); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/src/org/thoughtcrime/securesms/dependencies/SignalCommunicationModule.java b/src/org/thoughtcrime/securesms/dependencies/SignalCommunicationModule.java index 747c7bfbc1..833c7f895f 100644 --- a/src/org/thoughtcrime/securesms/dependencies/SignalCommunicationModule.java +++ b/src/org/thoughtcrime/securesms/dependencies/SignalCommunicationModule.java @@ -47,8 +47,9 @@ import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob; import org.thoughtcrime.securesms.jobs.TypingSendJob; import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository; import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.loki.PushMessageSyncSendJob; import org.thoughtcrime.securesms.preferences.AppProtectionPreferenceFragment; -import org.thoughtcrime.securesms.push.SecurityEventListener; +import org.thoughtcrime.securesms.push.MessageSenderEventListener; import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess; import org.thoughtcrime.securesms.service.IncomingMessageObserver; import org.thoughtcrime.securesms.service.WebRtcCallService; @@ -112,7 +113,8 @@ import network.loki.messenger.BuildConfig; StickerPackDownloadJob.class, MultiDeviceStickerPackOperationJob.class, MultiDeviceStickerPackSyncJob.class, - LinkPreviewRepository.class}) + LinkPreviewRepository.class, + PushMessageSyncSendJob.class}) public class SignalCommunicationModule { @@ -151,7 +153,7 @@ public class SignalCommunicationModule { TextSecurePreferences.isMultiDevice(context), Optional.fromNullable(IncomingMessageObserver.getPipe()), Optional.fromNullable(IncomingMessageObserver.getUnidentifiedPipe()), - Optional.of(new SecurityEventListener(context)), + Optional.of(new MessageSenderEventListener(context)), TextSecurePreferences.getLocalNumber(context), DatabaseFactory.getLokiAPIDatabase(context), DatabaseFactory.getLokiThreadDatabase(context), diff --git a/src/org/thoughtcrime/securesms/jobmanager/Data.java b/src/org/thoughtcrime/securesms/jobmanager/Data.java index f7f77b0aa4..a55069ec38 100644 --- a/src/org/thoughtcrime/securesms/jobmanager/Data.java +++ b/src/org/thoughtcrime/securesms/jobmanager/Data.java @@ -24,6 +24,7 @@ public class Data { @JsonProperty private final Map doubleArrays; @JsonProperty private final Map booleans; @JsonProperty private final Map booleanArrays; + @JsonProperty private final Map byteArrays; public Data(@JsonProperty("strings") @NonNull Map strings, @JsonProperty("stringArrays") @NonNull Map stringArrays, @@ -36,7 +37,8 @@ public class Data { @JsonProperty("doubles") @NonNull Map doubles, @JsonProperty("doubleArrays") @NonNull Map doubleArrays, @JsonProperty("booleans") @NonNull Map booleans, - @JsonProperty("booleanArrays") @NonNull Map booleanArrays) + @JsonProperty("booleanArrays") @NonNull Map booleanArrays, + @JsonProperty("byteArrays") @NonNull Map byteArrays) { this.strings = strings; this.stringArrays = stringArrays; @@ -50,6 +52,7 @@ public class Data { this.doubleArrays = doubleArrays; this.booleans = booleans; this.booleanArrays = booleanArrays; + this.byteArrays = byteArrays; } public boolean hasString(@NonNull String key) { @@ -201,6 +204,14 @@ public class Data { return booleanArrays.get(key); } + public boolean hasByteArray(@NonNull String key) { + return byteArrays.containsKey(key); + } + + public byte[] getByteArray(@NonNull String key) { + throwIfAbsent(byteArrays, key); + return byteArrays.get(key); + } private void throwIfAbsent(@NonNull Map map, @NonNull String key) { if (!map.containsKey(key)) { @@ -223,6 +234,7 @@ public class Data { private final Map doubleArrays = new HashMap<>(); private final Map booleans = new HashMap<>(); private final Map booleanArrays = new HashMap<>(); + private final Map byteArrays = new HashMap<>(); public Builder putString(@NonNull String key, @Nullable String value) { strings.put(key, value); @@ -284,6 +296,11 @@ public class Data { return this; } + public Builder putByteArray(@NonNull String key, @NonNull byte[] value) { + byteArrays.put(key, value); + return this; + } + public Data build() { return new Data(strings, stringArrays, @@ -296,7 +313,8 @@ public class Data { doubles, doubleArrays, booleans, - booleanArrays); + booleanArrays, + byteArrays); } } diff --git a/src/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.java b/src/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.java index 9a091150ce..dce85f83e6 100644 --- a/src/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.java +++ b/src/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.java @@ -27,6 +27,7 @@ import org.whispersystems.signalservice.api.SignalServiceMessageSender; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.loki.api.LokiStorageAPI; import java.io.IOException; import java.io.InputStream; @@ -84,14 +85,17 @@ public class AttachmentUploadJob extends BaseJob implements InjectableType { if (databaseAttachment == null) { throw new IllegalStateException("Cannot find the specified attachment."); } + + // Only upload attachment if necessary + if (databaseAttachment.getUrl().isEmpty()) { + MediaConstraints mediaConstraints = MediaConstraints.getPushMediaConstraints(); + Attachment scaledAttachment = scaleAndStripExif(database, mediaConstraints, databaseAttachment); + SignalServiceAttachment localAttachment = getAttachmentFor(scaledAttachment); + SignalServiceAttachmentPointer remoteAttachment = messageSender.uploadAttachment(localAttachment.asStream(), databaseAttachment.isSticker(), new SignalServiceAddress(destination.serialize())); + Attachment attachment = PointerAttachment.forPointer(Optional.of(remoteAttachment), null, databaseAttachment.getFastPreflightId()).get(); - MediaConstraints mediaConstraints = MediaConstraints.getPushMediaConstraints(); - Attachment scaledAttachment = scaleAndStripExif(database, mediaConstraints, databaseAttachment); - SignalServiceAttachment localAttachment = getAttachmentFor(scaledAttachment); - SignalServiceAttachmentPointer remoteAttachment = messageSender.uploadAttachment(localAttachment.asStream(), databaseAttachment.isSticker(), new SignalServiceAddress(destination.serialize())); - Attachment attachment = PointerAttachment.forPointer(Optional.of(remoteAttachment), null, databaseAttachment.getFastPreflightId()).get(); - - database.updateAttachmentAfterUpload(databaseAttachment.getAttachmentId(), attachment); + database.updateAttachmentAfterUpload(databaseAttachment.getAttachmentId(), attachment); + } } @Override diff --git a/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index af769eda20..cfa9aa7beb 100644 --- a/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -13,6 +13,8 @@ import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraintObserver; import org.thoughtcrime.securesms.jobmanager.impl.NetworkOrCellServiceConstraint; import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraint; import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraintObserver; +import org.thoughtcrime.securesms.loki.PushBackgroundMessageSendJob; +import org.thoughtcrime.securesms.loki.PushMessageSyncSendJob; import java.util.Arrays; import java.util.HashMap; @@ -70,6 +72,8 @@ public final class JobManagerFactories { put(TrimThreadJob.KEY, new TrimThreadJob.Factory()); put(TypingSendJob.KEY, new TypingSendJob.Factory()); put(UpdateApkJob.KEY, new UpdateApkJob.Factory()); + put(PushMessageSyncSendJob.KEY, new PushMessageSyncSendJob.Factory()); + put(PushBackgroundMessageSendJob.KEY, new PushBackgroundMessageSendJob.Factory()); }}; } diff --git a/src/org/thoughtcrime/securesms/jobs/MultiDeviceContactUpdateJob.java b/src/org/thoughtcrime/securesms/jobs/MultiDeviceContactUpdateJob.java index 953e6b623c..6479ef0b64 100644 --- a/src/org/thoughtcrime/securesms/jobs/MultiDeviceContactUpdateJob.java +++ b/src/org/thoughtcrime/securesms/jobs/MultiDeviceContactUpdateJob.java @@ -15,6 +15,7 @@ import org.thoughtcrime.securesms.contacts.ContactAccessor.ContactData; import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; import org.thoughtcrime.securesms.database.Address; +import org.thoughtcrime.securesms.database.Database; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.IdentityDatabase; import org.thoughtcrime.securesms.dependencies.InjectableType; @@ -36,15 +37,19 @@ import org.whispersystems.signalservice.api.messages.multidevice.DeviceContact; import org.whispersystems.signalservice.api.messages.multidevice.DeviceContactsOutputStream; import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; import org.whispersystems.signalservice.api.util.InvalidNumberException; +import org.whispersystems.signalservice.loki.messaging.LokiThreadFriendRequestStatus; import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; +import java.util.ArrayList; import java.util.Collection; +import java.util.List; import java.util.concurrent.TimeUnit; import javax.inject.Inject; @@ -58,41 +63,57 @@ public class MultiDeviceContactUpdateJob extends BaseJob implements InjectableTy private static final long FULL_SYNC_TIME = TimeUnit.HOURS.toMillis(6); private static final String KEY_ADDRESS = "address"; + private static final String KEY_RECIPIENT = "recipient"; private static final String KEY_FORCE_SYNC = "force_sync"; @Inject SignalServiceMessageSender messageSender; private @Nullable String address; + // The recipient of this sync message. If null then we send to all devices + private @Nullable String recipient; + private boolean forceSync; + /** + * Create a full contact sync job which syncs across to all other devices + */ public MultiDeviceContactUpdateJob(@NonNull Context context) { this(context, false); } + public MultiDeviceContactUpdateJob(@NonNull Context context, boolean forceSync) { this(context, null, forceSync); } - public MultiDeviceContactUpdateJob(@NonNull Context context, boolean forceSync) { - this(context, null, forceSync); + /** + * Create a full contact sync job which only gets sent to `recipient` + */ + public MultiDeviceContactUpdateJob(@NonNull Context context, @Nullable Address recipient, boolean forceSync) { + this(context, recipient, null, forceSync); } + /** + * Create a single contact sync job which syncs across `address` to the all other devices + */ public MultiDeviceContactUpdateJob(@NonNull Context context, @Nullable Address address) { - this(context, address, true); + this(context, null, address, true); } - public MultiDeviceContactUpdateJob(@NonNull Context context, @Nullable Address address, boolean forceSync) { + private MultiDeviceContactUpdateJob(@NonNull Context context, @Nullable Address recipient, @Nullable Address address, boolean forceSync) { this(new Job.Parameters.Builder() .addConstraint(NetworkConstraint.KEY) .setQueue("MultiDeviceContactUpdateJob") .setLifespan(TimeUnit.DAYS.toMillis(1)) - .setMaxAttempts(Parameters.UNLIMITED) + .setMaxAttempts(1) .build(), + recipient, address, forceSync); } - private MultiDeviceContactUpdateJob(@NonNull Job.Parameters parameters, @Nullable Address address, boolean forceSync) { + private MultiDeviceContactUpdateJob(@NonNull Job.Parameters parameters, @Nullable Address recipient, @Nullable Address address, boolean forceSync) { super(parameters); this.forceSync = forceSync; + this.recipient = (recipient != null) ? recipient.serialize() : null; if (address != null) this.address = address.serialize(); else this.address = null; @@ -102,6 +123,7 @@ public class MultiDeviceContactUpdateJob extends BaseJob implements InjectableTy public @NonNull Data serialize() { return new Data.Builder().putString(KEY_ADDRESS, address) .putBoolean(KEY_FORCE_SYNC, forceSync) + .putString(KEY_RECIPIENT, recipient) .build(); } @@ -120,12 +142,15 @@ public class MultiDeviceContactUpdateJob extends BaseJob implements InjectableTy } if (address == null) generateFullContactUpdate(); - else generateSingleContactUpdate(Address.fromSerialized(address)); + else if (!address.equals(TextSecurePreferences.getMasterHexEncodedPublicKey(context))) generateSingleContactUpdate(Address.fromSerialized(address)); } private void generateSingleContactUpdate(@NonNull Address address) throws IOException, UntrustedIdentityException, NetworkException { + // Loki - Only sync regular contacts + if (!address.isPhone()) { return; } + File contactDataFile = createTempFile("multidevice-contact-update"); try { @@ -134,16 +159,19 @@ public class MultiDeviceContactUpdateJob extends BaseJob implements InjectableTy Optional identityRecord = DatabaseFactory.getIdentityDatabase(context).getIdentity(address); Optional verifiedMessage = getVerifiedMessage(recipient, identityRecord); - out.write(new DeviceContact(address.toPhoneString(), - Optional.fromNullable(recipient.getName()), - getAvatar(recipient.getContactUri()), - Optional.fromNullable(recipient.getColor().serialize()), - verifiedMessage, - Optional.fromNullable(recipient.getProfileKey()), - recipient.isBlocked(), - recipient.getExpireMessages() > 0 ? - Optional.of(recipient.getExpireMessages()) : - Optional.absent())); + // Loki - Only sync contacts we are friends with + if (getFriendRequestStatus(recipient) == LokiThreadFriendRequestStatus.FRIENDS) { + out.write(new DeviceContact(address.toPhoneString(), + Optional.fromNullable(recipient.getName()), + getAvatar(recipient.getContactUri()), + Optional.fromNullable(recipient.getColor().serialize()), + verifiedMessage, + Optional.fromNullable(recipient.getProfileKey()), + recipient.isBlocked(), + recipient.getExpireMessages() > 0 ? + Optional.of(recipient.getExpireMessages()) : + Optional.absent())); + } out.close(); sendUpdate(messageSender, contactDataFile, false); @@ -158,12 +186,6 @@ public class MultiDeviceContactUpdateJob extends BaseJob implements InjectableTy private void generateFullContactUpdate() throws IOException, UntrustedIdentityException, NetworkException { - /* Loki - Disabled - if (!Permissions.hasAny(context, Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) { - Log.w(TAG, "No contact permissions, skipping multi-device contact update..."); - return; - } - boolean isAppVisible = ApplicationContext.getInstance(context).isAppVisible(); long timeSinceLastSync = System.currentTimeMillis() - TextSecurePreferences.getLastFullContactSyncTime(context); @@ -181,8 +203,8 @@ public class MultiDeviceContactUpdateJob extends BaseJob implements InjectableTy File contactDataFile = createTempFile("multidevice-contact-update"); try { - DeviceContactsOutputStream out = new DeviceContactsOutputStream(new FileOutputStream(contactDataFile)); - Collection contacts = ContactAccessor.getInstance().getContactsWithPush(context); + DeviceContactsOutputStream out = new DeviceContactsOutputStream(new FileOutputStream(contactDataFile)); + List contacts = getAllContacts(); for (ContactData contactData : contacts) { Uri contactUri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_URI, String.valueOf(contactData.id)); @@ -196,7 +218,10 @@ public class MultiDeviceContactUpdateJob extends BaseJob implements InjectableTy boolean blocked = recipient.isBlocked(); Optional expireTimer = recipient.getExpireMessages() > 0 ? Optional.of(recipient.getExpireMessages()) : Optional.absent(); - out.write(new DeviceContact(address.toPhoneString(), name, getAvatar(contactUri), color, verified, profileKey, blocked, expireTimer)); + // Loki - Only sync contacts we are friends with + if (getFriendRequestStatus(recipient) == LokiThreadFriendRequestStatus.FRIENDS) { + out.write(new DeviceContact(address.toPhoneString(), name, getAvatar(contactUri), color, verified, profileKey, blocked, expireTimer)); + } } if (ProfileKeyUtil.hasProfileKey(context)) { @@ -215,12 +240,31 @@ public class MultiDeviceContactUpdateJob extends BaseJob implements InjectableTy } finally { if (contactDataFile != null) contactDataFile.delete(); } - */ + } + + private List getAllContacts() { + List
contactAddresses = DatabaseFactory.getRecipientDatabase(context).getRegistered(); + List contacts = new ArrayList<>(contactAddresses.size()); + for (Address address : contactAddresses) { + if (!address.isPhone()) { continue; } + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(Recipient.from(context, address, false)); + String name = DatabaseFactory.getLokiUserDatabase(context).getDisplayName(address.serialize()); + ContactData contactData = new ContactData(threadId, name); + contactData.numbers.add(new ContactAccessor.NumberData("TextSecure", address.serialize())); + contacts.add(contactData); + } + return contacts; + } + + private LokiThreadFriendRequestStatus getFriendRequestStatus(Recipient recipient) { + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdIfExistsFor(recipient); + return DatabaseFactory.getLokiThreadDatabase(context).getFriendRequestStatus(threadId); } @Override public boolean onShouldRetry(@NonNull Exception exception) { - if (exception instanceof PushNetworkException) return true; + // Loki - Disabled because we have our own retrying + // if (exception instanceof PushNetworkException) return true; return false; } @@ -240,10 +284,10 @@ public class MultiDeviceContactUpdateJob extends BaseJob implements InjectableTy .withLength(contactsFile.length()) .build(); + SignalServiceAddress messageRecipient = recipient != null ? new SignalServiceAddress(recipient) : null; + try { - // TODO: Message ID - messageSender.sendMessage(0, SignalServiceSyncMessage.forContacts(new ContactsMessage(attachmentStream, complete)), - UnidentifiedAccessUtil.getAccessForSync(context)); + messageSender.sendMessage(0, SignalServiceSyncMessage.forContacts(new ContactsMessage(attachmentStream, complete)), messageRecipient); } catch (IOException ioe) { throw new NetworkException(ioe); } @@ -251,6 +295,9 @@ public class MultiDeviceContactUpdateJob extends BaseJob implements InjectableTy } private Optional getAvatar(@Nullable Uri uri) throws IOException { + return Optional.absent(); + + /* Loki - Disabled until we support custom avatars. This will need to be reworked if (uri == null) { return Optional.absent(); } @@ -304,6 +351,7 @@ public class MultiDeviceContactUpdateJob extends BaseJob implements InjectableTy cursor.close(); } } + */ } private Optional getVerifiedMessage(Recipient recipient, Optional identity) throws InvalidNumberException { @@ -344,7 +392,10 @@ public class MultiDeviceContactUpdateJob extends BaseJob implements InjectableTy String serialized = data.getString(KEY_ADDRESS); Address address = serialized != null ? Address.fromSerialized(serialized) : null; - return new MultiDeviceContactUpdateJob(parameters, address, data.getBoolean(KEY_FORCE_SYNC)); + String recipientSerialized = data.getString(KEY_RECIPIENT); + Address recipient = recipientSerialized != null ? Address.fromSerialized(recipientSerialized) : null; + + return new MultiDeviceContactUpdateJob(parameters, recipient, address, data.getBoolean(KEY_FORCE_SYNC)); } } } diff --git a/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java b/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java index 625efd9455..ff57377e16 100644 --- a/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java +++ b/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java @@ -14,6 +14,7 @@ import android.util.Pair; import com.annimon.stream.Collectors; import com.annimon.stream.Stream; +import com.google.android.gms.common.util.IOUtils; import org.signal.libsignal.metadata.InvalidMetadataMessageException; import org.signal.libsignal.metadata.InvalidMetadataVersionException; @@ -66,12 +67,13 @@ import org.thoughtcrime.securesms.linkpreview.Link; import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil; import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.loki.FriendRequestHandler; import org.thoughtcrime.securesms.loki.LokiAPIUtilities; import org.thoughtcrime.securesms.loki.LokiMessageDatabase; import org.thoughtcrime.securesms.loki.LokiPreKeyBundleDatabase; import org.thoughtcrime.securesms.loki.LokiPreKeyRecordDatabase; import org.thoughtcrime.securesms.loki.LokiThreadDatabase; -import org.thoughtcrime.securesms.loki.MultiDeviceUtilitiesKt; +import org.thoughtcrime.securesms.loki.MultiDeviceUtilities; import org.thoughtcrime.securesms.mms.IncomingMediaMessage; import org.thoughtcrime.securesms.mms.MmsException; import org.thoughtcrime.securesms.mms.OutgoingExpirationUpdateMessage; @@ -87,6 +89,7 @@ import org.thoughtcrime.securesms.service.WebRtcCallService; import org.thoughtcrime.securesms.sms.IncomingEncryptedMessage; import org.thoughtcrime.securesms.sms.IncomingEndSessionMessage; import org.thoughtcrime.securesms.sms.IncomingTextMessage; +import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.sms.OutgoingEncryptedMessage; import org.thoughtcrime.securesms.sms.OutgoingEndSessionMessage; import org.thoughtcrime.securesms.sms.OutgoingTextMessage; @@ -114,6 +117,9 @@ import org.whispersystems.signalservice.api.messages.calls.HangupMessage; import org.whispersystems.signalservice.api.messages.calls.IceUpdateMessage; import org.whispersystems.signalservice.api.messages.calls.OfferMessage; import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; +import org.whispersystems.signalservice.api.messages.multidevice.ContactsMessage; +import org.whispersystems.signalservice.api.messages.multidevice.DeviceContact; +import org.whispersystems.signalservice.api.messages.multidevice.DeviceContactsInputStream; import org.whispersystems.signalservice.api.messages.multidevice.ReadMessage; import org.whispersystems.signalservice.api.messages.multidevice.RequestMessage; import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage; @@ -131,7 +137,10 @@ import org.whispersystems.signalservice.loki.messaging.LokiMessageFriendRequestS import org.whispersystems.signalservice.loki.messaging.LokiServiceMessage; import org.whispersystems.signalservice.loki.messaging.LokiThreadFriendRequestStatus; import org.whispersystems.signalservice.loki.messaging.LokiThreadSessionResetStatus; +import org.whispersystems.signalservice.loki.utilities.PromiseUtil; +import java.io.IOException; +import java.io.InputStream; import java.security.MessageDigest; import java.security.SecureRandom; import java.util.ArrayList; @@ -303,6 +312,12 @@ public class PushDecryptJob extends BaseJob implements InjectableType { Optional rawSenderDisplayName = content.senderDisplayName; if (rawSenderDisplayName.isPresent() && rawSenderDisplayName.get().length() > 0) { setDisplayName(envelope.getSource(), rawSenderDisplayName.get()); + + // If we got a name from our primary device then we also set that + String ourPrimaryDevice = TextSecurePreferences.getMasterHexEncodedPublicKey(context); + if (ourPrimaryDevice != null && envelope.getSource().equals(ourPrimaryDevice)) { + TextSecurePreferences.setProfileName(context, rawSenderDisplayName.get()); + } } // TODO: Deleting the display name @@ -343,6 +358,7 @@ public class PushDecryptJob extends BaseJob implements InjectableType { else if (syncMessage.getRead().isPresent()) handleSynchronizeReadMessage(syncMessage.getRead().get(), content.getTimestamp()); else if (syncMessage.getVerified().isPresent()) handleSynchronizeVerifiedMessage(syncMessage.getVerified().get()); else if (syncMessage.getStickerPackOperations().isPresent()) handleSynchronizeStickerPackOperation(syncMessage.getStickerPackOperations().get()); + else if (syncMessage.getContacts().isPresent()) handleSynchronizeContactMessage(syncMessage.getContacts().get()); else Log.w(TAG, "Contains no known sync types..."); } else if (content.getCallMessage().isPresent()) { Log.i(TAG, "Got call message..."); @@ -516,7 +532,7 @@ public class PushDecryptJob extends BaseJob implements InjectableType { Log.d("Loki", "Sending a ping back to " + content.getSender() + "."); String contactID = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId).getAddress().toString(); - sendBackgroundMessage(contactID); + MessageSender.sendBackgroundMessage(context, contactID); SecurityEvent.broadcastSecurityUpdateEvent(context); MessageNotifier.updateNotification(context, threadId); @@ -632,6 +648,48 @@ public class PushDecryptJob extends BaseJob implements InjectableType { } } + private void handleSynchronizeContactMessage(@NonNull ContactsMessage contactsMessage) { + if (contactsMessage.getContactsStream().isStream()) { + Log.d("Loki", "Received contact sync message"); + + try { + InputStream in = contactsMessage.getContactsStream().asStream().getInputStream(); + DeviceContactsInputStream contactsInputStream = new DeviceContactsInputStream(in); + List devices = contactsInputStream.readAll(); + for (DeviceContact deviceContact : devices) { + // Check if we have the contact as a friend and that we're not trying to sync our own device + String pubKey = deviceContact.getNumber(); + Address address = Address.fromSerialized(pubKey); + if (!address.isPhone() || address.toPhoneString().equals(TextSecurePreferences.getLocalNumber(context))) { continue; } + + /* + If we're not friends with the contact we received or our friend request expired then we should send them a friend request + otherwise if we have received a friend request with from them then we should automatically accept the friend request + */ + Recipient recipient = Recipient.from(context, address, false); + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient); + LokiThreadFriendRequestStatus status = DatabaseFactory.getLokiThreadDatabase(context).getFriendRequestStatus(threadId); + if (status == LokiThreadFriendRequestStatus.NONE || status == LokiThreadFriendRequestStatus.REQUEST_EXPIRED) { + MessageSender.sendBackgroundFriendRequest(context, pubKey, "Please accept to enable messages to be synced across devices"); + Log.d("Loki", "Sent friend request to " + pubKey); + } else if (status == LokiThreadFriendRequestStatus.REQUEST_RECEIVED) { + // Accept the incoming friend request + becomeFriendsWithContact(pubKey, false); + // Send them an accept message back + MessageSender.sendBackgroundMessage(context, pubKey); + Log.d("Loki", "Became friends with " + deviceContact.getNumber()); + } + + // TODO: Handle blocked - If user is not blocked then we should do the friend request logic otherwise add them to our block list + // TODO: Handle expiration timer - Update expiration timer? + // TODO: Handle avatar - Download and set avatar? + } + } catch (Exception e) { + Log.d("Loki", "Failed to sync contact: " + e); + } + } + } + private void handleSynchronizeSentMessage(@NonNull SignalServiceContent content, @NonNull SentTranscriptMessage message) throws StorageFailedException @@ -749,13 +807,19 @@ public class PushDecryptJob extends BaseJob implements InjectableType { @NonNull Optional messageServerIDOrNull) throws StorageFailedException { - notifyTypingStoppedFromIncomingMessage(getMessageDestination(content, message), content.getSender(), content.getSenderDevice()); + Recipient originalRecipient = getMessageDestination(content, message); + Recipient primaryDeviceRecipient = getMessagePrimaryDestination(content, message); + + notifyTypingStoppedFromIncomingMessage(primaryDeviceRecipient, content.getSender(), content.getSenderDevice()); Optional quote = getValidatedQuote(message.getQuote()); Optional> sharedContacts = getContacts(message.getSharedContacts()); Optional> linkPreviews = getLinkPreviews(message.getPreviews(), message.getBody().or("")); Optional sticker = getStickerAttachment(message.getSticker()); - IncomingMediaMessage mediaMessage = new IncomingMediaMessage(Address.fromExternal(context, content.getSender()), message.getTimestamp(), -1, + + // If message is from group then we need to map it to the correct sender + Address sender = message.isGroupUpdate() ? Address.fromSerialized(content.getSender()) : primaryDeviceRecipient.getAddress(); + IncomingMediaMessage mediaMessage = new IncomingMediaMessage(sender, message.getTimestamp(), -1, message.getExpiresInSeconds() * 1000L, false, content.isNeedsReceipt(), message.getBody(), message.getGroupInfo(), message.getAttachments(), quote, sharedContacts, linkPreviews, sticker); @@ -798,14 +862,20 @@ public class PushDecryptJob extends BaseJob implements InjectableType { // Loki - Store message server ID updateGroupChatMessageServerID(messageServerIDOrNull, insertResult); + // Loki - Update mapping of message to original thread id if (insertResult.isPresent()) { MessageNotifier.updateNotification(context, insertResult.get().getThreadId()); + + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); + LokiMessageDatabase lokiMessageDatabase = DatabaseFactory.getLokiMessageDatabase(context); + long originalThreadId = threadDatabase.getThreadIdFor(originalRecipient); + lokiMessageDatabase.setOriginalThreadID(insertResult.get().getMessageId(), originalThreadId); } } private long handleSynchronizeSentExpirationUpdate(@NonNull SentTranscriptMessage message) throws MmsException { - MmsDatabase database = DatabaseFactory.getMmsDatabase(context); - Recipient recipient = getSyncMessageDestination(message); + MmsDatabase database = DatabaseFactory.getMmsDatabase(context); + Recipient recipient = getSyncMessagePrimaryDestination(message); OutgoingExpirationUpdateMessage expirationUpdateMessage = new OutgoingExpirationUpdateMessage(recipient, message.getTimestamp(), @@ -821,11 +891,11 @@ public class PushDecryptJob extends BaseJob implements InjectableType { return threadId; } - private long handleSynchronizeSentMediaMessage(@NonNull SentTranscriptMessage message) + public long handleSynchronizeSentMediaMessage(@NonNull SentTranscriptMessage message) throws MmsException { MmsDatabase database = DatabaseFactory.getMmsDatabase(context); - Recipient recipients = getSyncMessageDestination(message); + Recipient recipients = getSyncMessagePrimaryDestination(message); Optional quote = getValidatedQuote(message.getMessage().getQuote()); Optional sticker = getStickerAttachment(message.getMessage().getSticker()); Optional> sharedContacts = getContacts(message.getMessage().getSharedContacts()); @@ -857,6 +927,7 @@ public class PushDecryptJob extends BaseJob implements InjectableType { try { long messageId = database.insertMessageOutbox(mediaMessage, threadId, false, null); + if (message.messageServerID >= 0) { DatabaseFactory.getLokiMessageDatabase(context).setServerID(messageId, message.messageServerID); } if (recipients.getAddress().isGroup()) { GroupReceiptDatabase receiptDatabase = DatabaseFactory.getGroupReceiptDatabase(context); @@ -912,20 +983,23 @@ public class PushDecryptJob extends BaseJob implements InjectableType { { SmsDatabase database = DatabaseFactory.getSmsDatabase(context); String body = message.getBody().isPresent() ? message.getBody().get() : ""; - Recipient recipient = getMessageDestination(content, message); + Recipient originalRecipient = getMessageDestination(content, message); + Recipient primaryDeviceRecipient = getMessagePrimaryDestination(content, message); - if (message.getExpiresInSeconds() != recipient.getExpireMessages()) { + if (message.getExpiresInSeconds() != originalRecipient.getExpireMessages()) { handleExpirationUpdate(content, message, Optional.absent()); } - Long threadId; + Long threadId = null; if (smsMessageId.isPresent() && !message.getGroupInfo().isPresent()) { threadId = database.updateBundleMessageBody(smsMessageId.get(), body).second; } else { - notifyTypingStoppedFromIncomingMessage(recipient, content.getSender(), content.getSenderDevice()); + notifyTypingStoppedFromIncomingMessage(primaryDeviceRecipient, content.getSender(), content.getSenderDevice()); - IncomingTextMessage _textMessage = new IncomingTextMessage(Address.fromSerialized(content.getSender()), + // If message is from group then we need to map it to the correct sender + Address sender = message.isGroupUpdate() ? Address.fromSerialized(content.getSender()) : primaryDeviceRecipient.getAddress(); + IncomingTextMessage _textMessage = new IncomingTextMessage(sender, content.getSenderDevice(), message.getTimestamp(), body, message.getGroupInfo(), @@ -940,8 +1014,11 @@ public class PushDecryptJob extends BaseJob implements InjectableType { // Insert the message into the database Optional insertResult = database.insertMessageInbox(textMessage); - if (insertResult.isPresent()) threadId = insertResult.get().getThreadId(); - else threadId = null; + Long messageId = null; + if (insertResult.isPresent()) { + threadId = insertResult.get().getThreadId(); + messageId = insertResult.get().getMessageId(); + } if (smsMessageId.isPresent()) database.deleteMessage(smsMessageId.get()); @@ -954,6 +1031,14 @@ public class PushDecryptJob extends BaseJob implements InjectableType { // Loki - Store message server ID updateGroupChatMessageServerID(messageServerIDOrNull, insertResult); + // Loki - Update mapping of message to original thread id + if (messageId != null) { + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); + LokiMessageDatabase lokiMessageDatabase = DatabaseFactory.getLokiMessageDatabase(context); + long originalThreadId = threadDatabase.getThreadIdFor(originalRecipient); + lokiMessageDatabase.setOriginalThreadID(messageId, originalThreadId); + } + boolean isGroupMessage = message.getGroupInfo().isPresent(); if (threadId != null && !isGroupMessage) { MessageNotifier.updateNotification(context, threadId); @@ -984,13 +1069,13 @@ public class PushDecryptJob extends BaseJob implements InjectableType { private void handlePairingMessage(@NonNull PairingAuthorisation authorisation, @NonNull SignalServiceEnvelope envelope, @NonNull SignalServiceContent content) { String userHexEncodedPublicKey = TextSecurePreferences.getLocalNumber(context); if (authorisation.getType() == PairingAuthorisation.Type.REQUEST) { - handlePairingRequestMessage(authorisation, envelope); + handlePairingRequestMessage(authorisation); } else if (authorisation.getSecondaryDevicePublicKey().equals(userHexEncodedPublicKey)) { handlePairingAuthorisationMessage(authorisation, envelope, content); } } - private void handlePairingRequestMessage(@NonNull PairingAuthorisation authorisation, @NonNull SignalServiceEnvelope envelope) { + private void handlePairingRequestMessage(@NonNull PairingAuthorisation authorisation) { boolean isValid = isValidPairingMessage(authorisation); DeviceLinkingSession linkingSession = DeviceLinkingSession.Companion.getShared(); if (isValid && linkingSession.isListeningForLinkingRequests()) { @@ -1023,8 +1108,9 @@ public class PushDecryptJob extends BaseJob implements InjectableType { DatabaseFactory.getLokiAPIDatabase(context).removePairingAuthorisations(userHexEncodedPublicKey); DatabaseFactory.getLokiAPIDatabase(context).insertOrUpdatePairingAuthorisation(authorisation); TextSecurePreferences.setMasterHexEncodedPublicKey(context, authorisation.getPrimaryDevicePublicKey()); + TextSecurePreferences.setMultiDevice(context, true); // Send a background message to the primary device - sendBackgroundMessage(authorisation.getPrimaryDevicePublicKey()); + MessageSender.sendBackgroundMessage(context, authorisation.getPrimaryDevicePublicKey()); // Propagate the updates to the file server LokiStorageAPI storageAPI = LokiStorageAPI.Companion.getShared(); storageAPI.updateUserDeviceMappings(); @@ -1032,6 +1118,11 @@ public class PushDecryptJob extends BaseJob implements InjectableType { if (content.senderDisplayName.isPresent() && content.senderDisplayName.get().length() > 0) { setDisplayName(envelope.getSource(), content.senderDisplayName.get()); } + + // Contact sync + if (content.getSyncMessage().isPresent() && content.getSyncMessage().get().getContacts().isPresent()) { + handleSynchronizeContactMessage(content.getSyncMessage().get().getContacts().get()); + } } private void setDisplayName(String hexEncodedPublicKey, String profileName) { @@ -1049,103 +1140,94 @@ public class PushDecryptJob extends BaseJob implements InjectableType { private void acceptFriendRequestIfNeeded(@NonNull SignalServiceEnvelope envelope, @NonNull SignalServiceContent content) { // If we get anything other than a friend request, we can assume that we have a session with the other user - if (envelope.isFriendRequest()) { return; } - becomeFriendsWithContact(content.getSender()); + if (envelope.isFriendRequest() || isGroupChatMessage(content)) { return; } + becomeFriendsWithContact(content.getSender(), true); } - private void becomeFriendsWithContact(String pubKey) { + private void becomeFriendsWithContact(String pubKey, boolean syncContact) { LokiThreadDatabase lokiThreadDatabase = DatabaseFactory.getLokiThreadDatabase(context); Recipient contactID = Recipient.from(context, Address.fromSerialized(pubKey), false); + if (contactID.isGroupRecipient()) return; + long threadID = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(contactID); LokiThreadFriendRequestStatus threadFriendRequestStatus = lokiThreadDatabase.getFriendRequestStatus(threadID); if (threadFriendRequestStatus == LokiThreadFriendRequestStatus.FRIENDS) { return; } // If the thread's friend request status is not `FRIENDS`, but we're receiving a message, // it must be a friend request accepted message. Declining a friend request doesn't send a message. lokiThreadDatabase.setFriendRequestStatus(threadID, LokiThreadFriendRequestStatus.FRIENDS); - // Update the last message if needed - SmsDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context); - LokiMessageDatabase lokiMessageDatabase = DatabaseFactory.getLokiMessageDatabase(context); - int messageCount = smsDatabase.getMessageCountForThread(threadID); - long messageID = smsDatabase.getIDForMessageAtIndex(threadID, messageCount - 1); - if (messageID > -1 && lokiMessageDatabase.getFriendRequestStatus(messageID) != LokiMessageFriendRequestStatus.REQUEST_ACCEPTED) { - lokiMessageDatabase.setFriendRequestStatus(messageID, LokiMessageFriendRequestStatus.REQUEST_ACCEPTED); + // Send out a contact sync message + if (syncContact) { + MessageSender.syncContact(context, contactID.getAddress()); } - } - - private void updateFriendRequestStatusIfNeeded(@NonNull SignalServiceEnvelope envelope, @NonNull SignalServiceContent content, @NonNull SignalServiceDataMessage message) { - if (!envelope.isFriendRequest()) { return; } - // This handles the case where another user sends us a regular message without authorisation - MultiDeviceUtilitiesKt.shouldAutomaticallyBecomeFriendsWithDevice(content.getSender(), context).success(becomeFriends -> { - if (becomeFriends) { - // Become friends AND update the message they sent - becomeFriendsWithContact(content.getSender()); - // Send them an accept message back - sendBackgroundMessage(content.getSender()); - } else { - // Do regular friend request logic checks - Recipient contactID = getMessageDestination(content, message); - LokiThreadDatabase lokiThreadDatabase = DatabaseFactory.getLokiThreadDatabase(context); - long threadID = DatabaseFactory.getThreadDatabase(context).getThreadIdIfExistsFor(contactID); - LokiThreadFriendRequestStatus threadFriendRequestStatus = lokiThreadDatabase.getFriendRequestStatus(threadID); - SmsDatabase smsMessageDatabase = DatabaseFactory.getSmsDatabase(context); - MmsDatabase mmsMessageDatabase = DatabaseFactory.getMmsDatabase(context); - LokiMessageDatabase lokiMessageDatabase= DatabaseFactory.getLokiMessageDatabase(context); - int messageCount = smsMessageDatabase.getMessageCountForThread(threadID); - if (threadFriendRequestStatus == LokiThreadFriendRequestStatus.REQUEST_SENT) { - // This can happen if Alice sent Bob a friend request, Bob declined, but then Bob changed his - // mind and sent a friend request to Alice. In this case we want Alice to auto-accept the request - // and send a friend request accepted message back to Bob. We don't check that sending the - // friend request accepted message succeeded. Even if it doesn't, the thread's current friend - // request status will be set to `FRIENDS` for Alice making it possible - // for Alice to send messages to Bob. When Bob receives a message, his thread's friend request status - // will then be set to `FRIENDS`. If we do check for a successful send - // before updating Alice's thread's friend request status to `FRIENDS`, - // we can end up in a deadlock where both users' threads' friend request statuses are - // `REQUEST_SENT`. - lokiThreadDatabase.setFriendRequestStatus(threadID, LokiThreadFriendRequestStatus.FRIENDS); - long messageID = smsMessageDatabase.getIDForMessageAtIndex(threadID, messageCount - 2); // The message before the one that was just received - // TODO: MMS - lokiMessageDatabase.setFriendRequestStatus(messageID, LokiMessageFriendRequestStatus.REQUEST_ACCEPTED); - // Accept the friend request - sendBackgroundMessage(content.getSender()); - } else if (threadFriendRequestStatus != LokiThreadFriendRequestStatus.FRIENDS) { - // Checking that the sender of the message isn't already a friend is necessary because otherwise - // the following situation can occur: Alice and Bob are friends. Bob loses his database and his - // friend request status is reset to `NONE`. Bob now sends Alice a friend - // request. Alice's thread's friend request status is reset to - // `REQUEST_RECEIVED`. - lokiThreadDatabase.setFriendRequestStatus(threadID, LokiThreadFriendRequestStatus.REQUEST_RECEIVED); - long messageID = smsMessageDatabase.getIDForMessageAtIndex(threadID, messageCount - 1); // The message that was just received - if (messageID != -1) { - lokiMessageDatabase.setFriendRequestStatus(messageID, LokiMessageFriendRequestStatus.REQUEST_PENDING); - } else { - // TODO: The code below is ugly due to Java limitations - lokiMessageDatabase.setFriendRequestStatus(mmsMessageDatabase.getIDForMessageAtIndex(threadID, 0), LokiMessageFriendRequestStatus.REQUEST_PENDING); - } - } - } + // Update the last message if needed + LokiStorageAPI.shared.getPrimaryDevicePublicKey(pubKey).success(primaryDevice -> { + Util.runOnMain(() -> { + long primaryDeviceThreadID = primaryDevice == null ? threadID : DatabaseFactory.getThreadDatabase(context).getThreadIdFor(Recipient.from(context, Address.fromSerialized(primaryDevice), false)); + FriendRequestHandler.updateLastFriendRequestMessage(context, primaryDeviceThreadID, LokiMessageFriendRequestStatus.REQUEST_ACCEPTED); + }); return Unit.INSTANCE; }); } - private void sendBackgroundMessage(String contactHexEncodedPublicKey) { - Util.runOnMain(() -> { - SignalServiceMessageSender messageSender = ApplicationContext.getInstance(context).communicationModule.provideSignalMessageSender(); - SignalServiceAddress address = new SignalServiceAddress(contactHexEncodedPublicKey); - SignalServiceDataMessage message = new SignalServiceDataMessage(System.currentTimeMillis(), ""); - try { - messageSender.sendMessage(0, address, Optional.absent(), message); // The message ID doesn't matter - } catch (Exception e) { - Log.d("Loki", "Failed to send background message to: " + contactHexEncodedPublicKey + "."); + private void updateFriendRequestStatusIfNeeded(@NonNull SignalServiceEnvelope envelope, @NonNull SignalServiceContent content, @NonNull SignalServiceDataMessage message) { + if (!envelope.isFriendRequest() || message.isGroupUpdate()) { return; } + // This handles the case where another user sends us a regular message without authorisation + boolean shouldBecomeFriends = PromiseUtil.get(MultiDeviceUtilities.shouldAutomaticallyBecomeFriendsWithDevice(content.getSender(), context), false); + if (shouldBecomeFriends) { + // Become friends AND update the message they sent + becomeFriendsWithContact(content.getSender(), true); + // Send them an accept message back + MessageSender.sendBackgroundMessage(context, content.getSender()); + } else { + // Do regular friend request logic checks + Recipient originalRecipient = getMessageDestination(content, message); + Recipient primaryDeviceRecipient = getMessagePrimaryDestination(content, message); + LokiThreadDatabase lokiThreadDatabase = DatabaseFactory.getLokiThreadDatabase(context); + + // Loki - Friend requests only work in direct chats + if (!originalRecipient.getAddress().isPhone()) { return; } + + long threadID = DatabaseFactory.getThreadDatabase(context).getThreadIdIfExistsFor(originalRecipient); + long primaryDeviceThreadID = DatabaseFactory.getThreadDatabase(context).getThreadIdIfExistsFor(primaryDeviceRecipient); + LokiThreadFriendRequestStatus threadFriendRequestStatus = lokiThreadDatabase.getFriendRequestStatus(threadID); + + if (threadFriendRequestStatus == LokiThreadFriendRequestStatus.REQUEST_SENT) { + // This can happen if Alice sent Bob a friend request, Bob declined, but then Bob changed his + // mind and sent a friend request to Alice. In this case we want Alice to auto-accept the request + // and send a friend request accepted message back to Bob. We don't check that sending the + // friend request accepted message succeeded. Even if it doesn't, the thread's current friend + // request status will be set to `FRIENDS` for Alice making it possible + // for Alice to send messages to Bob. When Bob receives a message, his thread's friend request status + // will then be set to `FRIENDS`. If we do check for a successful send + // before updating Alice's thread's friend request status to `FRIENDS`, + // we can end up in a deadlock where both users' threads' friend request statuses are + // `REQUEST_SENT`. + lokiThreadDatabase.setFriendRequestStatus(threadID, LokiThreadFriendRequestStatus.FRIENDS); + // Since messages are forwarded to the primary device thread, we need to update it there + FriendRequestHandler.updateLastFriendRequestMessage(context, primaryDeviceThreadID, LokiMessageFriendRequestStatus.REQUEST_ACCEPTED); + // Accept the friend request + MessageSender.sendBackgroundMessage(context, content.getSender()); + // Send contact sync message + MessageSender.syncContact(context, originalRecipient.getAddress()); + } else if (threadFriendRequestStatus != LokiThreadFriendRequestStatus.FRIENDS) { + // Checking that the sender of the message isn't already a friend is necessary because otherwise + // the following situation can occur: Alice and Bob are friends. Bob loses his database and his + // friend request status is reset to `NONE`. Bob now sends Alice a friend + // request. Alice's thread's friend request status is reset to + // `REQUEST_RECEIVED`. + lokiThreadDatabase.setFriendRequestStatus(threadID, LokiThreadFriendRequestStatus.REQUEST_RECEIVED); + + // Since messages are forwarded to the primary device thread, we need to update it there + FriendRequestHandler.receivedIncomingFriendRequestMessage(context, primaryDeviceThreadID); } - }); + } } - private long handleSynchronizeSentTextMessage(@NonNull SentTranscriptMessage message) + public long handleSynchronizeSentTextMessage(@NonNull SentTranscriptMessage message) throws MmsException { - Recipient recipient = getSyncMessageDestination(message); + Recipient recipient = getSyncMessagePrimaryDestination(message); String body = message.getMessage().getBody().or(""); long expiresInMillis = message.getMessage().getExpiresInSeconds() * 1000L; @@ -1164,6 +1246,8 @@ public class PushDecryptJob extends BaseJob implements InjectableType { outgoingMediaMessage = new OutgoingSecureMediaMessage(outgoingMediaMessage); messageId = DatabaseFactory.getMmsDatabase(context).insertMessageOutbox(outgoingMediaMessage, threadId, false, null); + if (message.messageServerID >= 0) { DatabaseFactory.getLokiMessageDatabase(context).setServerID(messageId, message.messageServerID); } + database = DatabaseFactory.getMmsDatabase(context); GroupReceiptDatabase receiptDatabase = DatabaseFactory.getGroupReceiptDatabase(context); @@ -1308,10 +1392,17 @@ public class PushDecryptJob extends BaseJob implements InjectableType { private void handleDeliveryReceipt(@NonNull SignalServiceContent content, @NonNull SignalServiceReceiptMessage message) { + // Redirect message to primary device conversation + Address sender = Address.fromSerialized(content.getSender()); + if (sender.isPhone()) { + Recipient primaryDevice = getPrimaryDeviceRecipient(content.getSender()); + sender = primaryDevice.getAddress(); + } + for (long timestamp : message.getTimestamps()) { Log.i(TAG, String.format("Received encrypted delivery receipt: (XXXXX, %d)", timestamp)); DatabaseFactory.getMmsSmsDatabase(context) - .incrementDeliveryReceiptCount(new SyncMessageId(Address.fromSerialized(content.getSender()), timestamp), System.currentTimeMillis()); + .incrementDeliveryReceiptCount(new SyncMessageId(sender, timestamp), System.currentTimeMillis()); } } @@ -1320,11 +1411,19 @@ public class PushDecryptJob extends BaseJob implements InjectableType { @NonNull SignalServiceReceiptMessage message) { if (TextSecurePreferences.isReadReceiptsEnabled(context)) { + + // Redirect message to primary device conversation + Address sender = Address.fromSerialized(content.getSender()); + if (sender.isPhone()) { + Recipient primaryDevice = getPrimaryDeviceRecipient(content.getSender()); + sender = primaryDevice.getAddress(); + } + for (long timestamp : message.getTimestamps()) { Log.i(TAG, String.format("Received encrypted read receipt: (XXXXX, %d)", timestamp)); DatabaseFactory.getMmsSmsDatabase(context) - .incrementReadReceiptCount(new SyncMessageId(Address.fromSerialized(content.getSender()), timestamp), content.getTimestamp()); + .incrementReadReceiptCount(new SyncMessageId(sender, timestamp), content.getTimestamp()); } } } @@ -1346,6 +1445,8 @@ public class PushDecryptJob extends BaseJob implements InjectableType { threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient); } else { + // See if we need to redirect the message + author = getPrimaryDeviceRecipient(content.getSender()); threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(author); } @@ -1479,8 +1580,9 @@ public class PushDecryptJob extends BaseJob implements InjectableType { } private Optional insertPlaceholder(@NonNull String sender, int senderDevice, long timestamp) { + Recipient primaryDevice = getPrimaryDeviceRecipient(sender); SmsDatabase database = DatabaseFactory.getSmsDatabase(context); - IncomingTextMessage textMessage = new IncomingTextMessage(Address.fromSerialized(sender), + IncomingTextMessage textMessage = new IncomingTextMessage(primaryDevice.getAddress(), senderDevice, timestamp, "", Optional.absent(), 0, false); @@ -1496,6 +1598,14 @@ public class PushDecryptJob extends BaseJob implements InjectableType { } } + private Recipient getSyncMessagePrimaryDestination(SentTranscriptMessage message) { + if (message.getMessage().getGroupInfo().isPresent()) { + return getSyncMessageDestination(message); + } else { + return getPrimaryDeviceRecipient(message.getDestination().get()); + } + } + private Recipient getMessageDestination(SignalServiceContent content, SignalServiceDataMessage message) { if (message.getGroupInfo().isPresent()) { return Recipient.from(context, Address.fromSerialized(GroupUtil.getEncodedId(message.getGroupInfo().get().getGroupId(), false)), false); @@ -1504,6 +1614,37 @@ public class PushDecryptJob extends BaseJob implements InjectableType { } } + private Recipient getMessagePrimaryDestination(SignalServiceContent content, SignalServiceDataMessage message) { + if (message.getGroupInfo().isPresent()) { + return getMessageDestination(content, message); + } else { + return getPrimaryDeviceRecipient(content.getSender()); + } + } + + /** + * Get the primary device recipient of the passed in device. + * + * If the device doesn't have a primary device then it will return the same device. + * If the device is our primary device then it will return our current device. + * Otherwise it will return the primary device. + */ + private Recipient getPrimaryDeviceRecipient(String pubKey) { + try { + String primaryDevice = LokiStorageAPI.shared.getPrimaryDevicePublicKey(pubKey).get(); + String publicKey = (primaryDevice != null) ? primaryDevice : pubKey; + // If the public key matches our primary device then we need to forward the message to ourselves (Note to self) + String ourPrimaryDevice = TextSecurePreferences.getMasterHexEncodedPublicKey(context); + if (ourPrimaryDevice != null && ourPrimaryDevice.equals(publicKey)) { + publicKey = TextSecurePreferences.getLocalNumber(context); + } + return Recipient.from(context, Address.fromSerialized(publicKey), false); + } catch (Exception e) { + Log.d("Loki", "Failed to get primary device public key for message. " + e.getMessage()); + return Recipient.from(context, Address.fromSerialized(pubKey), false); + } + } + private void notifyTypingStoppedFromIncomingMessage(@NonNull Recipient conversationRecipient, @NonNull String sender, int device) { Recipient author = Recipient.from(context, Address.fromSerialized(sender), false); long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(conversationRecipient); @@ -1522,7 +1663,9 @@ public class PushDecryptJob extends BaseJob implements InjectableType { Recipient sender = Recipient.from(context, Address.fromSerialized(content.getSender()), false); - if (content.getDataMessage().isPresent()) { + if (content.getPairingAuthorisation().isPresent()) { + return false; + } else if (content.getDataMessage().isPresent()) { SignalServiceDataMessage message = content.getDataMessage().get(); Recipient conversation = getMessageDestination(content, message); @@ -1550,11 +1693,26 @@ public class PushDecryptJob extends BaseJob implements InjectableType { } } else if (content.getCallMessage().isPresent() || content.getTypingMessage().isPresent()) { return sender.isBlocked(); + } else if (content.getSyncMessage().isPresent()) { + try { + // We should ignore a sync message if the sender is not one of our devices + boolean isOurDevice = MultiDeviceUtilities.isOneOfOurDevices(context, sender.getAddress()).get(); + if (!isOurDevice) { + Log.w(TAG, "Got a sync message from a device that is not ours!."); + } + return !isOurDevice; + } catch (Exception e) { + return true; + } } return false; } + private boolean isGroupChatMessage(SignalServiceContent content) { + return content.getDataMessage().isPresent() && content.getDataMessage().get().isGroupUpdate(); + } + private void resetRecipientToPush(@NonNull Recipient recipient) { if (recipient.isForceSmsSelection()) { DatabaseFactory.getRecipientDatabase(context).setForceSmsSelection(recipient, false); diff --git a/src/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java b/src/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java index 263dedc322..ae133a0394 100644 --- a/src/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java +++ b/src/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java @@ -22,6 +22,7 @@ import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.JobManager; import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.loki.MultiDeviceUtilities; import org.thoughtcrime.securesms.mms.MmsException; import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; import org.thoughtcrime.securesms.recipients.Recipient; @@ -42,9 +43,14 @@ import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSy import org.whispersystems.signalservice.api.messages.shared.SharedContact; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException; +import org.whispersystems.signalservice.loki.api.LokiStorageAPI; +import org.whispersystems.signalservice.loki.messaging.LokiSyncMessage; +import org.whispersystems.signalservice.loki.utilities.PromiseUtil; import java.io.FileNotFoundException; import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; import java.util.LinkedList; import java.util.List; @@ -61,6 +67,7 @@ public class PushMediaSendJob extends PushSendJob implements InjectableType { private static final String KEY_DESTINATION = "destination"; private static final String KEY_IS_FRIEND_REQUEST = "is_friend_request"; private static final String KEY_CUSTOM_FR_MESSAGE = "custom_friend_request_message"; + private static final String KEY_SHOULD_SEND_SYNC_MESSAGE = "should_send_sync_message"; @Inject SignalServiceMessageSender messageSender; @@ -71,53 +78,47 @@ public class PushMediaSendJob extends PushSendJob implements InjectableType { private Address destination; // Destination to check whether this is another device we're sending to private boolean isFriendRequest; // Whether this is a friend request message private String customFriendRequestMessage; // If this isn't set then we use the message body + private boolean shouldSendSyncMessage; public PushMediaSendJob(long messageId, Address destination) { this(messageId, messageId, destination); } - public PushMediaSendJob(long templateMessageId, long messageId, Address destination) { this(templateMessageId, messageId, destination, false, null); } - public PushMediaSendJob(long templateMessageId, long messageId, Address destination, boolean isFriendRequest, String customFriendRequestMessage) { - this(constructParameters(destination), templateMessageId, messageId, destination, isFriendRequest, customFriendRequestMessage); + public PushMediaSendJob(long templateMessageId, long messageId, Address destination) { this(templateMessageId, messageId, destination, false, null, false); } + public PushMediaSendJob(long templateMessageId, long messageId, Address destination, boolean isFriendRequest, String customFriendRequestMessage, boolean shouldSendSyncMessage) { + this(constructParameters(destination), templateMessageId, messageId, destination, isFriendRequest, customFriendRequestMessage, shouldSendSyncMessage); } - private PushMediaSendJob(@NonNull Job.Parameters parameters, long templateMessageId, long messageId, Address destination, boolean isFriendRequest, String customFriendRequestMessage) { + private PushMediaSendJob(@NonNull Job.Parameters parameters, long templateMessageId, long messageId, Address destination, boolean isFriendRequest, String customFriendRequestMessage, boolean shouldSendSyncMessage) { super(parameters); this.templateMessageId = templateMessageId; this.messageId = messageId; this.destination = destination; this.isFriendRequest = isFriendRequest; this.customFriendRequestMessage = customFriendRequestMessage; + this.shouldSendSyncMessage = shouldSendSyncMessage; + } + + public static void enqueue(@NonNull Context context, @NonNull JobManager jobManager, long messageId, @NonNull Address destination, boolean shouldSendSyncMessage) { + enqueue(context, jobManager, messageId, messageId, destination, false, null, shouldSendSyncMessage); + } + + public static void enqueue(@NonNull Context context, @NonNull JobManager jobManager, long templateMessageId, long messageId, @NonNull Address destination, Boolean isFriendRequest, @Nullable String customFriendRequestMessage, boolean shouldSendSyncMessage) { + enqueue(context, jobManager, Collections.singletonList(new PushMediaSendJob(templateMessageId, messageId, destination, isFriendRequest, customFriendRequestMessage, shouldSendSyncMessage))); } @WorkerThread - public static void enqueue(@NonNull Context context, @NonNull JobManager jobManager, long messageId, @NonNull Address destination) { - enqueue(context, jobManager, messageId, messageId, destination); - } - - @WorkerThread - public static void enqueue(@NonNull Context context, @NonNull JobManager jobManager, long templateMessageId, long messageId, @NonNull Address destination) { - enqueue(context, jobManager, templateMessageId, messageId, destination, false, null); - } - - @WorkerThread - public static void enqueue(@NonNull Context context, @NonNull JobManager jobManager, long templateMessageId, long messageId, @NonNull Address destination, Boolean isFriendRequest, @Nullable String customFriendRequestMessage) { + public static void enqueue(@NonNull Context context, @NonNull JobManager jobManager, List jobs) { + if (jobs.size() == 0) { return; } + PushMediaSendJob first = jobs.get(0); + long messageId = first.templateMessageId; try { - MmsDatabase database = DatabaseFactory.getMmsDatabase(context); - OutgoingMediaMessage message = database.getOutgoingMessage(messageId); - List attachments = new LinkedList<>(); - - attachments.addAll(message.getAttachments()); - attachments.addAll(Stream.of(message.getLinkPreviews()).filter(p -> p.getThumbnail().isPresent()).map(p -> p.getThumbnail().get()).toList()); - attachments.addAll(Stream.of(message.getSharedContacts()).filter(c -> c.getAvatar() != null).map(c -> c.getAvatar().getAttachment()).withoutNulls().toList()); - - List attachmentJobs = Stream.of(attachments).map(a -> new AttachmentUploadJob(((DatabaseAttachment) a).getAttachmentId(), destination)).toList(); + List attachmentJobs = getAttachmentUploadJobs(context, messageId, first.destination); if (attachmentJobs.isEmpty()) { - jobManager.add(new PushMediaSendJob(templateMessageId, messageId, destination, isFriendRequest, customFriendRequestMessage)); + for (PushMediaSendJob job : jobs) { jobManager.add(job); } } else { jobManager.startChain(attachmentJobs) - .then(new PushMediaSendJob(templateMessageId, messageId, destination, isFriendRequest, customFriendRequestMessage)) - .enqueue(); + .then((List)(List)jobs) + .enqueue(); } - } catch (NoSuchMessageException | MmsException e) { Log.w(TAG, "Failed to enqueue message.", e); DatabaseFactory.getMmsDatabase(context).markAsSentFailed(messageId); @@ -125,13 +126,28 @@ public class PushMediaSendJob extends PushSendJob implements InjectableType { } } + public static List getAttachmentUploadJobs(@NonNull Context context, long messageId, @NonNull Address destination) + throws NoSuchMessageException, MmsException + { + MmsDatabase database = DatabaseFactory.getMmsDatabase(context); + OutgoingMediaMessage message = database.getOutgoingMessage(messageId); + List attachments = new LinkedList<>(); + + attachments.addAll(message.getAttachments()); + attachments.addAll(Stream.of(message.getLinkPreviews()).filter(p -> p.getThumbnail().isPresent()).map(p -> p.getThumbnail().get()).toList()); + attachments.addAll(Stream.of(message.getSharedContacts()).filter(c -> c.getAvatar() != null).map(c -> c.getAvatar().getAttachment()).withoutNulls().toList()); + + return Stream.of(attachments).map(a -> new AttachmentUploadJob(((DatabaseAttachment) a).getAttachmentId(), destination)).toList(); + } + @Override public @NonNull Data serialize() { Data.Builder builder = new Data.Builder() - .putLong(KEY_TEMPLATE_MESSAGE_ID, templateMessageId) - .putLong(KEY_MESSAGE_ID, messageId) - .putString(KEY_DESTINATION, destination.serialize()) - .putBoolean(KEY_IS_FRIEND_REQUEST, isFriendRequest); + .putLong(KEY_TEMPLATE_MESSAGE_ID, templateMessageId) + .putLong(KEY_MESSAGE_ID, messageId) + .putString(KEY_DESTINATION, destination.serialize()) + .putBoolean(KEY_IS_FRIEND_REQUEST, isFriendRequest) + .putBoolean(KEY_SHOULD_SEND_SYNC_MESSAGE, shouldSendSyncMessage); if (customFriendRequestMessage != null) { builder.putString(KEY_CUSTOM_FR_MESSAGE, customFriendRequestMessage); } return builder.build(); @@ -211,8 +227,10 @@ public class PushMediaSendJob extends PushSendJob implements InjectableType { } } catch (UntrustedIdentityException uie) { warn(TAG, "Failure", uie); - database.addMismatchedIdentity(messageId, Address.fromSerialized(uie.getE164Number()), uie.getIdentityKey()); - database.markAsSentFailed(messageId); + if (messageId >= 0) { + database.addMismatchedIdentity(messageId, Address.fromSerialized(uie.getE164Number()), uie.getIdentityKey()); + database.markAsSentFailed(messageId); + } } } @@ -268,10 +286,18 @@ public class PushMediaSendJob extends PushSendJob implements InjectableType { Optional syncAccess = UnidentifiedAccessUtil.getAccessForSync(context); SignalServiceSyncMessage syncMessage = buildSelfSendSyncMessage(context, mediaMessage, syncAccess); - messageSender.sendMessage(messageId, syncMessage, syncAccess); + messageSender.sendMessage(templateMessageId, syncMessage, syncAccess); return syncAccess.isPresent(); } else { - return messageSender.sendMessage(messageId, address, UnidentifiedAccessUtil.getAccessFor(context, recipient), mediaMessage).getSuccess().isUnidentified(); + LokiSyncMessage syncMessage = null; + if (shouldSendSyncMessage) { + // Set the sync message destination the primary device, this way it will show that we sent a message to the primary device and not a secondary device + String primaryDevice = PromiseUtil.get(LokiStorageAPI.shared.getPrimaryDevicePublicKey(address.getNumber()), null); + SignalServiceAddress primaryAddress = primaryDevice == null ? address : new SignalServiceAddress(primaryDevice); + // We also need to use the original message id and not -1 + syncMessage = new LokiSyncMessage(primaryAddress, templateMessageId); + } + return messageSender.sendMessage(messageId, address, UnidentifiedAccessUtil.getAccessFor(context, recipient), mediaMessage, Optional.fromNullable(syncMessage)).getSuccess().isUnidentified(); } } catch (UnregisteredUserException e) { warn(TAG, e); @@ -292,8 +318,9 @@ public class PushMediaSendJob extends PushSendJob implements InjectableType { long messageID = data.getLong(KEY_MESSAGE_ID); Address destination = Address.fromSerialized(data.getString(KEY_DESTINATION)); boolean isFriendRequest = data.getBoolean(KEY_IS_FRIEND_REQUEST); + boolean shouldSendSyncMessage = data.getBoolean(KEY_SHOULD_SEND_SYNC_MESSAGE); String frMessage = data.hasString(KEY_CUSTOM_FR_MESSAGE) ? data.getString(KEY_CUSTOM_FR_MESSAGE) : null; - return new PushMediaSendJob(parameters, templateMessageID, messageID, destination, isFriendRequest, frMessage); + return new PushMediaSendJob(parameters, templateMessageID, messageID, destination, isFriendRequest, frMessage, shouldSendSyncMessage); } } } diff --git a/src/org/thoughtcrime/securesms/jobs/PushSendJob.java b/src/org/thoughtcrime/securesms/jobs/PushSendJob.java index aaedf8f25a..526264ef67 100644 --- a/src/org/thoughtcrime/securesms/jobs/PushSendJob.java +++ b/src/org/thoughtcrime/securesms/jobs/PushSendJob.java @@ -303,7 +303,8 @@ public abstract class PushSendJob extends SendJob { } protected SignalServiceSyncMessage buildSelfSendSyncMessage(@NonNull Context context, @NonNull SignalServiceDataMessage message, Optional syncAccess) { - String localNumber = TextSecurePreferences.getLocalNumber(context); + String primary = TextSecurePreferences.getMasterHexEncodedPublicKey(context); + String localNumber = primary != null ? primary : TextSecurePreferences.getLocalNumber(context); SentTranscriptMessage transcript = new SentTranscriptMessage(localNumber, message.getTimestamp(), message, diff --git a/src/org/thoughtcrime/securesms/jobs/PushTextSendJob.java b/src/org/thoughtcrime/securesms/jobs/PushTextSendJob.java index db24ce9fe8..a8a084a447 100644 --- a/src/org/thoughtcrime/securesms/jobs/PushTextSendJob.java +++ b/src/org/thoughtcrime/securesms/jobs/PushTextSendJob.java @@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.database.model.SmsMessageRecord; import org.thoughtcrime.securesms.dependencies.InjectableType; import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.loki.MultiDeviceUtilities; import org.thoughtcrime.securesms.notifications.MessageNotifier; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.service.ExpiringMessageManager; @@ -29,6 +30,9 @@ import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException; +import org.whispersystems.signalservice.loki.api.LokiStorageAPI; +import org.whispersystems.signalservice.loki.messaging.LokiSyncMessage; +import org.whispersystems.signalservice.loki.utilities.PromiseUtil; import java.io.IOException; @@ -45,6 +49,7 @@ public class PushTextSendJob extends PushSendJob implements InjectableType { private static final String KEY_DESTINATION = "destination"; private static final String KEY_IS_FRIEND_REQUEST = "is_friend_request"; private static final String KEY_CUSTOM_FR_MESSAGE = "custom_friend_request_message"; + private static final String KEY_SHOULD_SEND_SYNC_MESSAGE = "should_send_sync_message"; @Inject SignalServiceMessageSender messageSender; @@ -55,29 +60,32 @@ public class PushTextSendJob extends PushSendJob implements InjectableType { private Address destination; // Destination to check whether this is another device we're sending to private boolean isFriendRequest; // Whether this is a friend request message private String customFriendRequestMessage; // If this isn't set then we use the message body + private boolean shouldSendSyncMessage; - public PushTextSendJob(long messageId, Address destination) { this(messageId, messageId, destination); } - public PushTextSendJob(long templateMessageId, long messageId, Address destination) { this(templateMessageId, messageId, destination, false, null); } - public PushTextSendJob(long templateMessageId, long messageId, Address destination, boolean isFriendRequest, String customFriendRequestMessage) { - this(constructParameters(destination), templateMessageId, messageId, destination, isFriendRequest, customFriendRequestMessage); + public PushTextSendJob(long messageId, Address destination) { this(messageId, messageId, destination, false); } + public PushTextSendJob(long templateMessageId, long messageId, Address destination, boolean shouldSendSyncMessage) { this(templateMessageId, messageId, destination, false, null, shouldSendSyncMessage); } + public PushTextSendJob(long templateMessageId, long messageId, Address destination, boolean isFriendRequest, String customFriendRequestMessage, boolean shouldSendSyncMessage) { + this(constructParameters(destination), templateMessageId, messageId, destination, isFriendRequest, customFriendRequestMessage, shouldSendSyncMessage); } - private PushTextSendJob(@NonNull Job.Parameters parameters, long templateMessageId, long messageId, Address destination, boolean isFriendRequest, String customFriendRequestMessage) { + private PushTextSendJob(@NonNull Job.Parameters parameters, long templateMessageId, long messageId, Address destination, boolean isFriendRequest, String customFriendRequestMessage, boolean shouldSendSyncMessage) { super(parameters); this.templateMessageId = templateMessageId; this.messageId = messageId; this.destination = destination; this.isFriendRequest = isFriendRequest; this.customFriendRequestMessage = customFriendRequestMessage; + this.shouldSendSyncMessage = shouldSendSyncMessage; } @Override public @NonNull Data serialize() { Data.Builder builder = new Data.Builder() - .putLong(KEY_TEMPLATE_MESSAGE_ID, templateMessageId) - .putLong(KEY_MESSAGE_ID, messageId) - .putString(KEY_DESTINATION, destination.serialize()) - .putBoolean(KEY_IS_FRIEND_REQUEST, isFriendRequest); + .putLong(KEY_TEMPLATE_MESSAGE_ID, templateMessageId) + .putLong(KEY_MESSAGE_ID, messageId) + .putString(KEY_DESTINATION, destination.serialize()) + .putBoolean(KEY_IS_FRIEND_REQUEST, isFriendRequest) + .putBoolean(KEY_SHOULD_SEND_SYNC_MESSAGE, shouldSendSyncMessage); if (customFriendRequestMessage != null) { builder.putString(KEY_CUSTOM_FR_MESSAGE, customFriendRequestMessage); } return builder.build(); @@ -151,14 +159,18 @@ public class PushTextSendJob extends PushSendJob implements InjectableType { } catch (InsecureFallbackApprovalException e) { warn(TAG, "Failure", e); - database.markAsPendingInsecureSmsFallback(record.getId()); - MessageNotifier.notifyMessageDeliveryFailed(context, record.getRecipient(), record.getThreadId()); - ApplicationContext.getInstance(context).getJobManager().add(new DirectoryRefreshJob(false)); + if (messageId >= 0) { + database.markAsPendingInsecureSmsFallback(record.getId()); + MessageNotifier.notifyMessageDeliveryFailed(context, record.getRecipient(), record.getThreadId()); + ApplicationContext.getInstance(context).getJobManager().add(new DirectoryRefreshJob(false)); + } } catch (UntrustedIdentityException e) { warn(TAG, "Failure", e); - database.addMismatchedIdentity(record.getId(), Address.fromSerialized(e.getE164Number()), e.getIdentityKey()); - database.markAsSentFailed(record.getId()); - database.markAsPush(record.getId()); + if (messageId >= 0) { + database.addMismatchedIdentity(record.getId(), Address.fromSerialized(e.getE164Number()), e.getIdentityKey()); + database.markAsSentFailed(record.getId()); + database.markAsPush(record.getId()); + } } } @@ -219,10 +231,18 @@ public class PushTextSendJob extends PushSendJob implements InjectableType { Optional syncAccess = UnidentifiedAccessUtil.getAccessForSync(context); SignalServiceSyncMessage syncMessage = buildSelfSendSyncMessage(context, textSecureMessage, syncAccess); - messageSender.sendMessage(messageId, syncMessage, syncAccess); + messageSender.sendMessage(templateMessageId, syncMessage, syncAccess); return syncAccess.isPresent(); } else { - return messageSender.sendMessage(messageId, address, unidentifiedAccess, textSecureMessage).getSuccess().isUnidentified(); + LokiSyncMessage syncMessage = null; + if (shouldSendSyncMessage) { + // Set the sync message destination to the primary device, this way it will show that we sent a message to the primary device and not a secondary device + String primaryDevice = PromiseUtil.get(LokiStorageAPI.shared.getPrimaryDevicePublicKey(address.getNumber()), null); + SignalServiceAddress primaryAddress = primaryDevice == null ? address : new SignalServiceAddress(primaryDevice); + // We also need to use the original message id and not -1 + syncMessage = new LokiSyncMessage(primaryAddress, templateMessageId); + } + return messageSender.sendMessage(messageId, address, unidentifiedAccess, textSecureMessage, Optional.fromNullable(syncMessage)).getSuccess().isUnidentified(); } } catch (UnregisteredUserException e) { warn(TAG, "Failure", e); @@ -241,7 +261,8 @@ public class PushTextSendJob extends PushSendJob implements InjectableType { Address destination = Address.fromSerialized(data.getString(KEY_DESTINATION)); boolean isFriendRequest = data.getBoolean(KEY_IS_FRIEND_REQUEST); String frMessage = data.hasString(KEY_CUSTOM_FR_MESSAGE) ? data.getString(KEY_CUSTOM_FR_MESSAGE) : null; - return new PushTextSendJob(parameters, templateMessageID, messageID, destination, isFriendRequest, frMessage); + boolean shouldSendSyncMessage = data.getBoolean(KEY_SHOULD_SEND_SYNC_MESSAGE); + return new PushTextSendJob(parameters, templateMessageID, messageID, destination, isFriendRequest, frMessage, shouldSendSyncMessage); } } } diff --git a/src/org/thoughtcrime/securesms/jobs/TypingSendJob.java b/src/org/thoughtcrime/securesms/jobs/TypingSendJob.java index d1dc6e53fb..babe7fe04e 100644 --- a/src/org/thoughtcrime/securesms/jobs/TypingSendJob.java +++ b/src/org/thoughtcrime/securesms/jobs/TypingSendJob.java @@ -10,6 +10,7 @@ import org.thoughtcrime.securesms.dependencies.InjectableType; import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.loki.MultiDeviceUtilities; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; @@ -19,6 +20,7 @@ import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage.Action; import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.loki.utilities.PromiseUtil; import java.util.Collections; import java.util.List; @@ -96,9 +98,11 @@ public class TypingSendJob extends BaseJob implements InjectableType { List> unidentifiedAccess = Stream.of(recipients).map(r -> UnidentifiedAccessUtil.getAccessFor(context, r)).toList(); SignalServiceTypingMessage typingMessage = new SignalServiceTypingMessage(typing ? Action.STARTED : Action.STOPPED, System.currentTimeMillis(), groupId); - // Loki - Don't send typing indicators in group chats - if (!recipient.isGroupRecipient()) { - // TODO: Message ID + // Loki - Don't send typing indicators in group chats or to ourselves + if (recipient.isGroupRecipient()) { return; } + + boolean isOurDevice = PromiseUtil.get(MultiDeviceUtilities.isOneOfOurDevices(context, recipient.getAddress()), false); + if (!isOurDevice) { messageSender.sendTyping(0, addresses, unidentifiedAccess, typingMessage); } } diff --git a/src/org/thoughtcrime/securesms/loki/FriendRequestHandler.kt b/src/org/thoughtcrime/securesms/loki/FriendRequestHandler.kt new file mode 100644 index 0000000000..050f9a1bb6 --- /dev/null +++ b/src/org/thoughtcrime/securesms/loki/FriendRequestHandler.kt @@ -0,0 +1,100 @@ +package org.thoughtcrime.securesms.loki + +import android.content.Context +import nl.komponents.kovenant.ui.successUi +import org.thoughtcrime.securesms.database.DatabaseFactory +import org.whispersystems.signalservice.loki.messaging.LokiMessageFriendRequestStatus +import org.whispersystems.signalservice.loki.messaging.LokiThreadFriendRequestStatus + +object FriendRequestHandler { + enum class ActionType { Sending, Sent, Failed } + + @JvmStatic + fun updateFriendRequestState(context: Context, type: ActionType, messageId: Long, threadId: Long) { + if (threadId < 0) return + val recipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId) ?: return + if (!recipient.address.isPhone) { return } + + val currentFriendStatus = DatabaseFactory.getLokiThreadDatabase(context).getFriendRequestStatus(threadId) + // Update thread status if we haven't sent a friend request before + if (currentFriendStatus != LokiThreadFriendRequestStatus.REQUEST_RECEIVED && + currentFriendStatus != LokiThreadFriendRequestStatus.REQUEST_SENT && + currentFriendStatus != LokiThreadFriendRequestStatus.FRIENDS + ) { + val threadFriendStatus = when (type) { + ActionType.Sending -> LokiThreadFriendRequestStatus.REQUEST_SENDING + ActionType.Failed -> LokiThreadFriendRequestStatus.NONE + ActionType.Sent -> LokiThreadFriendRequestStatus.REQUEST_SENT + } + DatabaseFactory.getLokiThreadDatabase(context).setFriendRequestStatus(threadId, threadFriendStatus) + } + + // Update message status + if (messageId >= 0) { + val messageDatabase = DatabaseFactory.getLokiMessageDatabase(context) + val friendRequestStatus = messageDatabase.getFriendRequestStatus(messageId) + if (type == ActionType.Sending) { + // We only want to update message status if we aren't friends with another of their devices + // This avoids spam in the ui where it would keep telling the user that they sent a friend request on every single message + isFriendsWithAnyLinkedDevice(context, recipient).successUi { isFriends -> + if (!isFriends && friendRequestStatus == LokiMessageFriendRequestStatus.NONE) { + messageDatabase.setFriendRequestStatus(messageId, LokiMessageFriendRequestStatus.REQUEST_SENDING) + } + } + } else if (friendRequestStatus != LokiMessageFriendRequestStatus.NONE) { + // Update the friend request status of the message if we have it + val messageFriendRequestStatus = when (type) { + ActionType.Failed -> LokiMessageFriendRequestStatus.REQUEST_FAILED + ActionType.Sent -> LokiMessageFriendRequestStatus.REQUEST_PENDING + else -> throw IllegalStateException() + } + messageDatabase.setFriendRequestStatus(messageId, messageFriendRequestStatus) + } + } + } + + @JvmStatic + fun updateLastFriendRequestMessage(context: Context, threadId: Long, status: LokiMessageFriendRequestStatus) { + if (threadId < 0) { return } + + val messages = DatabaseFactory.getSmsDatabase(context).getAllMessageIDs(threadId) + val lokiMessageDatabase = DatabaseFactory.getLokiMessageDatabase(context) + val lastMessage = messages.find { + val friendRequestStatus = lokiMessageDatabase.getFriendRequestStatus(it) + friendRequestStatus == LokiMessageFriendRequestStatus.REQUEST_PENDING + } ?: return + + DatabaseFactory.getLokiMessageDatabase(context).setFriendRequestStatus(lastMessage, status) + } + + @JvmStatic + fun receivedIncomingFriendRequestMessage(context: Context, threadId: Long) { + val smsMessageDatabase = DatabaseFactory.getSmsDatabase(context) + + // We only want to update the last message status if we're not friends with any of their linked devices + // This ensures that we don't spam the UI with accept/decline messages + val recipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId) ?: return + if (!recipient.address.isPhone) { return } + + isFriendsWithAnyLinkedDevice(context, recipient).successUi { isFriends -> + if (isFriends) { return@successUi } + + // Since messages are forwarded to the primary device thread, we need to update it there + val messageCount = smsMessageDatabase.getMessageCountForThread(threadId) + val messageID = smsMessageDatabase.getIDForMessageAtIndex(threadId, messageCount - 1) // The message that was just received + if (messageID < 0) { return@successUi } + + val messageDatabase = DatabaseFactory.getLokiMessageDatabase(context) + + // We need to go through and set all messages which are REQUEST_PENDING to NONE + smsMessageDatabase.getAllMessageIDs(threadId) + .filter { messageDatabase.getFriendRequestStatus(it) == LokiMessageFriendRequestStatus.REQUEST_PENDING } + .forEach { + messageDatabase.setFriendRequestStatus(it, LokiMessageFriendRequestStatus.NONE) + } + + // Set the last message to pending + messageDatabase.setFriendRequestStatus(messageID, LokiMessageFriendRequestStatus.REQUEST_PENDING) + } + } +} \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/loki/LokiMessageDatabase.kt b/src/org/thoughtcrime/securesms/loki/LokiMessageDatabase.kt index 26d93228e4..817b76f5dd 100644 --- a/src/org/thoughtcrime/securesms/loki/LokiMessageDatabase.kt +++ b/src/org/thoughtcrime/securesms/loki/LokiMessageDatabase.kt @@ -12,11 +12,14 @@ import org.whispersystems.signalservice.loki.messaging.LokiMessageFriendRequestS class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper), LokiMessageDatabaseProtocol { companion object { - private val tableName = "loki_message_friend_request_database" + private val messageFriendRequestTableName = "loki_message_friend_request_database" + private val messageThreadMappingTableName = "loki_message_thread_mapping_database" private val messageID = "message_id" private val serverID = "server_id" private val friendRequestStatus = "friend_request_status" - @JvmStatic val createTableCommand = "CREATE TABLE $tableName ($messageID INTEGER PRIMARY KEY, $serverID INTEGER DEFAULT 0, $friendRequestStatus INTEGER DEFAULT 0);" + private val threadID = "thread_id" + @JvmStatic val createMessageFriendRequestTableCommand = "CREATE TABLE $messageFriendRequestTableName ($messageID INTEGER PRIMARY KEY, $serverID INTEGER DEFAULT 0, $friendRequestStatus INTEGER DEFAULT 0);" + @JvmStatic val createMessageToThreadMappingTableCommand = "CREATE TABLE $messageThreadMappingTableName ($messageID INTEGER PRIMARY KEY, $threadID INTEGER);" } override fun getQuoteServerID(quoteID: Long, quoteeHexEncodedPublicKey: String): Long? { @@ -26,14 +29,14 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab fun getServerID(messageID: Long): Long? { val database = databaseHelper.readableDatabase - return database.get(tableName, "${Companion.messageID} = ?", arrayOf( messageID.toString() )) { cursor -> + return database.get(messageFriendRequestTableName, "${Companion.messageID} = ?", arrayOf( messageID.toString() )) { cursor -> cursor.getInt(Companion.serverID) }?.toLong() } fun getMessageID(serverID: Long): Long? { val database = databaseHelper.readableDatabase - return database.get(tableName, "${Companion.serverID} = ?", arrayOf( serverID.toString() )) { cursor -> + return database.get(messageFriendRequestTableName, "${Companion.serverID} = ?", arrayOf( serverID.toString() )) { cursor -> cursor.getInt(messageID) }?.toLong() } @@ -43,12 +46,27 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab val contentValues = ContentValues(2) contentValues.put(Companion.messageID, messageID) contentValues.put(Companion.serverID, serverID) - database.insertOrUpdate(tableName, contentValues, "${Companion.messageID} = ?", arrayOf( messageID.toString() )) + database.insertOrUpdate(messageFriendRequestTableName, contentValues, "${Companion.messageID} = ?", arrayOf( messageID.toString() )) + } + + fun getOriginalThreadID(messageID: Long): Long { + val database = databaseHelper.readableDatabase + return database.get(messageThreadMappingTableName, "${Companion.messageID} = ?", arrayOf( messageID.toString() )) { cursor -> + cursor.getInt(Companion.threadID) + }?.toLong() ?: -1L + } + + fun setOriginalThreadID(messageID: Long, threadID: Long) { + val database = databaseHelper.writableDatabase + val contentValues = ContentValues(2) + contentValues.put(Companion.messageID, messageID) + contentValues.put(Companion.threadID, threadID) + database.insertOrUpdate(messageThreadMappingTableName, contentValues, "${Companion.messageID} = ?", arrayOf( messageID.toString() )) } fun getFriendRequestStatus(messageID: Long): LokiMessageFriendRequestStatus { val database = databaseHelper.readableDatabase - val result = database.get(tableName, "${Companion.messageID} = ?", arrayOf( messageID.toString() )) { cursor -> + val result = database.get(messageFriendRequestTableName, "${Companion.messageID} = ?", arrayOf( messageID.toString() )) { cursor -> cursor.getInt(friendRequestStatus) } return if (result != null) { @@ -63,7 +81,7 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab val contentValues = ContentValues(2) contentValues.put(Companion.messageID, messageID) contentValues.put(Companion.friendRequestStatus, friendRequestStatus.rawValue) - database.insertOrUpdate(tableName, contentValues, "${Companion.messageID} = ?", arrayOf( messageID.toString() )) + database.insertOrUpdate(messageFriendRequestTableName, contentValues, "${Companion.messageID} = ?", arrayOf( messageID.toString() )) val threadID = DatabaseFactory.getSmsDatabase(context).getThreadIdForMessage(messageID) notifyConversationListeners(threadID) } diff --git a/src/org/thoughtcrime/securesms/loki/LokiPreKeyBundleDatabase.kt b/src/org/thoughtcrime/securesms/loki/LokiPreKeyBundleDatabase.kt index becd442e51..6eee3387b1 100644 --- a/src/org/thoughtcrime/securesms/loki/LokiPreKeyBundleDatabase.kt +++ b/src/org/thoughtcrime/securesms/loki/LokiPreKeyBundleDatabase.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.loki import android.content.ContentValues import android.content.Context +import net.sqlcipher.Cursor import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.crypto.PreKeyUtil import org.thoughtcrime.securesms.database.Database @@ -35,6 +36,11 @@ class LokiPreKeyBundleDatabase(context: Context, helper: SQLCipherOpenHelper) : "$signedPreKeySignature TEXT," + "$identityKey TEXT NOT NULL," + "$deviceID INTEGER," + "$registrationID INTEGER" + ");" } + fun resetAllPreKeyBundleInfo() { + TextSecurePreferences.removeLocalRegistrationId(context) + TextSecurePreferences.setSignedPreKeyRegistered(context, false) + } + fun generatePreKeyBundle(hexEncodedPublicKey: String): PreKeyBundle? { var registrationID = TextSecurePreferences.getLocalRegistrationId(context) if (registrationID == 0) { @@ -92,7 +98,14 @@ class LokiPreKeyBundleDatabase(context: Context, helper: SQLCipherOpenHelper) : fun hasPreKeyBundle(hexEncodedPublicKey: String): Boolean { val database = databaseHelper.readableDatabase - val cursor = database.query(tableName, null, "${Companion.hexEncodedPublicKey} = ?", arrayOf( hexEncodedPublicKey ), null, null, null) - return cursor != null && cursor.count > 0 + var cursor: Cursor? = null + return try { + cursor = database.query(tableName, null, "${Companion.hexEncodedPublicKey} = ?", arrayOf( hexEncodedPublicKey ), null, null, null) + cursor != null && cursor.count > 0 + } catch (e: Exception) { + false + } finally { + cursor?.close() + } } } \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/loki/LokiPublicChatPoller.kt b/src/org/thoughtcrime/securesms/loki/LokiPublicChatPoller.kt index 3e6685e429..28bca2fea1 100644 --- a/src/org/thoughtcrime/securesms/loki/LokiPublicChatPoller.kt +++ b/src/org/thoughtcrime/securesms/loki/LokiPublicChatPoller.kt @@ -4,27 +4,23 @@ import android.content.Context import android.os.Handler import android.util.Log import org.thoughtcrime.securesms.crypto.IdentityKeyUtil -import org.thoughtcrime.securesms.database.Address import org.thoughtcrime.securesms.database.DatabaseFactory -import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.jobs.PushDecryptJob -import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository -import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil -import org.thoughtcrime.securesms.mms.OutgoingMediaMessage -import org.thoughtcrime.securesms.mms.QuoteModel -import org.thoughtcrime.securesms.recipients.Recipient -import org.thoughtcrime.securesms.util.GroupUtil import org.thoughtcrime.securesms.util.TextSecurePreferences -import org.thoughtcrime.securesms.util.Util import org.whispersystems.libsignal.util.guava.Optional import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer import org.whispersystems.signalservice.api.messages.SignalServiceContent import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage import org.whispersystems.signalservice.api.messages.SignalServiceGroup +import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage import org.whispersystems.signalservice.api.push.SignalServiceAddress import org.whispersystems.signalservice.loki.api.LokiPublicChat import org.whispersystems.signalservice.loki.api.LokiPublicChatAPI import org.whispersystems.signalservice.loki.api.LokiPublicChatMessage +import org.whispersystems.signalservice.loki.api.LokiStorageAPI +import org.whispersystems.signalservice.loki.utilities.get +import org.whispersystems.signalservice.loki.utilities.successBackground +import java.util.* class LokiPublicChatPoller(private val context: Context, private val group: LokiPublicChat) { private val handler = Handler() @@ -94,18 +90,17 @@ class LokiPublicChatPoller(private val context: Context, private val group: Loki // endregion // region Polling - private fun pollForNewMessages() { - fun processIncomingMessage(message: LokiPublicChatMessage) { - val id = group.id.toByteArray() - val serviceGroup = SignalServiceGroup(SignalServiceGroup.Type.UPDATE, id, null, null, null) - val quote = if (message.quote != null) { - SignalServiceDataMessage.Quote(message.quote!!.quotedMessageTimestamp, SignalServiceAddress(message.quote!!.quoteeHexEncodedPublicKey), message.quote!!.quotedMessageBody, listOf()) - } else { - null - } - val attachments = message.attachments.mapNotNull { attachment -> - if (attachment.kind != LokiPublicChatMessage.Attachment.Kind.Attachment) { return@mapNotNull null } - SignalServiceAttachmentPointer( + private fun getDataMessage(message: LokiPublicChatMessage): SignalServiceDataMessage { + val id = group.id.toByteArray() + val serviceGroup = SignalServiceGroup(SignalServiceGroup.Type.UPDATE, id, null, null, null) + val quote = if (message.quote != null) { + SignalServiceDataMessage.Quote(message.quote!!.quotedMessageTimestamp, SignalServiceAddress(message.quote!!.quoteeHexEncodedPublicKey), message.quote!!.quotedMessageBody, listOf()) + } else { + null + } + val attachments = message.attachments.mapNotNull { attachment -> + if (attachment.kind != LokiPublicChatMessage.Attachment.Kind.Attachment) { return@mapNotNull null } + SignalServiceAttachmentPointer( attachment.serverID, attachment.contentType, ByteArray(0), @@ -117,30 +112,35 @@ class LokiPublicChatPoller(private val context: Context, private val group: Loki false, Optional.fromNullable(attachment.caption), attachment.url) - } - val linkPreview = message.attachments.firstOrNull { it.kind == LokiPublicChatMessage.Attachment.Kind.LinkPreview } - val signalLinkPreviews = mutableListOf() - if (linkPreview != null) { - val attachment = SignalServiceAttachmentPointer( - linkPreview.serverID, - linkPreview.contentType, - ByteArray(0), - Optional.of(linkPreview.size), - Optional.absent(), - linkPreview.width, linkPreview.height, - Optional.absent(), - Optional.of(linkPreview.fileName), - false, - Optional.fromNullable(linkPreview.caption), - linkPreview.url) - signalLinkPreviews.add(SignalServiceDataMessage.Preview(linkPreview.linkPreviewURL!!, linkPreview.linkPreviewTitle!!, Optional.of(attachment))) - } - val body = if (message.body == message.timestamp.toString()) "" else message.body // Workaround for the fact that the back-end doesn't accept messages without a body - val serviceDataMessage = SignalServiceDataMessage(message.timestamp, serviceGroup, attachments, body, false, 0, false, null, false, quote, null, signalLinkPreviews, null) + } + val linkPreview = message.attachments.firstOrNull { it.kind == LokiPublicChatMessage.Attachment.Kind.LinkPreview } + val signalLinkPreviews = mutableListOf() + if (linkPreview != null) { + val attachment = SignalServiceAttachmentPointer( + linkPreview.serverID, + linkPreview.contentType, + ByteArray(0), + Optional.of(linkPreview.size), + Optional.absent(), + linkPreview.width, linkPreview.height, + Optional.absent(), + Optional.of(linkPreview.fileName), + false, + Optional.fromNullable(linkPreview.caption), + linkPreview.url) + signalLinkPreviews.add(SignalServiceDataMessage.Preview(linkPreview.linkPreviewURL!!, linkPreview.linkPreviewTitle!!, Optional.of(attachment))) + } + val body = if (message.body == message.timestamp.toString()) "" else message.body // Workaround for the fact that the back-end doesn't accept messages without a body + return SignalServiceDataMessage(message.timestamp, serviceGroup, attachments, body, false, 0, false, null, false, quote, null, signalLinkPreviews, null) + } + + private fun pollForNewMessages() { + fun processIncomingMessage(message: LokiPublicChatMessage) { + val serviceDataMessage = getDataMessage(message) val serviceContent = SignalServiceContent(serviceDataMessage, message.hexEncodedPublicKey, SignalServiceAddress.DEFAULT_DEVICE_ID, message.timestamp, false) val senderDisplayName = "${message.displayName} (...${message.hexEncodedPublicKey.takeLast(8)})" DatabaseFactory.getLokiUserDatabase(context).setServerDisplayName(group.id, message.hexEncodedPublicKey, senderDisplayName) - if (quote != null || attachments.count() > 0 || linkPreview != null) { + if (serviceDataMessage.quote.isPresent || (serviceDataMessage.attachments.isPresent && serviceDataMessage.attachments.get().size > 0) || serviceDataMessage.previews.isPresent) { PushDecryptJob(context).handleMediaMessage(serviceContent, serviceDataMessage, Optional.absent(), Optional.of(message.serverID)) } else { PushDecryptJob(context).handleTextMessage(serviceContent, serviceDataMessage, Optional.absent(), Optional.of(message.serverID)) @@ -148,61 +148,29 @@ class LokiPublicChatPoller(private val context: Context, private val group: Loki } fun processOutgoingMessage(message: LokiPublicChatMessage) { val messageServerID = message.serverID ?: return - val lokiMessageDatabase = DatabaseFactory.getLokiMessageDatabase(context) - val isDuplicate = lokiMessageDatabase.getMessageID(messageServerID) != null + val isDuplicate = DatabaseFactory.getLokiMessageDatabase(context).getMessageID(messageServerID) != null if (isDuplicate) { return } if (message.body.isEmpty() && message.attachments.isEmpty() && message.quote == null) { return } - val id = group.id.toByteArray() - val mmsDatabase = DatabaseFactory.getMmsDatabase(context) - val recipient = Recipient.from(context, Address.fromSerialized(GroupUtil.getEncodedId(id, false)), false) - val quote: QuoteModel? - if (message.quote != null) { - quote = QuoteModel(message.quote!!.quotedMessageTimestamp, Address.fromSerialized(message.quote!!.quoteeHexEncodedPublicKey), message.quote!!.quotedMessageBody, false, listOf()) + val localNumber = TextSecurePreferences.getLocalNumber(context) + val dataMessage = getDataMessage(message) + val transcript = SentTranscriptMessage(localNumber, dataMessage.timestamp, dataMessage, dataMessage.expiresInSeconds.toLong(), Collections.singletonMap(localNumber, false)) + transcript.messageServerID = messageServerID + if (dataMessage.quote.isPresent || (dataMessage.attachments.isPresent && dataMessage.attachments.get().size > 0) || dataMessage.previews.isPresent) { + PushDecryptJob(context).handleSynchronizeSentMediaMessage(transcript) } else { - quote = null - } - // TODO: Handle attachments correctly for our previous messages - val body = if (message.body == message.timestamp.toString()) "" else message.body // Workaround for the fact that the back-end doesn't accept messages without a body - val signalMessage = OutgoingMediaMessage(recipient, body, listOf(), message.timestamp, 0, 0, - ThreadDatabase.DistributionTypes.DEFAULT, quote, listOf(), listOf(), listOf(), listOf()) - val threadID = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient) - fun finalize() { - val messageID = mmsDatabase.insertMessageOutbox(signalMessage, threadID, false, null) - mmsDatabase.markAsSent(messageID, true) - mmsDatabase.markUnidentified(messageID, false) - lokiMessageDatabase.setServerID(messageID, messageServerID) - } - val urls = LinkPreviewUtil.findWhitelistedUrls(message.body) - val urlCount = urls.size - if (urlCount != 0) { - val lpr = LinkPreviewRepository(context) - var count = 0 - urls.forEach { url -> - lpr.getLinkPreview(context, url.url) { lp -> - Util.runOnMain { - count += 1 - if (lp.isPresent) { signalMessage.linkPreviews.add(lp.get()) } - if (count == urlCount) { - try { - finalize() - } catch (e: Exception) { - // TODO: Handle - } - - } - } - } - } - } else { - finalize() + PushDecryptJob(context).handleSynchronizeSentTextMessage(transcript) } } - api.getMessages(group.channel, group.server).success { messages -> - messages.forEach { message -> - if (message.hexEncodedPublicKey != userHexEncodedPublicKey) { - processIncomingMessage(message) - } else { - processOutgoingMessage(message) + api.getMessages(group.channel, group.server).successBackground { messages -> + if (messages.isNotEmpty()) { + val ourDevices = LokiStorageAPI.shared.getAllDevicePublicKeys(userHexEncodedPublicKey).get(setOf()) + // Process messages in the background + messages.forEach { message -> + if (ourDevices.contains(message.hexEncodedPublicKey)) { + processOutgoingMessage(message) + } else { + processIncomingMessage(message) + } } } }.fail { diff --git a/src/org/thoughtcrime/securesms/loki/LokiThreadDatabase.kt b/src/org/thoughtcrime/securesms/loki/LokiThreadDatabase.kt index 4c177bc108..3b66359ea9 100644 --- a/src/org/thoughtcrime/securesms/loki/LokiThreadDatabase.kt +++ b/src/org/thoughtcrime/securesms/loki/LokiThreadDatabase.kt @@ -41,6 +41,8 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa } fun getFriendRequestStatus(threadID: Long): LokiThreadFriendRequestStatus { + if (threadID < 0) { return LokiThreadFriendRequestStatus.NONE } + val database = databaseHelper.readableDatabase val result = database.get(friendRequestTableName, "${Companion.threadID} = ?", arrayOf( threadID.toString() )) { cursor -> cursor.getInt(friendRequestStatus) @@ -53,6 +55,8 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa } override fun setFriendRequestStatus(threadID: Long, friendRequestStatus: LokiThreadFriendRequestStatus) { + if (threadID < 0) { return } + val database = databaseHelper.writableDatabase val contentValues = ContentValues(2) contentValues.put(Companion.threadID, threadID) diff --git a/src/org/thoughtcrime/securesms/loki/MultiDeviceUtilities.kt b/src/org/thoughtcrime/securesms/loki/MultiDeviceUtilities.kt index adaee4ef49..257336de05 100644 --- a/src/org/thoughtcrime/securesms/loki/MultiDeviceUtilities.kt +++ b/src/org/thoughtcrime/securesms/loki/MultiDeviceUtilities.kt @@ -1,13 +1,19 @@ +@file:JvmName("MultiDeviceUtilities") package org.thoughtcrime.securesms.loki import android.content.Context import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.deferred +import nl.komponents.kovenant.all +import nl.komponents.kovenant.functional.bind +import nl.komponents.kovenant.functional.map +import nl.komponents.kovenant.toFailVoid import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.database.Address import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.logging.Log import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.sms.MessageSender import org.thoughtcrime.securesms.util.TextSecurePreferences import org.whispersystems.libsignal.util.guava.Optional import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair @@ -16,54 +22,76 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress import org.whispersystems.signalservice.loki.api.LokiStorageAPI import org.whispersystems.signalservice.loki.api.PairingAuthorisation import org.whispersystems.signalservice.loki.messaging.LokiThreadFriendRequestStatus +import org.whispersystems.signalservice.loki.utilities.recover +import org.whispersystems.signalservice.loki.utilities.retryIfNeeded +import java.util.* +import kotlin.concurrent.schedule -fun getAllDevicePublicKeys(context: Context, hexEncodedPublicKey: String, storageAPI: LokiStorageAPI, block: (devicePublicKey: String, isFriend: Boolean, friendCount: Int) -> Unit) { +fun getAllDeviceFriendRequestStatuses(context: Context, hexEncodedPublicKey: String): Promise, Exception> { + val lokiThreadDatabase = DatabaseFactory.getLokiThreadDatabase(context) + return LokiStorageAPI.shared.getAllDevicePublicKeys(hexEncodedPublicKey).map { keys -> + val map = mutableMapOf() + for (devicePublicKey in keys) { + val device = Recipient.from(context, Address.fromSerialized(devicePublicKey), false) + val threadID = DatabaseFactory.getThreadDatabase(context).getThreadIdIfExistsFor(device) + val friendRequestStatus = if (threadID < 0) LokiThreadFriendRequestStatus.NONE else lokiThreadDatabase.getFriendRequestStatus(threadID) + map[devicePublicKey] = friendRequestStatus + } + map + }.recover { mutableMapOf() } +} + +fun getAllDevicePublicKeysWithFriendStatus(context: Context, hexEncodedPublicKey: String): Promise, Unit> { val userHexEncodedPublicKey = TextSecurePreferences.getLocalNumber(context) - storageAPI.getAllDevicePublicKeys(hexEncodedPublicKey).success { items -> - val devices = items.toMutableSet() + return LokiStorageAPI.shared.getAllDevicePublicKeys(hexEncodedPublicKey).map { keys -> + val devices = keys.toMutableSet() if (hexEncodedPublicKey != userHexEncodedPublicKey) { devices.remove(userHexEncodedPublicKey) } val friends = getFriendPublicKeys(context, devices) + val friendMap = mutableMapOf() for (device in devices) { - block(device, friends.contains(device), friends.count()) + friendMap[device] = friends.contains(device) } - } + friendMap + }.toFailVoid() } -fun shouldAutomaticallyBecomeFriendsWithDevice(publicKey: String, context: Context): Promise { - val lokiThreadDatabase = DatabaseFactory.getLokiThreadDatabase(context) - val storageAPI = LokiStorageAPI.shared - val deferred = deferred() - storageAPI.getPrimaryDevicePublicKey(publicKey).success { primaryDevicePublicKey -> - if (primaryDevicePublicKey == null) { - deferred.resolve(false) - return@success - } - val userHexEncodedPublicKey = TextSecurePreferences.getLocalNumber(context) - if (primaryDevicePublicKey == userHexEncodedPublicKey) { - storageAPI.getSecondaryDevicePublicKeys(userHexEncodedPublicKey).success { secondaryDevices -> - deferred.resolve(secondaryDevices.contains(publicKey)) - }.fail { - deferred.resolve(false) - } - return@success - } - val primaryDevice = Recipient.from(context, Address.fromSerialized(primaryDevicePublicKey), false) - val threadID = DatabaseFactory.getThreadDatabase(context).getThreadIdIfExistsFor(primaryDevice) - if (threadID < 0) { - deferred.resolve(false) - return@success - } - deferred.resolve(lokiThreadDatabase.getFriendRequestStatus(threadID) == LokiThreadFriendRequestStatus.FRIENDS) +fun getFriendCount(context: Context, devices: Set): Int { + return getFriendPublicKeys(context, devices).count() +} + +fun shouldAutomaticallyBecomeFriendsWithDevice(publicKey: String, context: Context): Promise { + // Don't become friends if we're a group + if (!Address.fromSerialized(publicKey).isPhone) { + return Promise.of(false) + } + + // If this public key is our primary device then we should become friends + if (publicKey == TextSecurePreferences.getMasterHexEncodedPublicKey(context)) { + return Promise.of(true) + } + + return LokiStorageAPI.shared.getPrimaryDevicePublicKey(publicKey).bind { primaryDevicePublicKey -> + // If the public key doesn't have any other devices then go through regular friend request logic + if (primaryDevicePublicKey == null) { + return@bind Promise.of(false) + } + + // If the primary device public key matches our primary device then we should become friends since this is our other device + if (primaryDevicePublicKey == TextSecurePreferences.getMasterHexEncodedPublicKey(context)) { + return@bind Promise.of(true) + } + + // If we are friends with any of the other devices then we should become friends + isFriendsWithAnyLinkedDevice(context, Address.fromSerialized(primaryDevicePublicKey)) } - return deferred.promise } fun sendPairingAuthorisationMessage(context: Context, contactHexEncodedPublicKey: String, authorisation: PairingAuthorisation): Promise { val messageSender = ApplicationContext.getInstance(context).communicationModule.provideSignalMessageSender() val address = SignalServiceAddress(contactHexEncodedPublicKey) - val message = SignalServiceDataMessage.newBuilder().withBody("").withPairingAuthorisation(authorisation) + val message = SignalServiceDataMessage.newBuilder().withBody(null).withPairingAuthorisation(authorisation) // A REQUEST should always act as a friend request. A GRANT should always be replying back as a normal message. if (authorisation.type == PairingAuthorisation.Type.REQUEST) { val preKeyBundle = DatabaseFactory.getLokiPreKeyBundleDatabase(context).generatePreKeyBundle(address.number) @@ -84,4 +112,77 @@ fun sendPairingAuthorisationMessage(context: Context, contactHexEncodedPublicKey Log.d("Loki", "Failed to send authorisation message to: $contactHexEncodedPublicKey.") Promise.ofFail(e) } -} \ No newline at end of file +} + +fun signAndSendPairingAuthorisationMessage(context: Context, pairingAuthorisation: PairingAuthorisation) { + val userPrivateKey = IdentityKeyUtil.getIdentityKeyPair(context).privateKey.serialize() + val signedPairingAuthorisation = pairingAuthorisation.sign(PairingAuthorisation.Type.GRANT, userPrivateKey) + if (signedPairingAuthorisation == null || signedPairingAuthorisation.type != PairingAuthorisation.Type.GRANT) { + Log.d("Loki", "Failed to sign pairing authorization.") + return + } + DatabaseFactory.getLokiAPIDatabase(context).insertOrUpdatePairingAuthorisation(signedPairingAuthorisation) + TextSecurePreferences.setMultiDevice(context, true) + + val address = Address.fromSerialized(pairingAuthorisation.secondaryDevicePublicKey); + + val sendPromise = retryIfNeeded(8) { + sendPairingAuthorisationMessage(context, address.serialize(), signedPairingAuthorisation) + }.fail { + Log.d("Loki", "Failed to send pairing authorization message to ${address.serialize()}.") + } + + val updatePromise = LokiStorageAPI.shared.updateUserDeviceMappings().fail { + Log.d("Loki", "Failed to update device mapping") + } + + // If both promises complete successfully then we should sync our contacts + all(listOf(sendPromise, updatePromise), cancelOthersOnError = false).success { + Log.d("Loki", "Successfully pairing with a secondary device! Syncing contacts.") + // Send out sync contact after a delay + Timer().schedule(3000) { + MessageSender.syncAllContacts(context, address) + } + } +} + +fun isOneOfOurDevices(context: Context, address: Address): Promise { + if (address.isGroup || address.isEmail || address.isMmsGroup) { + return Promise.of(false) + } + + val ourPublicKey = TextSecurePreferences.getLocalNumber(context) + return LokiStorageAPI.shared.getAllDevicePublicKeys(ourPublicKey).map { devices -> + devices.contains(address.serialize()) + } +} + +fun isFriendsWithAnyLinkedDevice(context: Context, recipient: Recipient): Promise { + return isFriendsWithAnyLinkedDevice(context, recipient.address) +} + +fun isFriendsWithAnyLinkedDevice(context: Context, address: Address): Promise { + if (!address.isPhone) { return Promise.of(true) } + + return getAllDeviceFriendRequestStatuses(context, address.serialize()).map { map -> + for (status in map.values) { + if (status == LokiThreadFriendRequestStatus.FRIENDS) { + return@map true + } + } + false + } +} + +fun hasPendingFriendRequestWithAnyLinkedDevice(context: Context, recipient: Recipient): Promise { + if (recipient.isGroupRecipient) { return Promise.of(false) } + + return getAllDeviceFriendRequestStatuses(context, recipient.address.serialize()).map { map -> + for (status in map.values) { + if (status == LokiThreadFriendRequestStatus.REQUEST_SENDING || status == LokiThreadFriendRequestStatus.REQUEST_SENT || status == LokiThreadFriendRequestStatus.REQUEST_RECEIVED) { + return@map true + } + } + false + } +} diff --git a/src/org/thoughtcrime/securesms/loki/NewConversationActivity.kt b/src/org/thoughtcrime/securesms/loki/NewConversationActivity.kt index bacbdaa5f4..9ad53944a5 100644 --- a/src/org/thoughtcrime/securesms/loki/NewConversationActivity.kt +++ b/src/org/thoughtcrime/securesms/loki/NewConversationActivity.kt @@ -68,8 +68,9 @@ class NewConversationActivity : PassphraseRequiredActionBarActivity(), ScanListe fun startNewConversationIfPossible(hexEncodedPublicKey: String) { if (!PublicKeyValidation.isValid(hexEncodedPublicKey)) { return Toast.makeText(this, R.string.fragment_new_conversation_invalid_public_key_message, Toast.LENGTH_SHORT).show() } val userHexEncodedPublicKey = TextSecurePreferences.getLocalNumber(this) - if (hexEncodedPublicKey == userHexEncodedPublicKey) { return Toast.makeText(this, R.string.fragment_new_conversation_note_to_self_not_supported_message, Toast.LENGTH_SHORT).show() } - val contact = Recipient.from(this, Address.fromSerialized(hexEncodedPublicKey), true) + // If we try to contact our master device then redirect to note to self + val contactPublicKey = if (TextSecurePreferences.getMasterHexEncodedPublicKey(this) == hexEncodedPublicKey) userHexEncodedPublicKey else hexEncodedPublicKey + val contact = Recipient.from(this, Address.fromSerialized(contactPublicKey), true) val intent = Intent(this, ConversationActivity::class.java) intent.putExtra(ConversationActivity.ADDRESS_EXTRA, contact.address) intent.putExtra(ConversationActivity.TEXT_EXTRA, getIntent().getStringExtra(ConversationActivity.TEXT_EXTRA)) diff --git a/src/org/thoughtcrime/securesms/loki/PushBackgroundMessageSendJob.kt b/src/org/thoughtcrime/securesms/loki/PushBackgroundMessageSendJob.kt new file mode 100644 index 0000000000..0c7e055ff1 --- /dev/null +++ b/src/org/thoughtcrime/securesms/loki/PushBackgroundMessageSendJob.kt @@ -0,0 +1,94 @@ +package org.thoughtcrime.securesms.loki + +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.jobmanager.Data +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +import org.thoughtcrime.securesms.jobs.BaseJob +import org.thoughtcrime.securesms.logging.Log +import org.whispersystems.libsignal.util.guava.Optional +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage +import org.whispersystems.signalservice.api.push.SignalServiceAddress +import java.io.IOException +import java.util.concurrent.TimeUnit + +class PushBackgroundMessageSendJob private constructor( + parameters: Parameters, + private val recipient: String, + private val messageBody: String?, + private val friendRequest: Boolean +) : BaseJob(parameters) { + companion object { + const val KEY = "PushBackgroundMessageSendJob" + + private val TAG = PushBackgroundMessageSendJob::class.java.simpleName + + private val KEY_RECIPIENT = "recipient" + private val KEY_MESSAGE_BODY = "message_body" + private val KEY_FRIEND_REQUEST = "asFriendRequest" + } + + constructor(recipient: String): this(recipient, null, false) + constructor(recipient: String, messageBody: String?, friendRequest: Boolean) : this(Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setQueue(KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(1) + .build(), + recipient, messageBody, friendRequest) + + override fun serialize(): Data { + return Data.Builder() + .putString(KEY_RECIPIENT, recipient) + .putString(KEY_MESSAGE_BODY, messageBody) + .putBoolean(KEY_FRIEND_REQUEST, friendRequest) + .build() + } + + override fun getFactoryKey(): String { + return KEY + } + + public override fun onRun() { + val message = SignalServiceDataMessage.newBuilder() + .withTimestamp(System.currentTimeMillis()) + .withBody(messageBody) + + if (friendRequest) { + val bundle = DatabaseFactory.getLokiPreKeyBundleDatabase(context).generatePreKeyBundle(recipient) + message.withPreKeyBundle(bundle) + .asFriendRequest(true) + } + + val messageSender = ApplicationContext.getInstance(context).communicationModule.provideSignalMessageSender() + val address = SignalServiceAddress(recipient) + try { + messageSender.sendMessage(-1, address, Optional.absent(), message.build()) // The message ID doesn't matter + } catch (e: Exception) { + Log.d("Loki", "Failed to send background message to: $recipient.") + throw e + } + } + + public override fun onShouldRetry(e: Exception): Boolean { + // Loki - Disable since we have our own retrying when sending messages + return false + } + + override fun onCanceled() {} + + class Factory : Job.Factory { + override fun create(parameters: Parameters, data: Data): PushBackgroundMessageSendJob { + try { + val recipient = data.getString(KEY_RECIPIENT) + val messageBody = if (data.hasString(KEY_MESSAGE_BODY)) data.getString(KEY_MESSAGE_BODY) else null + val friendRequest = data.getBooleanOrDefault(KEY_FRIEND_REQUEST, false) + return PushBackgroundMessageSendJob(parameters, recipient, messageBody, friendRequest) + } catch (e: IOException) { + throw AssertionError(e) + } + } + } +} diff --git a/src/org/thoughtcrime/securesms/loki/PushMessageSyncSendJob.kt b/src/org/thoughtcrime/securesms/loki/PushMessageSyncSendJob.kt new file mode 100644 index 0000000000..f18e941fda --- /dev/null +++ b/src/org/thoughtcrime/securesms/loki/PushMessageSyncSendJob.kt @@ -0,0 +1,90 @@ +package org.thoughtcrime.securesms.loki + +import org.thoughtcrime.securesms.database.Address +import org.thoughtcrime.securesms.dependencies.InjectableType +import org.thoughtcrime.securesms.jobmanager.Data +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +import org.thoughtcrime.securesms.jobs.BaseJob +import org.whispersystems.signalservice.api.SignalServiceMessageSender +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException +import org.whispersystems.signalservice.api.push.SignalServiceAddress +import java.io.IOException +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +class PushMessageSyncSendJob private constructor( + parameters: Parameters, + private val messageID: Long, + private val recipient: Address, + private val timestamp: Long, + private val message: ByteArray, + private val ttl: Int +) : BaseJob(parameters), InjectableType { + + companion object { + const val KEY = "PushMessageSyncSendJob" + + private val TAG = PushMessageSyncSendJob::class.java.simpleName + + private val KEY_MESSAGE_ID = "message_id" + private val KEY_RECIPIENT = "recipient" + private val KEY_TIMESTAMP = "timestamp" + private val KEY_MESSAGE = "message" + private val KEY_TTL = "ttl" + } + + @Inject + lateinit var messageSender: SignalServiceMessageSender + + constructor(messageID: Long, recipient: Address, timestamp: Long, message: ByteArray, ttl: Int) : this(Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setQueue(KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(1) + .build(), + messageID, recipient, timestamp, message, ttl) + + override fun serialize(): Data { + return Data.Builder() + .putLong(KEY_MESSAGE_ID, messageID) + .putString(KEY_RECIPIENT, recipient.serialize()) + .putLong(KEY_TIMESTAMP, timestamp) + .putByteArray(KEY_MESSAGE, message) + .putInt(KEY_TTL, ttl) + .build() + } + + override fun getFactoryKey(): String { + return KEY + } + + @Throws(IOException::class, UntrustedIdentityException::class) + public override fun onRun() { + // Don't send sync messages to a group + if (recipient.isGroup || recipient.isEmail) { return } + messageSender.lokiSendSyncMessage(messageID, SignalServiceAddress(recipient.toPhoneString()), timestamp, message, ttl) + } + + public override fun onShouldRetry(e: Exception): Boolean { + // Loki - Disable since we have our own retrying when sending messages + return false + } + + override fun onCanceled() {} + + class Factory : Job.Factory { + override fun create(parameters: Parameters, data: Data): PushMessageSyncSendJob { + try { + return PushMessageSyncSendJob(parameters, + data.getLong(KEY_MESSAGE_ID), + Address.fromSerialized(data.getString(KEY_RECIPIENT)), + data.getLong(KEY_TIMESTAMP), + data.getByteArray(KEY_MESSAGE), + data.getInt(KEY_TTL)) + } catch (e: IOException) { + throw AssertionError(e) + } + } + } +} diff --git a/src/org/thoughtcrime/securesms/loki/SeedActivity.kt b/src/org/thoughtcrime/securesms/loki/SeedActivity.kt index 78a957f5a4..87954908a6 100644 --- a/src/org/thoughtcrime/securesms/loki/SeedActivity.kt +++ b/src/org/thoughtcrime/securesms/loki/SeedActivity.kt @@ -4,6 +4,7 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent +import android.os.AsyncTask import android.os.Bundle import android.view.View import android.view.inputmethod.InputMethodManager @@ -22,7 +23,6 @@ import org.thoughtcrime.securesms.util.Hex import org.thoughtcrime.securesms.util.TextSecurePreferences import org.whispersystems.curve25519.Curve25519 import org.whispersystems.libsignal.util.KeyHelper -import org.whispersystems.signalservice.loki.api.LokiStorageAPI import org.whispersystems.signalservice.loki.api.PairingAuthorisation import org.whispersystems.signalservice.loki.crypto.MnemonicCodec import org.whispersystems.signalservice.loki.utilities.Analytics @@ -55,8 +55,7 @@ class SeedActivity : BaseActionBarActivity(), DeviceLinkingDialogDelegate { copyButton.setOnClickListener { copy() } toggleRegisterModeButton.setOnClickListener { mode = Mode.Register } toggleRestoreModeButton.setOnClickListener { mode = Mode.Restore } - // TODO: Enable this again later -// toggleLinkModeButton.setOnClickListener { mode = Mode.Link } + toggleLinkModeButton.setOnClickListener { mode = Mode.Link } mainButton.setOnClickListener { handleMainButtonTapped() } Analytics.shared.track("Seed Screen Viewed") } @@ -205,8 +204,10 @@ class SeedActivity : BaseActionBarActivity(), DeviceLinkingDialogDelegate { application.setUpP2PAPI() application.setUpStorageAPIIfNeeded() DeviceLinkingDialog.show(this, DeviceLinkingView.Mode.Slave, this) - retryIfNeeded(8) { - sendPairingAuthorisationMessage(this@SeedActivity, authorisation.primaryDevicePublicKey, authorisation).get() + AsyncTask.execute { + retryIfNeeded(8) { + sendPairingAuthorisationMessage(this@SeedActivity, authorisation.primaryDevicePublicKey, authorisation) + } } } else { startActivity(Intent(this, DisplayNameActivity::class.java)) @@ -227,25 +228,9 @@ class SeedActivity : BaseActionBarActivity(), DeviceLinkingDialogDelegate { resetForRegistration() } - override fun sendPairingAuthorizedMessage(pairingAuthorisation: PairingAuthorisation) { - val userPrivateKey = IdentityKeyUtil.getIdentityKeyPair(this).privateKey.serialize() - val signedPairingAuthorisation = pairingAuthorisation.sign(PairingAuthorisation.Type.GRANT, userPrivateKey) - if (signedPairingAuthorisation == null || signedPairingAuthorisation.type != PairingAuthorisation.Type.GRANT) { - Log.d("Loki", "Failed to sign pairing authorization.") - return - } - retryIfNeeded(8) { - sendPairingAuthorisationMessage(this, pairingAuthorisation.secondaryDevicePublicKey, signedPairingAuthorisation).get() - }.fail { - Log.d("Loki", "Failed to send pairing authorization message to ${pairingAuthorisation.secondaryDevicePublicKey}.") - } - DatabaseFactory.getLokiAPIDatabase(this).insertOrUpdatePairingAuthorisation(signedPairingAuthorisation) - LokiStorageAPI.shared.updateUserDeviceMappings() - } - private fun resetForRegistration() { IdentityKeyUtil.delete(this, IdentityKeyUtil.lokiSeedKey) - TextSecurePreferences.removeLocalRegistrationId(this) + DatabaseFactory.getLokiPreKeyBundleDatabase(this).resetAllPreKeyBundleInfo() TextSecurePreferences.removeLocalNumber(this) TextSecurePreferences.setHasSeenWelcomeScreen(this, false) TextSecurePreferences.setPromptedPushRegistration(this, false) diff --git a/src/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java b/src/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java index f72f614fd6..f92ff642f0 100644 --- a/src/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java +++ b/src/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java @@ -20,8 +20,9 @@ import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId; import org.thoughtcrime.securesms.jobs.MultiDeviceReadUpdateJob; import org.thoughtcrime.securesms.jobs.SendReadReceiptJob; import org.thoughtcrime.securesms.logging.Log; -import org.thoughtcrime.securesms.loki.MultiDeviceUtilitiesKt; +import org.thoughtcrime.securesms.loki.MultiDeviceUtilities; import org.thoughtcrime.securesms.service.ExpiringMessageManager; +import org.thoughtcrime.securesms.util.Util; import org.whispersystems.signalservice.loki.api.LokiStorageAPI; import java.util.LinkedList; @@ -29,6 +30,7 @@ import java.util.List; import java.util.Map; import kotlin.Unit; +import kotlin.contracts.Returns; public class MarkReadReceiver extends BroadcastReceiver { @@ -76,7 +78,9 @@ public class MarkReadReceiver extends BroadcastReceiver { for (MarkedMessageInfo messageInfo : markedReadMessages) { scheduleDeletion(context, messageInfo.getExpirationInfo()); - syncMessageIds.add(messageInfo.getSyncMessageId()); + if (!messageInfo.getSyncMessageId().getAddress().isGroup()) { + syncMessageIds.add(messageInfo.getSyncMessageId()); + } } ApplicationContext.getInstance(context) @@ -88,14 +92,15 @@ public class MarkReadReceiver extends BroadcastReceiver { .collect(Collectors.groupingBy(SyncMessageId::getAddress)); for (Address address : addressMap.keySet()) { - LokiStorageAPI storageAPI = LokiStorageAPI.Companion.getShared(); - List timestamps = Stream.of(addressMap.get(address)).map(SyncMessageId::getTimetamp).toList(); - - MultiDeviceUtilitiesKt.getAllDevicePublicKeys(context, address.serialize(), storageAPI, (devicePublicKey, isFriend, friendCount) -> { - // Loki - This also prevents read receipts from being sent in group chats as they don't maintain a friend request status - if (isFriend) { - ApplicationContext.getInstance(context).getJobManager().add(new SendReadReceiptJob(Address.fromSerialized(devicePublicKey), timestamps)); + MultiDeviceUtilities.getAllDevicePublicKeysWithFriendStatus(context, address.serialize()).success(devices -> { + for (Map.Entry entry : devices.entrySet()) { + String device = entry.getKey(); + boolean isFriend = entry.getValue(); + // Loki - This also prevents read receipts from being sent in group chats as they don't maintain a friend request status + if (isFriend) { + Util.runOnMain(() -> ApplicationContext.getInstance(context).getJobManager().add(new SendReadReceiptJob(Address.fromSerialized(device), timestamps))); + } } return Unit.INSTANCE; }); diff --git a/src/org/thoughtcrime/securesms/preferences/widgets/ProfilePreference.java b/src/org/thoughtcrime/securesms/preferences/widgets/ProfilePreference.java index 0d464dd325..63b82c4bf7 100644 --- a/src/org/thoughtcrime/securesms/preferences/widgets/ProfilePreference.java +++ b/src/org/thoughtcrime/securesms/preferences/widgets/ProfilePreference.java @@ -30,6 +30,7 @@ public class ProfilePreference extends Preference { private ImageView avatarView; private TextView profileNameView; private TextView profileNumberView; + private TextView profileTagView; @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) public ProfilePreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { @@ -64,6 +65,7 @@ public class ProfilePreference extends Preference { avatarView = (ImageView)viewHolder.findViewById(R.id.avatar); profileNameView = (TextView)viewHolder.findViewById(R.id.profile_name); profileNumberView = (TextView)viewHolder.findViewById(R.id.number); + profileTagView = (TextView)viewHolder.findViewById(R.id.tag); refresh(); } @@ -72,13 +74,15 @@ public class ProfilePreference extends Preference { if (profileNumberView == null) return; String userHexEncodedPublicKey = TextSecurePreferences.getLocalNumber(getContext()); - final Address localAddress = Address.fromSerialized(userHexEncodedPublicKey); + String primaryDevicePublicKey = TextSecurePreferences.getMasterHexEncodedPublicKey(getContext()); + String publicKey = primaryDevicePublicKey != null ? primaryDevicePublicKey : userHexEncodedPublicKey; + final Address localAddress = Address.fromSerialized(publicKey); final String profileName = TextSecurePreferences.getProfileName(getContext()); Context context = getContext(); containerView.setOnLongClickListener(v -> { ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clip = ClipData.newPlainText("Public Key", userHexEncodedPublicKey); + ClipData clip = ClipData.newPlainText("Public Key", publicKey); clipboard.setPrimaryClip(clip); Toast.makeText(context, R.string.activity_settings_public_key_copied_message, Toast.LENGTH_SHORT).show(); return true; @@ -100,7 +104,7 @@ public class ProfilePreference extends Preference { int height = avatarView.getHeight(); if (width == 0 || height == 0) return true; avatarView.getViewTreeObserver().removeOnPreDrawListener(this); - JazzIdenticonDrawable identicon = new JazzIdenticonDrawable(width, height, userHexEncodedPublicKey.toLowerCase()); + JazzIdenticonDrawable identicon = new JazzIdenticonDrawable(width, height, publicKey.toLowerCase()); avatarView.setImageDrawable(identicon); return true; } @@ -120,7 +124,9 @@ public class ProfilePreference extends Preference { } profileNameView.setVisibility(TextUtils.isEmpty(profileName) ? View.GONE : View.VISIBLE); - profileNumberView.setText(localAddress.toPhoneString()); + + profileTagView.setVisibility(primaryDevicePublicKey == null ? View.GONE : View.VISIBLE); + profileTagView.setText(R.string.activity_settings_secondary_device_tag); } } diff --git a/src/org/thoughtcrime/securesms/push/MessageSenderEventListener.java b/src/org/thoughtcrime/securesms/push/MessageSenderEventListener.java new file mode 100644 index 0000000000..5cc2d886c1 --- /dev/null +++ b/src/org/thoughtcrime/securesms/push/MessageSenderEventListener.java @@ -0,0 +1,44 @@ +package org.thoughtcrime.securesms.push; + +import android.content.Context; + +import org.thoughtcrime.securesms.crypto.SecurityEvent; +import org.thoughtcrime.securesms.loki.FriendRequestHandler; +import org.thoughtcrime.securesms.sms.MessageSender; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +public class MessageSenderEventListener implements SignalServiceMessageSender.EventListener { + + private static final String TAG = MessageSenderEventListener.class.getSimpleName(); + + private final Context context; + + public MessageSenderEventListener(Context context) { + this.context = context.getApplicationContext(); + } + + @Override + public void onSecurityEvent(SignalServiceAddress textSecureAddress) { + SecurityEvent.broadcastSecurityUpdateEvent(context); + } + + @Override + public void onSyncEvent(long messageID, long timestamp, byte[] message, int ttl) { + if (messageID >= 0 && timestamp > 0 && message != null && ttl > 0) { + MessageSender.sendSyncMessageToOurDevices(context, messageID, timestamp, message, ttl); + } + } + + @Override public void onFriendRequestSending(long messageID, long threadID) { + FriendRequestHandler.updateFriendRequestState(context, FriendRequestHandler.ActionType.Sending, messageID, threadID); + } + + @Override public void onFriendRequestSent(long messageID, long threadID) { + FriendRequestHandler.updateFriendRequestState(context, FriendRequestHandler.ActionType.Sent, messageID, threadID); + } + + @Override public void onFriendRequestSendingFail(long messageID, long threadID) { + FriendRequestHandler.updateFriendRequestState(context, FriendRequestHandler.ActionType.Failed, messageID, threadID); + } +} diff --git a/src/org/thoughtcrime/securesms/push/SecurityEventListener.java b/src/org/thoughtcrime/securesms/push/SecurityEventListener.java deleted file mode 100644 index f876a6a91b..0000000000 --- a/src/org/thoughtcrime/securesms/push/SecurityEventListener.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.thoughtcrime.securesms.push; - -import android.content.Context; - -import org.thoughtcrime.securesms.crypto.SecurityEvent; -import org.thoughtcrime.securesms.database.Address; -import org.thoughtcrime.securesms.database.DatabaseFactory; -import org.thoughtcrime.securesms.database.RecipientDatabase; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.whispersystems.signalservice.api.SignalServiceMessageSender; -import org.whispersystems.signalservice.api.push.SignalServiceAddress; - -public class SecurityEventListener implements SignalServiceMessageSender.EventListener { - - private static final String TAG = SecurityEventListener.class.getSimpleName(); - - private final Context context; - - public SecurityEventListener(Context context) { - this.context = context.getApplicationContext(); - } - - @Override - public void onSecurityEvent(SignalServiceAddress textSecureAddress) { - SecurityEvent.broadcastSecurityUpdateEvent(context); - } -} diff --git a/src/org/thoughtcrime/securesms/sms/MessageSender.java b/src/org/thoughtcrime/securesms/sms/MessageSender.java index 195db0e6d9..de240c81e4 100644 --- a/src/org/thoughtcrime/securesms/sms/MessageSender.java +++ b/src/org/thoughtcrime/securesms/sms/MessageSender.java @@ -33,8 +33,10 @@ import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.SmsMessageRecord; +import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.JobManager; import org.thoughtcrime.securesms.jobs.MmsSendJob; +import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob; import org.thoughtcrime.securesms.jobs.PushGroupSendJob; import org.thoughtcrime.securesms.jobs.PushMediaSendJob; import org.thoughtcrime.securesms.jobs.PushTextSendJob; @@ -42,8 +44,11 @@ import org.thoughtcrime.securesms.jobs.SmsSendJob; import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository; import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil; import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.loki.FriendRequestHandler; import org.thoughtcrime.securesms.loki.GeneralUtilitiesKt; -import org.thoughtcrime.securesms.loki.MultiDeviceUtilitiesKt; +import org.thoughtcrime.securesms.loki.MultiDeviceUtilities; +import org.thoughtcrime.securesms.loki.PushBackgroundMessageSendJob; +import org.thoughtcrime.securesms.loki.PushMessageSyncSendJob; import org.thoughtcrime.securesms.mms.MmsException; import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; import org.thoughtcrime.securesms.push.AccountManagerFactory; @@ -55,10 +60,13 @@ import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.SignalServiceAccountManager; import org.whispersystems.signalservice.api.push.ContactTokenDetails; import org.whispersystems.signalservice.loki.api.LokiStorageAPI; -import org.whispersystems.signalservice.loki.messaging.LokiMessageFriendRequestStatus; +import org.whispersystems.signalservice.loki.messaging.LokiThreadFriendRequestStatus; +import org.whispersystems.signalservice.loki.utilities.PromiseUtil; import java.io.IOException; -import java.util.function.Function; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; import kotlin.Unit; @@ -66,6 +74,63 @@ public class MessageSender { private static final String TAG = MessageSender.class.getSimpleName(); + private enum MessageType { TEXT, MEDIA } + + public static void syncAllContacts(Context context, Address recipient) { + ApplicationContext.getInstance(context).getJobManager().add(new MultiDeviceContactUpdateJob(context, recipient, true)); + } + + /** + * Send a contact sync message to all our devices telling them that we want to sync `contact` + */ + public static void syncContact(Context context, Address contact) { + // Don't bother sending a contact sync message if it's one of our devices that we want to sync across + MultiDeviceUtilities.isOneOfOurDevices(context, contact).success(isOneOfOurDevice -> { + if (!isOneOfOurDevice) { + ApplicationContext.getInstance(context).getJobManager().add(new MultiDeviceContactUpdateJob(context, contact)); + } + return Unit.INSTANCE; + }); + } + + public static void sendBackgroundMessageToAllDevices(Context context, String contactHexEncodedPublicKey) { + // Send the background message to the original pubkey + sendBackgroundMessage(context, contactHexEncodedPublicKey); + + // Go through the other devices and only send background messages if we're friends or we have received friend request + LokiStorageAPI.shared.getAllDevicePublicKeys(contactHexEncodedPublicKey).success(devices -> { + Util.runOnMain(() -> { + for (String device : devices) { + // Don't send message to the device we already have sent to + if (device.equals(contactHexEncodedPublicKey)) { continue; } + Recipient recipient = Recipient.from(context, Address.fromSerialized(device), false); + long threadID = DatabaseFactory.getThreadDatabase(context).getThreadIdIfExistsFor(recipient); + if (threadID < 0) { continue; } + LokiThreadFriendRequestStatus friendRequestStatus = DatabaseFactory.getLokiThreadDatabase(context).getFriendRequestStatus(threadID); + if (friendRequestStatus == LokiThreadFriendRequestStatus.FRIENDS || friendRequestStatus == LokiThreadFriendRequestStatus.REQUEST_RECEIVED) { + sendBackgroundMessage(context, device); + } else if (friendRequestStatus == LokiThreadFriendRequestStatus.NONE || friendRequestStatus == LokiThreadFriendRequestStatus.REQUEST_EXPIRED) { + sendBackgroundFriendRequest(context, device, "Please accept to enable messages to be synced across devices"); + } + } + }); + return Unit.INSTANCE; + }); + } + + // region Background message + + // We don't call the message sender here directly and instead we just opt to create a specific job for the send + // This is because calling message sender directly would cause the application to freeze in some cases as it was blocking the thread when waiting for a response from the send + public static void sendBackgroundMessage(Context context, String contactHexEncodedPublicKey) { + ApplicationContext.getInstance(context).getJobManager().add(new PushBackgroundMessageSendJob(contactHexEncodedPublicKey)); + } + + public static void sendBackgroundFriendRequest(Context context, String contactHexEncodedPublicKey, String messageBody) { + ApplicationContext.getInstance(context).getJobManager().add(new PushBackgroundMessageSendJob(contactHexEncodedPublicKey, messageBody, true)); + } + // endregion + public static long send(final Context context, final OutgoingTextMessage message, final long threadId, @@ -88,7 +153,7 @@ public class MessageSender { // Loki - Set the message's friend request status as soon as it has hit the database if (message.isFriendRequest) { - DatabaseFactory.getLokiMessageDatabase(context).setFriendRequestStatus(messageId, LokiMessageFriendRequestStatus.REQUEST_SENDING); + FriendRequestHandler.updateFriendRequestState(context, FriendRequestHandler.ActionType.Sending, messageId, allocatedThreadId); } sendTextMessage(context, recipient, forceSms, keyExchange, messageId); @@ -124,7 +189,7 @@ public class MessageSender { long messageID = database.insertMessageOutbox(message, allocatedThreadId, forceSms, insertListener); // Loki - Set the message's friend request status as soon as it has hit the database if (message.isFriendRequest) { - DatabaseFactory.getLokiMessageDatabase(context).setFriendRequestStatus(messageID, LokiMessageFriendRequestStatus.REQUEST_SENDING); + FriendRequestHandler.updateFriendRequestState(context, FriendRequestHandler.ActionType.Sending, messageID, allocatedThreadId); } sendMediaMessage(context, recipient, forceSms, messageID, message.getExpiresIn()); } catch (Exception e) { @@ -137,7 +202,7 @@ public class MessageSender { long messageID = database.insertMessageOutbox(message, allocatedThreadId, forceSms, insertListener); // Loki - Set the message's friend request status as soon as it has hit the database if (message.isFriendRequest) { - DatabaseFactory.getLokiMessageDatabase(context).setFriendRequestStatus(messageID, LokiMessageFriendRequestStatus.REQUEST_SENDING); + FriendRequestHandler.updateFriendRequestState(context, FriendRequestHandler.ActionType.Sending, messageID, allocatedThreadId); } sendMediaMessage(context, recipient, forceSms, messageID, message.getExpiresIn()); } catch (MmsException e) { @@ -149,6 +214,28 @@ public class MessageSender { return allocatedThreadId; } + public static void sendSyncMessageToOurDevices(final Context context, + final long messageID, + final long timestamp, + final byte[] message, + final int ttl) { + String ourPublicKey = TextSecurePreferences.getLocalNumber(context); + JobManager jobManager = ApplicationContext.getInstance(context).getJobManager(); + LokiStorageAPI.shared.getAllDevicePublicKeys(ourPublicKey).success(devices -> { + Util.runOnMain(() -> { + for (String device : devices) { + // Don't send to ourselves + if (device.equals(ourPublicKey)) { continue; } + + // Create a send job for our device + Address address = Address.fromSerialized(device); + jobManager.add(new PushMessageSyncSendJob(messageID, address, timestamp, message, ttl)); + } + }); + return Unit.INSTANCE; + }); + } + public static void resendGroupMessage(Context context, MessageRecord messageRecord, Address filterAddress) { if (!messageRecord.isMms()) throw new AssertionError("Not Group"); sendGroupPush(context, messageRecord.getRecipient(), messageRecord.getId(), filterAddress); @@ -195,64 +282,73 @@ public class MessageSender { } private static void sendTextPush(Context context, Recipient recipient, long messageId) { - LokiStorageAPI storageAPI = LokiStorageAPI.Companion.getShared(); - JobManager jobManager = ApplicationContext.getInstance(context).getJobManager(); - - // Just send the message normally if it's a group message - String recipientPublicKey = recipient.getAddress().serialize(); - if (GeneralUtilitiesKt.isPublicChat(context, recipientPublicKey)) { - jobManager.add(new PushTextSendJob(messageId, recipient.getAddress())); - return; - } - - MultiDeviceUtilitiesKt.getAllDevicePublicKeys(context, recipientPublicKey, storageAPI, (devicePublicKey, isFriend, friendCount) -> { - Address address = Address.fromSerialized(devicePublicKey); - long messageIDToUse = recipientPublicKey.equals(devicePublicKey) ? messageId : -1L; - - if (isFriend) { - // Send a normal message if the user is friends with the recipient - jobManager.add(new PushTextSendJob(messageId, messageIDToUse, address)); - } else { - // Send friend requests to non friends. If the user is friends with any - // of the devices then send out a default friend request message. - boolean isFriendsWithAny = (friendCount > 0); - String defaultFriendRequestMessage = isFriendsWithAny ? "Accept this friend request to enable messages to be synced across devices" : null; - jobManager.add(new PushTextSendJob(messageId, messageIDToUse, address, true, defaultFriendRequestMessage)); - } - - return Unit.INSTANCE; - }); + sendMessagePush(context, MessageType.TEXT, recipient, messageId); } private static void sendMediaPush(Context context, Recipient recipient, long messageId) { - LokiStorageAPI storageAPI = LokiStorageAPI.Companion.getShared(); + sendMessagePush(context, MessageType.MEDIA, recipient, messageId); + } + + private static void sendMessagePush(Context context, MessageType type, Recipient recipient, long messageId) { JobManager jobManager = ApplicationContext.getInstance(context).getJobManager(); - // Just send the message normally if it's a group message + // Just send the message normally if it's a group message or we're sending to one of our devices String recipientPublicKey = recipient.getAddress().serialize(); - if (GeneralUtilitiesKt.isPublicChat(context, recipientPublicKey)) { - PushMediaSendJob.enqueue(context, jobManager, messageId, recipient.getAddress()); + if (GeneralUtilitiesKt.isPublicChat(context, recipientPublicKey) || PromiseUtil.get(MultiDeviceUtilities.isOneOfOurDevices(context, recipient.getAddress()), false)) { + if (type == MessageType.MEDIA) { + PushMediaSendJob.enqueue(context, jobManager, messageId, recipient.getAddress(), false); + } else { + jobManager.add(new PushTextSendJob(messageId, recipient.getAddress())); + } return; } - MultiDeviceUtilitiesKt.getAllDevicePublicKeys(context, recipientPublicKey, storageAPI, (devicePublicKey, isFriend, friendCount) -> { - Address address = Address.fromSerialized(devicePublicKey); - long messageIDToUse = recipientPublicKey.equals(devicePublicKey) ? messageId : -1L; + // If we get here then we are sending a message to a device that is not ours + boolean[] hasSentSyncMessage = { false }; + MultiDeviceUtilities.getAllDevicePublicKeysWithFriendStatus(context, recipientPublicKey).success(devices -> { + int friendCount = MultiDeviceUtilities.getFriendCount(context, devices.keySet()); + Util.runOnMain(() -> { + ArrayList jobs = new ArrayList<>(); + for (Map.Entry entry : devices.entrySet()) { + String devicePublicKey = entry.getKey(); + boolean isFriend = entry.getValue(); - if (isFriend) { - // Send a normal message if the user is friends with the recipient - PushMediaSendJob.enqueue(context, jobManager, messageId, messageIDToUse, address); - } else { - // Send friend requests to non friends. If the user is friends with any - // of the devices then send out a default friend request message. - boolean isFriendsWithAny = friendCount > 0; - String defaultFriendRequestMessage = isFriendsWithAny ? "Accept this friend request to enable messages to be synced across devices" : null; - PushMediaSendJob.enqueue(context, jobManager, messageId, messageIDToUse, address, true, defaultFriendRequestMessage); - } + Address address = Address.fromSerialized(devicePublicKey); + long messageIDToUse = recipientPublicKey.equals(devicePublicKey) ? messageId : -1L; + if (isFriend) { + // Send a normal message if the user is friends with the recipient + // We should also send a sync message if we haven't already sent one + boolean shouldSendSyncMessage = !hasSentSyncMessage[0] && address.isPhone(); + if (type == MessageType.MEDIA) { + jobs.add(new PushMediaSendJob(messageId, messageIDToUse, address, false, null, shouldSendSyncMessage)); + } else { + jobs.add(new PushTextSendJob(messageId, messageIDToUse, address, shouldSendSyncMessage)); + } + if (shouldSendSyncMessage) { hasSentSyncMessage[0] = true; } + } else { + // Send friend requests to non friends. If the user is friends with any + // of the devices then send out a default friend request message. + boolean isFriendsWithAny = (friendCount > 0); + String defaultFriendRequestMessage = isFriendsWithAny ? "Please accept to enable messages to be synced across devices" : null; + if (type == MessageType.MEDIA) { + jobs.add(new PushMediaSendJob(messageId, messageIDToUse, address, true, defaultFriendRequestMessage, false)); + } else { + jobs.add(new PushTextSendJob(messageId, messageIDToUse, address, true, defaultFriendRequestMessage, false)); + } + } + } + + // Start the send + if (type == MessageType.MEDIA) { + PushMediaSendJob.enqueue(context, jobManager, (List)(List)jobs); + } else { + // Schedule text send jobs + jobManager.startChain(jobs).enqueue(); + } + }); return Unit.INSTANCE; }); - } private static void sendGroupPush(Context context, Recipient recipient, long messageId, Address filterAddress) { diff --git a/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java b/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java index 0b2ab2d91b..ef82974f7f 100644 --- a/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java +++ b/src/org/thoughtcrime/securesms/util/TextSecurePreferences.java @@ -640,7 +640,7 @@ public class TextSecurePreferences { } public static void setLocalNumber(Context context, String localNumber) { - setStringPreference(context, LOCAL_NUMBER_PREF, localNumber); + setStringPreference(context, LOCAL_NUMBER_PREF, localNumber.toLowerCase()); } public static void removeLocalNumber(Context context) { @@ -1183,7 +1183,7 @@ public class TextSecurePreferences { } public static void setMasterHexEncodedPublicKey(Context context, String masterHexEncodedPublicKey) { - setStringPreference(context, "master_hex_encoded_publicKey", masterHexEncodedPublicKey); + setStringPreference(context, "master_hex_encoded_public_key", masterHexEncodedPublicKey.toLowerCase()); } // endregion }