package org.thoughtcrime.securesms.groups import android.content.Context import android.content.Intent import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.View import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager import android.widget.EditText import android.widget.LinearLayout import android.widget.TextView import android.widget.Toast import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import nl.komponents.kovenant.Promise import nl.komponents.kovenant.task import nl.komponents.kovenant.ui.failUi import nl.komponents.kovenant.ui.successUi import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.groupSizeLimit import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.contacts.SelectContactsActivity import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.groups.ClosedGroupManager.updateLegacyGroup import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.util.fadeIn import org.thoughtcrime.securesms.util.fadeOut import java.io.IOException import javax.inject.Inject @AndroidEntryPoint class EditLegacyClosedGroupActivity : PassphraseRequiredActionBarActivity() { @Inject lateinit var groupConfigFactory: ConfigFactory @Inject lateinit var storage: Storage private val originalMembers = HashSet() private val zombies = HashSet() private val members = HashSet() private val allMembers: Set get() { return members + zombies } private var hasNameChanged = false private var isSelfAdmin = false private var isLoading = false set(newValue) { field = newValue; invalidateOptionsMenu() } private lateinit var groupID: String private lateinit var originalName: String private lateinit var name: String private var isEditingName = false set(value) { if (field == value) return field = value handleIsEditingNameChanged() } private val memberListAdapter by lazy { if (isSelfAdmin) EditClosedGroupMembersAdapter(this, GlideApp.with(this), isSelfAdmin, this::onMemberClick) else EditClosedGroupMembersAdapter(this, GlideApp.with(this), isSelfAdmin) } private lateinit var mainContentContainer: LinearLayout private lateinit var cntGroupNameEdit: LinearLayout private lateinit var cntGroupNameDisplay: LinearLayout private lateinit var edtGroupName: EditText private lateinit var emptyStateContainer: LinearLayout private lateinit var lblGroupNameDisplay: TextView private lateinit var loaderContainer: View companion object { @JvmStatic val groupIDKey = "groupIDKey" private val loaderID = 0 val addUsersRequestCode = 124 val legacyGroupSizeLimit = 10 } // region Lifecycle override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) setContentView(R.layout.activity_edit_closed_group) supportActionBar!!.setHomeAsUpIndicator( ThemeUtil.getThemedDrawableResId(this, R.attr.actionModeCloseDrawable)) groupID = intent.getStringExtra(groupIDKey)!! val groupInfo = DatabaseComponent.get(this).groupDatabase().getGroup(groupID).get() originalName = groupInfo.title isSelfAdmin = groupInfo.admins.any{ it.serialize() == TextSecurePreferences.getLocalNumber(this) } name = originalName mainContentContainer = findViewById(R.id.mainContentContainer) cntGroupNameEdit = findViewById(R.id.cntGroupNameEdit) cntGroupNameDisplay = findViewById(R.id.cntGroupNameDisplay) edtGroupName = findViewById(R.id.edtGroupName) emptyStateContainer = findViewById(R.id.emptyStateContainer) lblGroupNameDisplay = findViewById(R.id.lblGroupNameDisplay) loaderContainer = findViewById(R.id.loaderContainer) findViewById(R.id.addMembersClosedGroupButton).setOnClickListener { onAddMembersClick() } findViewById(R.id.rvUserList).apply { adapter = memberListAdapter layoutManager = LinearLayoutManager(this@EditLegacyClosedGroupActivity) } lblGroupNameDisplay.text = originalName cntGroupNameDisplay.setOnClickListener { isEditingName = true } findViewById(R.id.btnCancelGroupNameEdit).setOnClickListener { isEditingName = false } findViewById(R.id.btnSaveGroupNameEdit).setOnClickListener { saveName() } edtGroupName.setImeActionLabel(getString(R.string.save), EditorInfo.IME_ACTION_DONE) edtGroupName.setOnEditorActionListener { _, actionId, _ -> when (actionId) { EditorInfo.IME_ACTION_DONE -> { saveName() return@setOnEditorActionListener true } else -> return@setOnEditorActionListener false } } LoaderManager.getInstance(this).initLoader(loaderID, null, object : LoaderManager.LoaderCallbacks { override fun onCreateLoader(id: Int, bundle: Bundle?): Loader { return EditClosedGroupLoader(this@EditLegacyClosedGroupActivity, groupID) } override fun onLoadFinished(loader: Loader, groupMembers: GroupMembers) { // We no longer need any subsequent loading events // (they will occur on every activity resume). LoaderManager.getInstance(this@EditLegacyClosedGroupActivity).destroyLoader(loaderID) members.clear() members.addAll(groupMembers.members.toHashSet()) zombies.clear() zombies.addAll(groupMembers.zombieMembers.toHashSet()) originalMembers.clear() originalMembers.addAll(members + zombies) updateMembers() } override fun onLoaderReset(loader: Loader) { updateMembers() } }) } override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.menu_edit_closed_group, menu) return allMembers.isNotEmpty() && !isLoading } // endregion // region Updating override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) when (requestCode) { addUsersRequestCode -> { if (resultCode != RESULT_OK) return if (data == null || data.extras == null || !data.hasExtra(SelectContactsActivity.selectedContactsKey)) return val selectedContacts = data.extras!!.getStringArray(SelectContactsActivity.selectedContactsKey)!!.toSet() members.addAll(selectedContacts) updateMembers() } } } private fun handleIsEditingNameChanged() { cntGroupNameEdit.visibility = if (isEditingName) View.VISIBLE else View.INVISIBLE cntGroupNameDisplay.visibility = if (isEditingName) View.INVISIBLE else View.VISIBLE val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager if (isEditingName) { edtGroupName.setText(name) edtGroupName.selectAll() edtGroupName.requestFocus() inputMethodManager.showSoftInput(edtGroupName, 0) } else { inputMethodManager.hideSoftInputFromWindow(edtGroupName.windowToken, 0) } } private fun updateMembers() { memberListAdapter.setMembers(allMembers) memberListAdapter.setZombieMembers(zombies) mainContentContainer.visibility = if (allMembers.isEmpty()) View.GONE else View.VISIBLE emptyStateContainer.visibility = if (allMembers.isEmpty()) View.VISIBLE else View.GONE invalidateOptionsMenu() } // endregion // region Interaction override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.action_apply -> if (!isLoading) { commitChanges() } } return super.onOptionsItemSelected(item) } private fun onMemberClick(member: String) { val bottomSheet = ClosedGroupEditingOptionsBottomSheet() bottomSheet.onRemoveTapped = { if (zombies.contains(member)) zombies.remove(member) else members.remove(member) updateMembers() bottomSheet.dismiss() } bottomSheet.show(supportFragmentManager, "GroupEditingOptionsBottomSheet") } private fun onAddMembersClick() { val intent = Intent(this@EditLegacyClosedGroupActivity, SelectContactsActivity::class.java) intent.putExtra(SelectContactsActivity.usersToExcludeKey, allMembers.toTypedArray()) intent.putExtra(SelectContactsActivity.emptyStateTextKey, "No contacts to add") startActivityForResult(intent, addUsersRequestCode) } private fun saveName() { val name = edtGroupName.text.toString().trim() if (name.isEmpty()) { return Toast.makeText(this, R.string.activity_edit_closed_group_group_name_missing_error, Toast.LENGTH_SHORT).show() } if (name.length >= 64) { return Toast.makeText(this, R.string.activity_edit_closed_group_group_name_too_long_error, Toast.LENGTH_SHORT).show() } this.name = name lblGroupNameDisplay.text = name hasNameChanged = true isEditingName = false } private fun commitChanges() { val hasMemberListChanges = (allMembers != originalMembers) if (!hasNameChanged && !hasMemberListChanges) { return finish() } val name = if (hasNameChanged) this.name else originalName 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() var isClosedGroup: Boolean var groupPublicKey: String? try { groupPublicKey = GroupUtil.doubleDecodeGroupID(groupID).toHexString() isClosedGroup = DatabaseComponent.get(this).lokiAPIDatabase().isClosedGroup(groupPublicKey) } catch (e: IOException) { groupPublicKey = null isClosedGroup = false } if (members.isEmpty()) { return Toast.makeText(this, R.string.activity_edit_closed_group_not_enough_group_members_error, Toast.LENGTH_LONG).show() } val maxGroupMembers = if (isClosedGroup) groupSizeLimit else legacyGroupSizeLimit if (members.size >= maxGroupMembers) { return Toast.makeText(this, R.string.activity_create_closed_group_too_many_group_members_error, Toast.LENGTH_LONG).show() } val userPublicKey = TextSecurePreferences.getLocalNumber(this)!! val userAsRecipient = Recipient.from(this, Address.fromSerialized(userPublicKey), false) if (!members.contains(userAsRecipient) && !members.map { it.address.toString() }.containsAll(originalMembers.minus(userPublicKey))) { val message = "Can't leave while adding or removing other members." return Toast.makeText(this@EditLegacyClosedGroupActivity, message, Toast.LENGTH_LONG).show() } if (isClosedGroup) { isLoading = true loaderContainer.fadeIn() val promise: Promise = if (!members.contains(Recipient.from(this, Address.fromSerialized(userPublicKey), false))) { MessageSender.explicitLeave(groupPublicKey!!, false) } else { task { if (hasNameChanged) { MessageSender.explicitNameChange(groupPublicKey!!, name) } members.filterNot { it in originalMembers }.let { adds -> if (adds.isNotEmpty()) MessageSender.explicitAddMembers(groupPublicKey!!, adds.map { it.address.serialize() }) } originalMembers.filterNot { it in members }.let { removes -> if (removes.isNotEmpty()) MessageSender.explicitRemoveMembers(groupPublicKey!!, removes.map { it.address.serialize() }) } } } promise.successUi { loaderContainer.fadeOut() isLoading = false updateGroupConfig() finish() }.failUi { exception -> val message = if (exception is MessageSender.Error) exception.description else "An error occurred" Toast.makeText(this@EditLegacyClosedGroupActivity, message, Toast.LENGTH_LONG).show() loaderContainer.fadeOut() isLoading = false } } } private fun updateGroupConfig() { val latestRecipient = storage.getRecipientSettings(Address.fromSerialized(groupID)) ?: return Log.w("Loki", "No recipient settings when trying to update group config") val latestGroup = storage.getGroup(groupID) ?: return Log.w("Loki", "No group record when trying to update group config") groupConfigFactory.updateLegacyGroup(latestRecipient, latestGroup) } class GroupMembers(val members: List, val zombieMembers: List) }