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) + } +}