Fixed crashes leaving un-polled groups

This commit is contained in:
SessionHero01 2024-10-08 10:21:59 +11:00
parent 71009c373b
commit 962df473b6
No known key found for this signature in database
11 changed files with 123 additions and 70 deletions

View File

@ -360,7 +360,7 @@ object ConversationMenuHelper {
val group = configFactory.withUserConfigs { it.userGroups.getClosedGroup(accountId.hexString) } ?: return
val name = configFactory.withGroupConfigs(accountId) {
it.groupInfo.getName()
}
} ?: group.name
confirmAndLeaveClosedGroup(
context = context,
@ -412,6 +412,7 @@ object ConversationMenuHelper {
doLeave()
} catch (e: Exception) {
Log.e("Conversation", "Error leaving group", e)
withContext(Dispatchers.Main) {
onLeaveFailed()
}

View File

@ -62,6 +62,7 @@ import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.ProfileKeyUtil
import org.session.libsession.utilities.SSKEnvironment
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.getClosedGroup
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.recipients.Recipient.DisappearingState
import org.session.libsignal.crypto.ecc.DjbECPublicKey
@ -1004,13 +1005,8 @@ open class Storage @Inject constructor(
it.groupMembers.all()
}
override fun getLibSessionClosedGroup(groupAccountId: String): GroupInfo.ClosedGroupInfo? {
return configFactory.withUserConfigs { it.userGroups.getClosedGroup(groupAccountId) }
}
override fun getClosedGroupDisplayInfo(groupAccountId: String): GroupDisplayInfo? {
val groupIsAdmin = getLibSessionClosedGroup(groupAccountId)?.hasAdminKey() ?: return null
val groupIsAdmin = configFactory.getClosedGroup(AccountId(groupAccountId))?.hasAdminKey() ?: return null
return configFactory.withGroupConfigs(AccountId(groupAccountId)) { configs ->
val info = configs.groupInfo
@ -1031,8 +1027,9 @@ open class Storage @Inject constructor(
val sentTimestamp = message.sentTimestamp ?: clock.currentTimeMills()
val senderPublicKey = message.sender
val groupName = configFactory.withGroupConfigs(closedGroup) { it.groupInfo.getName() }
?: configFactory.getClosedGroup(closedGroup)?.name
val updateData = UpdateMessageData.buildGroupUpdate(message, groupName) ?: return null
val updateData = UpdateMessageData.buildGroupUpdate(message, groupName.orEmpty()) ?: return null
return insertUpdateControlMessage(updateData, sentTimestamp, senderPublicKey, closedGroup)
}

View File

@ -5,11 +5,10 @@ import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import dagger.hilt.android.AndroidEntryPoint
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.AccountId
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.groups.compose.EditGroupScreen
import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme
import javax.inject.Inject
@AndroidEntryPoint
class EditGroupActivity: PassphraseRequiredActionBarActivity() {
@ -28,7 +27,7 @@ class EditGroupActivity: PassphraseRequiredActionBarActivity() {
setContent {
SessionMaterialTheme {
EditGroupScreen(
groupSessionId = intent.getStringExtra(EXTRA_GROUP_ID)!!,
groupId = AccountId(intent.getStringExtra(EXTRA_GROUP_ID)!!),
onFinish = this::finish
)
}

View File

@ -1,30 +1,36 @@
package org.thoughtcrime.securesms.groups
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.GroupDisplayInfo
import network.loki.messenger.libsession_util.util.GroupMember
import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.groups.GroupManagerV2
import org.session.libsession.messaging.jobs.InviteContactsJob
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.utilities.ConfigUpdateNotification
import org.session.libsignal.utilities.AccountId
import org.thoughtcrime.securesms.dependencies.ConfigFactory
@ -32,7 +38,8 @@ const val MAX_GROUP_NAME_LENGTH = 100
@HiltViewModel(assistedFactory = EditGroupViewModel.Factory::class)
class EditGroupViewModel @AssistedInject constructor(
@Assisted private val groupSessionId: String,
@Assisted private val groupId: AccountId,
@ApplicationContext private val context: Context,
private val storage: StorageProtocol,
configFactory: ConfigFactory,
private val groupManager: GroupManagerV2,
@ -40,39 +47,52 @@ class EditGroupViewModel @AssistedInject constructor(
// Input/Output state
private val mutableEditingName = MutableStateFlow<String?>(null)
// Input: invite/promote member's intermediate states. This is needed because we don't have
// a state that we can map into in the config system. The config system only provides "sent", "failed", etc.
// The intermediate states are needed to show the user that the operation is in progress, and the
// states are limited to the view model (i.e. lost if the user navigates away). This is a trade-off
// between the complexity of the config system and the user experience.
private val memberPendingState = MutableStateFlow<Map<AccountId, MemberPendingState>>(emptyMap())
// Output: The name of the group being edited. Null if it's not in edit mode, not to be confused
// with empty string, where it's a valid editing state.
val editingName: StateFlow<String?> get() = mutableEditingName
// Output: the source-of-truth group information. Other states are derived from this.
@OptIn(ExperimentalCoroutinesApi::class)
private val groupInfo: StateFlow<Pair<GroupDisplayInfo, List<GroupMemberState>>?> =
(configFactory.configUpdateNotifications as Flow<Any>)
.onStart { emit(Unit) }
.map {
withContext(Dispatchers.Default) {
val currentUserId = checkNotNull(storage.getUserPublicKey()) {
"User public key is null"
combine(
configFactory.configUpdateNotifications
.filterIsInstance<ConfigUpdateNotification.GroupConfigsUpdated>()
.filter { it.groupId == groupId }
.onStart { emit(ConfigUpdateNotification.GroupConfigsUpdated(groupId)) },
memberPendingState
) { _, pending ->
withContext(Dispatchers.Default) {
val currentUserId = checkNotNull(storage.getUserPublicKey()) {
"User public key is null"
}
val displayInfo = storage.getClosedGroupDisplayInfo(groupId.hexString)
?: return@withContext null
val members = storage.getMembers(groupId.hexString)
.asSequence()
.filter { !it.removed }
.mapTo(arrayListOf()) { member ->
createGroupMember(
member = member,
myAccountId = currentUserId,
amIAdmin = displayInfo.isUserAdmin,
pendingState = pending[AccountId(member.sessionId)]
)
}
val displayInfo = storage.getClosedGroupDisplayInfo(groupSessionId)
?: return@withContext null
sortMembers(members, currentUserId)
val members = storage.getMembers(groupSessionId)
.asSequence()
.filter { !it.removed }
.mapTo(mutableListOf()) { member ->
createGroupMember(
member = member,
myAccountId = currentUserId,
amIAdmin = displayInfo.isUserAdmin,
)
}
sortMembers(members, currentUserId)
displayInfo to members
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
displayInfo to members
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
// Output: whether the group name can be edited. This is true if the group is loaded successfully.
val canEditGroupName: StateFlow<Boolean> = groupInfo
@ -110,6 +130,7 @@ class EditGroupViewModel @AssistedInject constructor(
member: GroupMember,
myAccountId: String,
amIAdmin: Boolean,
pendingState: MemberPendingState?
): GroupMemberState {
var status = ""
var highlightStatus = false
@ -117,24 +138,32 @@ class EditGroupViewModel @AssistedInject constructor(
when {
member.sessionId == myAccountId -> {
name = "You"
name = context.getString(R.string.you)
}
pendingState == MemberPendingState.Inviting -> {
status = context.getString(R.string.groupInviteSending)
}
pendingState == MemberPendingState.Promoting -> {
status = context.getString(R.string.groupInviteSending)
}
member.promotionPending -> {
status = "Promotion sent"
status = context.getString(R.string.adminPromotionSent)
}
member.invitePending -> {
status = "Invite Sent"
status = context.getString(R.string.groupInviteSent)
}
member.inviteFailed -> {
status = "Invite Failed"
status = context.getString(R.string.groupInviteFailed)
highlightStatus = true
}
member.promotionFailed -> {
status = "Promotion Failed"
status = context.getString(R.string.adminPromotionFailed)
highlightStatus = true
}
}
@ -145,7 +174,8 @@ class EditGroupViewModel @AssistedInject constructor(
canRemove = amIAdmin && member.sessionId != myAccountId && !member.isAdminOrBeingPromoted,
canPromote = amIAdmin && member.sessionId != myAccountId && !member.isAdminOrBeingPromoted,
canResendPromotion = amIAdmin && member.sessionId != myAccountId && member.promotionFailed,
canResendInvite = amIAdmin && member.sessionId != myAccountId && member.inviteFailed,
canResendInvite = amIAdmin && member.sessionId != myAccountId &&
(member.inviteFailed || member.invitePending),
status = status,
highlightStatus = highlightStatus
)
@ -167,30 +197,46 @@ class EditGroupViewModel @AssistedInject constructor(
fun onContactSelected(contacts: Set<Contact>) {
performGroupOperation {
groupManager.inviteMembers(
AccountId(hexString = groupSessionId),
contacts.map { AccountId(it.accountID) },
shareHistory = true
)
try {
// Mark the contacts as pending
memberPendingState.update { states ->
states + contacts.associate { AccountId(it.accountID) to MemberPendingState.Inviting }
}
groupManager.inviteMembers(
groupId,
contacts.map { AccountId(it.accountID) },
shareHistory = false
)
} finally {
// Remove pending state (so the real state will be revealed)
memberPendingState.update { states -> states - contacts.mapTo(hashSetOf()) { AccountId(it.accountID) } }
}
}
}
fun onResendInviteClicked(contactSessionId: String) {
viewModelScope.launch(Dispatchers.Default) {
JobQueue.shared.add(InviteContactsJob(groupSessionId, arrayOf(contactSessionId)))
}
onContactSelected(setOf(Contact(contactSessionId)))
}
fun onPromoteContact(memberSessionId: String) {
performGroupOperation {
groupManager.promoteMember(AccountId(groupSessionId), listOf(AccountId(memberSessionId)))
try {
memberPendingState.update { states ->
states + (AccountId(memberSessionId) to MemberPendingState.Promoting)
}
groupManager.promoteMember(groupId, listOf(AccountId(memberSessionId)))
} finally {
memberPendingState.update { states -> states - AccountId(memberSessionId) }
}
}
}
fun onRemoveContact(contactSessionId: String, removeMessages: Boolean) {
performGroupOperation {
groupManager.removeMembers(
groupAccountId = AccountId(groupSessionId),
groupAccountId = groupId,
removedMembers = listOf(AccountId(contactSessionId)),
removeMessages = removeMessages
)
@ -223,7 +269,7 @@ class EditGroupViewModel @AssistedInject constructor(
performGroupOperation {
if (!newName.isNullOrBlank()) {
groupManager.setName(AccountId(groupSessionId), newName)
groupManager.setName(groupId, newName)
mutableEditingName.value = null
}
}
@ -238,12 +284,15 @@ class EditGroupViewModel @AssistedInject constructor(
*
* This is a helper function that encapsulates the common error handling and progress tracking.
*/
private fun performGroupOperation(operation: suspend () -> Unit) {
private fun performGroupOperation(
genericErrorMessage: (() -> String?)? = null,
operation: suspend () -> Unit) {
viewModelScope.launch {
mutableInProgress.value = true
// We need to use GlobalScope here because we don't want
// any group operation to be cancelled when the view model is cleared.
@Suppress("OPT_IN_USAGE")
val task = GlobalScope.async {
operation()
}
@ -251,7 +300,8 @@ class EditGroupViewModel @AssistedInject constructor(
try {
task.await()
} catch (e: Exception) {
mutableError.value = e.localizedMessage.orEmpty()
mutableError.value = genericErrorMessage?.invoke()
?: context.getString(R.string.errorUnknown)
} finally {
mutableInProgress.value = false
}
@ -260,10 +310,15 @@ class EditGroupViewModel @AssistedInject constructor(
@AssistedFactory
interface Factory {
fun create(groupSessionId: String): EditGroupViewModel
fun create(groupId: AccountId): EditGroupViewModel
}
}
private enum class MemberPendingState {
Inviting,
Promoting,
}
data class GroupMemberState(
val accountId: String,
val name: String,

View File

@ -741,7 +741,7 @@ class GroupManagerV2Impl @Inject constructor(
// read the group name anymore.
val groupName = configFactory.withGroupConfigs(groupId) { configs ->
configs.groupInfo.getName()
}
} ?: group.name
configFactory.withMutableUserConfigs {
it.userGroups.set(

View File

@ -44,6 +44,7 @@ import kotlinx.serialization.Serializable
import network.loki.messenger.R
import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY
import org.session.libsignal.utilities.AccountId
import org.thoughtcrime.securesms.groups.EditGroupViewModel
import org.thoughtcrime.securesms.groups.GroupMemberState
import org.thoughtcrime.securesms.ui.AlertDialog
@ -60,12 +61,12 @@ import org.thoughtcrime.securesms.ui.theme.bold
@Composable
fun EditGroupScreen(
groupSessionId: String,
groupId: AccountId,
onFinish: () -> Unit,
) {
val navController = rememberNavController()
val viewModel = hiltViewModel<EditGroupViewModel, EditGroupViewModel.Factory> { factory ->
factory.create(groupSessionId)
factory.create(groupId)
}
NavHost(navController = navController, startDestination = RouteEditGroup) {

View File

@ -328,7 +328,7 @@ interface ReadableGroupInfoConfig: ReadableConfig {
fun getDeleteAttachmentsBefore(): Long?
fun getDeleteBefore(): Long?
fun getExpiryTimer(): Long
fun getName(): String
fun getName(): String?
fun getCreated(): Long?
fun getProfilePic(): UserPic
fun isDestroyed(): Boolean
@ -367,7 +367,7 @@ class GroupInfoConfig private constructor(pointer: Long): ConfigBase(pointer), M
external override fun getDeleteAttachmentsBefore(): Long?
external override fun getDeleteBefore(): Long?
external override fun getExpiryTimer(): Long
external override fun getName(): String
external override fun getName(): String?
external override fun getProfilePic(): UserPic
external override fun isDestroyed(): Boolean
external override fun setCreated(createdAt: Long)

View File

@ -170,7 +170,6 @@ interface StorageProtocol {
// Closed Groups
fun getMembers(groupPublicKey: String): List<LibSessionGroupMember>
fun getLibSessionClosedGroup(groupAccountId: String): GroupInfo.ClosedGroupInfo?
fun getClosedGroupDisplayInfo(groupAccountId: String): GroupDisplayInfo?
fun insertGroupInfoChange(message: GroupUpdated, closedGroup: AccountId): Long?
fun insertGroupInfoLeaving(closedGroup: AccountId): Long?

View File

@ -20,6 +20,7 @@ import org.session.libsession.snode.utilities.await
import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.OTHER_NAME_KEY
import org.session.libsession.utilities.getClosedGroup
import org.session.libsession.utilities.truncateIdForDisplay
import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateInviteMessage
import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMessage
@ -100,6 +101,7 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array<
}
val groupName = configs.withGroupConfigs(sessionId) { it.groupInfo.getName() }
?: configs.getClosedGroup(sessionId)?.name
val failures = results.filter { it.second.isFailure }
// if there are failed invites, display a message
@ -117,7 +119,7 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array<
toaster.toast(R.string.groupInviteFailedUser, Toast.LENGTH_LONG,
mapOf(
NAME_KEY to firstString,
GROUP_NAME_KEY to groupName
GROUP_NAME_KEY to groupName.orEmpty()
)
)
}
@ -134,7 +136,7 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array<
mapOf(
NAME_KEY to firstString,
OTHER_NAME_KEY to secondString,
GROUP_NAME_KEY to groupName
GROUP_NAME_KEY to groupName.orEmpty()
)
)
}
@ -149,7 +151,7 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array<
mapOf(
NAME_KEY to firstString,
OTHER_NAME_KEY to remaining.toString(),
GROUP_NAME_KEY to groupName
GROUP_NAME_KEY to groupName.orEmpty()
)
)
}

View File

@ -50,7 +50,6 @@ import org.session.libsignal.utilities.defaultRequiresAuth
import org.session.libsignal.utilities.hasNamespaces
import org.session.libsignal.utilities.hexEncodedPublicKey
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import org.session.libsession.messaging.sending_receiving.attachments.Attachment as SignalAttachment
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview as SignalLinkPreview
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel as SignalQuote

View File

@ -645,7 +645,7 @@ object SnodeAPI {
for ((req, resp) in batch.zip(responses.results)) {
val result = runCatching {
check(resp.code == 200) {
"Error with code = ${resp.code}, msg = ${resp.body}"
"Error calling \"${req.request.method}\" with code = ${resp.code}, msg = ${resp.body}"
}
JsonUtil.fromJson(resp.body, req.responseType)