Merge pull request #512 from Brice-W/closed-groups-update

Closed groups update
This commit is contained in:
Niels Andriesse 2021-04-29 09:36:47 +10:00 committed by GitHub
commit 6a116100ae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 197 additions and 83 deletions

View File

@ -28,7 +28,6 @@ import org.session.libsignal.service.api.messages.SignalServiceAttachmentPointer
import org.session.libsignal.service.loki.database.LokiOpenGroupDatabaseProtocol;
import java.io.Closeable;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.Collections;
import java.util.LinkedList;
@ -44,6 +43,7 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt
static final String GROUP_ID = "group_id";
private static final String TITLE = "title";
private static final String MEMBERS = "members";
private static final String ZOMBIE_MEMBERS = "zombie_members";
private static final String AVATAR = "avatar";
private static final String AVATAR_ID = "avatar_id";
private static final String AVATAR_KEY = "avatar_key";
@ -64,6 +64,7 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt
GROUP_ID + " TEXT, " +
TITLE + " TEXT, " +
MEMBERS + " TEXT, " +
ZOMBIE_MEMBERS + " TEXT, " +
AVATAR + " BLOB, " +
AVATAR_ID + " INTEGER, " +
AVATAR_KEY + " BLOB, " +
@ -81,7 +82,7 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt
};
private static final String[] GROUP_PROJECTION = {
GROUP_ID, TITLE, MEMBERS, AVATAR, AVATAR_ID, AVATAR_KEY, AVATAR_CONTENT_TYPE, AVATAR_RELAY, AVATAR_DIGEST,
GROUP_ID, TITLE, MEMBERS, ZOMBIE_MEMBERS, AVATAR, AVATAR_ID, AVATAR_KEY, AVATAR_CONTENT_TYPE, AVATAR_RELAY, AVATAR_DIGEST,
TIMESTAMP, ACTIVE, MMS, AVATAR_URL, ADMINS
};
@ -162,7 +163,7 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt
}
public @NonNull List<Recipient> getGroupMembers(String groupId, boolean includeSelf) {
List<Address> members = getCurrentMembers(groupId);
List<Address> members = getCurrentMembers(groupId, false);
List<Recipient> recipients = new LinkedList<>();
for (Address member : members) {
@ -177,6 +178,17 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt
return recipients;
}
public @NonNull List<Recipient> getGroupZombieMembers(String groupId) {
List<Address> members = getCurrentZombieMembers(groupId);
List<Recipient> recipients = new LinkedList<>();
for (Address member : members) {
recipients.add(Recipient.from(context, member, false));
}
return recipients;
}
public long create(@NonNull String groupId, @Nullable String title, @NonNull List<Address> members,
@Nullable SignalServiceAttachmentPointer avatar, @Nullable String relay, @Nullable List<Address> admins, @NonNull Long formationTimestamp)
{
@ -300,6 +312,16 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt
});
}
public void updateZombieMembers(String groupId, List<Address> members) {
Collections.sort(members);
ContentValues contents = new ContentValues();
contents.put(ZOMBIE_MEMBERS, Address.toSerializedList(members, ','));
contents.put(ACTIVE, 1);
databaseHelper.getWritableDatabase().update(TABLE_NAME, contents, GROUP_ID + " = ?",
new String[] {groupId});
}
public void updateAdmins(String groupId, List<Address> admins) {
Collections.sort(admins);
@ -311,7 +333,7 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt
}
public void removeMember(String groupId, Address source) {
List<Address> currentMembers = getCurrentMembers(groupId);
List<Address> currentMembers = getCurrentMembers(groupId, false);
currentMembers.remove(source);
ContentValues contents = new ContentValues();
@ -329,18 +351,22 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt
});
}
private List<Address> getCurrentMembers(String groupId) {
private List<Address> getCurrentMembers(String groupId, boolean zombieMembers) {
Cursor cursor = null;
String membersColumn = MEMBERS;
if (zombieMembers) membersColumn = ZOMBIE_MEMBERS;
try {
cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, new String[] {MEMBERS},
cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, new String[] {membersColumn},
GROUP_ID + " = ?",
new String[] {groupId},
null, null, null);
if (cursor != null && cursor.moveToFirst()) {
String serializedMembers = cursor.getString(cursor.getColumnIndexOrThrow(MEMBERS));
return Address.fromSerializedList(serializedMembers, ',');
String serializedMembers = cursor.getString(cursor.getColumnIndexOrThrow(membersColumn));
if (serializedMembers != null && !serializedMembers.isEmpty())
return Address.fromSerializedList(serializedMembers, ',');
}
return new LinkedList<>();
@ -350,6 +376,10 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt
}
}
private List<Address> getCurrentZombieMembers(String groupId) {
return getCurrentMembers(groupId, true);
}
public boolean isActive(String groupId) {
Optional<GroupRecord> record = getGroup(groupId);
return record.isPresent() && record.get().isActive();

View File

@ -391,6 +391,10 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
DatabaseFactory.getGroupDatabase(context).setActive(groupID, value)
}
override fun getZombieMember(groupID: String): Set<String> {
return DatabaseFactory.getGroupDatabase(context).getGroupZombieMembers(groupID).map { it.address.serialize() }.toHashSet()
}
override fun removeMember(groupID: String, member: Address) {
DatabaseFactory.getGroupDatabase(context).removeMember(groupID, member)
}
@ -399,6 +403,10 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
DatabaseFactory.getGroupDatabase(context).updateMembers(groupID, members)
}
override fun updateZombieMembers(groupID: String, members: List<Address>) {
DatabaseFactory.getGroupDatabase(context).updateZombieMembers(groupID, members)
}
override fun insertIncomingInfoMessage(context: Context, senderPublicKey: String, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection<String>, admins: Collection<String>, sentTimestamp: Long) {
val group = SignalServiceGroup(type, GroupUtil.getDecodedGroupIDAsData(groupID), SignalServiceGroup.GroupType.SIGNAL, name, members.toList(), null, admins.toList())
val m = IncomingTextMessage(Address.fromSerialized(senderPublicKey), 1, sentTimestamp, "", Optional.of(group), 0, true)

View File

@ -54,9 +54,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int lokiV20 = 41;
private static final int lokiV21 = 42;
private static final int lokiV22 = 43;
private static final int lokiV23 = 44;
// Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes
private static final int DATABASE_VERSION = lokiV22;
private static final int DATABASE_VERSION = lokiV23;
private static final String DATABASE_NAME = "signal.db";
private final Context context;
@ -272,6 +273,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
"SendDeliveryReceiptJob");
}
if (oldVersion < lokiV23) {
db.execSQL("ALTER TABLE groups ADD COLUMN zombie_members TEXT");
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();

View File

@ -37,8 +37,14 @@ import java.io.IOException
class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
private val originalMembers = HashSet<String>()
private val zombies = HashSet<String>()
private val members = HashSet<String>()
private val allMembers: Set<String>
get() {
return members + zombies
}
private var hasNameChanged = false
private var isSelfAdmin = false
private var isLoading = false
set(newValue) { field = newValue; invalidateOptionsMenu() }
@ -54,7 +60,10 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
}
private val memberListAdapter by lazy {
EditClosedGroupMembersAdapter(this, GlideApp.with(this), this::onMemberClick)
if (isSelfAdmin)
EditClosedGroupMembersAdapter(this, GlideApp.with(this), isSelfAdmin, this::onMemberClick)
else
EditClosedGroupMembersAdapter(this, GlideApp.with(this), isSelfAdmin)
}
private lateinit var mainContentContainer: LinearLayout
@ -81,7 +90,10 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
ThemeUtil.getThemedDrawableResId(this, R.attr.actionModeCloseDrawable))
groupID = intent.getStringExtra(groupIDKey)!!
originalName = DatabaseFactory.getGroupDatabase(this).getGroup(groupID).get().title
val groupInfo = DatabaseFactory.getGroupDatabase(this).getGroup(groupID).get()
originalName = groupInfo.title
isSelfAdmin = groupInfo.admins.any{ it.serialize() == TextSecurePreferences.getLocalNumber(this) }
name = originalName
mainContentContainer = findViewById(R.id.mainContentContainer)
@ -116,31 +128,35 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
}
}
LoaderManager.getInstance(this).initLoader(loaderID, null, object : LoaderManager.LoaderCallbacks<List<String>> {
LoaderManager.getInstance(this).initLoader(loaderID, null, object : LoaderManager.LoaderCallbacks<GroupMembers> {
override fun onCreateLoader(id: Int, bundle: Bundle?): Loader<List<String>> {
override fun onCreateLoader(id: Int, bundle: Bundle?): Loader<GroupMembers> {
return EditClosedGroupLoader(this@EditClosedGroupActivity, groupID)
}
override fun onLoadFinished(loader: Loader<List<String>>, members: List<String>) {
override fun onLoadFinished(loader: Loader<GroupMembers>, groupMembers: GroupMembers) {
// We no longer need any subsequent loading events
// (they will occur on every activity resume).
LoaderManager.getInstance(this@EditClosedGroupActivity).destroyLoader(loaderID)
members.clear()
members.addAll(groupMembers.members.toHashSet())
zombies.clear()
zombies.addAll(groupMembers.zombieMembers.toHashSet())
originalMembers.clear()
originalMembers.addAll(members.toHashSet())
updateMembers(originalMembers)
originalMembers.addAll(members + zombies)
updateMembers()
}
override fun onLoaderReset(loader: Loader<List<String>>) {
updateMembers(setOf())
override fun onLoaderReset(loader: Loader<GroupMembers>) {
updateMembers()
}
})
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_edit_closed_group, menu)
return members.isNotEmpty() && !isLoading
return allMembers.isNotEmpty() && !isLoading
}
// endregion
@ -153,8 +169,8 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
if (data == null || data.extras == null || !data.hasExtra(SelectContactsActivity.selectedContactsKey)) return
val selectedContacts = data.extras!!.getStringArray(SelectContactsActivity.selectedContactsKey)!!.toSet()
val changedMembers = members + selectedContacts
updateMembers(changedMembers)
members.addAll(selectedContacts)
updateMembers()
}
}
}
@ -173,17 +189,12 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
}
}
private fun updateMembers(members: Set<String>) {
this.members.clear()
this.members.addAll(members)
memberListAdapter.setMembers(members)
private fun updateMembers() {
memberListAdapter.setMembers(allMembers)
memberListAdapter.setZombieMembers(zombies)
val admins = DatabaseFactory.getGroupDatabase(this).getGroup(groupID).get().admins.map { it.toString() }.toMutableSet()
admins.remove(TextSecurePreferences.getLocalNumber(this))
memberListAdapter.setLockedMembers(admins)
mainContentContainer.visibility = if (members.isEmpty()) View.GONE else View.VISIBLE
emptyStateContainer.visibility = if (members.isEmpty()) View.VISIBLE else View.GONE
mainContentContainer.visibility = if (allMembers.isEmpty()) View.GONE else View.VISIBLE
emptyStateContainer.visibility = if (allMembers.isEmpty()) View.VISIBLE else View.GONE
invalidateOptionsMenu()
}
@ -200,8 +211,9 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
private fun onMemberClick(member: String) {
val bottomSheet = ClosedGroupEditingOptionsBottomSheet()
bottomSheet.onRemoveTapped = {
val changedMembers = members - member
updateMembers(changedMembers)
if (zombies.contains(member)) zombies.remove(member)
else members.remove(member)
updateMembers()
bottomSheet.dismiss()
}
bottomSheet.show(supportFragmentManager, "GroupEditingOptionsBottomSheet")
@ -209,7 +221,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
private fun onAddMembersClick() {
val intent = Intent(this@EditClosedGroupActivity, SelectContactsActivity::class.java)
intent.putExtra(SelectContactsActivity.usersToExcludeKey, members.toTypedArray())
intent.putExtra(SelectContactsActivity.usersToExcludeKey, allMembers.toTypedArray())
intent.putExtra(SelectContactsActivity.emptyStateTextKey, "No contacts to add")
startActivityForResult(intent, addUsersRequestCode)
}
@ -229,7 +241,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
}
private fun commitChanges() {
val hasMemberListChanges = (members != originalMembers)
val hasMemberListChanges = (allMembers != originalMembers)
if (!hasNameChanged && !hasMemberListChanges) {
return finish()
@ -237,15 +249,13 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
val name = if (hasNameChanged) this.name else originalName
val members = this.members.map {
val members = this.allMembers.map {
Recipient.from(this, Address.fromSerialized(it), false)
}.toSet()
val originalMembers = this.originalMembers.map {
Recipient.from(this, Address.fromSerialized(it), false)
}.toSet()
val admins = members.toSet() //TODO For now, consider all the users to be admins.
var isClosedGroup: Boolean
var groupPublicKey: String?
try {
@ -303,4 +313,6 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
}
}
}
class GroupMembers(val members: List<String>, val zombieMembers: List<String>) { }
}

View File

@ -4,12 +4,19 @@ import android.content.Context
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.util.AsyncLoader
class EditClosedGroupLoader(context: Context, val groupID: String) : AsyncLoader<List<String>>(context) {
class EditClosedGroupLoader(context: Context, val groupID: String) : AsyncLoader<EditClosedGroupActivity.GroupMembers>(context) {
override fun loadInBackground(): List<String> {
val members = DatabaseFactory.getGroupDatabase(context).getGroupMembers(groupID, true)
return members.map {
it.address.toString()
}
override fun loadInBackground(): EditClosedGroupActivity.GroupMembers {
val groupDatabase = DatabaseFactory.getGroupDatabase(context)
val members = groupDatabase.getGroupMembers(groupID, true)
val zombieMembers = groupDatabase.getGroupZombieMembers(groupID)
return EditClosedGroupActivity.GroupMembers(
members.map {
it.address.toString()
},
zombieMembers.map {
it.address.toString()
}
)
}
}

View File

@ -7,15 +7,17 @@ import org.session.libsession.messaging.threads.Address
import org.thoughtcrime.securesms.loki.views.UserView
import org.thoughtcrime.securesms.mms.GlideRequests
import org.session.libsession.messaging.threads.recipients.Recipient
import org.session.libsession.utilities.TextSecurePreferences
class EditClosedGroupMembersAdapter(
private val context: Context,
private val glide: GlideRequests,
private val admin: Boolean,
private val memberClickListener: ((String) -> Unit)? = null
) : RecyclerView.Adapter<EditClosedGroupMembersAdapter.ViewHolder>() {
private val members = ArrayList<String>()
private val lockedMembers = HashSet<String>()
private val zombieMembers = ArrayList<String>()
fun setMembers(members: Collection<String>) {
this.members.clear()
@ -23,9 +25,9 @@ class EditClosedGroupMembersAdapter(
notifyDataSetChanged()
}
fun setLockedMembers(members: Collection<String>) {
this.lockedMembers.clear()
this.lockedMembers.addAll(members)
fun setZombieMembers(members: Collection<String>) {
this.zombieMembers.clear()
this.zombieMembers.addAll(members)
notifyDataSetChanged()
}
@ -39,15 +41,20 @@ class EditClosedGroupMembersAdapter(
override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
val member = members[position]
val lockedMember = lockedMembers.contains(member)
val unlocked = admin && member != TextSecurePreferences.getLocalNumber(context)
viewHolder.view.bind(Recipient.from(
context,
Address.fromSerialized(member), false),
glide,
if (lockedMember) UserView.ActionIndicator.None else UserView.ActionIndicator.Menu)
if (unlocked) UserView.ActionIndicator.Menu else UserView.ActionIndicator.None)
if (!lockedMember) {
if (zombieMembers.contains(member))
viewHolder.view.alpha = 0.5F
else
viewHolder.view.alpha = 1F
if (unlocked) {
viewHolder.view.setOnClickListener { this.memberClickListener?.invoke(member) }
}
}

View File

@ -146,13 +146,13 @@ class ProfilePictureView : RelativeLayout {
private fun setProfilePictureIfNeeded(imageView: ImageView, publicKey: String, displayName: String?, @DimenRes sizeResId: Int) {
if (publicKey.isNotEmpty()) {
val recipient = Recipient.from(context, Address.fromSerialized(publicKey), false)
if (imagesCached.contains(recipient.profileAvatar.orEmpty())) return
if (imagesCached.contains(publicKey)) return
val signalProfilePicture = recipient.contactPhoto
if (signalProfilePicture != null && (signalProfilePicture as? ProfileContactPhoto)?.avatarObject != "0"
&& (signalProfilePicture as? ProfileContactPhoto)?.avatarObject != "") {
glide.clear(imageView)
glide.load(signalProfilePicture).diskCacheStrategy(DiskCacheStrategy.AUTOMATIC).circleCrop().into(imageView)
imagesCached.add(recipient.profileAvatar.orEmpty())
imagesCached.add(publicKey)
} else {
val sizeInPX = resources.getDimensionPixelSize(sizeResId)
glide.clear(imageView)
@ -162,7 +162,7 @@ class ProfilePictureView : RelativeLayout {
publicKey,
displayName
)).diskCacheStrategy(DiskCacheStrategy.ALL).circleCrop().into(imageView)
imagesCached.add(recipient.profileAvatar.orEmpty())
imagesCached.add(publicKey)
}
} else {
imageView.setImageDrawable(null)

View File

@ -108,8 +108,10 @@ interface StorageProtocol {
fun createGroup(groupID: String, title: String?, members: List<Address>, avatar: SignalServiceAttachmentPointer?, relay: String?, admins: List<Address>, formationTimestamp: Long)
fun isGroupActive(groupPublicKey: String): Boolean
fun setActive(groupID: String, value: Boolean)
fun getZombieMember(groupID: String): Set<String>
fun removeMember(groupID: String, member: Address)
fun updateMembers(groupID: String, members: List<Address>)
fun updateZombieMembers(groupID: String, members: List<Address>)
// Closed Group
fun getAllClosedGroupPublicKeys(): Set<String>
fun getAllActiveClosedGroupPublicKeys(): Set<String>

View File

@ -145,7 +145,7 @@ class ClosedGroupControlMessage() : ControlMessage() {
}
is Kind.EncryptionKeyPair -> {
closedGroupControlMessage.type = DataMessage.ClosedGroupControlMessage.Type.ENCRYPTION_KEY_PAIR
closedGroupControlMessage.publicKey = kind.publicKey
closedGroupControlMessage.publicKey = kind.publicKey ?: ByteString.EMPTY
closedGroupControlMessage.addAllWrappers(kind.wrappers.map { it.toProto() })
}
is Kind.NameChange -> {

View File

@ -169,15 +169,24 @@ fun MessageSender.removeMembers(groupPublicKey: String, membersToRemove: List<St
Log.d("Loki", "Invalid closed group update.")
throw Error.InvalidClosedGroupUpdate
}
val updatedMembers = group.members.map { it.serialize() }.toSet() - membersToRemove
// Save the new group members
storage.updateMembers(groupID, updatedMembers.map { Address.fromSerialized(it) })
val removeMembersAsData = membersToRemove.map { ByteString.copyFrom(Hex.fromStringCondensed(it)) }
val admins = group.admins.map { it.serialize() }
if (!admins.contains(userPublicKey)) {
Log.d("Loki", "Only an admin can remove members from a group.")
throw Error.InvalidClosedGroupUpdate
}
val updatedMembers = group.members.map { it.serialize() }.toSet() - membersToRemove
if (membersToRemove.any { it in admins } && updatedMembers.isNotEmpty()) {
Log.d("Loki", "Can't remove admin from closed group unless the group is destroyed entirely.")
throw Error.InvalidClosedGroupUpdate
}
// Save the new group members
storage.updateMembers(groupID, updatedMembers.map { Address.fromSerialized(it) })
// Update the zombie list
val oldZombies = storage.getZombieMember(groupID)
storage.updateZombieMembers(groupID, oldZombies.minus(membersToRemove).map { Address.fromSerialized(it) })
val removeMembersAsData = membersToRemove.map { ByteString.copyFrom(Hex.fromStringCondensed(it)) }
val name = group.title
// Send the update to the group
val memberUpdateKind = ClosedGroupControlMessage.Kind.MembersRemoved(removeMembersAsData)
@ -185,14 +194,21 @@ fun MessageSender.removeMembers(groupPublicKey: String, membersToRemove: List<St
val closedGroupControlMessage = ClosedGroupControlMessage(memberUpdateKind)
closedGroupControlMessage.sentTimestamp = sentTime
send(closedGroupControlMessage, Address.fromSerialized(groupID))
val isCurrentUserAdmin = admins.contains(userPublicKey)
if (isCurrentUserAdmin) {
generateAndSendNewEncryptionKeyPair(groupPublicKey, updatedMembers)
}
// Send the new encryption key pair to the remaining group members
// At this stage we know the user is admin, no need to test
generateAndSendNewEncryptionKeyPair(groupPublicKey, updatedMembers)
// Notify the user
val infoType = SignalServiceGroup.Type.MEMBER_REMOVED
val threadID = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID))
storage.insertOutgoingInfoMessage(context, groupID, infoType, name, membersToRemove, admins, threadID, sentTime)
// Insert an outgoing notification
// we don't display zombie members in the notification as users have already been notified when those members left
val notificationMembers = membersToRemove.minus(oldZombies)
if (notificationMembers.isNotEmpty()) {
// no notification to display when only zombies have been removed
val infoType = SignalServiceGroup.Type.MEMBER_REMOVED
val threadID = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID))
storage.insertOutgoingInfoMessage(context, groupID, infoType, name, notificationMembers, admins, threadID, sentTime)
}
}
fun MessageSender.leave(groupPublicKey: String, notifyUser: Boolean = true): Promise<Unit, Exception> {

View File

@ -286,7 +286,8 @@ private fun MessageReceiver.handleClosedGroupEncryptionKeyPair(message: ClosedGr
val storage = MessagingModuleConfiguration.shared.storage
val senderPublicKey = message.sender ?: return
val kind = message.kind!! as? ClosedGroupControlMessage.Kind.EncryptionKeyPair ?: return
val groupPublicKey = kind.publicKey?.toByteArray()?.toHexString() ?: message.groupPublicKey ?: return
var groupPublicKey = kind.publicKey?.toByteArray()?.toHexString()
if (groupPublicKey.isNullOrEmpty()) groupPublicKey = message.groupPublicKey ?: return
val userPublicKey = storage.getUserPublicKey()!!
val userKeyPair = storage.getUserX25519KeyPair()
// Unwrap the message
@ -300,7 +301,7 @@ private fun MessageReceiver.handleClosedGroupEncryptionKeyPair(message: ClosedGr
return
}
if (!group.admins.map { it.toString() }.contains(senderPublicKey)) {
Log.d("Loki", "Ignoring closed group encryption key pair from non-member.")
Log.d("Loki", "Ignoring closed group encryption key pair from non-admin.")
return
}
// Find our wrapper and decrypt it if possible
@ -381,6 +382,7 @@ private fun MessageReceiver.handleClosedGroupMembersAdded(message: ClosedGroupCo
val updateMembers = kind.members.map { it.toByteArray().toHexString() }
val newMembers = members + updateMembers
storage.updateMembers(groupID, newMembers.map { Address.fromSerialized(it) })
// Notify the user
if (userPublicKey == senderPublicKey) {
// sender is a linked device
@ -403,6 +405,11 @@ private fun MessageReceiver.handleClosedGroupMembersAdded(message: ClosedGroupCo
}
}
/// Removes the given members from the group IF
/// • it wasn't the admin that was removed (that should happen through a `MEMBER_LEFT` message).
/// • the admin sent the message (only the admin can truly remove members).
/// If we're among the users that were removed, delete all encryption key pairs and the group public key, unsubscribe
/// from push notifications for this closed group, and remove the given members from the zombie list for this group.
private fun MessageReceiver.handleClosedGroupMembersRemoved(message: ClosedGroupControlMessage) {
val context = MessagingModuleConfiguration.shared.context
val storage = MessagingModuleConfiguration.shared.storage
@ -416,7 +423,7 @@ private fun MessageReceiver.handleClosedGroupMembersRemoved(message: ClosedGroup
return
}
if (!group.isActive) {
Log.d("Loki", "Ignoring closed group info message for inactive group")
Log.d("Loki", "Ignoring closed group info message for inactive group.")
return
}
val name = group.title
@ -427,6 +434,18 @@ private fun MessageReceiver.handleClosedGroupMembersRemoved(message: ClosedGroup
// Users that are part of this remove update
val updateMembers = kind.members.map { it.toByteArray().toHexString() }
// Check that the admin wasn't removed
if (updateMembers.contains(admins.first())) {
Log.d("Loki", "Ignoring invalid closed group update.")
return
}
// Check that the message was sent by the group admin
if (!admins.contains(senderPublicKey)) {
Log.d("Loki", "Ignoring invalid closed group update.")
return
}
if (!isValidGroupUpdate(group, message.sentTimestamp!!, senderPublicKey)) { return }
// If admin leaves the group is disbanded
val didAdminLeave = admins.any { it in updateMembers }
@ -443,25 +462,34 @@ private fun MessageReceiver.handleClosedGroupMembersRemoved(message: ClosedGroup
if (didAdminLeave || wasCurrentUserRemoved) {
disableLocalGroupAndUnsubscribe(groupPublicKey, groupID, userPublicKey)
} else {
val isCurrentUserAdmin = admins.contains(userPublicKey)
storage.updateMembers(groupID, newMembers.map { Address.fromSerialized(it) })
if (isCurrentUserAdmin) {
MessageSender.generateAndSendNewEncryptionKeyPair(groupPublicKey, newMembers)
}
}
// update zombie members
val zombies = storage.getZombieMember(groupID)
storage.updateZombieMembers(groupID, zombies.minus(updateMembers).map { Address.fromSerialized(it) })
val type = if (senderLeft) SignalServiceGroup.Type.QUIT
else SignalServiceGroup.Type.MEMBER_REMOVED
// Notify the user
if (userPublicKey == senderPublicKey) {
// sender is a linked device
val threadID = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID))
storage.insertOutgoingInfoMessage(context, groupID, type, name, updateMembers, admins, threadID, message.sentTimestamp!!)
} else {
storage.insertIncomingInfoMessage(context, senderPublicKey, groupID, type, name, updateMembers, admins, message.sentTimestamp!!)
// we don't display zombie members in the notification as users have already been notified when those members left
val notificationMembers = updateMembers.minus(zombies)
if (notificationMembers.isNotEmpty()) {
// no notification to display when only zombies have been removed
if (userPublicKey == senderPublicKey) {
// sender is a linked device
val threadID = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID))
storage.insertOutgoingInfoMessage(context, groupID, type, name, notificationMembers, admins, threadID, message.sentTimestamp!!)
} else {
storage.insertIncomingInfoMessage(context, senderPublicKey, groupID, type, name, notificationMembers, admins, message.sentTimestamp!!)
}
}
}
/// If a regular member left:
/// • Mark them as a zombie (to be removed by the admin later).
/// If the admin left:
/// • Unsubscribe from PNs, delete the group public key, etc. as the group will be disbanded.
private fun MessageReceiver.handleClosedGroupMemberLeft(message: ClosedGroupControlMessage) {
val context = MessagingModuleConfiguration.shared.context
val storage = MessagingModuleConfiguration.shared.storage
@ -494,11 +522,10 @@ private fun MessageReceiver.handleClosedGroupMemberLeft(message: ClosedGroupCont
// admin left the group of linked device left the group
disableLocalGroupAndUnsubscribe(groupPublicKey, groupID, userPublicKey)
} else {
val isCurrentUserAdmin = admins.contains(userPublicKey)
storage.updateMembers(groupID, updatedMemberList.map { Address.fromSerialized(it) })
if (isCurrentUserAdmin) {
MessageSender.generateAndSendNewEncryptionKeyPair(groupPublicKey, updatedMemberList)
}
// update zombie members
val zombies = storage.getZombieMember(groupID)
storage.updateZombieMembers(groupID, zombies.plus(senderPublicKey).map { Address.fromSerialized(it) })
}
// Notify the user
if (userLeft) {