From 0d12eca576136527ad0fff13b1a5bf032eef7d58 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Fri, 4 Sep 2026 18:09:46 +0200 Subject: [PATCH] fix(conversations): apply the selected preset's parameters when creating a conversation The creation screen sent only the preset identifier, but the endpoint derives just the conversation attributes from it and stores whatever parameters the request carried. Voice rooms were therefore neither listable nor expiring, and channels granted call and media permissions instead of reactions only. Allowing guests now sets the room type, so the switch creates a public conversation instead of having no effect, and the password travels in the creation request where the server supports it. Servers without conversation-creation-all are served by follow up requests instead, which no longer discard a conversation that was already created when one of them fails. Assisted-by: Claude Code:claude-opus-5 Claude-Session: https://claude.ai/code/session_014YnDswTbCmRrVETnwE4twB Signed-off-by: Andy Scherzinger --- .../ConversationCreationActivity.kt | 109 +++----- .../ConversationCreator.kt | 256 ++++++++++++++++++ .../CreateConversationParams.kt | 113 ++++++++ .../ConversationCreationViewModel.kt | 240 ++++++---------- .../nextcloud/talk/utils/CapabilitiesUtil.kt | 1 + .../talk/utils/preview/ComposePreviewUtils.kt | 3 +- .../CreateConversationParamsTest.kt | 85 ++++++ 7 files changed, 586 insertions(+), 221 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreator.kt create mode 100644 app/src/main/java/com/nextcloud/talk/conversationcreation/CreateConversationParams.kt create mode 100644 app/src/test/java/com/nextcloud/talk/conversationcreation/CreateConversationParamsTest.kt diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt index 0638284ba9..687cf094a1 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -107,7 +107,6 @@ import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.PickImage -import com.nextcloud.talk.utils.SpreedFeatures import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.preview.ComposePreviewUtils import javax.inject.Inject @@ -242,12 +241,7 @@ fun ConversationCreationScreen( ConversationNameAndDescription(conversationCreationViewModel) - if ( - CapabilitiesUtil.hasSpreedFeatureCapability( - conversationCreationViewModel.currentUser.capabilities?.spreedCapability, - SpreedFeatures.CONVERSATION_PRESETS - ) - ) { + if (conversationCreationViewModel.showPresetSelection) { ConversationPresets(conversationCreationViewModel) } AddParticipants(launcher, context, conversationCreationViewModel) @@ -389,12 +383,6 @@ fun ConversationNameAndDescription(conversationCreationViewModel: ConversationCr @Composable fun ConversationPresets(conversationCreationViewModel: ConversationCreationViewModel) { val preset by conversationCreationViewModel.conversationPreset - val hasAnnouncementPresetCapability = CapabilitiesUtil.hasSpreedFeatureCapability( - conversationCreationViewModel.currentUser.capabilities?.spreedCapability, - SpreedFeatures.ANNOUNCEMENT_PRESET - ) - val showAnnouncementPreset = hasAnnouncementPresetCapability && - CapabilitiesUtil.isAdmin(conversationCreationViewModel.currentUser.capabilities?.spreedCapability) Column( modifier = Modifier @@ -410,21 +398,25 @@ fun ConversationPresets(conversationCreationViewModel: ConversationCreationViewM title = stringResource(R.string.default_room), subtitle = stringResource(R.string.default_room_preset), icon = Icons.Outlined.Chat, - isSelected = preset == "default", - onClick = { conversationCreationViewModel.updateConversationPreset("default") } + isSelected = preset == ConversationPreset.DEFAULT, + onClick = { conversationCreationViewModel.updateConversationPreset(ConversationPreset.DEFAULT) } ) - SelectableCard( - modifier = Modifier.weight(1f), - title = stringResource(R.string.voice_room), - subtitle = stringResource(R.string.voice_room_preset), - icon = Icons.Outlined.VolumeUp, - isSelected = preset == "voiceroom", - onClick = { conversationCreationViewModel.updateConversationPreset("voiceroom") } - ) + if (conversationCreationViewModel.canCreateVoiceRoom) { + SelectableCard( + modifier = Modifier.weight(1f), + title = stringResource(R.string.voice_room), + subtitle = stringResource(R.string.voice_room_preset), + icon = Icons.Outlined.VolumeUp, + isSelected = preset == ConversationPreset.VOICE_ROOM, + onClick = { conversationCreationViewModel.updateConversationPreset(ConversationPreset.VOICE_ROOM) } + ) + } else { + Spacer(modifier = Modifier.weight(1f)) + } } - if (hasAnnouncementPresetCapability) { + if (conversationCreationViewModel.canCreateChannel) { Row( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -433,18 +425,22 @@ fun ConversationPresets(conversationCreationViewModel: ConversationCreationViewM title = stringResource(R.string.nc_channel), subtitle = stringResource(R.string.nc_channel_description), icon = Icons.Outlined.Podcasts, - isSelected = preset == "channel", - onClick = { conversationCreationViewModel.updateConversationPreset("channel") } + isSelected = preset == ConversationPreset.CHANNEL, + onClick = { conversationCreationViewModel.updateConversationPreset(ConversationPreset.CHANNEL) } ) - if (showAnnouncementPreset) { + if (conversationCreationViewModel.canCreateAnnouncement) { SelectableCard( modifier = Modifier.weight(1f), title = stringResource(R.string.nc_announcement), subtitle = stringResource(R.string.nc_announcement_description), icon = Icons.Outlined.Campaign, - isSelected = preset == "announcement", - onClick = { conversationCreationViewModel.updateConversationPreset("announcement") } + isSelected = preset == ConversationPreset.ANNOUNCEMENT, + onClick = { + conversationCreationViewModel.updateConversationPreset( + ConversationPreset.ANNOUNCEMENT + ) + } ) } else { Spacer(modifier = Modifier.weight(1f)) @@ -614,12 +610,12 @@ fun AddParticipants( @Suppress("LongMethod") @Composable fun RoomCreationOptions(conversationCreationViewModel: ConversationCreationViewModel) { - val isGuestsAllowed = conversationCreationViewModel.isGuestsAllowed.value + val isGuestsAllowed = conversationCreationViewModel.isGuestsAllowed val isConversationAvailableForRegisteredUsers = conversationCreationViewModel - .isConversationAvailableForRegisteredUsers.value - val isOpenForGuestAppUsers = conversationCreationViewModel.openForGuestAppUsers.value + .isConversationAvailableForRegisteredUsers + val isOpenForGuestAppUsers = conversationCreationViewModel.isOpenForGuestAppUsers - val isPasswordSet = conversationCreationViewModel.isPasswordEnabled.value + val isPasswordSet = conversationCreationViewModel.password.collectAsState().value.isNotEmpty() Text( text = stringResource(id = R.string.nc_new_conversation_visibility), @@ -633,9 +629,7 @@ fun RoomCreationOptions(conversationCreationViewModel: ConversationCreationViewM switch = { Switch( checked = isGuestsAllowed, - onCheckedChange = { - conversationCreationViewModel.isGuestsAllowed.value = it - } + onCheckedChange = { conversationCreationViewModel.allowGuests(it) } ) }, conversationCreationViewModel = conversationCreationViewModel @@ -663,9 +657,7 @@ fun RoomCreationOptions(conversationCreationViewModel: ConversationCreationViewM switch = { Switch( checked = isConversationAvailableForRegisteredUsers, - onCheckedChange = { - conversationCreationViewModel.isConversationAvailableForRegisteredUsers.value = it - } + onCheckedChange = { conversationCreationViewModel.openConversationToRegisteredUsers(it) } ) }, conversationCreationViewModel = conversationCreationViewModel @@ -677,9 +669,7 @@ fun RoomCreationOptions(conversationCreationViewModel: ConversationCreationViewM switch = { Switch( checked = isOpenForGuestAppUsers, - onCheckedChange = { - conversationCreationViewModel.openForGuestAppUsers.value = it - } + onCheckedChange = { conversationCreationViewModel.openConversationToGuestAppUsers(it) } ) }, conversationCreationViewModel = conversationCreationViewModel @@ -701,16 +691,12 @@ fun ConversationOption( .fillMaxWidth() .padding(start = 16.dp, end = 16.dp, bottom = 8.dp) .then( - if (!conversationCreationViewModel.isPasswordEnabled.value) { - Modifier.clickable { - showPasswordDialog = true - } - } else if (conversationCreationViewModel.isPasswordEnabled.value) { - Modifier.clickable { - showPasswordChangeDialog = true - } - } else { - Modifier + when { + switch != null -> Modifier + conversationCreationViewModel.password.collectAsState().value.isEmpty() -> + Modifier.clickable { showPasswordDialog = true } + + else -> Modifier.clickable { showPasswordChangeDialog = true } } ), horizontalArrangement = Arrangement.SpaceBetween, @@ -794,7 +780,6 @@ fun ShowChangePassword(onDismiss: () -> Unit, conversationCreationViewModel: Con TextButton( onClick = { conversationCreationViewModel.updatePassword(changedPassword) - conversationCreationViewModel.isPasswordEnabled.value = true onDismiss() }, enabled = changedPassword.isNotEmpty() && changedPassword.isNotBlank(), @@ -805,7 +790,7 @@ fun ShowChangePassword(onDismiss: () -> Unit, conversationCreationViewModel: Con Spacer(modifier = Modifier.height(4.dp)) TextButton( onClick = { - conversationCreationViewModel.isPasswordEnabled.value = false + conversationCreationViewModel.updatePassword("") onDismiss() }, contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) @@ -849,7 +834,6 @@ fun ShowPasswordDialog(onDismiss: () -> Unit, conversationCreationViewModel: Con onClick = { if (password.isNotEmpty() && password.isNotBlank()) { conversationCreationViewModel.updatePassword(password) - conversationCreationViewModel.isPasswordEnabled(true) onDismiss() } } @@ -867,7 +851,6 @@ fun ShowPasswordDialog(onDismiss: () -> Unit, conversationCreationViewModel: Con @Composable fun CreateConversation(conversationCreationViewModel: ConversationCreationViewModel, context: Context) { - val selectedParticipants by conversationCreationViewModel.selectedParticipants.collectAsState() val isCreatingRoom by conversationCreationViewModel.isCreatingRoom.collectAsState() Box( modifier = Modifier @@ -878,12 +861,7 @@ fun CreateConversation(conversationCreationViewModel: ConversationCreationViewMo Button( enabled = !isCreatingRoom, onClick = { - conversationCreationViewModel.createRoomAndAddParticipants( - roomType = CompanionClass.ROOM_TYPE_GROUP, - conversationName = conversationCreationViewModel.roomName.value, - participants = selectedParticipants.toSet(), - preset = conversationCreationViewModel.conversationPreset.value - ) { roomToken -> + conversationCreationViewModel.createRoomAndAddParticipants { roomToken -> val bundle = Bundle() bundle.putString(BundleKeys.KEY_ROOM_TOKEN, roomToken) val chatIntent = Intent(context, ChatActivity::class.java) @@ -906,13 +884,6 @@ fun CreateConversation(conversationCreationViewModel: ConversationCreationViewMo } } -class CompanionClass { - companion object { - internal val TAG = ConversationCreationActivity::class.simpleName - internal const val ROOM_TYPE_GROUP = "2" - } -} - @Preview(name = "Light Mode") @Preview(name = "Dark Mode", uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) @Preview(name = "RTL / Arabic", locale = "ar") diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreator.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreator.kt new file mode 100644 index 0000000000..a2ea60759e --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreator.kt @@ -0,0 +1,256 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.conversationcreation + +import android.net.Uri +import android.util.Log +import androidx.core.net.toFile +import com.nextcloud.talk.conversationcreation.data.ConversationCreationRepository +import com.nextcloud.talk.conversationinfo.CreateRoomRequest +import com.nextcloud.talk.conversationinfo.Participants +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser +import com.nextcloud.talk.models.json.conversations.Conversation +import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.CapabilitiesUtil +import com.nextcloud.talk.utils.SpreedFeatures +import kotlinx.coroutines.CancellationException +import javax.inject.Inject + +/** + * A conversation that is about to be created. + */ +data class NewConversation( + val name: String, + val description: String, + val preset: String, + val params: CreateConversationParams, + val password: String, + val participants: List, + val emoji: String? = null, + val emojiColor: Int? = null, + val imageUri: Uri? = null +) { + val isPasswordProtected: Boolean + get() = params.roomType == CreateConversationParams.ROOM_TYPE_PUBLIC && password.isNotEmpty() +} + +/** + * Creates conversations on the server, in a single request where the server supports all creation + * parameters and with follow up requests otherwise. + */ +class ConversationCreator @Inject constructor(private val repository: ConversationCreationRepository) { + + suspend fun create(user: User, newConversation: NewConversation): Conversation? { + val capabilities = user.capabilities?.spreedCapability + val credentials = ApiUtils.getCredentials(user.username, user.token) + val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1)) + val context = RequestContext(user, credentials, apiVersion) + + val conversation = if ( + CapabilitiesUtil.hasSpreedFeatureCapability(capabilities, SpreedFeatures.CONVERSATION_CREATION_ALL) + ) { + createWithAllParameters( + context, + newConversation, + CapabilitiesUtil.hasSpreedFeatureCapability( + capabilities, + SpreedFeatures.CONVERSATION_CREATION_PASSWORD + ) + ) + } else { + createWithFollowUpRequests(context, newConversation) + } + + conversation?.token?.let { saveAvatar(context, newConversation, it) } + return conversation + } + + private suspend fun createWithAllParameters( + context: RequestContext, + newConversation: NewConversation, + supportsPasswordOnCreation: Boolean + ): Conversation? { + val params = newConversation.params + val sendsPassword = supportsPasswordOnCreation && newConversation.isPasswordProtected + val body = CreateRoomRequest().apply { + roomType = params.roomType.toString() + roomName = newConversation.name + preset = newConversation.preset.takeIf { it != ConversationPreset.DEFAULT } + description = newConversation.description + readOnly = params.readOnly + listable = params.listable + messageExpiration = params.messageExpiration + lobbyState = params.lobbyState + sipEnabled = params.sipEnabled + permissions = params.permissions + recordingConsent = params.recordingConsent + mentionPermissions = params.mentionPermissions + participants = participantsOf(newConversation.participants) + if (sendsPassword) { + password = newConversation.password + } + } + + val url = ApiUtils.getUrlForRooms(context.apiVersion, context.user.baseUrl) + val conversation = repository.createRoomWithBody(context.credentials, url, body).ocs?.data + val token = conversation?.token + if (!token.isNullOrEmpty() && newConversation.isPasswordProtected && !sendsPassword) { + conversation.hasPassword = applyAfterCreation(context, newConversation, token, passwordOnly = true) + } + return conversation + } + + private suspend fun createWithFollowUpRequests( + context: RequestContext, + newConversation: NewConversation + ): Conversation? { + val params = newConversation.params + val retrofitBucket = ApiUtils.getRetrofitBucketForCreateRoom( + version = context.apiVersion, + roomType = params.roomType.toString(), + baseUrl = context.user.baseUrl, + conversationName = newConversation.name, + preset = newConversation.preset.takeIf { it != ConversationPreset.DEFAULT } + ) + val conversation = repository.createRoom(context.credentials, retrofitBucket).ocs?.data + val token = conversation?.token?.takeIf { it.isNotEmpty() } ?: return conversation + + if (newConversation.isPasswordProtected) { + conversation.hasPassword = applyAfterCreation(context, newConversation, token) + } else { + applyAfterCreation(context, newConversation, token) + } + addParticipants(context, newConversation.participants, token) + + return conversation + } + + @Suppress("Detekt.TooGenericExceptionCaught") + private suspend fun applyAfterCreation( + context: RequestContext, + newConversation: NewConversation, + token: String, + passwordOnly: Boolean = false + ): Boolean { + var passwordApplied = false + try { + if (!passwordOnly && newConversation.description.isNotEmpty()) { + repository.setConversationDescription( + context.credentials, + ApiUtils.getUrlForConversationDescription(context.apiVersion, context.user.baseUrl, token), + token, + newConversation.description + ) + } + + if (!passwordOnly && newConversation.params.listable != CreateConversationParams.LISTABLE_NONE) { + repository.openConversation( + context.credentials, + ApiUtils.getUrlForOpeningConversations(context.apiVersion, context.user.baseUrl, token), + token, + newConversation.params.listable + ) + } + + if (newConversation.isPasswordProtected) { + setPassword(context, newConversation.password, token) + passwordApplied = true + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Failed to apply a setting to the created conversation", e) + } + return passwordApplied + } + + private suspend fun setPassword(context: RequestContext, password: String, token: String) { + repository.setPassword( + context.credentials, + ApiUtils.getUrlForRoomPassword(context.apiVersion, context.user.baseUrl, token), + token, + password + ) + } + + @Suppress("Detekt.TooGenericExceptionCaught") + private suspend fun addParticipants(context: RequestContext, participants: List, token: String) { + participants.forEach { participant -> + val participantId = participant.id ?: return@forEach + try { + repository.addParticipants( + context.credentials, + ApiUtils.getRetrofitBucketForAddParticipantWithSource( + context.apiVersion, + context.user.baseUrl, + token, + participant.source ?: SOURCE_USERS, + participantId + ) + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Failed to add a participant to the created conversation", e) + } + } + } + + private fun participantsOf(autocompleteUsers: List): Participants { + val participants = Participants() + autocompleteUsers.forEach { autocompleteUser -> + val id = autocompleteUser.id ?: return@forEach + when (autocompleteUser.source) { + SOURCE_GROUPS -> participants.groups.add(id) + SOURCE_EMAILS -> participants.emails.add(id) + SOURCE_CIRCLES -> participants.teams.add(id) + SOURCE_FEDERATED -> participants.federatedUsers.add(id) + SOURCE_PHONES -> participants.phones.add(id) + else -> participants.users.add(id) + } + } + return participants + } + + private suspend fun saveAvatar(context: RequestContext, newConversation: NewConversation, token: String) { + val emoji = newConversation.emoji + val baseUrl = context.user.baseUrl ?: return + if (emoji != null) { + repository.setConversationEmojiAvatar( + context.credentials, + ApiUtils.getUrlForConversationEmojiAvatar(1, baseUrl, token), + emoji, + newConversation.emojiColor?.let { "%06X".format(COLOR_HEX_MASK and it) } + ) + } else { + newConversation.imageUri?.let { + repository.uploadConversationAvatar( + context.credentials, + context.user, + ApiUtils.getUrlForConversationAvatar(1, baseUrl, token), + it.toFile(), + token + ) + } + } + } + + private data class RequestContext(val user: User, val credentials: String?, val apiVersion: Int) + + companion object { + private val TAG = ConversationCreator::class.simpleName + private const val COLOR_HEX_MASK = 0xFFFFFF + private const val SOURCE_USERS = "users" + private const val SOURCE_GROUPS = "groups" + private const val SOURCE_EMAILS = "emails" + private const val SOURCE_CIRCLES = "circles" + private const val SOURCE_FEDERATED = "federated" + private const val SOURCE_PHONES = "phones" + } +} diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/CreateConversationParams.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/CreateConversationParams.kt new file mode 100644 index 0000000000..b4bf3c72cb --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/CreateConversationParams.kt @@ -0,0 +1,113 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.conversationcreation + +import com.nextcloud.talk.utils.CapabilitiesUtil +import com.nextcloud.talk.utils.ParticipantPermissions + +/** + * The parameters a conversation is created with, before the request body is built. + */ +data class CreateConversationParams( + val roomType: Int = ROOM_TYPE_GROUP, + val readOnly: Int = READ_WRITE, + val listable: Int = LISTABLE_NONE, + val messageExpiration: Int = MESSAGE_EXPIRATION_OFF, + val lobbyState: Int = LOBBY_DISABLED, + val sipEnabled: Int = SIP_DISABLED, + val permissions: Int = ParticipantPermissions.DEFAULT, + val recordingConsent: Int = CapabilitiesUtil.RECORDING_CONSENT_NOT_REQUIRED, + val mentionPermissions: Int = MENTION_PERMISSIONS_EVERYONE +) { + companion object { + const val ROOM_TYPE_GROUP = 2 + const val ROOM_TYPE_PUBLIC = 3 + + const val READ_WRITE = 0 + + const val LISTABLE_NONE = 0 + const val LISTABLE_USERS = 1 + const val LISTABLE_ALL = 2 + + const val MESSAGE_EXPIRATION_OFF = 0 + const val MESSAGE_EXPIRATION_ONE_HOUR = 3600 + + const val LOBBY_DISABLED = 0 + + const val SIP_DISABLED = 0 + + const val MENTION_PERMISSIONS_EVERYONE = 0 + } +} + +/** + * Names of the conversation parameters as the server addresses them. + */ +object ConversationParameter { + const val ROOM_TYPE = "roomType" + const val READ_ONLY = "readOnly" + const val LISTABLE = "listable" + const val MESSAGE_EXPIRATION = "messageExpiration" + const val LOBBY_STATE = "lobbyState" + const val SIP_ENABLED = "sipEnabled" + const val PERMISSIONS = "permissions" + const val RECORDING_CONSENT = "recordingConsent" + const val MENTION_PERMISSIONS = "mentionPermissions" +} + +/** + * Identifiers of the conversation presets the server offers. + */ +object ConversationPreset { + const val DEFAULT = "default" + const val VOICE_ROOM = "voiceroom" + const val CHANNEL = "channel" + const val ANNOUNCEMENT = "announcement" +} + +/** + * Parameters each preset applies, mirroring the definitions of the server. + */ +object ConversationPresetParameters { + + fun of(preset: String): Map = presets[preset].orEmpty() + + private val channel = mapOf( + ConversationParameter.LISTABLE to CreateConversationParams.LISTABLE_USERS, + ConversationParameter.PERMISSIONS to (ParticipantPermissions.CUSTOM or ParticipantPermissions.REACT) + ) + + private val presets = mapOf( + ConversationPreset.DEFAULT to emptyMap(), + ConversationPreset.VOICE_ROOM to mapOf( + ConversationParameter.LISTABLE to CreateConversationParams.LISTABLE_USERS, + ConversationParameter.MESSAGE_EXPIRATION to CreateConversationParams.MESSAGE_EXPIRATION_ONE_HOUR + ), + ConversationPreset.CHANNEL to channel, + ConversationPreset.ANNOUNCEMENT to channel + mapOf( + ConversationParameter.LISTABLE to CreateConversationParams.LISTABLE_NONE + ) + ) +} + +/** + * Returns these parameters with the given ones applied on top, leaving those the caller does not + * address untouched. + */ +fun CreateConversationParams.withParameters(parameters: Map): CreateConversationParams = + copy( + roomType = parameters[ConversationParameter.ROOM_TYPE] ?: roomType, + readOnly = parameters[ConversationParameter.READ_ONLY] ?: readOnly, + listable = parameters[ConversationParameter.LISTABLE] ?: listable, + messageExpiration = parameters[ConversationParameter.MESSAGE_EXPIRATION] ?: messageExpiration, + lobbyState = parameters[ConversationParameter.LOBBY_STATE] ?: lobbyState, + sipEnabled = parameters[ConversationParameter.SIP_ENABLED] ?: sipEnabled, + permissions = parameters[ConversationParameter.PERMISSIONS] ?: permissions, + recordingConsent = parameters[ConversationParameter.RECORDING_CONSENT] ?: recordingConsent, + mentionPermissions = parameters[ConversationParameter.MENTION_PERMISSIONS] ?: mentionPermissions + ) diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt index a61e92b07b..8c462b85c9 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt @@ -10,16 +10,20 @@ package com.nextcloud.talk.conversationcreation.viewmodel import android.net.Uri import android.util.Log import androidx.compose.runtime.mutableStateOf -import androidx.core.net.toFile import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.nextcloud.talk.conversationcreation.data.ConversationCreationRepository -import com.nextcloud.talk.conversationinfo.CreateRoomRequest +import com.nextcloud.talk.conversationcreation.ConversationCreator +import com.nextcloud.talk.conversationcreation.ConversationPreset +import com.nextcloud.talk.conversationcreation.ConversationPresetParameters +import com.nextcloud.talk.conversationcreation.CreateConversationParams +import com.nextcloud.talk.conversationcreation.NewConversation +import com.nextcloud.talk.conversationcreation.withParameters import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser import com.nextcloud.talk.models.json.conversations.Conversation import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.ParticipantPermissions +import com.nextcloud.talk.utils.CapabilitiesUtil +import com.nextcloud.talk.utils.SpreedFeatures import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -27,7 +31,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject class ConversationCreationViewModel @Inject constructor( - private val repository: ConversationCreationRepository, + private val conversationCreator: ConversationCreator, private val currentUserProvider: CurrentUserProviderOld ) : ViewModel() { private val _selectedParticipants = MutableStateFlow>(emptyList()) @@ -49,17 +53,26 @@ class ConversationCreationViewModel @Inject constructor( private val _currentUser = currentUserProvider.currentUser.blockingGet() val currentUser: User = _currentUser - private val _isPasswordEnabled = mutableStateOf(false) - val isPasswordEnabled = _isPasswordEnabled + private val spreedCapabilities = _currentUser.capabilities?.spreedCapability + + val showPresetSelection = CapabilitiesUtil.hasSpreedFeatureCapability( + spreedCapabilities, + SpreedFeatures.CONVERSATION_PRESETS + ) + + val canCreateVoiceRoom = spreedCapabilities != null && CapabilitiesUtil.isAbleToCall(spreedCapabilities) + + val canCreateChannel = CapabilitiesUtil.hasSpreedFeatureCapability( + spreedCapabilities, + SpreedFeatures.ANNOUNCEMENT_PRESET + ) + + val canCreateAnnouncement = canCreateChannel && CapabilitiesUtil.isAdmin(spreedCapabilities) fun updateSelectedParticipants(participants: List) { _selectedParticipants.value = participants } - fun isPasswordEnabled(value: Boolean) { - _isPasswordEnabled.value = value - } - fun updateSelectedImageUri(uri: Uri?) { _selectedImageUri.value = uri if (uri != null) { @@ -89,11 +102,10 @@ class ConversationCreationViewModel @Inject constructor( val password: StateFlow = _password private val _conversationDescription = MutableStateFlow("") val conversationDescription: StateFlow = _conversationDescription - var isGuestsAllowed = mutableStateOf(false) - var isConversationAvailableForRegisteredUsers = mutableStateOf(false) - val conversationPreset = mutableStateOf("default") - var openForGuestAppUsers = mutableStateOf(false) - private val allowGuestsResult = MutableStateFlow(AllowGuestsUiState.None) + val conversationPreset = mutableStateOf(ConversationPreset.DEFAULT) + + private val conversationParams = mutableStateOf(CreateConversationParams()) + fun updateRoomName(roomName: String) { _roomName.value = roomName } @@ -108,172 +120,98 @@ class ConversationCreationViewModel @Inject constructor( fun updateConversationPreset(preset: String) { conversationPreset.value = preset - when (preset) { - "default", "voiceroom" -> { - isConversationAvailableForRegisteredUsers.value = false - openForGuestAppUsers.value = false - } - "channel" -> { - isConversationAvailableForRegisteredUsers.value = true - openForGuestAppUsers.value = false - } - "announcement" -> { - isConversationAvailableForRegisteredUsers.value = false - openForGuestAppUsers.value = false - } + val visibilityChosenByUser = CreateConversationParams(roomType = conversationParams.value.roomType) + updateParams(visibilityChosenByUser.withParameters(ConversationPresetParameters.of(preset))) + } + + val isGuestsAllowed: Boolean + get() = conversationParams.value.roomType == CreateConversationParams.ROOM_TYPE_PUBLIC + + val isConversationAvailableForRegisteredUsers: Boolean + get() = conversationParams.value.listable != CreateConversationParams.LISTABLE_NONE + + val isOpenForGuestAppUsers: Boolean + get() = conversationParams.value.listable == CreateConversationParams.LISTABLE_ALL + + fun allowGuests(allow: Boolean) { + val roomType = if (allow) { + CreateConversationParams.ROOM_TYPE_PUBLIC + } else { + CreateConversationParams.ROOM_TYPE_GROUP + } + updateParams(conversationParams.value.copy(roomType = roomType)) + } + + fun openConversationToRegisteredUsers(open: Boolean) { + val listable = if (open) { + CreateConversationParams.LISTABLE_USERS + } else { + CreateConversationParams.LISTABLE_NONE + } + updateParams(conversationParams.value.copy(listable = listable)) + } + + fun openConversationToGuestAppUsers(open: Boolean) { + val listable = if (open) { + CreateConversationParams.LISTABLE_ALL + } else { + CreateConversationParams.LISTABLE_USERS } + updateParams(conversationParams.value.copy(listable = listable)) } - @Suppress("Detekt.TooGenericExceptionCaught", "LongMethod") - fun createRoomAndAddParticipants( - roomType: String, - conversationName: String, - preset: String = "default", - participants: Set, - onRoomCreated: (String) -> Unit - ) { + private fun updateParams(params: CreateConversationParams) { + conversationParams.value = params + } + + @Suppress("Detekt.TooGenericExceptionCaught") + fun createRoomAndAddParticipants(onRoomCreated: (String) -> Unit) { if (_isCreatingRoom.value) { return } _isCreatingRoom.value = true - val credentials = ApiUtils.getCredentials(_currentUser.username, _currentUser.token) - val scope = when { - isConversationAvailableForRegisteredUsers.value && !openForGuestAppUsers.value -> 1 - isConversationAvailableForRegisteredUsers.value && openForGuestAppUsers.value -> 2 - else -> 0 - } viewModelScope.launch { roomViewState.value = RoomUIState.None try { - val apiVersion = - ApiUtils.getConversationApiVersion(_currentUser, intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1)) - val url = ApiUtils.getUrlForRooms(apiVersion, _currentUser.baseUrl) - val body = CreateRoomRequest().apply { - this.roomType = roomType - this.roomName = conversationName - this.preset = preset - this.description = _conversationDescription.value - this.listable = scope - this.participants = convertAutocompleteUserToParticipants(participants) - - if (preset == "channel" || preset == "announcement") { - this.permissions = ParticipantPermissions.DEFAULT_GROUP_PERMISSIONS and - ParticipantPermissions.CHAT.inv() - } - } - val roomResult = repository.createRoomWithBody( - credentials, - url, - body + val conversation = conversationCreator.create( + _currentUser, + NewConversation( + name = _roomName.value, + description = _conversationDescription.value, + preset = conversationPreset.value, + params = conversationParams.value, + password = _password.value, + participants = _selectedParticipants.value.distinctBy { it.source to it.id }, + emoji = _selectedEmoji.value, + emojiColor = _selectedEmojiColor.value, + imageUri = _selectedImageUri.value + ) ) - val conversation = roomResult.ocs?.data - - if (conversation != null) { - val token = conversation.token - if (token != null) { - try { - if (_password.value.isNotEmpty()) { - val url = ApiUtils.getUrlForRoomPassword( - apiVersion, - _currentUser.baseUrl!!, - token - ) - repository.setPassword( - credentials, - url, - token, - _password.value - ) - } - - val urlForOpeningConversations = ApiUtils.getUrlForOpeningConversations( - apiVersion, - _currentUser.baseUrl, - token - ) - - repository.openConversation( - credentials, - urlForOpeningConversations, - token, - scope - ) - - saveAvatar(credentials, token) - onRoomCreated(token) - } catch (exception: Exception) { - allowGuestsResult.value = AllowGuestsUiState.Error(exception.message ?: "") - } - } + val token = conversation?.token + if (!token.isNullOrEmpty()) { roomViewState.value = RoomUIState.Success(conversation) + onRoomCreated(token) } else { roomViewState.value = RoomUIState.Error("Conversation is null") } } catch (e: Exception) { roomViewState.value = RoomUIState.Error(e.message ?: "Unknown error") - Log.e("ConversationCreationViewModel", "Error - ${e.message}") + Log.e(TAG, "Error - ${e.message}") } finally { _isCreatingRoom.value = false } } } - private fun convertAutocompleteUserToParticipants( - autocompleteUsers: Set - ): com.nextcloud.talk.conversationinfo.Participants { - val participants = com.nextcloud.talk.conversationinfo.Participants() - autocompleteUsers.forEach { autocompleteUser -> - when (autocompleteUser.source) { - "groups" -> participants.groups.add(autocompleteUser.id!!) - "emails" -> participants.emails.add(autocompleteUser.id!!) - "circles" -> participants.teams.add(autocompleteUser.id!!) - "federated" -> participants.federatedUsers.add(autocompleteUser.id!!) - "phones" -> participants.phones.add(autocompleteUser.id!!) - else -> participants.users.add(autocompleteUser.id!!) - } - } - return participants - } - fun getImageUri(avatarId: String, requestBigSize: Boolean, isDarkMode: Boolean): String = ApiUtils.getUrlForAvatar(_currentUser.baseUrl, avatarId, requestBigSize, darkMode = isDarkMode) - private suspend fun saveAvatar(credentials: String?, token: String) { - val emoji = _selectedEmoji.value - if (emoji != null) { - val urlForConversationEmojiAvatar = ApiUtils.getUrlForConversationEmojiAvatar( - 1, - _currentUser.baseUrl!!, - token - ) - val color = _selectedEmojiColor.value?.let { "%06X".format(COLOR_HEX_MASK and it) } - repository.setConversationEmojiAvatar(credentials, urlForConversationEmojiAvatar, emoji, color) - } else { - selectedImageUri.value?.let { - val urlForConversationAvatar = ApiUtils.getUrlForConversationAvatar(1, _currentUser.baseUrl!!, token) - repository.uploadConversationAvatar( - credentials, - _currentUser, - urlForConversationAvatar, - it.toFile(), - token - ) - } - } - } - companion object { - private const val COLOR_HEX_MASK = 0xFFFFFF + private val TAG = ConversationCreationViewModel::class.simpleName } } -sealed class AllowGuestsUiState { - data object None : AllowGuestsUiState() - data class Success(val result: Boolean) : AllowGuestsUiState() - data class Error(val message: String) : AllowGuestsUiState() -} - sealed class RoomUIState { data object None : RoomUIState() data class Success(val conversation: Conversation?) : RoomUIState() diff --git a/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt b/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt index 1d1961c130..ac7635ba56 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt @@ -59,6 +59,7 @@ enum class SpreedFeatures(val value: String) { EDIT_MESSAGES_NOTE_TO_SELF("edit-messages-note-to-self"), ARCHIVE_CONVERSATIONS("archived-conversations-v2"), CONVERSATION_CREATION_ALL("conversation-creation-all"), + CONVERSATION_CREATION_PASSWORD("conversation-creation-password"), UNBIND_CONVERSATION("unbind-conversation"), SENSITIVE_CONVERSATIONS("sensitive-conversations"), IMPORTANT_CONVERSATIONS("important-conversations"), diff --git a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt index 54b6a1098c..8ab1dc681b 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtils.kt @@ -29,6 +29,7 @@ import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.contacts.ContactsRepository import com.nextcloud.talk.contacts.ContactsRepositoryImpl import com.nextcloud.talk.contacts.ContactsViewModel +import com.nextcloud.talk.conversationcreation.ConversationCreator import com.nextcloud.talk.conversationcreation.data.ConversationCreationRepositoryImpl import com.nextcloud.talk.conversationcreation.viewmodel.ConversationCreationViewModel import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository @@ -244,7 +245,7 @@ class ComposePreviewUtils private constructor(context: Context) { val conversationCreationViewModel: ConversationCreationViewModel get() = ConversationCreationViewModel( - ConversationCreationRepositoryImpl(ncApiCoroutines), + ConversationCreator(ConversationCreationRepositoryImpl(ncApiCoroutines)), userProvider ) } diff --git a/app/src/test/java/com/nextcloud/talk/conversationcreation/CreateConversationParamsTest.kt b/app/src/test/java/com/nextcloud/talk/conversationcreation/CreateConversationParamsTest.kt new file mode 100644 index 0000000000..55dd72f8c5 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/conversationcreation/CreateConversationParamsTest.kt @@ -0,0 +1,85 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.conversationcreation + +import com.nextcloud.talk.utils.ParticipantPermissions +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Preset parameters and how they fold onto the parameters of a new conversation. + * + * The expected values mirror the preset definitions of the server in `lib/RoomPresets` of the + * `spreed` app, which the client has to apply itself: the creation endpoint derives only the + * conversation attributes from the preset identifier and stores whatever parameters the request + * carried. + */ +class CreateConversationParamsTest { + + @Test + fun `default preset applies no parameters of its own`() { + val params = CreateConversationParams().withParameters( + ConversationPresetParameters.of(ConversationPreset.DEFAULT) + ) + + assertEquals(CreateConversationParams(), params) + } + + @Test + fun `voice room is listable for users and expires messages after an hour`() { + val params = CreateConversationParams().withParameters( + ConversationPresetParameters.of(ConversationPreset.VOICE_ROOM) + ) + + assertEquals(CreateConversationParams.LISTABLE_USERS, params.listable) + assertEquals(CreateConversationParams.MESSAGE_EXPIRATION_ONE_HOUR, params.messageExpiration) + } + + @Test + fun `channel grants reactions only and is listable for users`() { + val params = CreateConversationParams().withParameters( + ConversationPresetParameters.of(ConversationPreset.CHANNEL) + ) + + assertEquals( + ParticipantPermissions.CUSTOM or ParticipantPermissions.REACT, + params.permissions + ) + assertEquals(CreateConversationParams.LISTABLE_USERS, params.listable) + } + + @Test + fun `announcement is a channel that is not listable`() { + val channel = CreateConversationParams().withParameters( + ConversationPresetParameters.of(ConversationPreset.CHANNEL) + ) + val announcement = CreateConversationParams().withParameters( + ConversationPresetParameters.of(ConversationPreset.ANNOUNCEMENT) + ) + + assertEquals(channel.permissions, announcement.permissions) + assertEquals(CreateConversationParams.LISTABLE_NONE, announcement.listable) + } + + @Test + fun `parameters the preset does not address are left untouched`() { + val chosenByUser = CreateConversationParams(roomType = CreateConversationParams.ROOM_TYPE_PUBLIC) + + val params = chosenByUser.withParameters( + ConversationPresetParameters.of(ConversationPreset.CHANNEL) + ) + + assertEquals(CreateConversationParams.ROOM_TYPE_PUBLIC, params.roomType) + } + + @Test + fun `an unknown preset leaves the parameters at their defaults`() { + val params = CreateConversationParams().withParameters(ConversationPresetParameters.of("webinar")) + + assertEquals(CreateConversationParams(), params) + } +}