diff --git a/AndroidManifest.xml b/AndroidManifest.xml index e4693b99ed..0e5a5d0654 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -158,6 +158,9 @@ + + + diff --git a/res/layout/activity_create_closed_group.xml b/res/layout/activity_create_closed_group.xml new file mode 100644 index 0000000000..fa6585f797 --- /dev/null +++ b/res/layout/activity_create_closed_group.xml @@ -0,0 +1,30 @@ + + + + + + + + + + \ No newline at end of file diff --git a/res/layout/activity_home.xml b/res/layout/activity_home.xml index d03b9cb65d..28e927d94d 100644 --- a/res/layout/activity_home.xml +++ b/res/layout/activity_home.xml @@ -40,13 +40,27 @@ android:layout_centerVertical="true" android:layout_marginLeft="64dp" /> - + android:layout_centerVertical="true"> + + + + + + diff --git a/res/layout/view_user.xml b/res/layout/view_user.xml new file mode 100644 index 0000000000..6c63b7759c --- /dev/null +++ b/res/layout/view_user.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/menu/menu_create_closed_group.xml b/res/menu/menu_create_closed_group.xml new file mode 100644 index 0000000000..75b41dcd68 --- /dev/null +++ b/res/menu/menu_create_closed_group.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/res/values/strings.xml b/res/values/strings.xml index 71c2c3e4ab..d5d65e2d57 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -1073,7 +1073,12 @@ %1$s joined the group. %1$s joined the group. + + %1$s was removed from the group. + %1$s were removed from the group. + Group name is now \'%1$s\'. + You were removed from the group. Make your profile name and photo visible to this group? diff --git a/res/xml/network_security_configuration.xml b/res/xml/network_security_configuration.xml index cef100a280..79ba86792a 100644 --- a/res/xml/network_security_configuration.xml +++ b/res/xml/network_security_configuration.xml @@ -4,7 +4,7 @@ imaginary.stream storage.seed1.loki.network storage.seed2.loki.network - http://public.loki.foundation + public.loki.foundation:22023 127.0.0.1 \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/ApplicationContext.java b/src/org/thoughtcrime/securesms/ApplicationContext.java index 203c84df9f..eed7ba2f38 100644 --- a/src/org/thoughtcrime/securesms/ApplicationContext.java +++ b/src/org/thoughtcrime/securesms/ApplicationContext.java @@ -521,18 +521,18 @@ public class ApplicationContext extends MultiDexApplication implements Dependenc public void createDefaultPublicChatsIfNeeded() { List defaultPublicChats = LokiPublicChatAPI.Companion.getDefaultChats(BuildConfig.DEBUG); - for (LokiPublicChat publiChat : defaultPublicChats) { - long threadID = GroupManager.getThreadId(publiChat.getId(), this); - String migrationKey = publiChat.getId() + "_migrated"; + for (LokiPublicChat publicChat : defaultPublicChats) { + long threadID = GroupManager.getPublicChatThreadId(publicChat.getId(), this); + String migrationKey = publicChat.getId() + "_migrated"; boolean isChatMigrated = TextSecurePreferences.getBooleanPreference(this, migrationKey, false); - boolean isChatSetUp = TextSecurePreferences.isChatSetUp(this, publiChat.getId()); - if (!isChatSetUp || !publiChat.isDeletable()) { - lokiPublicChatManager.addChat(publiChat.getServer(), publiChat.getChannel(), publiChat.getDisplayName()); - TextSecurePreferences.markChatSetUp(this, publiChat.getId()); + boolean isChatSetUp = TextSecurePreferences.isChatSetUp(this, publicChat.getId()); + if (!isChatSetUp || !publicChat.isDeletable()) { + lokiPublicChatManager.addChat(publicChat.getServer(), publicChat.getChannel(), publicChat.getDisplayName()); + TextSecurePreferences.markChatSetUp(this, publicChat.getId()); TextSecurePreferences.setBooleanPreference(this, migrationKey, true); } else if (threadID > -1 && !isChatMigrated) { // Migrate the old public chats - DatabaseFactory.getLokiThreadDatabase(this).setPublicChat(publiChat, threadID); + DatabaseFactory.getLokiThreadDatabase(this).setPublicChat(publicChat, threadID); TextSecurePreferences.setBooleanPreference(this, migrationKey, true); } } @@ -545,7 +545,7 @@ public class ApplicationContext extends MultiDexApplication implements Dependenc for (LokiRSSFeed feed : feeds) { boolean isFeedSetUp = TextSecurePreferences.isChatSetUp(this, feed.getId()); if (!isFeedSetUp || !feed.isDeletable()) { - GroupManager.createGroup(feed.getId(), this, new HashSet<>(), null, feed.getDisplayName(), false); + GroupManager.createRSSFeedGroup(feed.getId(), this, null, feed.getDisplayName()); TextSecurePreferences.markChatSetUp(this, feed.getId()); } } @@ -554,7 +554,7 @@ public class ApplicationContext extends MultiDexApplication implements Dependenc private void createRSSFeedPollersIfNeeded() { // Only create the RSS feed pollers if their threads aren't deleted LokiRSSFeed lokiNewsFeed = lokiNewsFeed(); - long lokiNewsFeedThreadID = GroupManager.getThreadId(lokiNewsFeed.getId(), this); + long lokiNewsFeedThreadID = GroupManager.getRSSFeedThreadId(lokiNewsFeed.getId(), this); if (lokiNewsFeedThreadID >= 0 && lokiNewsFeedPoller == null) { lokiNewsFeedPoller = new LokiRSSFeedPoller(this, lokiNewsFeed); // Set up deletion listeners if needed diff --git a/src/org/thoughtcrime/securesms/GroupCreateActivity.java b/src/org/thoughtcrime/securesms/GroupCreateActivity.java index 9408b45bd2..d87946c97d 100644 --- a/src/org/thoughtcrime/securesms/GroupCreateActivity.java +++ b/src/org/thoughtcrime/securesms/GroupCreateActivity.java @@ -72,6 +72,7 @@ import org.whispersystems.signalservice.api.util.InvalidNumberException; import java.io.File; import java.util.Collection; +import java.util.Collections; import java.util.HashSet; import java.util.LinkedList; import java.util.List; @@ -246,7 +247,8 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity return; } if (isSignalGroup()) { - new CreateSignalGroupTask(this, avatarBmp, getGroupName(), getAdapter().getRecipients()).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + Recipient local = Recipient.from(this, Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)), false); + new CreateSignalGroupTask(this, avatarBmp, getGroupName(), getAdapter().getRecipients(), Collections.singleton(local)).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); } else { new CreateMmsGroupTask(this, getAdapter().getRecipients()).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); } @@ -254,7 +256,7 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity private void handleGroupUpdate() { new UpdateSignalGroupTask(this, groupToUpdate.get().id, avatarBmp, - getGroupName(), getAdapter().getRecipients()).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + getGroupName(), getAdapter().getRecipients(), groupToUpdate.get().admins).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); } private void handleOpenConversation(long threadId, Recipient recipient) { @@ -344,9 +346,10 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity for (Recipient recipient : members) { memberAddresses.add(recipient.getAddress()); } - memberAddresses.add(Address.fromSerialized(TextSecurePreferences.getLocalNumber(activity))); + Address local = Address.fromSerialized(TextSecurePreferences.getLocalNumber(activity)); + memberAddresses.add(local); - String groupId = DatabaseFactory.getGroupDatabase(activity).getOrCreateGroupForMembers(memberAddresses, true); + String groupId = DatabaseFactory.getGroupDatabase(activity).getOrCreateGroupForMembers(memberAddresses, true, Collections.singletonList(local)); Recipient groupRecipient = Recipient.from(activity, Address.fromSerialized(groupId), true); long threadId = DatabaseFactory.getThreadDatabase(activity).getThreadIdFor(groupRecipient, ThreadDatabase.DistributionTypes.DEFAULT); @@ -370,16 +373,19 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity protected Bitmap avatar; protected Set members; protected String name; + protected Set admins; public SignalGroupTask(GroupCreateActivity activity, Bitmap avatar, String name, - Set members) + Set members, + Set admins) { this.activity = activity; this.avatar = avatar; this.name = name; this.members = members; + this.admins = admins; } @Override @@ -403,13 +409,13 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity } private static class CreateSignalGroupTask extends SignalGroupTask { - public CreateSignalGroupTask(GroupCreateActivity activity, Bitmap avatar, String name, Set members) { - super(activity, avatar, name, members); + public CreateSignalGroupTask(GroupCreateActivity activity, Bitmap avatar, String name, Set members, Set admins) { + super(activity, avatar, name, members, admins); } @Override protected Optional doInBackground(Void... aVoid) { - return Optional.of(GroupManager.createGroup(activity, members, avatar, name, false)); + return Optional.of(GroupManager.createGroup(activity, members, avatar, name, false, admins)); } @Override @@ -430,16 +436,16 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity private String groupId; public UpdateSignalGroupTask(GroupCreateActivity activity, String groupId, - Bitmap avatar, String name, Set members) + Bitmap avatar, String name, Set members, Set admins) { - super(activity, avatar, name, members); + super(activity, avatar, name, members, admins); this.groupId = groupId; } @Override protected Optional doInBackground(Void... aVoid) { try { - return Optional.of(GroupManager.updateGroup(activity, groupId, members, avatar, name)); + return Optional.of(GroupManager.updateGroup(activity, groupId, members, avatar, name, admins)); } catch (InvalidNumberException e) { return Optional.absent(); } @@ -491,7 +497,7 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity for (Recipient recipient : recipients) { boolean isPush = isActiveInDirectory(recipient); - if (failIfNotPush && !isPush) { + if (failIfNotPush && !isPush && !recipient.getAddress().isPhone()) { results.add(new Result(null, false, activity.getString(R.string.GroupCreateActivity_cannot_add_non_push_to_existing_group, recipient.toShortString()))); } else if (TextUtils.equals(TextSecurePreferences.getLocalNumber(activity), recipient.getAddress().serialize())) { @@ -537,11 +543,17 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity existingContacts.addAll(recipients); if (group.isPresent()) { + List
adminList = group.get().getAdmins(); + final Set admins = new HashSet<>(adminList.size()); + for (Address admin : adminList) { + admins.add(Recipient.from(getContext(), admin, false)); + } return Optional.of(new GroupData(groupIds[0], existingContacts, BitmapUtil.fromByteArray(group.get().getAvatar()), group.get().getAvatar(), - group.get().getTitle())); + group.get().getTitle(), + admins)); } else { return Optional.absent(); } @@ -582,13 +594,15 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity Bitmap avatarBmp; byte[] avatarBytes; String name; + Set admins; - public GroupData(String id, Set recipients, Bitmap avatarBmp, byte[] avatarBytes, String name) { + public GroupData(String id, Set recipients, Bitmap avatarBmp, byte[] avatarBytes, String name, Set admins) { this.id = id; this.recipients = recipients; this.avatarBmp = avatarBmp; this.avatarBytes = avatarBytes; this.name = name; + this.admins = admins; } } } diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 4bad0eea90..34ee6efe26 100644 --- a/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -740,9 +740,9 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity MenuInflater inflater = this.getMenuInflater(); menu.clear(); - boolean isLokiPublicChat = isGroupConversation(); // TODO: Figure out a better way of determining this + boolean isLokiGroupChat = recipient.getAddress().isPublicChat() || recipient.getAddress().isRSSFeed(); - if (isSecureText && !isLokiPublicChat) { // TODO: + if (isSecureText && !isLokiGroupChat) { if (recipient.getExpireMessages() > 0) { inflater.inflate(R.menu.conversation_expiring_on, menu); @@ -762,7 +762,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity if (isSecureText) inflater.inflate(R.menu.conversation_callable_secure, menu); else inflater.inflate(R.menu.conversation_callable_insecure, menu); */ - } else if (isGroupConversation() && !isLokiPublicChat) { + } else if (isGroupConversation() && !isLokiGroupChat) { inflater.inflate(R.menu.conversation_group_options, menu); if (!isPushGroupConversation()) { @@ -1152,10 +1152,14 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity if (threadId != -1 && leaveMessage.isPresent()) { MessageSender.send(this, leaveMessage.get(), threadId, false, null); + // We need to remove the master device from the group + String masterHexEncodedPublicKey = TextSecurePreferences.getMasterHexEncodedPublicKey(this); + String localNumber = masterHexEncodedPublicKey != null ? masterHexEncodedPublicKey : TextSecurePreferences.getLocalNumber(this); + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(this); String groupId = groupRecipient.getAddress().toGroupString(); groupDatabase.setActive(groupId, false); - groupDatabase.remove(groupId, Address.fromSerialized(TextSecurePreferences.getLocalNumber(this))); + groupDatabase.remove(groupId, Address.fromSerialized(localNumber)); initializeEnabledCheck(); } else { @@ -2077,7 +2081,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } private void setBlockedUserState(Recipient recipient, boolean isSecureText, boolean isDefaultSms) { - if (recipient.isGroupRecipient() && (recipient.getName().equals("Loki News") || recipient.getName().equals("Session Updates"))) { + if (recipient.isGroupRecipient() && recipient.getAddress().isRSSFeed()) { unblockButton.setVisibility(View.GONE); composePanel.setVisibility(View.GONE); makeDefaultSmsButton.setVisibility(View.GONE); @@ -2106,7 +2110,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } private void setGroupShareProfileReminder(@NonNull Recipient recipient) { - if (recipient.isPushGroupRecipient() && !recipient.isProfileSharing()) { + if (recipient.isPushGroupRecipient() && !recipient.isProfileSharing() && !recipient.getAddress().isPublicChat() && !recipient.getAddress().isRSSFeed()) { groupShareProfileView.get().setRecipient(recipient); groupShareProfileView.get().setVisibility(View.VISIBLE); } else if (groupShareProfileView.resolved()) { @@ -2461,7 +2465,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity // Loki - Send a friend request if we're not yet friends with the user in question LokiThreadFriendRequestStatus friendRequestStatus = DatabaseFactory.getLokiThreadDatabase(context).getFriendRequestStatus(threadId); - outgoingMessage.isFriendRequest = (friendRequestStatus != LokiThreadFriendRequestStatus.FRIENDS); // Needed for stageOutgoingMessage(...) + outgoingMessage.isFriendRequest = !isGroupConversation() && friendRequestStatus != LokiThreadFriendRequestStatus.FRIENDS; // Needed for stageOutgoingMessage(...) Permissions.with(this) .request(Manifest.permission.SEND_SMS, Manifest.permission.READ_SMS) @@ -2521,7 +2525,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity // Loki - Send a friend request if we're not yet friends with the user in question LokiThreadFriendRequestStatus friendRequestStatus = DatabaseFactory.getLokiThreadDatabase(context).getFriendRequestStatus(threadId); - message.isFriendRequest = (friendRequestStatus != LokiThreadFriendRequestStatus.FRIENDS); // Needed for stageOutgoingMessage(...) + message.isFriendRequest = !isGroupConversation() && friendRequestStatus != LokiThreadFriendRequestStatus.FRIENDS; // Needed for stageOutgoingMessage(...) Permissions.with(this) .request(Manifest.permission.SEND_SMS) diff --git a/src/org/thoughtcrime/securesms/database/Address.java b/src/org/thoughtcrime/securesms/database/Address.java index d063025cfa..1286b4b302 100644 --- a/src/org/thoughtcrime/securesms/database/Address.java +++ b/src/org/thoughtcrime/securesms/database/Address.java @@ -52,17 +52,9 @@ public class Address implements Parcelable, Comparable
{ private final String address; - // Loki - Special flag to indicate whether this address represents a public chat or not - private Boolean isPublicChat; - private Address(@NonNull String address) { - this(address, false); - } - - private Address(@NonNull String address, Boolean isPublicChat) { if (address == null) throw new AssertionError(address); this.address = address.toLowerCase(); - this.isPublicChat = isPublicChat; } public Address(Parcel in) { @@ -77,10 +69,6 @@ public class Address implements Parcelable, Comparable
{ return Address.fromSerialized(external); } - public static @NonNull Address fromPublicChatGroupID(@NonNull String serialized) { - return new Address(serialized, true); - } - public static @NonNull List
fromSerializedList(@NonNull String serialized, char delimiter) { String[] escapedAddresses = DelimiterUtil.split(serialized, delimiter); List
addresses = new LinkedList<>(); @@ -121,13 +109,15 @@ public class Address implements Parcelable, Comparable
{ } } - public boolean isGroup() { - return GroupUtil.isEncodedGroup(address); - } + public boolean isGroup() { return GroupUtil.isEncodedGroup(address); } - public boolean isMmsGroup() { - return GroupUtil.isMmsGroup(address); - } + public boolean isSignalGroup() { return !isPublicChat() && !isRSSFeed(); } + + public boolean isPublicChat() { return GroupUtil.isPublicChat(address); } + + public boolean isRSSFeed() { return GroupUtil.isRssFeed(address); } + + public boolean isMmsGroup() { return GroupUtil.isMmsGroup(address); } public boolean isEmail() { return NumberUtil.isValidEmail(address); @@ -143,7 +133,7 @@ public class Address implements Parcelable, Comparable
{ } public @NonNull String toPhoneString() { - if (!isPhone() && !isPublicChat) { + if (!isPhone() && !isPublicChat()) { if (isEmail()) throw new AssertionError("Not e164, is email"); if (isGroup()) throw new AssertionError("Not e164, is group"); throw new AssertionError("Not e164, unknown"); diff --git a/src/org/thoughtcrime/securesms/database/GroupDatabase.java b/src/org/thoughtcrime/securesms/database/GroupDatabase.java index 747ed36e41..502a566f02 100644 --- a/src/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/src/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -24,7 +24,6 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPoin import java.io.Closeable; import java.io.IOException; -import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Collections; import java.util.LinkedList; @@ -52,6 +51,7 @@ public class GroupDatabase extends Database { // Loki private static final String AVATAR_URL = "avatar_url"; + private static final String ADMINS = "admins"; public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + @@ -68,6 +68,7 @@ public class GroupDatabase extends Database { ACTIVE + " INTEGER DEFAULT 1, " + AVATAR_DIGEST + " BLOB, " + AVATAR_URL + " TEXT, " + + ADMINS + " TEXT, " + MMS + " INTEGER DEFAULT 0);"; public static final String[] CREATE_INDEXS = { @@ -76,7 +77,7 @@ public class GroupDatabase extends Database { private static final String[] GROUP_PROJECTION = { GROUP_ID, TITLE, MEMBERS, AVATAR, AVATAR_ID, AVATAR_KEY, AVATAR_CONTENT_TYPE, AVATAR_RELAY, AVATAR_DIGEST, - TIMESTAMP, ACTIVE, MMS, AVATAR_URL + TIMESTAMP, ACTIVE, MMS, AVATAR_URL, ADMINS }; static final List TYPED_GROUP_PROJECTION = Stream.of(GROUP_PROJECTION).map(columnName -> TABLE_NAME + "." + columnName).toList(); @@ -116,8 +117,9 @@ public class GroupDatabase extends Database { return new Reader(cursor); } - public String getOrCreateGroupForMembers(List
members, boolean mms) { + public String getOrCreateGroupForMembers(List
members, boolean mms, List
admins) { Collections.sort(members); + Collections.sort(admins); Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, new String[] {GROUP_ID}, MEMBERS + " = ? AND " + MMS + " = ?", @@ -128,7 +130,7 @@ public class GroupDatabase extends Database { return cursor.getString(cursor.getColumnIndexOrThrow(GROUP_ID)); } else { String groupId = GroupUtil.getEncodedId(allocateGroupId(), mms); - create(groupId, null, members, null, null); + create(groupId, null, members, null, null, admins); return groupId; } } finally { @@ -150,14 +152,33 @@ public class GroupDatabase extends Database { if (!includeSelf && Util.isOwnNumber(context, member)) continue; - recipients.add(Recipient.from(context, member, false)); + if (member.isPhone()) { + recipients.add(Recipient.from(context, member, false)); + } } return recipients; } + public boolean signalGroupsHaveMember(String hexEncodedPublicKey) { + try { + Address address = Address.fromSerialized(hexEncodedPublicKey); + Reader reader = DatabaseFactory.getGroupDatabase(context).getGroups(); + GroupRecord record; + while ((record = reader.getNext()) != null) { + if (record.isSignalGroup() && record.members.contains(address)) { + return true; + } + } + + return false; + } catch (Exception e) { + return false; + } + } + public void create(@NonNull String groupId, @Nullable String title, @NonNull List
members, - @Nullable SignalServiceAttachmentPointer avatar, @Nullable String relay) + @Nullable SignalServiceAttachmentPointer avatar, @Nullable String relay, @Nullable List
admins) { Collections.sort(members); @@ -179,6 +200,10 @@ public class GroupDatabase extends Database { contentValues.put(ACTIVE, 1); contentValues.put(MMS, GroupUtil.isMmsGroup(groupId)); + if (admins != null) { + contentValues.put(ADMINS, Address.toSerializedList(admins, ',')); + } + databaseHelper.getWritableDatabase().insert(TABLE_NAME, null, contentValues); Recipient.applyCached(Address.fromSerialized(groupId), recipient -> { @@ -260,6 +285,17 @@ public class GroupDatabase extends Database { }); } + public void updateAdmins(String groupId, List
admins) { + Collections.sort(admins); + + ContentValues contents = new ContentValues(); + contents.put(ADMINS, Address.toSerializedList(admins, ',')); + contents.put(ACTIVE, 1); + + databaseHelper.getWritableDatabase().update(TABLE_NAME, contents, GROUP_ID + " = ?", + new String[] {groupId}); + } + public void remove(String groupId, Address source) { List
currentMembers = getCurrentMembers(groupId); currentMembers.remove(source); @@ -351,7 +387,8 @@ public class GroupDatabase extends Database { cursor.getInt(cursor.getColumnIndexOrThrow(ACTIVE)) == 1, cursor.getBlob(cursor.getColumnIndexOrThrow(AVATAR_DIGEST)), cursor.getInt(cursor.getColumnIndexOrThrow(MMS)) == 1, - cursor.getString(cursor.getColumnIndexOrThrow(AVATAR_URL))); + cursor.getString(cursor.getColumnIndexOrThrow(AVATAR_URL)), + cursor.getString(cursor.getColumnIndexOrThrow(ADMINS))); } @Override @@ -375,10 +412,11 @@ public class GroupDatabase extends Database { private final boolean active; private final boolean mms; private final String url; + private final List
admins; public GroupRecord(String id, String title, String members, byte[] avatar, long avatarId, byte[] avatarKey, String avatarContentType, - String relay, boolean active, byte[] avatarDigest, boolean mms, String url) + String relay, boolean active, byte[] avatarDigest, boolean mms, String url, String admins) { this.id = id; this.title = title; @@ -394,6 +432,9 @@ public class GroupDatabase extends Database { if (!TextUtils.isEmpty(members)) this.members = Address.fromSerializedList(members, ','); else this.members = new LinkedList<>(); + + if (!TextUtils.isEmpty(admins)) this.admins = Address.fromSerializedList(admins, ','); + else this.admins = new LinkedList<>(); } public byte[] getId() { @@ -448,6 +489,14 @@ public class GroupDatabase extends Database { return mms; } + public boolean isPublicChat() { return Address.fromSerialized(id).isPublicChat(); } + + public boolean isRSSFeed() { return Address.fromSerialized(id).isRSSFeed(); } + + public boolean isSignalGroup() { return Address.fromSerialized(id).isSignalGroup(); } + public String getUrl() { return url; } + + public List
getAdmins() { return admins; } } } diff --git a/src/org/thoughtcrime/securesms/database/SmsMigrator.java b/src/org/thoughtcrime/securesms/database/SmsMigrator.java index 071714e1f1..f73acd496f 100644 --- a/src/org/thoughtcrime/securesms/database/SmsMigrator.java +++ b/src/org/thoughtcrime/securesms/database/SmsMigrator.java @@ -220,7 +220,7 @@ public class SmsMigrator { memberAddresses.add(recipient.getAddress()); } - String ourGroupId = DatabaseFactory.getGroupDatabase(context).getOrCreateGroupForMembers(memberAddresses, true); + String ourGroupId = DatabaseFactory.getGroupDatabase(context).getOrCreateGroupForMembers(memberAddresses, true, null); Recipient ourGroupRecipient = Recipient.from(context, Address.fromSerialized(ourGroupId), true); long ourThreadId = threadDatabase.getThreadIdFor(ourGroupRecipient, ThreadDatabase.DistributionTypes.CONVERSATION); diff --git a/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index cb489b0971..cfa396d474 100644 --- a/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -35,14 +35,17 @@ import org.thoughtcrime.securesms.database.StickerDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob; import org.thoughtcrime.securesms.logging.Log; -import org.thoughtcrime.securesms.loki.*; +import org.thoughtcrime.securesms.loki.LokiMessageDatabase; +import org.thoughtcrime.securesms.loki.LokiThreadDatabase; import org.thoughtcrime.securesms.loki.redesign.messaging.LokiAPIDatabase; import org.thoughtcrime.securesms.loki.redesign.messaging.LokiPreKeyBundleDatabase; import org.thoughtcrime.securesms.loki.redesign.messaging.LokiPreKeyRecordDatabase; import org.thoughtcrime.securesms.loki.redesign.messaging.LokiUserDatabase; import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.service.KeyCachingService; +import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.signalservice.loki.api.LokiPublicChat; import java.io.File; @@ -76,8 +79,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int lokiV3 = 24; private static final int lokiV4 = 25; private static final int lokiV5 = 26; + private static final int lokiV6 = 27; - private static final int DATABASE_VERSION = lokiV5; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes + private static final int DATABASE_VERSION = lokiV6; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes private static final String DATABASE_NAME = "signal.db"; private final Context context; @@ -529,6 +533,43 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(LokiAPIDatabase.getCreateUserCountTableCommand()); } + if (oldVersion < lokiV6) { + // Migrate public chats from __textsecure_group__ to __loki_public_chat_group__ + try (Cursor lokiPublicChatCursor = db.rawQuery("SELECT public_chat FROM loki_public_chat_database", null)) { + while (lokiPublicChatCursor != null && lokiPublicChatCursor.moveToNext()) { + String chatString = lokiPublicChatCursor.getString(0); + LokiPublicChat publicChat = LokiPublicChat.fromJSON(chatString); + if (publicChat != null) { + byte[] groupId = publicChat.getId().getBytes(); + String oldId = GroupUtil.getEncodedId(groupId, false); + String newId = GroupUtil.getEncodedPublicChatId(groupId); + ContentValues threadUpdate = new ContentValues(); + threadUpdate.put("recipient_ids", newId); + db.update("thread", threadUpdate, "recipient_ids = ?", new String[]{ oldId }); + ContentValues groupUpdate = new ContentValues(); + groupUpdate.put("group_id", newId); + db.update("groups", groupUpdate,"group_id = ?", new String[] { oldId }); + } + } + } + + // Migrate rss feeds from __textsecure_group__ to __loki_rss_feed_group__ + String[] rssFeedIds = new String[] { "loki.network.feed", "loki.network.messenger-updates.feed" }; + for (String groupId : rssFeedIds) { + String oldId = GroupUtil.getEncodedId(groupId.getBytes(), false); + String newId = GroupUtil.getEncodedRSSFeedId(groupId.getBytes()); + ContentValues threadUpdate = new ContentValues(); + threadUpdate.put("recipient_ids", newId); + db.update("thread", threadUpdate, "recipient_ids = ?", new String[]{ oldId }); + ContentValues groupUpdate = new ContentValues(); + groupUpdate.put("group_id", newId); + db.update("groups", groupUpdate,"group_id = ?", new String[] { oldId }); + } + + // Add admin field in groups + db.execSQL("ALTER TABLE groups ADD COLUMN admins TEXT"); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/src/org/thoughtcrime/securesms/groups/GroupManager.java b/src/org/thoughtcrime/securesms/groups/GroupManager.java index e17b964d93..bcd4521bb8 100644 --- a/src/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/src/org/thoughtcrime/securesms/groups/GroupManager.java @@ -36,8 +36,13 @@ import java.util.Set; public class GroupManager { - public static long getThreadId(String id, @NonNull Context context) { - final String groupId = GroupUtil.getEncodedId(id.getBytes(), false); + public static long getPublicChatThreadId(String id, @NonNull Context context) { + final String groupId = GroupUtil.getEncodedPublicChatId(id.getBytes()); + return getThreadIdFromGroupId(groupId, context); + } + + public static long getRSSFeedThreadId(String id, @NonNull Context context) { + final String groupId = GroupUtil.getEncodedRSSFeedId(id.getBytes()); return getThreadIdFromGroupId(groupId, context); } @@ -50,11 +55,12 @@ public class GroupManager { @NonNull Set members, @Nullable Bitmap avatar, @Nullable String name, - boolean mms) + boolean mms, + @NonNull Set admins) { GroupDatabase database = DatabaseFactory.getGroupDatabase(context); String id = GroupUtil.getEncodedId(database.allocateGroupId(), mms); - return createGroup(id, context, members, avatar, name, mms); + return createGroup(id, context, members, avatar, name, mms, admins); } public static @NonNull GroupActionResult createGroup(@NonNull String id, @@ -62,56 +68,90 @@ public class GroupManager { @NonNull Set members, @Nullable Bitmap avatar, @Nullable String name, - boolean mms) + boolean mms, + @NonNull Set admins) { final byte[] avatarBytes = BitmapUtil.toByteArray(avatar); final GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); final String groupId = GroupUtil.getEncodedId(id.getBytes(), mms); final Recipient groupRecipient = Recipient.from(context, Address.fromSerialized(groupId), false); final Set
memberAddresses = getMemberAddresses(members); + final Set
adminAddresses = getMemberAddresses(admins); - memberAddresses.add(Address.fromSerialized(TextSecurePreferences.getLocalNumber(context))); - groupDatabase.create(groupId, name, new LinkedList<>(memberAddresses), null, null); + String masterHexEncodedPublicKey = TextSecurePreferences.getMasterHexEncodedPublicKey(context); + String ourNumber = masterHexEncodedPublicKey != null ? masterHexEncodedPublicKey : TextSecurePreferences.getLocalNumber(context); + + memberAddresses.add(Address.fromSerialized(ourNumber)); + groupDatabase.create(groupId, name, new LinkedList<>(memberAddresses), null, null, new LinkedList<>(adminAddresses)); if (!mms) { groupDatabase.updateAvatar(groupId, avatarBytes); DatabaseFactory.getRecipientDatabase(context).setProfileSharing(groupRecipient, true); - } - - long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient, ThreadDatabase.DistributionTypes.CONVERSATION); - return new GroupActionResult(groupRecipient, threadId); - - /* Loki: Original Code - ================== - if (!mms) { - groupDatabase.updateAvatar(groupId, avatarBytes); - DatabaseFactory.getRecipientDatabase(context).setProfileSharing(groupRecipient, true); - return sendGroupUpdate(context, groupId, memberAddresses, name, avatarBytes); + return sendGroupUpdate(context, groupId, memberAddresses, name, avatarBytes, adminAddresses); } else { long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient, ThreadDatabase.DistributionTypes.CONVERSATION); return new GroupActionResult(groupRecipient, threadId); } - */ + } + + public static @NonNull GroupActionResult createPublicChatGroup(@NonNull String id, + @NonNull Context context, + @Nullable Bitmap avatar, + @Nullable String name) + { + final String groupId = GroupUtil.getEncodedPublicChatId(id.getBytes()); + return createLokiGroup(groupId, context, avatar, name); + } + + public static @NonNull GroupActionResult createRSSFeedGroup(@NonNull String id, + @NonNull Context context, + @Nullable Bitmap avatar, + @Nullable String name) + { + final String groupId = GroupUtil.getEncodedRSSFeedId(id.getBytes()); + return createLokiGroup(groupId, context, avatar, name); + } + + private static @NonNull GroupActionResult createLokiGroup(@NonNull String groupId, + @NonNull Context context, + @Nullable Bitmap avatar, + @Nullable String name) + { + final byte[] avatarBytes = BitmapUtil.toByteArray(avatar); + final GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + final Recipient groupRecipient = Recipient.from(context, Address.fromSerialized(groupId), false); + final Set
memberAddresses = new HashSet<>(); + + memberAddresses.add(Address.fromSerialized(TextSecurePreferences.getLocalNumber(context))); + groupDatabase.create(groupId, name, new LinkedList<>(memberAddresses), null, null, new LinkedList<>()); + + groupDatabase.updateAvatar(groupId, avatarBytes); + + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient, ThreadDatabase.DistributionTypes.CONVERSATION); + return new GroupActionResult(groupRecipient, threadId); } public static GroupActionResult updateGroup(@NonNull Context context, @NonNull String groupId, @NonNull Set members, @Nullable Bitmap avatar, - @Nullable String name) + @Nullable String name, + @NonNull Set admins) throws InvalidNumberException { final GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); final Set
memberAddresses = getMemberAddresses(members); + final Set
adminAddresses = getMemberAddresses(admins); final byte[] avatarBytes = BitmapUtil.toByteArray(avatar); memberAddresses.add(Address.fromSerialized(TextSecurePreferences.getLocalNumber(context))); groupDatabase.updateMembers(groupId, new LinkedList<>(memberAddresses)); + groupDatabase.updateAdmins(groupId, new LinkedList<>(adminAddresses)); groupDatabase.updateTitle(groupId, name); groupDatabase.updateAvatar(groupId, avatarBytes); if (!GroupUtil.isMmsGroup(groupId)) { - return sendGroupUpdate(context, groupId, memberAddresses, name, avatarBytes); + return sendGroupUpdate(context, groupId, memberAddresses, name, avatarBytes, adminAddresses); } else { Recipient groupRecipient = Recipient.from(context, Address.fromSerialized(groupId), true); long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient); @@ -123,7 +163,8 @@ public class GroupManager { @NonNull String groupId, @NonNull Set
members, @Nullable String groupName, - @Nullable byte[] avatar) + @Nullable byte[] avatar, + @NonNull Set
admins) { try { Attachment avatarAttachment = null; @@ -131,15 +172,20 @@ public class GroupManager { Recipient groupRecipient = Recipient.from(context, groupAddress, false); List numbers = new LinkedList<>(); - for (Address member : members) { numbers.add(member.serialize()); } + List adminNumbers = new LinkedList<>(); + for (Address admin : admins) { + adminNumbers.add(admin.serialize()); + } + GroupContext.Builder groupContextBuilder = GroupContext.newBuilder() .setId(ByteString.copyFrom(GroupUtil.getDecodedId(groupId))) .setType(GroupContext.Type.UPDATE) - .addAllMembers(numbers); + .addAllMembers(numbers) + .addAllAdmins(adminNumbers); if (groupName != null) groupContextBuilder.setName(groupName); GroupContext groupContext = groupContextBuilder.build(); diff --git a/src/org/thoughtcrime/securesms/groups/GroupMessageProcessor.java b/src/org/thoughtcrime/securesms/groups/GroupMessageProcessor.java index 4033a8823f..9f216e2fad 100644 --- a/src/org/thoughtcrime/securesms/groups/GroupMessageProcessor.java +++ b/src/org/thoughtcrime/securesms/groups/GroupMessageProcessor.java @@ -8,6 +8,7 @@ import android.support.annotation.Nullable; import com.google.protobuf.ByteString; import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.crypto.storage.TextSecureSessionStore; import org.thoughtcrime.securesms.database.Address; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; @@ -23,14 +24,20 @@ import org.thoughtcrime.securesms.notifications.MessageNotifier; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.sms.IncomingGroupMessage; import org.thoughtcrime.securesms.sms.IncomingTextMessage; +import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.util.Base64; import org.thoughtcrime.securesms.util.GroupUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.SignalProtocolAddress; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; 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.SignalServiceGroup.Type; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.loki.api.LokiStorageAPI; +import org.whispersystems.signalservice.loki.utilities.PromiseUtil; import java.util.Collections; import java.util.HashSet; @@ -38,6 +45,8 @@ import java.util.LinkedList; import java.util.List; import java.util.Set; +import kotlin.Unit; + import static org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord; import static org.whispersystems.signalservice.internal.push.SignalServiceProtos.AttachmentPointer; import static org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext; @@ -58,7 +67,7 @@ public class GroupMessageProcessor { GroupDatabase database = DatabaseFactory.getGroupDatabase(context); SignalServiceGroup group = message.getGroupInfo().get(); - String id = GroupUtil.getEncodedId(group.getGroupId(), false); + String id = GroupUtil.getEncodedId(group); Optional record = database.getGroup(id); if (record.isPresent() && group.getType() == Type.UPDATE) { @@ -81,12 +90,13 @@ public class GroupMessageProcessor { boolean outgoing) { GroupDatabase database = DatabaseFactory.getGroupDatabase(context); - String id = GroupUtil.getEncodedId(group.getGroupId(), false); + String id = GroupUtil.getEncodedId(group); GroupContext.Builder builder = createGroupContext(group); builder.setType(GroupContext.Type.UPDATE); SignalServiceAttachment avatar = group.getAvatar().orNull(); List
members = group.getMembers().isPresent() ? new LinkedList
() : null; + List
admins = group.getAdmins().isPresent() ? new LinkedList<>() : null; if (group.getMembers().isPresent()) { for (String member : group.getMembers().get()) { @@ -94,8 +104,25 @@ public class GroupMessageProcessor { } } + // We should only create the group if we are part of the member list + String hexEncodedPublicKey = getMasterHexEncodedPublicKey(context, TextSecurePreferences.getLocalNumber(context)); + if (members == null || !members.contains(Address.fromSerialized(hexEncodedPublicKey))) { + Log.d("Loki - Group Message", "Received a group create message which doesn't include us in the member list. Ignoring."); + return null; + } + + if (group.getAdmins().isPresent()) { + for (String admin : group.getAdmins().get()) { + admins.add(Address.fromExternal(context, admin)); + } + } + database.create(id, group.getName().orNull(), members, - avatar != null && avatar.isPointer() ? avatar.asPointer() : null, null); + avatar != null && avatar.isPointer() ? avatar.asPointer() : null, null, admins); + + if (group.getMembers().isPresent()) { + establishSessionsWithMembersIfNeeded(context, group.getMembers().get()); + } return storeMessage(context, content, group, builder.build(), outgoing); } @@ -108,7 +135,16 @@ public class GroupMessageProcessor { { GroupDatabase database = DatabaseFactory.getGroupDatabase(context); - String id = GroupUtil.getEncodedId(group.getGroupId(), false); + String id = GroupUtil.getEncodedId(group); + + // Only update group if admin sent the message + if (group.getGroupType() == SignalServiceGroup.GroupType.SIGNAL) { + String hexEncodedPublicKey = getMasterHexEncodedPublicKey(context, content.getSender()); + if (!groupRecord.getAdmins().contains(Address.fromSerialized(hexEncodedPublicKey))) { + Log.d("Loki - Group Message", "Received a group update message from a non-admin user for " + id +". Ignoring."); + return null; + } + } Set
recordMembers = new HashSet<>(groupRecord.getMembers()); Set
messageMembers = new HashSet<>(); @@ -141,7 +177,9 @@ public class GroupMessageProcessor { } if (missingMembers.size() > 0) { - // TODO We should tell added and missing about each-other. + for (Address removedMember : missingMembers) { + builder.addMembers(removedMember.serialize()); + } } if (group.getName().isPresent() || group.getAvatar().isPresent()) { @@ -155,6 +193,10 @@ public class GroupMessageProcessor { if (!groupRecord.isActive()) database.setActive(id, true); + if (group.getMembers().isPresent()) { + establishSessionsWithMembersIfNeeded(context, group.getMembers().get()); + } + return storeMessage(context, content, group, builder.build(), outgoing); } @@ -163,7 +205,10 @@ public class GroupMessageProcessor { @NonNull SignalServiceGroup group, @NonNull GroupRecord record) { - if (record.getMembers().contains(Address.fromExternal(context, content.getSender()))) { + String hexEncodedPublicKey = getMasterHexEncodedPublicKey(context, content.getSender()); + String ourPublicKey = getMasterHexEncodedPublicKey(context, TextSecurePreferences.getLocalNumber(context)); + // If the requester is a group member and we are admin then we should send them the group update + if (record.getMembers().contains(Address.fromSerialized(hexEncodedPublicKey)) && record.getAdmins().contains(Address.fromSerialized(ourPublicKey))) { ApplicationContext.getInstance(context) .getJobManager() .add(new PushGroupUpdateJob(content.getSender(), group.getGroupId())); @@ -179,14 +224,15 @@ public class GroupMessageProcessor { boolean outgoing) { GroupDatabase database = DatabaseFactory.getGroupDatabase(context); - String id = GroupUtil.getEncodedId(group.getGroupId(), false); + String id = GroupUtil.getEncodedId(group); List
members = record.getMembers(); GroupContext.Builder builder = createGroupContext(group); builder.setType(GroupContext.Type.QUIT); - if (members.contains(Address.fromExternal(context, content.getSender()))) { - database.remove(id, Address.fromExternal(context, content.getSender())); + String hexEncodedPublicKey = getMasterHexEncodedPublicKey(context, content.getSender()); + if (members.contains(Address.fromExternal(context, hexEncodedPublicKey))) { + database.remove(id, Address.fromExternal(context, hexEncodedPublicKey)); if (outgoing) database.setActive(id, false); return storeMessage(context, content, group, builder.build(), outgoing); @@ -204,14 +250,14 @@ public class GroupMessageProcessor { { if (group.getAvatar().isPresent()) { ApplicationContext.getInstance(context).getJobManager() - .add(new AvatarDownloadJob(group.getGroupId())); + .add(new AvatarDownloadJob(GroupUtil.getEncodedId(group))); } try { if (outgoing) { MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context); - Address addres = Address.fromExternal(context, GroupUtil.getEncodedId(group.getGroupId(), false)); - Recipient recipient = Recipient.from(context, addres, false); + Address address = Address.fromExternal(context, GroupUtil.getEncodedId(group)); + Recipient recipient = Recipient.from(context, address, false); OutgoingGroupMediaMessage outgoingMessage = new OutgoingGroupMediaMessage(recipient, storage, null, content.getTimestamp(), 0, null, Collections.emptyList(), Collections.emptyList()); long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient); long messageId = mmsDatabase.insertMessageOutbox(outgoingMessage, threadId, false, null); @@ -260,7 +306,38 @@ public class GroupMessageProcessor { builder.addAllMembers(group.getMembers().get()); } + if (group.getAdmins().isPresent()) { + builder.addAllAdmins(group.getAdmins().get()); + } + return builder; } + private static String getMasterHexEncodedPublicKey(Context context, String hexEncodedPublicKey) { + String ourPublicKey = TextSecurePreferences.getLocalNumber(context); + try { + String masterHexEncodedPublicKey = hexEncodedPublicKey.equalsIgnoreCase(ourPublicKey) + ? TextSecurePreferences.getMasterHexEncodedPublicKey(context) + : PromiseUtil.timeout(LokiStorageAPI.shared.getPrimaryDevicePublicKey(hexEncodedPublicKey), 5000).get(); + return masterHexEncodedPublicKey != null ? masterHexEncodedPublicKey : hexEncodedPublicKey; + } catch (Exception e) { + return hexEncodedPublicKey; + } + } + + private static void establishSessionsWithMembersIfNeeded(Context context, List members) { + String ourNumber = TextSecurePreferences.getLocalNumber(context); + for (String member : members) { + // Make sure we have session with all of the members secondary devices + LokiStorageAPI.shared.getAllDevicePublicKeys(member).success(devices -> { + if (devices.contains(ourNumber)) { return Unit.INSTANCE; } + for (String device : devices) { + SignalProtocolAddress protocolAddress = new SignalProtocolAddress(device, SignalServiceAddress.DEFAULT_DEVICE_ID); + boolean haveSession = new TextSecureSessionStore(context).containsSession(protocolAddress); + if (!haveSession) { MessageSender.sendBackgroundSessionRequest(context, device); } + } + return Unit.INSTANCE; + }); + } + } } diff --git a/src/org/thoughtcrime/securesms/jobs/AvatarDownloadJob.java b/src/org/thoughtcrime/securesms/jobs/AvatarDownloadJob.java index 3536051086..77e4e1c883 100644 --- a/src/org/thoughtcrime/securesms/jobs/AvatarDownloadJob.java +++ b/src/org/thoughtcrime/securesms/jobs/AvatarDownloadJob.java @@ -40,9 +40,9 @@ public class AvatarDownloadJob extends BaseJob implements InjectableType { @Inject SignalServiceMessageReceiver receiver; - private byte[] groupId; + private String groupId; - public AvatarDownloadJob(@NonNull byte[] groupId) { + public AvatarDownloadJob(@NonNull String groupId) { this(new Job.Parameters.Builder() .addConstraint(NetworkConstraint.KEY) .setMaxAttempts(10) @@ -50,14 +50,14 @@ public class AvatarDownloadJob extends BaseJob implements InjectableType { groupId); } - private AvatarDownloadJob(@NonNull Job.Parameters parameters, @NonNull byte[] groupId) { + private AvatarDownloadJob(@NonNull Job.Parameters parameters, @NonNull String groupId) { super(parameters); this.groupId = groupId; } @Override public @NonNull Data serialize() { - return new Data.Builder().putString(KEY_GROUP_ID, GroupUtil.getEncodedId(groupId, false)).build(); + return new Data.Builder().putString(KEY_GROUP_ID, groupId).build(); } @Override @@ -67,9 +67,8 @@ public class AvatarDownloadJob extends BaseJob implements InjectableType { @Override public void onRun() throws IOException { - String encodeId = GroupUtil.getEncodedId(groupId, false); GroupDatabase database = DatabaseFactory.getGroupDatabase(context); - Optional record = database.getGroup(encodeId); + Optional record = database.getGroup(groupId); File attachment = null; try { @@ -97,7 +96,7 @@ public class AvatarDownloadJob extends BaseJob implements InjectableType { InputStream inputStream = receiver.retrieveAttachment(pointer, attachment, MAX_AVATAR_SIZE); Bitmap avatar = BitmapUtil.createScaledBitmap(context, new AttachmentModel(attachment, key, 0, digest), 500, 500); - database.updateAvatar(encodeId, avatar); + database.updateAvatar(groupId, avatar); inputStream.close(); } } catch (BitmapDecodingException | NonSuccessfulResponseCodeException | InvalidMessageException e) { @@ -120,11 +119,7 @@ public class AvatarDownloadJob extends BaseJob implements InjectableType { public static final class Factory implements Job.Factory { @Override public @NonNull AvatarDownloadJob create(@NonNull Parameters parameters, @NonNull Data data) { - try { - return new AvatarDownloadJob(parameters, GroupUtil.getDecodedId(data.getString(KEY_GROUP_ID))); - } catch (IOException e) { - throw new AssertionError(e); - } + return new AvatarDownloadJob(parameters, data.getString(KEY_GROUP_ID)); } } } diff --git a/src/org/thoughtcrime/securesms/jobs/DirectoryRefreshJob.java b/src/org/thoughtcrime/securesms/jobs/DirectoryRefreshJob.java index 5b367dd335..24d4e7d48b 100644 --- a/src/org/thoughtcrime/securesms/jobs/DirectoryRefreshJob.java +++ b/src/org/thoughtcrime/securesms/jobs/DirectoryRefreshJob.java @@ -9,9 +9,7 @@ import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; import org.thoughtcrime.securesms.logging.Log; - import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.util.DirectoryHelper; import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; import java.io.IOException; @@ -67,11 +65,11 @@ public class DirectoryRefreshJob extends BaseJob { public void onRun() throws IOException { Log.i(TAG, "DirectoryRefreshJob.onRun()"); - if (recipient == null) { - DirectoryHelper.refreshDirectory(context, notifyOfNewUsers); - } else { - DirectoryHelper.refreshDirectoryFor(context, recipient); - } +// if (recipient == null) { +// DirectoryHelper.refreshDirectory(context, notifyOfNewUsers); +// } else { +// DirectoryHelper.refreshDirectoryFor(context, recipient); +// } } @Override diff --git a/src/org/thoughtcrime/securesms/jobs/MmsDownloadJob.java b/src/org/thoughtcrime/securesms/jobs/MmsDownloadJob.java index ac6e206930..47411c08f0 100644 --- a/src/org/thoughtcrime/securesms/jobs/MmsDownloadJob.java +++ b/src/org/thoughtcrime/securesms/jobs/MmsDownloadJob.java @@ -244,7 +244,7 @@ public class MmsDownloadJob extends BaseJob { } if (members.size() > 2) { - group = Optional.of(Address.fromSerialized(DatabaseFactory.getGroupDatabase(context).getOrCreateGroupForMembers(new LinkedList<>(members), true))); + group = Optional.of(Address.fromSerialized(DatabaseFactory.getGroupDatabase(context).getOrCreateGroupForMembers(new LinkedList<>(members), true, new LinkedList<>()))); } IncomingMediaMessage message = new IncomingMediaMessage(from, group, body, retrieved.getDate() * 1000L, attachments, subscriptionId, 0, false, false); diff --git a/src/org/thoughtcrime/securesms/jobs/MultiDeviceGroupUpdateJob.java b/src/org/thoughtcrime/securesms/jobs/MultiDeviceGroupUpdateJob.java index be44c92233..52a81d5d57 100644 --- a/src/org/thoughtcrime/securesms/jobs/MultiDeviceGroupUpdateJob.java +++ b/src/org/thoughtcrime/securesms/jobs/MultiDeviceGroupUpdateJob.java @@ -85,7 +85,7 @@ public class MultiDeviceGroupUpdateJob extends BaseJob implements InjectableType reader = DatabaseFactory.getGroupDatabase(context).getGroups(); while ((record = reader.getNext()) != null) { - if (!record.isMms()) { + if (!record.isMms() && !record.isPublicChat() && !record.isRSSFeed()) { List members = new LinkedList<>(); for (Address member : record.getMembers()) { diff --git a/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java b/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java index c8a5e426b6..6282120598 100644 --- a/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java +++ b/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java @@ -291,6 +291,9 @@ public class PushDecryptJob extends BaseJob implements InjectableType { // Loki - Handle friend request acceptance if needed acceptFriendRequestIfNeeded(content); + // Loki - Session requests + handleSessionRequestIfNeeded(content); + // Loki - Store pre key bundle // We shouldn't store it if it's a pairing message if (!content.getPairingAuthorisation().isPresent()) { @@ -335,7 +338,8 @@ public class PushDecryptJob extends BaseJob implements InjectableType { } } else { // Loki - Don't process session restore message any further - if (message.isSessionRestore()) { return; } + if (message.isSessionRestore() || message.isSessionRequest()) { return; } + if (message.isEndSession()) handleEndSessionMessage(content, smsMessageId); else if (message.isGroupUpdate()) handleGroupMessage(content, message, smsMessageId); else if (message.isExpirationUpdate()) @@ -345,7 +349,7 @@ public class PushDecryptJob extends BaseJob implements InjectableType { else if (message.getBody().isPresent()) handleTextMessage(content, message, smsMessageId, Optional.absent()); - if (message.getGroupInfo().isPresent() && groupDatabase.isUnknownGroup(GroupUtil.getEncodedId(message.getGroupInfo().get().getGroupId(), false))) { + if (message.getGroupInfo().isPresent() && groupDatabase.isUnknownGroup(GroupUtil.getEncodedId(message.getGroupInfo().get()))) { handleUnknownGroupMessage(content, message.getGroupInfo().get()); } @@ -601,9 +605,11 @@ public class PushDecryptJob extends BaseJob implements InjectableType { private void handleUnknownGroupMessage(@NonNull SignalServiceContent content, @NonNull SignalServiceGroup group) { - ApplicationContext.getInstance(context) - .getJobManager() - .add(new RequestGroupInfoJob(content.getSender(), group.getGroupId())); + if (group.getGroupType() == SignalServiceGroup.GroupType.SIGNAL) { + ApplicationContext.getInstance(context) + .getJobManager() + .add(new RequestGroupInfoJob(content.getSender(), group.getGroupId())); + } } private void handleExpirationUpdate(@NonNull SignalServiceContent content, @@ -690,7 +696,7 @@ public class PushDecryptJob extends BaseJob implements InjectableType { Log.d("Loki", "Sent friend request to " + pubKey); } else if (status == LokiThreadFriendRequestStatus.REQUEST_RECEIVED) { // Accept the incoming friend request - becomeFriendsWithContact(pubKey, false); + becomeFriendsWithContact(pubKey, false, false); // Send them an accept message back MessageSender.sendBackgroundMessage(context, pubKey); Log.d("Loki", "Became friends with " + deviceContact.getNumber()); @@ -728,7 +734,7 @@ public class PushDecryptJob extends BaseJob implements InjectableType { threadId = handleSynchronizeSentTextMessage(message); } - if (message.getMessage().getGroupInfo().isPresent() && groupDatabase.isUnknownGroup(GroupUtil.getEncodedId(message.getMessage().getGroupInfo().get().getGroupId(), false))) { + if (message.getMessage().getGroupInfo().isPresent() && groupDatabase.isUnknownGroup(GroupUtil.getEncodedId(message.getMessage().getGroupInfo().get()))) { handleUnknownGroupMessage(content, message.getMessage().getGroupInfo().get()); } @@ -738,7 +744,7 @@ public class PushDecryptJob extends BaseJob implements InjectableType { Recipient recipient = null; if (message.getDestination().isPresent()) recipient = Recipient.from(context, Address.fromSerialized(message.getDestination().get()), false); - else if (message.getMessage().getGroupInfo().isPresent()) recipient = Recipient.from(context, Address.fromSerialized(GroupUtil.getEncodedId(message.getMessage().getGroupInfo().get().getGroupId(), false)), false); + else if (message.getMessage().getGroupInfo().isPresent()) recipient = Recipient.from(context, Address.fromSerialized(GroupUtil.getEncodedId(message.getMessage().getGroupInfo().get())), false); if (recipient != null && !recipient.isSystemContact() && !recipient.isProfileSharing()) { @@ -845,8 +851,16 @@ public class PushDecryptJob extends BaseJob implements InjectableType { Optional> linkPreviews = getLinkPreviews(message.getPreviews(), message.getBody().or("")); Optional sticker = getStickerAttachment(message.getSticker()); - // 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(); + Address sender = primaryDeviceRecipient.getAddress(); + + // If message is from group then we need to map it to get the sender of the message + if (message.isGroupMessage()) { + sender = getPrimaryDeviceRecipient(content.getSender()).getAddress(); + } + + // Ignore messages from ourselves + if (sender.serialize().equalsIgnoreCase(TextSecurePreferences.getLocalNumber(context))) { return; } + IncomingMediaMessage mediaMessage = new IncomingMediaMessage(sender, message.getTimestamp(), -1, message.getExpiresInSeconds() * 1000L, false, content.isNeedsReceipt(), message.getBody(), message.getGroupInfo(), message.getAttachments(), quote, sharedContacts, linkPreviews, sticker); @@ -1030,8 +1044,16 @@ public class PushDecryptJob extends BaseJob implements InjectableType { } else { notifyTypingStoppedFromIncomingMessage(primaryDeviceRecipient, content.getSender(), content.getSenderDevice()); - // 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(); + Address sender = primaryDeviceRecipient.getAddress(); + + // If message is from group then we need to map it to get the sender of the message + if (message.isGroupMessage()) { + sender = getPrimaryDeviceRecipient(content.getSender()).getAddress(); + } + + // Ignore messages from ourselves + if (sender.serialize().equalsIgnoreCase(TextSecurePreferences.getLocalNumber(context))) { return; } + IncomingTextMessage _textMessage = new IncomingTextMessage(sender, content.getSenderDevice(), message.getTimestamp(), body, @@ -1217,10 +1239,29 @@ public class PushDecryptJob extends BaseJob implements InjectableType { private void acceptFriendRequestIfNeeded(@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 (content.isFriendRequest() || isGroupChatMessage(content)) { return; } - becomeFriendsWithContact(content.getSender(), true); + becomeFriendsWithContact(content.getSender(), true, false); } - private void becomeFriendsWithContact(String pubKey, boolean syncContact) { + private void handleSessionRequestIfNeeded(@NonNull SignalServiceContent content) { + if (content.isFriendRequest() && isSessionRequest(content)) { + // Check if the session request from a member in one of our groups or our friend + LokiStorageAPI.shared.getPrimaryDevicePublicKey(content.getSender()).success(primaryDevicePublicKey -> { + String sender = primaryDevicePublicKey != null ? primaryDevicePublicKey : content.getSender(); + long threadID = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(Recipient.from(context, Address.fromSerialized(sender), false)); + LokiThreadFriendRequestStatus threadFriendRequestStatus = DatabaseFactory.getLokiThreadDatabase(context).getFriendRequestStatus(threadID); + boolean isOurFriend = threadFriendRequestStatus == LokiThreadFriendRequestStatus.FRIENDS; + boolean isInOneOfOurGroups = DatabaseFactory.getGroupDatabase(context).signalGroupsHaveMember(sender); + boolean shouldAcceptSessionRequest = isOurFriend || isInOneOfOurGroups; + if (shouldAcceptSessionRequest) { + // Send a background message to acknowledge session request + MessageSender.sendBackgroundMessage(context, content.getSender()); + } + return Unit.INSTANCE; + }); + } + } + + private void becomeFriendsWithContact(String pubKey, boolean syncContact, boolean force) { LokiThreadDatabase lokiThreadDatabase = DatabaseFactory.getLokiThreadDatabase(context); Recipient contactID = Recipient.from(context, Address.fromSerialized(pubKey), false); if (contactID.isGroupRecipient()) return; @@ -1228,6 +1269,11 @@ public class PushDecryptJob extends BaseJob implements InjectableType { long threadID = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(contactID); LokiThreadFriendRequestStatus threadFriendRequestStatus = lokiThreadDatabase.getFriendRequestStatus(threadID); if (threadFriendRequestStatus == LokiThreadFriendRequestStatus.FRIENDS) { return; } + + // We shouldn't be able to skip from None -> Friends in normal circumstances. + // Multi-device is the exception to this rule because we want to automatically be friends with a secondary device + if (!force && threadFriendRequestStatus == LokiThreadFriendRequestStatus.NONE) { 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); @@ -1248,13 +1294,13 @@ public class PushDecryptJob extends BaseJob implements InjectableType { } private void updateFriendRequestStatusIfNeeded(@NonNull SignalServiceContent content, @NonNull SignalServiceDataMessage message) { - if (!content.isFriendRequest() || message.isGroupUpdate()) { return; } + if (!content.isFriendRequest() || message.isGroupMessage() || message.isSessionRequest()) { return; } // This handles the case where another user sends us a regular message without authorisation Promise promise = PromiseUtil.timeout(MultiDeviceUtilities.shouldAutomaticallyBecomeFriendsWithDevice(content.getSender(), context), 8000); boolean shouldBecomeFriends = PromiseUtil.get(promise, false); if (shouldBecomeFriends) { // Become friends AND update the message they sent - becomeFriendsWithContact(content.getSender(), true); + becomeFriendsWithContact(content.getSender(), true, true); // Send them an accept message back MessageSender.sendBackgroundMessage(context, content.getSender()); } else { @@ -1561,10 +1607,11 @@ public class PushDecryptJob extends BaseJob implements InjectableType { long threadId; if (typingMessage.getGroupId().isPresent()) { + // Typing messages should only apply to signal groups, thus we use `getEncodedId` Address groupAddress = Address.fromSerialized(GroupUtil.getEncodedId(typingMessage.getGroupId().get(), false)); Recipient groupRecipient = Recipient.from(context, groupAddress, false); - threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient); + threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdIfExistsFor(groupRecipient); } else { // See if we need to redirect the message author = getPrimaryDeviceRecipient(content.getSender()); @@ -1712,15 +1759,15 @@ public class PushDecryptJob extends BaseJob implements InjectableType { } private Recipient getSyncMessageDestination(SentTranscriptMessage message) { - if (message.getMessage().getGroupInfo().isPresent()) { - return Recipient.from(context, Address.fromSerialized(GroupUtil.getEncodedId(message.getMessage().getGroupInfo().get().getGroupId(), false)), false); + if (message.getMessage().isGroupMessage()) { + return Recipient.from(context, Address.fromSerialized(GroupUtil.getEncodedId(message.getMessage().getGroupInfo().get())), false); } else { return Recipient.from(context, Address.fromSerialized(message.getDestination().get()), false); } } private Recipient getSyncMessagePrimaryDestination(SentTranscriptMessage message) { - if (message.getMessage().getGroupInfo().isPresent()) { + if (message.getMessage().isGroupMessage()) { return getSyncMessageDestination(message); } else { return getPrimaryDeviceRecipient(message.getDestination().get()); @@ -1728,15 +1775,15 @@ public class PushDecryptJob extends BaseJob implements InjectableType { } 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); + if (message.isGroupMessage()) { + return Recipient.from(context, Address.fromSerialized(GroupUtil.getEncodedId(message.getGroupInfo().get())), false); } else { return Recipient.from(context, Address.fromSerialized(content.getSender()), false); } } private Recipient getMessagePrimaryDestination(SignalServiceContent content, SignalServiceDataMessage message) { - if (message.getGroupInfo().isPresent()) { + if (message.isGroupMessage()) { return getMessageDestination(content, message); } else { return getPrimaryDeviceRecipient(content.getSender()); @@ -1794,7 +1841,7 @@ public class PushDecryptJob extends BaseJob implements InjectableType { return true; } else if (conversation.isGroupRecipient()) { GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); - Optional groupId = message.getGroupInfo().isPresent() ? Optional.of(GroupUtil.getEncodedId(message.getGroupInfo().get().getGroupId(), false)) + Optional groupId = message.getGroupInfo().isPresent() ? Optional.of(GroupUtil.getEncodedId(message.getGroupInfo().get())) : Optional.absent(); if (groupId.isPresent() && groupDatabase.isUnknownGroup(groupId.get())) { @@ -1830,8 +1877,12 @@ public class PushDecryptJob extends BaseJob implements InjectableType { return false; } + private boolean isSessionRequest(SignalServiceContent content) { + return content.getDataMessage().isPresent() && content.getDataMessage().get().isSessionRequest(); + } + private boolean isGroupChatMessage(SignalServiceContent content) { - return content.getDataMessage().isPresent() && content.getDataMessage().get().getGroupInfo().isPresent(); + return content.getDataMessage().isPresent() && content.getDataMessage().get().isGroupMessage(); } private void resetRecipientToPush(@NonNull Recipient recipient) { diff --git a/src/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java b/src/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java index d5d4fa6c99..2bc5241f58 100644 --- a/src/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java +++ b/src/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java @@ -12,8 +12,10 @@ import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.attachments.DatabaseAttachment; import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.crypto.storage.TextSecureSessionStore; import org.thoughtcrime.securesms.database.Address; import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupReceiptDatabase.GroupReceiptInfo; import org.thoughtcrime.securesms.database.MmsDatabase; import org.thoughtcrime.securesms.database.NoSuchMessageException; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; @@ -25,13 +27,17 @@ import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.JobManager; import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.loki.MultiDeviceUtilities; import org.thoughtcrime.securesms.mms.MmsException; import org.thoughtcrime.securesms.mms.OutgoingGroupMediaMessage; import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.transport.RetryLaterException; import org.thoughtcrime.securesms.transport.UndeliverableMessageException; import org.thoughtcrime.securesms.util.GroupUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.SignalProtocolAddress; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.SignalServiceMessageSender; import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; @@ -46,10 +52,13 @@ import org.whispersystems.signalservice.api.messages.shared.SharedContact; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext; import org.whispersystems.signalservice.loki.api.LokiPublicChat; +import org.whispersystems.signalservice.loki.api.LokiStorageAPI; +import org.whispersystems.signalservice.loki.utilities.PromiseUtil; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Set; @@ -157,7 +166,28 @@ public class PushGroupSendJob extends PushSendJob implements InjectableType { else if (!existingNetworkFailures.isEmpty()) target = Stream.of(existingNetworkFailures).map(NetworkFailure::getAddress).toList(); else target = getGroupMessageRecipients(message.getRecipient().getAddress().toGroupString(), messageId); - List results = deliver(message, target); + String localNumber = TextSecurePreferences.getLocalNumber(context); + + // Only send messages to the contacts we have sessions with + List
validTargets = Stream.of(target).filter(member -> { + // Our device is always valid + if (member.serialize().equalsIgnoreCase(localNumber)) { return true; } + + SignalProtocolAddress protocolAddress = new SignalProtocolAddress(member.toPhoneString(), SignalServiceAddress.DEFAULT_DEVICE_ID); + boolean hasSession = new TextSecureSessionStore(context).containsSession(protocolAddress); + if (hasSession) { return true; } + + // We should allow sending if we have a prekeybundle for the contact + return DatabaseFactory.getLokiPreKeyBundleDatabase(context).hasPreKeyBundle(member.toPhoneString()); + }).toList(); + + // Send a session request to the other devices + List
others = Stream.of(target).filter(t -> !validTargets.contains(t)).toList(); + for (Address device : others) { + MessageSender.sendBackgroundSessionRequest(context, device.toPhoneString()); + } + + List results = deliver(message, validTargets); List networkFailures = Stream.of(results).filter(SendMessageResult::isNetworkFailure).map(result -> new NetworkFailure(Address.fromSerialized(result.getAddress().getNumber()))).toList(); List identityMismatches = Stream.of(results).filter(result -> result.getIdentityFailure() != null).map(result -> new IdentityKeyMismatch(Address.fromSerialized(result.getAddress().getNumber()), result.getIdentityFailure().getIdentityKey())).toList(); Set
successAddresses = Stream.of(results).filter(result -> result.getSuccess() != null).map(result -> Address.fromSerialized(result.getAddress().getNumber())).collect(Collectors.toSet()); @@ -231,7 +261,15 @@ public class PushGroupSendJob extends PushSendJob implements InjectableType { throws IOException, UntrustedIdentityException, UndeliverableMessageException { // rotateSenderCertificateIfNecessary(); - String groupId = message.getRecipient().getAddress().toGroupString(); + // Messages shouldn't be able to be sent to RSS Feeds + Address groupAddress = message.getRecipient().getAddress(); + if (groupAddress.isRSSFeed()) { + List results = new ArrayList<>(); + for (Address destination : destinations) results.add(SendMessageResult.networkFailure(new SignalServiceAddress(destination.toPhoneString()))); + return results; + } + + String groupId = groupAddress.toGroupString(); Optional profileKey = getProfileKey(message.getRecipient()); Optional quote = getQuoteFor(message); Optional sticker = getStickerFor(message); @@ -247,24 +285,28 @@ public class PushGroupSendJob extends PushSendJob implements InjectableType { .map(recipient -> UnidentifiedAccessUtil.getAccessFor(context, recipient)) .toList(); - if (message.isGroup()) { + SignalServiceGroup.GroupType groupType = SignalServiceGroup.GroupType.SIGNAL; + if (groupAddress.isPublicChat()) { + groupType = SignalServiceGroup.GroupType.PUBLIC_CHAT; + } + + if (message.isGroup() && groupAddress.isSignalGroup()) { + // Loki - Only send GroupUpdate or GroupQuit to signal groups OutgoingGroupMediaMessage groupMessage = (OutgoingGroupMediaMessage) message; GroupContext groupContext = groupMessage.getGroupContext(); SignalServiceAttachment avatar = attachmentPointers.isEmpty() ? null : attachmentPointers.get(0); SignalServiceGroup.Type type = groupMessage.isGroupQuit() ? SignalServiceGroup.Type.QUIT : SignalServiceGroup.Type.UPDATE; - SignalServiceGroup group = new SignalServiceGroup(type, GroupUtil.getDecodedId(groupId), groupContext.getName(), groupContext.getMembersList(), avatar); + SignalServiceGroup group = new SignalServiceGroup(type, GroupUtil.getDecodedId(groupId), groupType, groupContext.getName(), groupContext.getMembersList(), avatar, groupContext.getAdminsList()); SignalServiceDataMessage groupDataMessage = SignalServiceDataMessage.newBuilder() .withTimestamp(message.getSentTimeMillis()) .withExpiration(message.getRecipient().getExpireMessages()) + .withBody(message.getBody()) .asGroupMessage(group) .build(); - // Loki - Disable group updates for now - List results = new ArrayList<>(); - for (Address destination : destinations) results.add(SendMessageResult.success(new SignalServiceAddress(destination.toPhoneString()), false, false)); - return results; + return messageSender.sendMessage(messageId, addresses, unidentifiedAccess, groupDataMessage); } else { - SignalServiceGroup group = new SignalServiceGroup(GroupUtil.getDecodedId(groupId)); + SignalServiceGroup group = new SignalServiceGroup(GroupUtil.getDecodedId(groupId), groupType); SignalServiceDataMessage groupMessage = SignalServiceDataMessage.newBuilder() .withTimestamp(message.getSentTimeMillis()) .asGroupMessage(group) @@ -284,26 +326,56 @@ public class PushGroupSendJob extends PushSendJob implements InjectableType { } private @NonNull List
getGroupMessageRecipients(String groupId, long messageId) { - ArrayList
result = new ArrayList<>(); + if (GroupUtil.isRssFeed(groupId)) { return new ArrayList<>(); } - // Loki - All group messages should be directed to their respective servers - long threadID = GroupManager.getThreadIdFromGroupId(groupId, context); - LokiPublicChat publicChat = DatabaseFactory.getLokiThreadDatabase(context).getPublicChat(threadID); - if (publicChat != null) { - // We need to somehow maintain information that will allow the sender to map - // a recipient to the correct public chat thread, and so this might be a bit hacky - result.add(Address.fromPublicChatGroupID(groupId)); + // Loki - All public chat group messages should be directed to their respective servers + if (GroupUtil.isPublicChat(groupId)) { + ArrayList
result = new ArrayList<>(); + long threadID = GroupManager.getThreadIdFromGroupId(groupId, context); + LokiPublicChat publicChat = DatabaseFactory.getLokiThreadDatabase(context).getPublicChat(threadID); + if (publicChat != null) { + result.add(Address.fromSerialized(groupId)); + } + return result; + } else { + /* + Our biggest assumption here is that group members will only consist of primary devices. + No secondary device should be able to be added to a group. + */ + List destinations = DatabaseFactory.getGroupReceiptDatabase(context).getGroupReceiptInfo(messageId); + + Set
memberSet = new HashSet<>(); + if (destinations.isEmpty()) { + List groupMembers = DatabaseFactory.getGroupDatabase(context).getGroupMembers(groupId, false); + memberSet.addAll(Stream.of(groupMembers).map(Recipient::getAddress).toList()); + } else { + memberSet.addAll(Stream.of(destinations).map(GroupReceiptInfo::getAddress).toList()); + } + + // Replace primary device public key with ours so message syncing works correctly + String masterHexEncodedPublicKey = TextSecurePreferences.getMasterHexEncodedPublicKey(context); + String localNumber = TextSecurePreferences.getLocalNumber(context); + if (masterHexEncodedPublicKey != null && memberSet.contains(Address.fromSerialized(masterHexEncodedPublicKey))) { + memberSet.remove(Address.fromSerialized(masterHexEncodedPublicKey)); + memberSet.add(Address.fromSerialized(localNumber)); + } + + // Add secondary devices to the list. We shouldn't add our secondary devices + for (Address member : memberSet) { + if (!member.isPhone() || member.serialize().equalsIgnoreCase(localNumber)) { continue; } + try { + List secondaryDevices = PromiseUtil.timeout(LokiStorageAPI.shared.getSecondaryDevicePublicKeys(member.serialize()), 5000).get(); + memberSet.addAll(Stream.of(secondaryDevices).map(string -> { + // Loki - Calling .map(Address::fromSerialized) is causing errors, thus we use the long method :( + return Address.fromSerialized(string); + }).toList()); + } catch (Exception e) { + // Timed out, go to the next member + } + } + + return new LinkedList<>(memberSet); } - - return result; - - /* - List destinations = DatabaseFactory.getGroupReceiptDatabase(context).getGroupReceiptInfo(messageId); - if (!destinations.isEmpty()) return Stream.of(destinations).map(GroupReceiptInfo::getAddress).toList(); - - List members = DatabaseFactory.getGroupDatabase(context).getGroupMembers(groupId, false); - return Stream.of(members).map(Recipient::getAddress).toList(); - */ } public static class Factory implements Job.Factory { diff --git a/src/org/thoughtcrime/securesms/jobs/PushGroupUpdateJob.java b/src/org/thoughtcrime/securesms/jobs/PushGroupUpdateJob.java index e12d094c11..460b028932 100644 --- a/src/org/thoughtcrime/securesms/jobs/PushGroupUpdateJob.java +++ b/src/org/thoughtcrime/securesms/jobs/PushGroupUpdateJob.java @@ -97,15 +97,20 @@ public class PushGroupUpdateJob extends BaseJob implements InjectableType { } List members = new LinkedList<>(); - for (Address member : record.get().getMembers()) { members.add(member.serialize()); } + List admins = new LinkedList<>(); + for (Address admin : record.get().getAdmins()) { + admins.add(admin.serialize()); + } + SignalServiceGroup groupContext = SignalServiceGroup.newBuilder(Type.UPDATE) .withAvatar(avatar) - .withId(groupId) + .withId(groupId, SignalServiceGroup.GroupType.SIGNAL) .withMembers(members) + .withAdmins(admins) .withName(record.get().getTitle()) .build(); diff --git a/src/org/thoughtcrime/securesms/jobs/RequestGroupInfoJob.java b/src/org/thoughtcrime/securesms/jobs/RequestGroupInfoJob.java index 380aeadc9b..795e1060c0 100644 --- a/src/org/thoughtcrime/securesms/jobs/RequestGroupInfoJob.java +++ b/src/org/thoughtcrime/securesms/jobs/RequestGroupInfoJob.java @@ -71,7 +71,7 @@ public class RequestGroupInfoJob extends BaseJob implements InjectableType { @Override public void onRun() throws IOException, UntrustedIdentityException { SignalServiceGroup group = SignalServiceGroup.newBuilder(Type.REQUEST_INFO) - .withId(groupId) + .withId(groupId, SignalServiceGroup.GroupType.SIGNAL) .build(); SignalServiceDataMessage message = SignalServiceDataMessage.newBuilder() diff --git a/src/org/thoughtcrime/securesms/jobs/TypingSendJob.java b/src/org/thoughtcrime/securesms/jobs/TypingSendJob.java index babe7fe04e..c6ecc30e2f 100644 --- a/src/org/thoughtcrime/securesms/jobs/TypingSendJob.java +++ b/src/org/thoughtcrime/securesms/jobs/TypingSendJob.java @@ -20,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.messaging.LokiThreadFriendRequestStatus; import org.whispersystems.signalservice.loki.utilities.PromiseUtil; import java.util.Collections; @@ -101,6 +102,10 @@ public class TypingSendJob extends BaseJob implements InjectableType { // Loki - Don't send typing indicators in group chats or to ourselves if (recipient.isGroupRecipient()) { return; } + + LokiThreadFriendRequestStatus friendRequestStatus = DatabaseFactory.getLokiThreadDatabase(context).getFriendRequestStatus(threadId); + if (friendRequestStatus != LokiThreadFriendRequestStatus.FRIENDS) { 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 index a8d9cae2eb..e59a2875a9 100644 --- a/src/org/thoughtcrime/securesms/loki/FriendRequestHandler.kt +++ b/src/org/thoughtcrime/securesms/loki/FriendRequestHandler.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.loki import android.content.Context import nl.komponents.kovenant.ui.successUi import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.util.TextSecurePreferences import org.whispersystems.signalservice.loki.messaging.LokiMessageFriendRequestStatus import org.whispersystems.signalservice.loki.messaging.LokiThreadFriendRequestStatus @@ -62,7 +63,7 @@ object FriendRequestHandler { fun updateLastFriendRequestMessage(context: Context, threadId: Long, status: LokiMessageFriendRequestStatus) { if (threadId < 0) { return } val recipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId) ?: return - if (!recipient.address.isPhone) { return } + if (!recipient.address.isPhone || recipient.address.serialize() == TextSecurePreferences.getLocalNumber(context)) { return } val messages = DatabaseFactory.getSmsDatabase(context).getAllMessageIDs(threadId) val lokiMessageDatabase = DatabaseFactory.getLokiMessageDatabase(context) @@ -81,7 +82,7 @@ object FriendRequestHandler { // 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 } + if (!recipient.address.isPhone || recipient.address.serialize() == TextSecurePreferences.getLocalNumber(context)) { return } isFriendsWithAnyLinkedDevice(context, recipient).successUi { isFriends -> if (isFriends) { return@successUi } diff --git a/src/org/thoughtcrime/securesms/loki/LokiPublicChatManager.kt b/src/org/thoughtcrime/securesms/loki/LokiPublicChatManager.kt index 3552efb63f..0a959521f5 100644 --- a/src/org/thoughtcrime/securesms/loki/LokiPublicChatManager.kt +++ b/src/org/thoughtcrime/securesms/loki/LokiPublicChatManager.kt @@ -14,7 +14,6 @@ import org.thoughtcrime.securesms.loki.redesign.messaging.LokiPublicChatPoller import org.thoughtcrime.securesms.util.TextSecurePreferences import org.thoughtcrime.securesms.util.Util import org.whispersystems.signalservice.loki.api.LokiPublicChat -import java.util.* class LokiPublicChatManager(private val context: Context) { private var chats = mutableMapOf() @@ -50,10 +49,10 @@ class LokiPublicChatManager(private val context: Context) { public fun addChat(server: String, channel: Long, name: String): LokiPublicChat { val chat = LokiPublicChat(channel, server, name, true) - var threadID = GroupManager.getThreadId(chat.id, context) + var threadID = GroupManager.getPublicChatThreadId(chat.id, context) // Create the group if we don't have one if (threadID < 0) { - val result = GroupManager.createGroup(chat.id, context, HashSet(), null, chat.displayName, false) + val result = GroupManager.createPublicChatGroup(chat.id, context, null, chat.displayName) threadID = result.threadId } DatabaseFactory.getLokiThreadDatabase(context).setPublicChat(chat, threadID) @@ -74,7 +73,7 @@ class LokiPublicChatManager(private val context: Context) { removedChatThreadIds.forEach { pollers.remove(it)?.stop() } // Only append to chats if we have a thread for the chat - chats = chatsInDB.filter { GroupManager.getThreadId(it.value.id, context) > -1 }.toMutableMap() + chats = chatsInDB.filter { GroupManager.getPublicChatThreadId(it.value.id, context) > -1 }.toMutableMap() } private fun listenToThreadDeletion(threadID: Long) { diff --git a/src/org/thoughtcrime/securesms/loki/LokiThreadDatabase.kt b/src/org/thoughtcrime/securesms/loki/LokiThreadDatabase.kt index 3348af38c6..b6aefdaa3a 100644 --- a/src/org/thoughtcrime/securesms/loki/LokiThreadDatabase.kt +++ b/src/org/thoughtcrime/securesms/loki/LokiThreadDatabase.kt @@ -45,6 +45,11 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa fun getFriendRequestStatus(threadID: Long): LokiThreadFriendRequestStatus { if (threadID < 0) { return LokiThreadFriendRequestStatus.NONE } + + // Loki - Friend request logic doesn't apply to group chats, always treat them as friends + val recipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadID) + if (recipient != null && recipient.isGroupRecipient) { return LokiThreadFriendRequestStatus.FRIENDS; } + val database = databaseHelper.readableDatabase val result = database.get(friendRequestTableName, "${Companion.threadID} = ?", arrayOf( threadID.toString() )) { cursor -> cursor.getInt(friendRequestStatus) diff --git a/src/org/thoughtcrime/securesms/loki/PushBackgroundMessageSendJob.kt b/src/org/thoughtcrime/securesms/loki/PushBackgroundMessageSendJob.kt index 347711abdc..8aa084eb54 100644 --- a/src/org/thoughtcrime/securesms/loki/PushBackgroundMessageSendJob.kt +++ b/src/org/thoughtcrime/securesms/loki/PushBackgroundMessageSendJob.kt @@ -33,6 +33,9 @@ data class BackgroundMessage private constructor(val data: Map) { @JvmStatic fun createSessionRestore(recipient: String) = BackgroundMessage(mapOf( "recipient" to recipient, "friendRequest" to true, "sessionRestore" to true )) + + @JvmStatic + fun createSessionRequest(recipient: String) = BackgroundMessage(mapOf("recipient" to recipient, "friendRequest" to true, "sessionRequest" to true)) internal fun parse(serialized: String): BackgroundMessage { val data = JsonUtil.fromJson(serialized, Map::class.java) as? Map ?: throw AssertionError("JSON parsing failed") @@ -99,6 +102,10 @@ class PushBackgroundMessageSendJob private constructor( dataMessage.asSessionRestore(true) } + if (message.get("sessionRequest", false)) { + dataMessage.asSessionRequest(true) + } + val messageSender = ApplicationContext.getInstance(context).communicationModule.provideSignalMessageSender() val address = SignalServiceAddress(recipient) try { diff --git a/src/org/thoughtcrime/securesms/loki/redesign/activities/CreateClosedGroupActivity.kt b/src/org/thoughtcrime/securesms/loki/redesign/activities/CreateClosedGroupActivity.kt new file mode 100644 index 0000000000..034176745a --- /dev/null +++ b/src/org/thoughtcrime/securesms/loki/redesign/activities/CreateClosedGroupActivity.kt @@ -0,0 +1,152 @@ +package org.thoughtcrime.securesms.loki.redesign.activities + +import android.content.Intent +import android.graphics.Bitmap +import android.os.AsyncTask +import android.os.Bundle +import android.support.v4.app.LoaderManager +import android.support.v4.content.Loader +import android.support.v7.widget.LinearLayoutManager +import android.view.Menu +import android.view.MenuItem +import android.widget.Toast +import kotlinx.android.synthetic.main.activity_create_closed_group.* +import kotlinx.android.synthetic.main.activity_linked_devices.recyclerView +import network.loki.messenger.R +import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity +import org.thoughtcrime.securesms.conversation.ConversationActivity +import org.thoughtcrime.securesms.database.Address +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.groups.GroupManager +import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.TextSecurePreferences +import org.whispersystems.libsignal.util.guava.Optional +import java.lang.ref.WeakReference + +class CreateClosedGroupActivity : PassphraseRequiredActionBarActivity(), MemberClickListener, LoaderManager.LoaderCallbacks> { + private var members = listOf() + set(value) { field = value; createClosedGroupAdapter.members = value } + + private val createClosedGroupAdapter by lazy { + val result = CreateClosedGroupAdapter(this) + result.glide = GlideApp.with(this) + result.memberClickListener = this + result + } + + private val selectedMembers: Set + get() { return createClosedGroupAdapter.selectedMembers } + + // region Lifecycle + override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { + super.onCreate(savedInstanceState, isReady) + setContentView(R.layout.activity_create_closed_group) + supportActionBar!!.title = "New Closed Group" + recyclerView.adapter = createClosedGroupAdapter + recyclerView.layoutManager = LinearLayoutManager(this) + LoaderManager.getInstance(this).initLoader(0, null, this) + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.menu_create_closed_group, menu) + return true + } + // endregion + + // region Updating + override fun onCreateLoader(id: Int, bundle: Bundle?): Loader> { + return CreateClosedGroupLoader(this) + } + + override fun onLoadFinished(loader: Loader>, members: List) { + update(members) + } + + override fun onLoaderReset(loader: Loader>) { + update(listOf()) + } + + private fun update(members: List) { + this.members = members + } + // endregion + + // region Interaction + override fun onOptionsItemSelected(item: MenuItem): Boolean { + val id = item.itemId + when(id) { + R.id.createClosedGroupButton -> createClosedGroup() + else -> { /* Do nothing */ } + } + return super.onOptionsItemSelected(item) + } + + override fun onMemberClick(member: String) { + createClosedGroupAdapter.onMemberClick(member) + } + + private fun createClosedGroup() { + val name = nameEditText.text.trim() + if (name.isEmpty()) { + return Toast.makeText(this, "Please enter a group name", Toast.LENGTH_LONG).show() + } + if (name.length >= 64) { + return Toast.makeText(this, "Please enter a shorter group name", Toast.LENGTH_LONG).show() + } + val selectedMembers = this.selectedMembers + if (selectedMembers.count() < 1) { + return Toast.makeText(this, "Please pick at least 1 group member", Toast.LENGTH_LONG).show() + } + val recipients = selectedMembers.map { + Recipient.from(this, Address.fromSerialized(it), false) + }.toSet() + val ourNumber = TextSecurePreferences.getMasterHexEncodedPublicKey(this) ?: TextSecurePreferences.getLocalNumber(this) + val local = Recipient.from(this, Address.fromSerialized(ourNumber), false) + CreateClosedGroupTask(WeakReference(this), null, name.toString(), recipients, setOf(local)).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) + } + + private fun handleOpenConversation(threadId: Long, recipient: Recipient) { + val intent = Intent(this, ConversationActivity::class.java) + intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId) + intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, ThreadDatabase.DistributionTypes.DEFAULT) + intent.putExtra(ConversationActivity.ADDRESS_EXTRA, recipient.address) + startActivity(intent) + finish() + } + // endregion + + // region Tasks + internal class CreateClosedGroupTask( + private val activity: WeakReference, + private val avatar: Bitmap?, + private val name: String?, + private val members: Set, + private val admins: Set + ) : AsyncTask>() { + + override fun doInBackground(vararg params: Void?): Optional { + val activity = activity.get() ?: return Optional.absent() + return Optional.of(GroupManager.createGroup(activity, members, avatar, name, false, admins)) + } + + override fun onPostExecute(result: Optional) { + val activity = activity.get() + if (activity == null) { + super.onPostExecute(result) + return + } + + if (result.isPresent && result.get().threadId > -1) { + if (!activity.isFinishing) { + activity.handleOpenConversation(result.get().threadId, result.get().groupRecipient) + } + } else { + super.onPostExecute(result) + Toast.makeText(activity.applicationContext, + R.string.GroupCreateActivity_contacts_invalid_number, Toast.LENGTH_LONG).show() + } + } + } + // endregion +} \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/loki/redesign/activities/CreateClosedGroupAdapter.kt b/src/org/thoughtcrime/securesms/loki/redesign/activities/CreateClosedGroupAdapter.kt new file mode 100644 index 0000000000..bd8c6eff02 --- /dev/null +++ b/src/org/thoughtcrime/securesms/loki/redesign/activities/CreateClosedGroupAdapter.kt @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.loki.redesign.activities + +import android.content.Context +import android.support.v7.widget.RecyclerView +import android.view.ViewGroup +import org.thoughtcrime.securesms.loki.redesign.views.UserView +import org.thoughtcrime.securesms.mms.GlideRequests + +class CreateClosedGroupAdapter(private val context: Context) : RecyclerView.Adapter() { + lateinit var glide: GlideRequests + val selectedMembers = mutableSetOf() + var members = listOf() + set(value) { field = value; notifyDataSetChanged() } + var memberClickListener: MemberClickListener? = null + + class ViewHolder(val view: UserView) : RecyclerView.ViewHolder(view) + + override fun getItemCount(): Int { + return members.size + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = UserView(context) + return ViewHolder(view) + } + + override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { + val member = members[position] + viewHolder.view.setOnClickListener { memberClickListener?.onMemberClick(member) } + val isSelected = selectedMembers.contains(member) + viewHolder.view.bind(member, isSelected, glide) + } + + fun onMemberClick(member: String) { + if (selectedMembers.contains(member)) { + selectedMembers.remove(member) + } else { + selectedMembers.add(member) + } + val index = members.indexOf(member) + notifyItemChanged(index) + } +} + +interface MemberClickListener { + + fun onMemberClick(member: String) +} \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/loki/redesign/activities/CreateClosedGroupLoader.kt b/src/org/thoughtcrime/securesms/loki/redesign/activities/CreateClosedGroupLoader.kt new file mode 100644 index 0000000000..620d47b5c6 --- /dev/null +++ b/src/org/thoughtcrime/securesms/loki/redesign/activities/CreateClosedGroupLoader.kt @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.loki.redesign.activities + +import android.content.Context +import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.util.AsyncLoader +import org.thoughtcrime.securesms.util.TextSecurePreferences +import org.whispersystems.signalservice.loki.messaging.LokiThreadFriendRequestStatus + +class CreateClosedGroupLoader(context: Context) : AsyncLoader>(context) { + + override fun loadInBackground(): List { + val threadDatabase = DatabaseFactory.getThreadDatabase(context) + val lokiThreadDatabase = DatabaseFactory.getLokiThreadDatabase(context) + val userHexEncodedPublicKey = TextSecurePreferences.getLocalNumber(context) + val deviceLinks = DatabaseFactory.getLokiAPIDatabase(context).getPairingAuthorisations(userHexEncodedPublicKey) + val userLinkedDeviceHexEncodedPublicKeys = deviceLinks.flatMap { + listOf( it.primaryDevicePublicKey.toLowerCase(), it.secondaryDevicePublicKey.toLowerCase() ) + }.toMutableSet() + userLinkedDeviceHexEncodedPublicKeys.add(userHexEncodedPublicKey.toLowerCase()) + val cursor = threadDatabase.conversationList + val reader = threadDatabase.readerFor(cursor) + val result = mutableListOf() + while (reader.next != null) { + val thread = reader.current + if (thread.recipient.isGroupRecipient) { continue } + if (lokiThreadDatabase.getFriendRequestStatus(thread.threadId) != LokiThreadFriendRequestStatus.FRIENDS) { continue } + val hexEncodedPublicKey = thread.recipient.address.toString().toLowerCase() + if (userLinkedDeviceHexEncodedPublicKeys.contains(hexEncodedPublicKey)) { continue } + result.add(hexEncodedPublicKey) + } + return result + } +} \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/loki/redesign/activities/HomeActivity.kt b/src/org/thoughtcrime/securesms/loki/redesign/activities/HomeActivity.kt index 798528dbbe..bb24b04dac 100644 --- a/src/org/thoughtcrime/securesms/loki/redesign/activities/HomeActivity.kt +++ b/src/org/thoughtcrime/securesms/loki/redesign/activities/HomeActivity.kt @@ -83,6 +83,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity, ConversationClickListe profileButton.hexEncodedPublicKey = hexEncodedPublicKey profileButton.update() profileButton.setOnClickListener { openSettings() } + createClosedGroupButton.setOnClickListener { createClosedGroup() } joinPublicChatButton.setOnClickListener { joinPublicChat() } // Set up seed reminder view val isMasterDevice = (TextSecurePreferences.getMasterHexEncodedPublicKey(this) == null) @@ -182,6 +183,11 @@ class HomeActivity : PassphraseRequiredActionBarActivity, ConversationClickListe show(intent) } + private fun createClosedGroup() { + val intent = Intent(this, CreateClosedGroupActivity::class.java) + show(intent) + } + private fun joinPublicChat() { val intent = Intent(this, JoinPublicChatActivity::class.java) show(intent) diff --git a/src/org/thoughtcrime/securesms/loki/redesign/messaging/LokiPublicChatPoller.kt b/src/org/thoughtcrime/securesms/loki/redesign/messaging/LokiPublicChatPoller.kt index e1359d9198..1a600e4293 100644 --- a/src/org/thoughtcrime/securesms/loki/redesign/messaging/LokiPublicChatPoller.kt +++ b/src/org/thoughtcrime/securesms/loki/redesign/messaging/LokiPublicChatPoller.kt @@ -111,7 +111,7 @@ class LokiPublicChatPoller(private val context: Context, private val group: Loki // region Polling private fun getDataMessage(message: LokiPublicChatMessage): SignalServiceDataMessage { val id = group.id.toByteArray() - val serviceGroup = SignalServiceGroup(SignalServiceGroup.Type.UPDATE, id, null, null, null) + val serviceGroup = SignalServiceGroup(SignalServiceGroup.Type.UPDATE, id, SignalServiceGroup.GroupType.PUBLIC_CHAT, null, null, null, null) val quote = if (message.quote != null) { SignalServiceDataMessage.Quote(message.quote!!.quotedMessageTimestamp, SignalServiceAddress(message.quote!!.quoteeHexEncodedPublicKey), message.quote!!.quotedMessageBody, listOf()) } else { diff --git a/src/org/thoughtcrime/securesms/loki/redesign/messaging/LokiRSSFeedPoller.kt b/src/org/thoughtcrime/securesms/loki/redesign/messaging/LokiRSSFeedPoller.kt index fa252d4531..d7a6c3398b 100644 --- a/src/org/thoughtcrime/securesms/loki/redesign/messaging/LokiRSSFeedPoller.kt +++ b/src/org/thoughtcrime/securesms/loki/redesign/messaging/LokiRSSFeedPoller.kt @@ -63,7 +63,7 @@ class LokiRSSFeedPoller(private val context: Context, private val feed: LokiRSSF bodyAsHTML = matcher.replaceAll("$2 ($1)") val body = Html.fromHtml(bodyAsHTML).toString().trim() val id = feed.id.toByteArray() - val x1 = SignalServiceGroup(SignalServiceGroup.Type.UPDATE, id, null, null, null) + val x1 = SignalServiceGroup(SignalServiceGroup.Type.UPDATE, id, SignalServiceGroup.GroupType.RSS_FEED, null, null, null, null) val x2 = SignalServiceDataMessage(timestamp, x1, null, body) val x3 = SignalServiceContent(x2, "Loki", SignalServiceAddress.DEFAULT_DEVICE_ID, timestamp, false, false) PushDecryptJob(context).handleTextMessage(x3, x2, Optional.absent(), Optional.absent()) diff --git a/src/org/thoughtcrime/securesms/loki/redesign/views/UserView.kt b/src/org/thoughtcrime/securesms/loki/redesign/views/UserView.kt new file mode 100644 index 0000000000..2b6e6536f7 --- /dev/null +++ b/src/org/thoughtcrime/securesms/loki/redesign/views/UserView.kt @@ -0,0 +1,52 @@ +package org.thoughtcrime.securesms.loki.redesign.views + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.LinearLayout +import kotlinx.android.synthetic.main.view_conversation.view.profilePictureView +import kotlinx.android.synthetic.main.view_user.view.* +import network.loki.messenger.R +import org.thoughtcrime.securesms.database.Address +import org.thoughtcrime.securesms.mms.GlideRequests +import org.thoughtcrime.securesms.recipients.Recipient + +class UserView : LinearLayout { + var user: String? = null + + // region Lifecycle + constructor(context: Context) : super(context) { + setUpViewHierarchy() + } + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { + setUpViewHierarchy() + } + + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { + setUpViewHierarchy() + } + + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) { + setUpViewHierarchy() + } + + private fun setUpViewHierarchy() { + val inflater = context.applicationContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater + val contentView = inflater.inflate(R.layout.view_user, null) + addView(contentView) + } + // endregion + + // region Updating + fun bind(user: String, isSelected: Boolean, glide: GlideRequests) { + profilePictureView.hexEncodedPublicKey = user + profilePictureView.additionalHexEncodedPublicKey = null + profilePictureView.isRSSFeed = false + profilePictureView.glide = glide + profilePictureView.update() + nameTextView.text = Recipient.from(context, Address.fromSerialized(user), false).name ?: "Unknown Contact" + tickImageView.setImageResource(if (isSelected) R.drawable.ic_circle_check else R.drawable.ic_circle) + } + // endregion +} \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/mms/IncomingMediaMessage.java b/src/org/thoughtcrime/securesms/mms/IncomingMediaMessage.java index 8d0c19962c..f4d0a4889e 100644 --- a/src/org/thoughtcrime/securesms/mms/IncomingMediaMessage.java +++ b/src/org/thoughtcrime/securesms/mms/IncomingMediaMessage.java @@ -79,7 +79,7 @@ public class IncomingMediaMessage { this.quote = quote.orNull(); this.unidentified = unidentified; - if (group.isPresent()) this.groupId = Address.fromSerialized(GroupUtil.getEncodedId(group.get().getGroupId(), false)); + if (group.isPresent()) this.groupId = Address.fromSerialized(GroupUtil.getEncodedId(group.get())); else this.groupId = null; this.attachments.addAll(PointerAttachment.forPointers(attachments)); diff --git a/src/org/thoughtcrime/securesms/sms/IncomingTextMessage.java b/src/org/thoughtcrime/securesms/sms/IncomingTextMessage.java index cfafcb7132..1c787355c3 100644 --- a/src/org/thoughtcrime/securesms/sms/IncomingTextMessage.java +++ b/src/org/thoughtcrime/securesms/sms/IncomingTextMessage.java @@ -78,7 +78,7 @@ public class IncomingTextMessage implements Parcelable { this.unidentified = unidentified; if (group.isPresent()) { - this.groupId = Address.fromSerialized(GroupUtil.getEncodedId(group.get().getGroupId(), false)); + this.groupId = Address.fromSerialized(GroupUtil.getEncodedId(group.get())); } else { this.groupId = null; } diff --git a/src/org/thoughtcrime/securesms/sms/MessageSender.java b/src/org/thoughtcrime/securesms/sms/MessageSender.java index 9bb30f7dc6..df26a6ec03 100644 --- a/src/org/thoughtcrime/securesms/sms/MessageSender.java +++ b/src/org/thoughtcrime/securesms/sms/MessageSender.java @@ -17,7 +17,6 @@ package org.thoughtcrime.securesms.sms; import android.content.Context; -import android.os.AsyncTask; import android.support.annotation.NonNull; import org.thoughtcrime.securesms.ApplicationContext; @@ -60,11 +59,7 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.SignalServiceAccountManager; -import org.whispersystems.signalservice.api.SignalServiceMessageSender; -import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; -import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.push.ContactTokenDetails; -import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.loki.api.LokiStorageAPI; import org.whispersystems.signalservice.loki.messaging.LokiThreadFriendRequestStatus; import org.whispersystems.signalservice.loki.utilities.PromiseUtil; @@ -143,6 +138,10 @@ public class MessageSender { public static void sendRestoreSessionMessage(Context context, String contactHexEncodedPublicKey) { ApplicationContext.getInstance(context).getJobManager().add(new PushBackgroundMessageSendJob(BackgroundMessage.createSessionRestore(contactHexEncodedPublicKey))); } + + public static void sendBackgroundSessionRequest(Context context, String contactHexEncodedPublicKey) { + ApplicationContext.getInstance(context).getJobManager().add(new PushBackgroundMessageSendJob(BackgroundMessage.createSessionRequest(contactHexEncodedPublicKey))); + } // endregion public static long send(final Context context, @@ -202,7 +201,7 @@ public class MessageSender { if (attachment != null) { message.getAttachments().add(attachment); } 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) { + if (message.isFriendRequest && !recipient.getAddress().isGroup() && !message.isGroup()) { FriendRequestHandler.updateFriendRequestState(context, FriendRequestHandler.ActionType.Sending, messageID, allocatedThreadId); } sendMediaMessage(context, recipient, forceSms, messageID, message.getExpiresIn()); @@ -215,7 +214,7 @@ public class MessageSender { try { 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) { + if (message.isFriendRequest && !recipient.getAddress().isGroup() && !message.isGroup()) { FriendRequestHandler.updateFriendRequestState(context, FriendRequestHandler.ActionType.Sending, messageID, allocatedThreadId); } sendMediaMessage(context, recipient, forceSms, messageID, message.getExpiresIn()); diff --git a/src/org/thoughtcrime/securesms/util/GroupUtil.java b/src/org/thoughtcrime/securesms/util/GroupUtil.java index 224887fb0a..9268e78264 100644 --- a/src/org/thoughtcrime/securesms/util/GroupUtil.java +++ b/src/org/thoughtcrime/securesms/util/GroupUtil.java @@ -11,11 +11,13 @@ import network.loki.messenger.R; import org.thoughtcrime.securesms.database.Address; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.GroupDatabase.*; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.OutgoingGroupMediaMessage; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientModifiedListener; import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.messages.SignalServiceGroup; import java.io.IOException; import java.util.Collections; @@ -28,12 +30,32 @@ public class GroupUtil { private static final String ENCODED_SIGNAL_GROUP_PREFIX = "__textsecure_group__!"; private static final String ENCODED_MMS_GROUP_PREFIX = "__signal_mms_group__!"; + private static final String ENCODED_PUBLIC_CHAT_GROUP_PREFIX = "__loki_public_chat_group__!"; + private static final String ENCODED_RSS_FEED_GROUP_PREFIX = "__loki_rss_feed_group__!"; private static final String TAG = GroupUtil.class.getSimpleName(); + public static String getEncodedId(SignalServiceGroup group) { + byte[] groupId = group.getGroupId(); + if (group.getGroupType() == SignalServiceGroup.GroupType.PUBLIC_CHAT) { + return getEncodedPublicChatId(groupId); + } else if (group.getGroupType() == SignalServiceGroup.GroupType.RSS_FEED) { + return getEncodedRSSFeedId(groupId); + } + return getEncodedId(groupId, false); + } + public static String getEncodedId(byte[] groupId, boolean mms) { return (mms ? ENCODED_MMS_GROUP_PREFIX : ENCODED_SIGNAL_GROUP_PREFIX) + Hex.toStringCondensed(groupId); } + public static String getEncodedPublicChatId(byte[] groupId) { + return ENCODED_PUBLIC_CHAT_GROUP_PREFIX + Hex.toStringCondensed(groupId); + } + + public static String getEncodedRSSFeedId(byte[] groupId) { + return ENCODED_RSS_FEED_GROUP_PREFIX + Hex.toStringCondensed(groupId); + } + public static byte[] getDecodedId(String groupId) throws IOException { if (!isEncodedGroup(groupId)) { throw new IOException("Invalid encoding"); @@ -48,13 +70,21 @@ public class GroupUtil { } public static boolean isEncodedGroup(@NonNull String groupId) { - return groupId.startsWith(ENCODED_SIGNAL_GROUP_PREFIX) || groupId.startsWith(ENCODED_MMS_GROUP_PREFIX); + return groupId.startsWith(ENCODED_SIGNAL_GROUP_PREFIX) || groupId.startsWith(ENCODED_MMS_GROUP_PREFIX) || groupId.startsWith(ENCODED_PUBLIC_CHAT_GROUP_PREFIX) || groupId.startsWith(ENCODED_RSS_FEED_GROUP_PREFIX); } public static boolean isMmsGroup(@NonNull String groupId) { return groupId.startsWith(ENCODED_MMS_GROUP_PREFIX); } + public static boolean isPublicChat(@NonNull String groupId) { + return groupId.startsWith(ENCODED_PUBLIC_CHAT_GROUP_PREFIX); + } + + public static boolean isRssFeed(@NonNull String groupId) { + return groupId.startsWith(ENCODED_RSS_FEED_GROUP_PREFIX); + } + @WorkerThread public static Optional createGroupLeaveMessage(@NonNull Context context, @NonNull Recipient groupRecipient) { String encodedGroupId = groupRecipient.getAddress().toGroupString(); @@ -88,7 +118,7 @@ public class GroupUtil { } try { - GroupContext groupContext = GroupContext.parseFrom(Base64.decode(encodedGroup)); + GroupContext groupContext = GroupContext.parseFrom(Base64.decode(encodedGroup)); return new GroupDescription(context, groupContext); } catch (IOException e) { Log.w(TAG, e); @@ -100,24 +130,49 @@ public class GroupUtil { @NonNull private final Context context; @Nullable private final GroupContext groupContext; - @Nullable private final List members; + private final List members; + private final List removedMembers; + private boolean ourDeviceWasRemoved; public GroupDescription(@NonNull Context context, @Nullable GroupContext groupContext) { this.context = context.getApplicationContext(); this.groupContext = groupContext; - if (groupContext == null || groupContext.getMembersList().isEmpty()) { - this.members = null; - } else { - this.members = new LinkedList<>(); + this.members = new LinkedList<>(); + this.removedMembers = new LinkedList<>(); + this.ourDeviceWasRemoved = false; - for (String member : groupContext.getMembersList()) { - this.members.add(Recipient.from(context, Address.fromExternal(context, member), true)); + if (groupContext != null && !groupContext.getMembersList().isEmpty()) { + List memberList = groupContext.getMembersList(); + List
currentMembers = getCurrentGroupMembers(); + + // Add them to the member or removed members lists + for (String member : memberList) { + Address address = Address.fromSerialized(member); + Recipient recipient = Recipient.from(context, address, true); + if (currentMembers == null || currentMembers.contains(address)) { + this.members.add(recipient); + } else { + this.removedMembers.add(recipient); + } + } + + // Check if our device was removed + if (!removedMembers.isEmpty()) { + String masterHexEncodedPublicKey = TextSecurePreferences.getMasterHexEncodedPublicKey(context); + String hexEncodedPublicKey = masterHexEncodedPublicKey != null ? masterHexEncodedPublicKey : TextSecurePreferences.getLocalNumber(context); + Recipient self = Recipient.from(context, Address.fromSerialized(hexEncodedPublicKey), false); + ourDeviceWasRemoved = removedMembers.contains(self); } } } public String toString(Recipient sender) { + // Show the local removed message + if (ourDeviceWasRemoved) { + return context.getString(R.string.GroupUtil_you_were_removed_from_group); + } + StringBuilder description = new StringBuilder(); description.append(context.getString(R.string.MessageRecord_s_updated_group, sender.toShortString())); @@ -127,14 +182,20 @@ public class GroupUtil { String title = groupContext.getName(); - if (members != null) { + if (!members.isEmpty()) { description.append("\n"); description.append(context.getResources().getQuantityString(R.plurals.GroupUtil_joined_the_group, members.size(), toString(members))); } + if (!removedMembers.isEmpty()) { + description.append("\n"); + description.append(context.getResources().getQuantityString(R.plurals.GroupUtil_removed_from_the_group, + removedMembers.size(), toString(removedMembers))); + } + if (title != null && !title.trim().isEmpty()) { - if (members != null) description.append(" "); + if (!members.isEmpty()) description.append(" "); else description.append("\n"); description.append(context.getString(R.string.GroupUtil_group_name_is_now, title)); } @@ -143,7 +204,7 @@ public class GroupUtil { } public void addListener(RecipientModifiedListener listener) { - if (this.members != null) { + if (!this.members.isEmpty()) { for (Recipient member : this.members) { member.addListener(listener); } @@ -162,5 +223,23 @@ public class GroupUtil { return result; } + + private List
getCurrentGroupMembers() { + if (groupContext == null) { return null; } + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + byte[] decodedGroupId = groupContext.getId().toByteArray(); + String signalGroupId = getEncodedId(decodedGroupId, false); + String publicChatId = getEncodedPublicChatId(decodedGroupId); + String rssFeedId = getEncodedRSSFeedId(decodedGroupId); + GroupRecord groupRecord = null; + if (!groupDatabase.isUnknownGroup(signalGroupId)) { + groupRecord = groupDatabase.getGroup(signalGroupId).orNull(); + } else if (!groupDatabase.isUnknownGroup(publicChatId)) { + groupRecord = groupDatabase.getGroup(publicChatId).orNull(); + } else if (!groupDatabase.isUnknownGroup(rssFeedId)) { + groupRecord = groupDatabase.getGroup(rssFeedId).orNull(); + } + return (groupRecord != null) ? groupRecord.getMembers() : null; + } } } diff --git a/src/org/thoughtcrime/securesms/util/IdentityUtil.java b/src/org/thoughtcrime/securesms/util/IdentityUtil.java index 07336f0629..56dff28e6a 100644 --- a/src/org/thoughtcrime/securesms/util/IdentityUtil.java +++ b/src/org/thoughtcrime/securesms/util/IdentityUtil.java @@ -74,8 +74,9 @@ public class IdentityUtil { GroupDatabase.GroupRecord groupRecord; while ((groupRecord = reader.getNext()) != null) { + if (groupRecord.isRSSFeed() || groupRecord.isPublicChat()) { continue; } if (groupRecord.getMembers().contains(recipient.getAddress()) && groupRecord.isActive() && !groupRecord.isMms()) { - SignalServiceGroup group = new SignalServiceGroup(groupRecord.getId()); + SignalServiceGroup group = new SignalServiceGroup(groupRecord.getId(), SignalServiceGroup.GroupType.SIGNAL); if (remote) { IncomingTextMessage incoming = new IncomingTextMessage(recipient.getAddress(), 1, time, null, Optional.of(group), 0, false); @@ -126,8 +127,9 @@ public class IdentityUtil { GroupDatabase.GroupRecord groupRecord; while ((groupRecord = reader.getNext()) != null) { + if (groupRecord.isRSSFeed() || groupRecord.isPublicChat()) { continue; } if (groupRecord.getMembers().contains(recipient.getAddress()) && groupRecord.isActive()) { - SignalServiceGroup group = new SignalServiceGroup(groupRecord.getId()); + SignalServiceGroup group = new SignalServiceGroup(groupRecord.getId(), SignalServiceGroup.GroupType.SIGNAL); IncomingTextMessage incoming = new IncomingTextMessage(recipient.getAddress(), 1, time, null, Optional.of(group), 0, false); IncomingIdentityUpdateMessage groupUpdate = new IncomingIdentityUpdateMessage(incoming);