From 05c2ef4b73d118f17c124d59a4c41747f7b13e9a Mon Sep 17 00:00:00 2001 From: sowjanyakch Date: Wed, 4 Mar 2026 18:09:20 +0100 Subject: [PATCH 1/2] add model Signed-off-by: sowjanyakch add password logic Signed-off-by: sowjanyakch handle states of password states Signed-off-by: sowjanyakch copy password Signed-off-by: sowjanyakch remove unused function and add copyright info Signed-off-by: sowjanyakch modify layout and import changes with the package name change Signed-off-by: sowjanyakch split GuestAccessPasswordDialog into two functions Signed-off-by: sowjanyakch fix detekt Signed-off-by: sowjanyakch --- .../com/nextcloud/talk/api/NcApiCoroutines.kt | 9 + .../ConversationCreationActivity.kt | 254 +++++++++---- .../data/ConversationCreationRepository.kt | 2 + .../ConversationCreationRepositoryImpl.kt | 10 + .../ConversationCreationViewModel.kt | 35 ++ .../conversationinfo/GuestAccessHelper.kt | 351 ++++++++++++++++++ .../viewmodel/ConversationInfoViewModel.kt | 144 ++++++- .../models/json/capabilities/Capabilities.kt | 6 +- .../json/capabilities/PasswordAccount.kt | 33 ++ .../models/json/capabilities/PasswordApi.kt | 26 ++ .../json/capabilities/PasswordCapability.kt | 23 ++ .../json/capabilities/PasswordPolicies.kt | 23 ++ .../json/capabilities/PasswordPolicy.kt | 35 ++ .../capabilities/ProvisioningCapability.kt | 2 - .../json/passwordResult/PasswordResult.kt | 25 ++ .../json/passwordResult/PasswordResultOCS.kt | 26 ++ .../passwordResult/PasswordResultOverall.kt | 23 ++ app/src/main/res/layout/dialog_password.xml | 27 -- app/src/main/res/values/strings.xml | 2 + 19 files changed, 955 insertions(+), 101 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/conversationinfo/GuestAccessHelper.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordAccount.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordApi.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordCapability.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordPolicies.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordPolicy.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/passwordResult/PasswordResult.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/passwordResult/PasswordResultOCS.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/passwordResult/PasswordResultOverall.kt delete mode 100644 app/src/main/res/layout/dialog_password.xml diff --git a/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt b/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt index 9f22d7f1bd7..0a0be7e17b3 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt +++ b/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt @@ -25,6 +25,7 @@ import com.nextcloud.talk.models.json.invitation.InvitationOverall import com.nextcloud.talk.models.json.participants.AddParticipantOverall import com.nextcloud.talk.models.json.participants.TalkBan import com.nextcloud.talk.models.json.participants.TalkBanOverall +import com.nextcloud.talk.models.json.passwordResult.PasswordResultOverall import com.nextcloud.talk.models.json.profile.ProfileOverall import com.nextcloud.talk.models.json.reactions.ReactionsOverall import com.nextcloud.talk.models.json.status.StatusOverall @@ -577,4 +578,12 @@ interface NcApiCoroutines { @FormUrlEncoded @POST suspend fun reportRemoteWipeSuccess(@Url url: String, @Field("token") token: String): Response + + @FormUrlEncoded + @POST + suspend fun validatePassword( + @Header("Authorization") authorization: String, + @Url url: String, + @Field("password") password: String + ): PasswordResultOverall } 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 0638284ba94..d382c05df88 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -11,6 +11,8 @@ package com.nextcloud.talk.conversationcreation import android.annotation.SuppressLint import android.app.Activity +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.net.Uri @@ -38,6 +40,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -89,6 +92,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle import autodagger.AutoInjector import coil.compose.AsyncImage import com.nextcloud.talk.R @@ -102,6 +106,7 @@ import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.contacts.ContactsActivity import com.nextcloud.talk.contacts.loadImage import com.nextcloud.talk.conversationcreation.viewmodel.ConversationCreationViewModel +import com.nextcloud.talk.conversationcreation.viewmodel.ValidPasswordUiState import com.nextcloud.talk.extensions.getParcelableArrayListExtraProvider import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser import com.nextcloud.talk.utils.CapabilitiesUtil @@ -625,95 +630,132 @@ fun RoomCreationOptions(conversationCreationViewModel: ConversationCreationViewM text = stringResource(id = R.string.nc_new_conversation_visibility), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 24.dp, start = 16.dp, end = 16.dp) - ) - ConversationOption( - icon = R.drawable.ic_avatar_link, - text = R.string.nc_guest_access_allow_title, - switch = { - Switch( - checked = isGuestsAllowed, - onCheckedChange = { - conversationCreationViewModel.isGuestsAllowed.value = it - } - ) - }, - conversationCreationViewModel = conversationCreationViewModel + modifier = Modifier.padding(top = 24.dp, start = 16.dp, end = 16.dp, bottom = 16.dp) ) - if (isGuestsAllowed && !isPasswordSet) { - ConversationOption( - icon = R.drawable.baseline_lock_open_24, - text = R.string.nc_set_password, - conversationCreationViewModel = conversationCreationViewModel + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ConversationOptions( + icon = R.drawable.ic_avatar_link, + text = R.string.nc_guest_access_allow_title, + switch = { + Switch( + checked = isGuestsAllowed, + onCheckedChange = { + conversationCreationViewModel.isGuestsAllowed.value = it + } + ) + }, + conversationCreationViewModel = conversationCreationViewModel, + isPasswordSetOrChange = false, + copyPassword = false ) - } - if (isGuestsAllowed && isPasswordSet) { - ConversationOption( - icon = R.drawable.ic_lock_grey600_24px, - text = R.string.nc_change_password, - conversationCreationViewModel = conversationCreationViewModel - ) - } + if (isGuestsAllowed && !isPasswordSet) { + ConversationOptions( + icon = R.drawable.baseline_lock_open_24, + text = R.string.nc_set_password, + conversationCreationViewModel = conversationCreationViewModel, + isPasswordSetOrChange = true, + copyPassword = false + ) + } - ConversationOption( - icon = R.drawable.baseline_format_list_bulleted_24, - text = R.string.nc_open_conversation_to_registered_users, - switch = { - Switch( - checked = isConversationAvailableForRegisteredUsers, - onCheckedChange = { - conversationCreationViewModel.isConversationAvailableForRegisteredUsers.value = it - } + if (isGuestsAllowed && isPasswordSet) { + ConversationOptions( + icon = R.drawable.ic_lock_grey600_24px, + text = R.string.nc_change_password, + conversationCreationViewModel = conversationCreationViewModel, + isPasswordSetOrChange = true, + copyPassword = false ) - }, - conversationCreationViewModel = conversationCreationViewModel - ) + } + if (isGuestsAllowed && isPasswordSet) { + ConversationOptions( + icon = R.drawable.ic_content_copy, + text = R.string.nc_copy_password, + conversationCreationViewModel = conversationCreationViewModel, + isPasswordSetOrChange = false, + copyPassword = true + ) + } - if (isConversationAvailableForRegisteredUsers) { - ConversationOption( - text = R.string.nc_open_to_guest_app_users, + ConversationOptions( + icon = R.drawable.baseline_format_list_bulleted_24, + text = R.string.nc_open_conversation_to_registered_users, switch = { Switch( - checked = isOpenForGuestAppUsers, + checked = isConversationAvailableForRegisteredUsers, onCheckedChange = { - conversationCreationViewModel.openForGuestAppUsers.value = it + conversationCreationViewModel.isConversationAvailableForRegisteredUsers.value = it } ) }, - conversationCreationViewModel = conversationCreationViewModel + conversationCreationViewModel = conversationCreationViewModel, + isPasswordSetOrChange = false, + copyPassword = false ) + if (isConversationAvailableForRegisteredUsers) { + ConversationOptions( + text = R.string.nc_open_to_guest_app_users, + switch = { + Switch( + checked = isOpenForGuestAppUsers, + onCheckedChange = { + conversationCreationViewModel.openForGuestAppUsers.value = it + } + ) + }, + conversationCreationViewModel = conversationCreationViewModel, + isPasswordSetOrChange = false, + copyPassword = false + ) + } } } @Composable -fun ConversationOption( +@Suppress("LongMethod", "LongParameterList") +fun ConversationOptions( icon: Int? = null, text: Int, switch: @Composable (() -> Unit)? = null, - conversationCreationViewModel: ConversationCreationViewModel + conversationCreationViewModel: ConversationCreationViewModel, + isPasswordSetOrChange: Boolean, + copyPassword: Boolean ) { + val context = LocalContext.current var showPasswordDialog by rememberSaveable { mutableStateOf(false) } var showPasswordChangeDialog by rememberSaveable { mutableStateOf(false) } + val passwordValidationState by conversationCreationViewModel.validPasswordViewState.collectAsStateWithLifecycle() Row( modifier = Modifier .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 8.dp) + .height(40.dp) + .padding(horizontal = 16.dp) .then( - if (!conversationCreationViewModel.isPasswordEnabled.value) { + if (!conversationCreationViewModel.isPasswordEnabled.value && isPasswordSetOrChange) { Modifier.clickable { showPasswordDialog = true } - } else if (conversationCreationViewModel.isPasswordEnabled.value) { + } else if (conversationCreationViewModel.isPasswordEnabled.value && isPasswordSetOrChange) { Modifier.clickable { showPasswordChangeDialog = true } + } else if (copyPassword) { + Modifier.clickable { + val clipboardManager = + context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText( + context.resources.getString(R.string.nc_app_product_name), + conversationCreationViewModel.password.value + ) + clipboardManager.setPrimaryClip(clip) + } } else { Modifier } ), - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically ) { if (icon != null) { @@ -736,7 +778,8 @@ fun ConversationOption( if (showPasswordDialog) { ShowPasswordDialog( onDismiss = { showPasswordDialog = false }, - conversationCreationViewModel = conversationCreationViewModel + conversationCreationViewModel = conversationCreationViewModel, + passwordValidationState = passwordValidationState ) } if (showPasswordChangeDialog) { @@ -744,7 +787,8 @@ fun ConversationOption( onDismiss = { showPasswordChangeDialog = false }, - conversationCreationViewModel = conversationCreationViewModel + conversationCreationViewModel = conversationCreationViewModel, + passwordValidationState = passwordValidationState ) } } @@ -752,7 +796,11 @@ fun ConversationOption( @Suppress("LongMethod") @Composable -fun ShowChangePassword(onDismiss: () -> Unit, conversationCreationViewModel: ConversationCreationViewModel) { +fun ShowChangePassword( + onDismiss: () -> Unit, + conversationCreationViewModel: ConversationCreationViewModel, + passwordValidationState: ValidPasswordUiState +) { var changedPassword by rememberSaveable { mutableStateOf("") } Dialog(onDismissRequest = { onDismiss() @@ -760,10 +808,11 @@ fun ShowChangePassword(onDismiss: () -> Unit, conversationCreationViewModel: Con Card( modifier = Modifier .fillMaxWidth() - .height(375.dp) + .wrapContentHeight() .padding(32.dp) .clip(RoundedCornerShape(16.dp)) .background(color = colorResource(id = R.color.appbar)) + .verticalScroll(rememberScrollState()) ) { Column( modifier = Modifier @@ -772,17 +821,23 @@ fun ShowChangePassword(onDismiss: () -> Unit, conversationCreationViewModel: Con verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { + val validatePasswordUrl = conversationCreationViewModel + .currentUser.capabilities?.passwordCapability?.api?.validatePasswordApi Text(text = stringResource(id = R.string.nc_set_new_password), fontWeight = FontWeight.SemiBold) Spacer(modifier = Modifier.height(16.dp)) OutlinedTextField( value = changedPassword, onValueChange = { changedPassword = it + if (validatePasswordUrl != null) { + conversationCreationViewModel.validatePassword(validatePasswordUrl, it) + } }, label = { Text(text = stringResource(id = R.string.nc_password)) }, singleLine = true ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(8.dp)) + PasswordValidationMessage(passwordValidationState) Column( modifier = Modifier @@ -791,13 +846,18 @@ fun ShowChangePassword(onDismiss: () -> Unit, conversationCreationViewModel: Con verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { + val securePassword = (passwordValidationState as? ValidPasswordUiState.Success)?.result?.passed + ?: false TextButton( onClick = { conversationCreationViewModel.updatePassword(changedPassword) conversationCreationViewModel.isPasswordEnabled.value = true + conversationCreationViewModel.resetPasswordViewState() onDismiss() }, - enabled = changedPassword.isNotEmpty() && changedPassword.isNotBlank(), + enabled = changedPassword.isNotEmpty() && + changedPassword.isNotBlank() && + securePassword, contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) ) { Text(text = stringResource(id = R.string.nc_change_password)) @@ -806,6 +866,7 @@ fun ShowChangePassword(onDismiss: () -> Unit, conversationCreationViewModel: Con TextButton( onClick = { conversationCreationViewModel.isPasswordEnabled.value = false + conversationCreationViewModel.resetPasswordViewState() onDismiss() }, contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) @@ -817,7 +878,11 @@ fun ShowChangePassword(onDismiss: () -> Unit, conversationCreationViewModel: Con } Spacer(modifier = Modifier.height(4.dp)) TextButton( - onClick = { onDismiss() }, + onClick = { + conversationCreationViewModel.resetPasswordViewState() + onDismiss() + }, + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) ) { Text(text = stringResource(id = R.string.nc_cancel)) @@ -829,28 +894,46 @@ fun ShowChangePassword(onDismiss: () -> Unit, conversationCreationViewModel: Con } @Composable -fun ShowPasswordDialog(onDismiss: () -> Unit, conversationCreationViewModel: ConversationCreationViewModel) { +fun ShowPasswordDialog( + onDismiss: () -> Unit, + conversationCreationViewModel: ConversationCreationViewModel, + passwordValidationState: ValidPasswordUiState +) { var password by rememberSaveable { mutableStateOf("") } + val validatePasswordUrl = conversationCreationViewModel + .currentUser.capabilities?.passwordCapability?.api?.validatePasswordApi AlertDialog( containerColor = colorResource(id = R.color.dialog_background), onDismissRequest = onDismiss, title = { Text(text = stringResource(id = R.string.nc_set_password)) }, text = { - TextField( - value = password, - onValueChange = { - password = it - }, - label = { Text(text = stringResource(id = R.string.nc_guest_access_password_dialog_hint)) } - ) + Column { + TextField( + value = password, + onValueChange = { + password = it + if (validatePasswordUrl != null) { + conversationCreationViewModel.validatePassword(validatePasswordUrl, it) + } + }, + label = { Text(text = stringResource(id = R.string.nc_guest_access_password_dialog_hint)) } + ) + Spacer(modifier = Modifier.height(8.dp)) + PasswordValidationMessage(passwordValidationState) + } }, confirmButton = { + val securePassword = (passwordValidationState as? ValidPasswordUiState.Success)?.result?.passed + ?: false TextButton( onClick = { - if (password.isNotEmpty() && password.isNotBlank()) { + if (password.isNotEmpty() && + password.isNotBlank() && + securePassword + ) { conversationCreationViewModel.updatePassword(password) conversationCreationViewModel.isPasswordEnabled(true) - onDismiss() + conversationCreationViewModel.resetPasswordViewState() } } ) { @@ -858,13 +941,46 @@ fun ShowPasswordDialog(onDismiss: () -> Unit, conversationCreationViewModel: Con } }, dismissButton = { - TextButton(onClick = { onDismiss() }) { + TextButton(onClick = { + conversationCreationViewModel.resetPasswordViewState() + onDismiss() + }) { Text(text = stringResource(id = R.string.nc_cancel)) } } ) } +@Composable +fun PasswordValidationMessage(passwordValidationState: ValidPasswordUiState) { + when (passwordValidationState) { + is ValidPasswordUiState.Success -> Text( + text = passwordValidationState.result.reason + ?: stringResource(R.string.nc_password_secure), + color = if ((passwordValidationState).result.passed == false) { + colorResource( + id = R.color + .nc_darkRed + ) + } else { + colorResource(id = R.color.nc_darkGreen) + }, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth() + ) + + is ValidPasswordUiState.Error -> { + Text( + text = passwordValidationState.message, + style = MaterialTheme.typography.bodySmall + ) + } + + else -> { + } + } +} + @Composable fun CreateConversation(conversationCreationViewModel: ConversationCreationViewModel, context: Context) { val selectedParticipants by conversationCreationViewModel.selectedParticipants.collectAsState() diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/data/ConversationCreationRepository.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/data/ConversationCreationRepository.kt index 7f3decfb7a4..de7e8fe8769 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/data/ConversationCreationRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/data/ConversationCreationRepository.kt @@ -14,6 +14,7 @@ import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.json.conversations.RoomOverall import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.participants.AddParticipantOverall +import com.nextcloud.talk.models.json.passwordResult.PasswordResultOverall import java.io.File interface ConversationCreationRepository { @@ -43,4 +44,5 @@ interface ConversationCreationRepository { color: String? ): RoomOverall suspend fun allowGuests(credentials: String?, url: String, token: String, allow: Boolean): GenericOverall + suspend fun validatePassword(credentials: String, url: String, password: String): PasswordResultOverall } diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/data/ConversationCreationRepositoryImpl.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/data/ConversationCreationRepositoryImpl.kt index a7cedd0cf9f..aa08b4fa02d 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/data/ConversationCreationRepositoryImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/data/ConversationCreationRepositoryImpl.kt @@ -15,6 +15,7 @@ import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.json.conversations.RoomOverall import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.participants.AddParticipantOverall +import com.nextcloud.talk.models.json.passwordResult.PasswordResultOverall import com.nextcloud.talk.utils.Mimetype import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody @@ -140,4 +141,13 @@ class ConversationCreationRepositoryImpl @Inject constructor(private val ncApiCo } return result } + + override suspend fun validatePassword(credentials: String, url: String, password: String): PasswordResultOverall { + val passwordOverall = ncApiCoroutines.validatePassword( + credentials, + url, + password + ) + return passwordOverall + } } 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 a61e92b07b0..181ffebb16e 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 @@ -18,6 +18,9 @@ import com.nextcloud.talk.conversationinfo.CreateRoomRequest 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.models.json.generic.GenericMeta +import com.nextcloud.talk.models.json.passwordResult.PasswordResult +import com.nextcloud.talk.repositories.conversations.ConversationsRepositoryImpl.Companion.STATUS_CODE_OK import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ParticipantPermissions import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld @@ -34,6 +37,9 @@ class ConversationCreationViewModel @Inject constructor( val selectedParticipants: StateFlow> = _selectedParticipants private val roomViewState = MutableStateFlow(RoomUIState.None) + private val _validPasswordViewState = MutableStateFlow(ValidPasswordUiState.None) + val validPasswordViewState: StateFlow = _validPasswordViewState + private val _selectedImageUri = MutableStateFlow(null) val selectedImageUri: StateFlow = _selectedImageUri @@ -106,6 +112,11 @@ class ConversationCreationViewModel @Inject constructor( _conversationDescription.value = conversationDescription } + fun resetPasswordViewState() { + _validPasswordViewState.value = ValidPasswordUiState.None + } + + @Suppress("Detekt.TooGenericExceptionCaught") fun updateConversationPreset(preset: String) { conversationPreset.value = preset when (preset) { @@ -124,6 +135,24 @@ class ConversationCreationViewModel @Inject constructor( } } + @Suppress("Detekt.TooGenericExceptionCaught", "LongMethod") + fun validatePassword(url: String, password: String) { + val credentials = ApiUtils.getCredentials(_currentUser.username, _currentUser.token) ?: "" + viewModelScope.launch { + try { + val passwordResult = repository.validatePassword( + credentials, + url, + password + ) + + _validPasswordViewState.value = ValidPasswordUiState.Success(passwordResult.ocs?.data!!) + } catch (exception: Exception) { + _validPasswordViewState.value = ValidPasswordUiState.Error(exception.message ?: "") + } + } + } + @Suppress("Detekt.TooGenericExceptionCaught", "LongMethod") fun createRoomAndAddParticipants( roomType: String, @@ -285,3 +314,9 @@ sealed class AddParticipantsUiState { data class Success(val participants: List?) : AddParticipantsUiState() data class Error(val message: String) : AddParticipantsUiState() } + +sealed class ValidPasswordUiState { + data object None : ValidPasswordUiState() + data class Success(val result: PasswordResult) : ValidPasswordUiState() + data class Error(val message: String) : ValidPasswordUiState() +} diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/GuestAccessHelper.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/GuestAccessHelper.kt new file mode 100644 index 00000000000..6b414ab8b62 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/GuestAccessHelper.kt @@ -0,0 +1,351 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Marcel Hibbe + * SPDX-FileCopyrightText: 2023 Julius Linus + * SPDX-FileCopyrightText: 2022 Tim Krüger + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.conversationinfo + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context.CLIPBOARD_SERVICE +import android.util.Log +import android.view.View +import androidx.appcompat.app.AlertDialog +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.LifecycleOwner +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import com.nextcloud.talk.R +import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.databinding.ActivityConversationInfoBinding +import com.nextcloud.talk.models.domain.ConversationModel +import com.nextcloud.talk.models.json.capabilities.SpreedCapability +import com.nextcloud.talk.models.json.conversations.ConversationEnums +import com.nextcloud.talk.repositories.conversations.ConversationsRepository +import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.ConversationUtils +import io.reactivex.Observer +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers + +class GuestAccessHelper( + private val activity: ConversationInfoActivity, + private val binding: ActivityConversationInfoBinding, + private val conversation: ConversationModel, + private val spreedCapabilities: SpreedCapability, + private val conversationUser: User, + private val viewModel: ConversationInfoViewModel, + private val lifecycleOwner: LifecycleOwner +) { + private val conversationsRepository = activity.conversationsRepository + private val context = activity.context + + private var shouldCopyPasswordAfterSet: Boolean = false + private var lastSetPassword: String = "" + private var passwordValidationState by mutableStateOf( + ConversationInfoViewModel.SecurePasswordViewState.None + ) + + fun setupGuestAccess() { + if (ConversationUtils.canModerate(conversation, spreedCapabilities)) { + binding.guestAccessView.guestAccessSettings.visibility = View.VISIBLE + } else { + binding.guestAccessView.guestAccessSettings.visibility = View.GONE + } + + if (conversation.type == ConversationEnums.ConversationType.ROOM_PUBLIC_CALL) { + binding.guestAccessView.allowGuestsSwitch.isChecked = true + showAllOptions() + if (conversation.hasPassword) { + binding.guestAccessView.passwordProtectionSwitch.isChecked = true + } + } else { + binding.guestAccessView.allowGuestsSwitch.isChecked = false + hideAllOptions() + } + + viewModel.allowGuestsViewState.observe(lifecycleOwner) { uiState -> + when (uiState) { + is ConversationInfoViewModel.AllowGuestsUIState.Success -> { + binding.guestAccessView.allowGuestsSwitch.isChecked = uiState.allow + if (uiState.allow) { + showAllOptions() + } else { + hideAllOptions() + } + } + + is ConversationInfoViewModel.AllowGuestsUIState.Error -> { + val exception = uiState.exception + val message = context.getString(R.string.nc_guest_access_allow_failed) + Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show() + Log.e(TAG, message, exception) + } + + ConversationInfoViewModel.AllowGuestsUIState.None -> Unit + } + } + + viewModel.securePasswordViewState.observe(lifecycleOwner) { uiState -> + passwordValidationState = uiState + } + + passwordObserver() + + binding.guestAccessView.guestAccessSettingsAllowGuest.setOnClickListener { + val isChecked = binding.guestAccessView.allowGuestsSwitch.isChecked + binding.guestAccessView.allowGuestsSwitch.isChecked = !isChecked + viewModel.allowGuests(conversationUser, conversation.token, !isChecked) + } + + binding.guestAccessView.guestAccessSettingsPasswordProtection.setOnClickListener { + val isChecked = binding.guestAccessView.passwordProtectionSwitch.isChecked + binding.guestAccessView.passwordProtectionSwitch.isChecked = !isChecked + if (isChecked) { + val apiVersion = ApiUtils.getConversationApiVersion( + conversationUser, + intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1) + ) + val url = ApiUtils.getUrlForRoomPassword( + apiVersion, + conversationUser.baseUrl!!, + conversation.token + ) + viewModel.setPassword(user = conversationUser, url = url, password = "") + } else { + showPasswordDialog() + } + } + + binding.guestAccessView.resendInvitationsButton.setOnClickListener { + val apiVersion = ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.API_V4)) + val url = ApiUtils.getUrlForParticipantsResendInvitations( + apiVersion, + conversationUser.baseUrl!!, + conversation.token + ) + + conversationsRepository.resendInvitations(user = conversationUser, url = url) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(ResendInvitationsObserver()) + } + } + + private fun passwordObserver() { + viewModel.passwordViewState.observe(lifecycleOwner) { uiState -> + when (uiState) { + is ConversationInfoViewModel.PasswordUiState.Success -> { + if (shouldCopyPasswordAfterSet && lastSetPassword.isNotEmpty()) { + val clipboardManager = activity.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager + val clipData = ClipData.newPlainText("Guest access password", lastSetPassword) + clipboardManager.setPrimaryClip(clipData) + } + shouldCopyPasswordAfterSet = false + lastSetPassword = "" + } + + is ConversationInfoViewModel.PasswordUiState.Error -> { + val exception = uiState.exception + val message = context.getString(R.string.nc_guest_access_password_failed) + Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show() + Log.e(TAG, message, exception) + } + + is ConversationInfoViewModel.PasswordUiState.None -> Unit + } + } + } + + private fun showPasswordDialog() { + val apiVersion = ApiUtils.getConversationApiVersion( + conversationUser, + intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1) + ) + val url = ApiUtils.getUrlForRoomPassword(apiVersion, conversationUser.baseUrl!!, conversation.token) + + val validPasswordUrl = conversationUser?.capabilities?.passwordCapability?.api?.validatePasswordApi ?: "" + passwordValidationState = ConversationInfoViewModel.SecurePasswordViewState.None + + val composeView = ComposeView(activity) + var materialDialog: AlertDialog? = null + val credentials = ApiUtils.getCredentials(conversationUser.username, conversationUser.token) + composeView.setContent { + GuestAccessPasswordDialog( + validationState = passwordValidationState, + onPasswordChanged = { password -> + viewModel.securePassword(credentials!!, validPasswordUrl, password) + }, + onDismiss = { + binding.guestAccessView.passwordProtectionSwitch.isChecked = false + materialDialog?.dismiss() + }, + onSave = { password, copyAfterSave -> + shouldCopyPasswordAfterSet = copyAfterSave + lastSetPassword = password + viewModel.setPassword(user = conversationUser, url = url, password = password) + materialDialog?.dismiss() + } + ) + } + + val builder = MaterialAlertDialogBuilder(activity) + .setView(composeView) + .setCancelable(true) + + materialDialog = builder.show() + } + + inner class ResendInvitationsObserver : Observer { + + private lateinit var resendInvitationsResult: ConversationsRepository.ResendInvitationsResult + + override fun onSubscribe(d: Disposable) = Unit + + override fun onNext(t: ConversationsRepository.ResendInvitationsResult) { + resendInvitationsResult = t + } + + override fun onError(e: Throwable) { + val message = context.getString(R.string.nc_guest_access_resend_invitations_failed) + Snackbar.make(binding.root, message, Snackbar.LENGTH_SHORT).show() + Log.e(TAG, message, e) + } + + override fun onComplete() { + if (resendInvitationsResult.successful) { + Snackbar.make( + binding.root, + R.string.nc_guest_access_resend_invitations_successful, + Snackbar.LENGTH_SHORT + ).show() + } + } + } + + private fun showAllOptions() { + binding.guestAccessView.guestAccessSettingsPasswordProtection.visibility = View.VISIBLE + if (conversationUser.capabilities?.spreedCapability?.features?.contains("sip-support") == true) { + binding.guestAccessView.resendInvitationsButton.visibility = View.VISIBLE + } + } + + private fun hideAllOptions() { + binding.guestAccessView.guestAccessSettingsPasswordProtection.visibility = View.GONE + binding.guestAccessView.resendInvitationsButton.visibility = View.GONE + } + + companion object { + private val TAG = GuestAccessHelper::class.simpleName + } +} + +@Composable +@Suppress("LongMethod") +private fun GuestAccessPasswordDialog( + validationState: ConversationInfoViewModel.SecurePasswordViewState, + onPasswordChanged: (String) -> Unit, + onDismiss: () -> Unit, + onSave: (password: String, copyAfterSave: Boolean) -> Unit +) { + var password by rememberSaveable { mutableStateOf("") } + val secureText = stringResource(R.string.nc_password_secure) + val warningMessage = passwordWarningMessage(validationState, secureText) + val isPasswordValid = + password.isNotBlank() && warningMessage == secureText + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(text = stringResource(id = R.string.nc_guest_access_password_dialog_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = password, + onValueChange = { + password = it + onPasswordChanged(it) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { + Text(text = stringResource(id = R.string.nc_guest_access_password_dialog_hint)) + }, + supportingText = { + warningMessage?.let { + Text( + text = it, + color = if (!isPasswordValid) { + colorResource(R.color.nc_darkRed) + } else { + colorResource(R.color.nc_darkGreen) + } + ) + } + } + ) + } + }, + confirmButton = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton( + onClick = { onSave(password, true) }, + enabled = isPasswordValid + ) { + Text(text = stringResource(R.string.nc_copy_password)) + } + TextButton( + onClick = { onSave(password, false) }, + enabled = isPasswordValid + ) { + Text(text = stringResource(R.string.save)) + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(id = R.string.nc_cancel)) + } + } + ) +} + +@Composable +private fun passwordWarningMessage( + validationState: ConversationInfoViewModel.SecurePasswordViewState, + secureText: String +): String? = + when (validationState) { + is ConversationInfoViewModel.SecurePasswordViewState.Success -> { + validationState.result.passed?.let { passed -> + if (passed) secureText else validationState.result.reason + } + } + + is ConversationInfoViewModel.SecurePasswordViewState.Error -> { + stringResource(R.string.nc_common_error_sorry) + } + + ConversationInfoViewModel.SecurePasswordViewState.None -> "" + } diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/viewmodel/ConversationInfoViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/viewmodel/ConversationInfoViewModel.kt index dae28789b46..ba33d21a843 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/viewmodel/ConversationInfoViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/viewmodel/ConversationInfoViewModel.kt @@ -16,6 +16,8 @@ import com.nextcloud.talk.R import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource +import com.nextcloud.talk.conversationcreation.data.ConversationCreationRepository +import com.nextcloud.talk.conversationcreation.ConversationCreationRepository import com.nextcloud.talk.conversationinfo.ConversationInfoUiEvent import com.nextcloud.talk.conversationinfo.ConversationInfoUiState import com.nextcloud.talk.conversationinfo.CreateRoomRequest @@ -36,6 +38,7 @@ import com.nextcloud.talk.models.json.participants.Participant.ActorType.GROUPS import com.nextcloud.talk.models.json.participants.Participant.ActorType.USERS import com.nextcloud.talk.models.json.participants.ParticipantsOverall import com.nextcloud.talk.models.json.participants.TalkBan +import com.nextcloud.talk.models.json.passwordResult.PasswordResult import com.nextcloud.talk.models.json.profile.Profile import com.nextcloud.talk.repositories.conversations.ConversationsRepository import com.nextcloud.talk.repositories.conversations.ConversationsRepository.ResendInvitationsResult @@ -74,7 +77,8 @@ import javax.inject.Inject class ConversationInfoViewModel @Inject constructor( private val chatNetworkDataSource: ChatNetworkDataSource, private val conversationsRepository: ConversationsRepository, - private val ncApi: NcApi + private val ncApi: NcApi, + private val conversationCreationRepository: ConversationCreationRepository ) : ViewModel() { object LifeCycleObserver : DefaultLifecycleObserver { enum class LifeCycleFlag { @@ -168,6 +172,62 @@ class ConversationInfoViewModel @Inject constructor( } return uiItems } + + private val _passwordViewState = MutableLiveData(PasswordUiState.None) + val passwordViewState: LiveData + get() = _passwordViewState + + private val _securePasswordViewState = MutableLiveData(SecurePasswordViewState.None) + val securePasswordViewState: LiveData = _securePasswordViewState + + private val _getCapabilitiesViewState: MutableLiveData = MutableLiveData(GetCapabilitiesStartState) + val getCapabilitiesViewState: LiveData + get() = _getCapabilitiesViewState + + private val _clearChatHistoryViewState: MutableLiveData = + MutableLiveData(ClearChatHistoryViewState.None) + val clearChatHistoryViewState: LiveData + get() = _clearChatHistoryViewState + + private val _getConversationReadOnlyState: MutableLiveData = + MutableLiveData(SetConversationReadOnlyViewState.None) + val getConversationReadOnlyState: LiveData + get() = _getConversationReadOnlyState + + @Suppress("PropertyName") + private val _markConversationAsImportantResult = + MutableLiveData(MarkConversationAsImportantViewState.None) + val markAsImportantResult: LiveData + get() = _markConversationAsImportantResult + + @Suppress("PropertyName") + private val _markConversationAsUnimportantResult = + MutableLiveData(MarkConversationAsUnimportantViewState.None) + val markAsUnimportantResult: LiveData + get() = _markConversationAsUnimportantResult + + private val _createRoomViewState = MutableLiveData(CreateRoomUIState.None) + val createRoomViewState: LiveData + get() = _createRoomViewState + + object GetProfileErrorState : ViewState + class GetProfileSuccessState(val profile: Profile) : ViewState + private val _getProfileViewState = MutableLiveData() + val getProfileViewState: LiveData + get() = _getProfileViewState + + @Suppress("PropertyName") + private val _markConversationAsSensitiveResult = + MutableLiveData(MarkConversationAsSensitiveViewState.None) + val markAsSensitiveResult: LiveData + get() = _markConversationAsSensitiveResult + + @Suppress("PropertyName") + private val _markConversationAsInsensitiveResult = + MutableLiveData(MarkConversationAsInsensitiveViewState.None) + val markAsInsensitiveResult: LiveData + get() = _markConversationAsInsensitiveResult + fun getRoom(user: User, token: String) { currentUser = user currentToken = token @@ -181,6 +241,10 @@ class ConversationInfoViewModel @Inject constructor( ?.subscribe(GetRoomObserver()) } + fun resetSecurePasswordViewState() { + _securePasswordViewState.value = SecurePasswordViewState.None + } + @Suppress("Detekt.TooGenericExceptionCaught") fun createRoomFromOneToOne( user: User, @@ -224,6 +288,24 @@ class ConversationInfoViewModel @Inject constructor( } } } + + @Suppress("Detekt.TooGenericExceptionCaught") + fun securePassword(credentials: String, url: String, password: String) { + viewModelScope.launch { + try { + val passwordResult = conversationCreationRepository.validatePassword( + credentials, + url, + password + ) + + _securePasswordViewState.value = SecurePasswordViewState.Success(passwordResult.ocs?.data!!) + } catch (exception: Exception) { + _securePasswordViewState.value = SecurePasswordViewState.Error(exception.message ?: "") + } + } + } + private fun convertAutocompleteUserToParticipant(autocompleteUsers: List): Participants { val participants = Participants() autocompleteUsers.forEach { autocompleteUser -> @@ -942,4 +1024,64 @@ class ConversationInfoViewModel @Inject constructor( return DisplayUtils.ellipsize(conversationName, MAX_ROOM_NAME_LENGTH) } } + + sealed class ClearChatHistoryViewState { + data object None : ClearChatHistoryViewState() + data object Success : ClearChatHistoryViewState() + data class Error(val exception: Exception) : ClearChatHistoryViewState() + } + + sealed class MarkConversationAsSensitiveViewState { + data object None : MarkConversationAsSensitiveViewState() + data class Success(val statusCode: Int) : MarkConversationAsSensitiveViewState() + data class Error(val exception: Exception) : MarkConversationAsSensitiveViewState() + } + + sealed class MarkConversationAsInsensitiveViewState { + data object None : MarkConversationAsInsensitiveViewState() + data class Success(val statusCode: Int) : MarkConversationAsInsensitiveViewState() + data class Error(val exception: Exception) : MarkConversationAsInsensitiveViewState() + } + + sealed class SetConversationReadOnlyViewState { + data object None : SetConversationReadOnlyViewState() + data object Success : SetConversationReadOnlyViewState() + data class Error(val exception: Exception) : SetConversationReadOnlyViewState() + } + + sealed class AllowGuestsUIState { + data object None : AllowGuestsUIState() + data class Success(val allow: Boolean) : AllowGuestsUIState() + data class Error(val exception: Exception) : AllowGuestsUIState() + } + + sealed class CreateRoomUIState { + data object None : CreateRoomUIState() + data class Success(val room: RoomOverall) : CreateRoomUIState() + data class Error(val exception: Exception) : CreateRoomUIState() + } + + sealed class PasswordUiState { + data object None : PasswordUiState() + data object Success : PasswordUiState() + data class Error(val exception: Exception) : PasswordUiState() + } + + sealed class MarkConversationAsImportantViewState { + data object None : MarkConversationAsImportantViewState() + data class Success(val statusCode: Int) : MarkConversationAsImportantViewState() + data class Error(val exception: Exception) : MarkConversationAsImportantViewState() + } + + sealed class MarkConversationAsUnimportantViewState { + data object None : MarkConversationAsUnimportantViewState() + data class Success(val statusCode: Int) : MarkConversationAsUnimportantViewState() + data class Error(val exception: Exception) : MarkConversationAsUnimportantViewState() + } + + sealed class SecurePasswordViewState { + data object None : SecurePasswordViewState() + data class Success(val result: PasswordResult) : SecurePasswordViewState() + data class Error(val message: String) : SecurePasswordViewState() + } } diff --git a/app/src/main/java/com/nextcloud/talk/models/json/capabilities/Capabilities.kt b/app/src/main/java/com/nextcloud/talk/models/json/capabilities/Capabilities.kt index c0cad49e802..16245f465f0 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/capabilities/Capabilities.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/capabilities/Capabilities.kt @@ -29,8 +29,10 @@ data class Capabilities( @JsonField(name = ["provisioning_api"]) var provisioningCapability: ProvisioningCapability?, @JsonField(name = ["user_status"]) - var userStatusCapability: UserStatusCapability? + var userStatusCapability: UserStatusCapability?, + @JsonField(name = ["password_policy"]) + var passwordCapability: PasswordPolicy? ) : Parcelable { // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' - constructor() : this(null, null, null, null, null, null, null) + constructor() : this(null, null, null, null, null, null, null, null) } diff --git a/app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordAccount.kt b/app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordAccount.kt new file mode 100644 index 00000000000..27d01245db3 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordAccount.kt @@ -0,0 +1,33 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Sowjanya Kota + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.models.json.capabilities + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonObject +data class PasswordAccount( + @JsonField(name = ["minLength"]) + var minLength: Int, + @JsonField(name = ["enforceHaveIBeenPwned"]) + var enforceHaveIBeenPwned: Boolean, + @JsonField(name = ["enforceNonCommonPassword"]) + var enforceNonCommonPassword: Boolean, + @JsonField(name = ["enforceNumericCharacters"]) + var enforceNumericCharacters: Boolean, + @JsonField(name = ["enforceSpecialCharacters"]) + var enforceSpecialCharacters: Boolean, + @JsonField(name = ["enforceUpperLowerCase"]) + var enforceUpperLowerCase: Boolean +) : Parcelable { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(0, false, false, false, false, false) +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordApi.kt b/app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordApi.kt new file mode 100644 index 00000000000..9ba832ac210 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordApi.kt @@ -0,0 +1,26 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Sowjanya Kota + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.models.json.capabilities + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonObject +data class PasswordApi( + @JsonField(name = ["generate"]) + var generatePasswordApi: String?, + @JsonField(name = ["validate"]) + var validatePasswordApi: String? + +) : Parcelable { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(null, null) +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordCapability.kt b/app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordCapability.kt new file mode 100644 index 00000000000..aabc10a719a --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordCapability.kt @@ -0,0 +1,23 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Sowjanya Kota + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.models.json.capabilities + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonObject +data class PasswordCapability( + @JsonField(name = ["password_policy"]) + var passwordPolicy: PasswordPolicy? +) : Parcelable { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(null) +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordPolicies.kt b/app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordPolicies.kt new file mode 100644 index 00000000000..98b47cc4954 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordPolicies.kt @@ -0,0 +1,23 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Sowjanya Kota + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.models.json.capabilities + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonObject +data class PasswordPolicies( + @JsonField(name = ["account"]) + var api: PasswordAccount? +) : Parcelable { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(null) +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordPolicy.kt b/app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordPolicy.kt new file mode 100644 index 00000000000..8bbc86fd144 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordPolicy.kt @@ -0,0 +1,35 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Sowjanya Kota + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.models.json.capabilities + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonObject +data class PasswordPolicy( + @JsonField(name = ["api"]) + var api: PasswordApi?, + @JsonField(name = ["policies"]) + var policies: PasswordPolicies?, + @JsonField(name = ["minLength"]) + var minLength: Int, + @JsonField(name = ["enforceNonCommonPassword"]) + var enforceNonCommonPassword: Boolean, + @JsonField(name = ["enforceNumericCharacters"]) + var enforceNumericCharacters: Boolean, + @JsonField(name = ["enforceSpecialCharacters"]) + var enforceSpecialCharacters: Boolean, + @JsonField(name = ["enforceUpperLowerCase"]) + var enforceUpperLowerCase: Boolean +) : Parcelable { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(null, null, 0, false, false, false, false) +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/capabilities/ProvisioningCapability.kt b/app/src/main/java/com/nextcloud/talk/models/json/capabilities/ProvisioningCapability.kt index 0cd08fef1ad..12c29d11305 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/capabilities/ProvisioningCapability.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/capabilities/ProvisioningCapability.kt @@ -12,11 +12,9 @@ import android.os.Parcelable import com.bluelinelabs.logansquare.annotation.JsonField import com.bluelinelabs.logansquare.annotation.JsonObject import kotlinx.parcelize.Parcelize -import kotlinx.serialization.Serializable @Parcelize @JsonObject -@Serializable data class ProvisioningCapability( @JsonField(name = ["AccountPropertyScopesVersion"]) var accountPropertyScopesVersion: Int? diff --git a/app/src/main/java/com/nextcloud/talk/models/json/passwordResult/PasswordResult.kt b/app/src/main/java/com/nextcloud/talk/models/json/passwordResult/PasswordResult.kt new file mode 100644 index 00000000000..cc45672141f --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/passwordResult/PasswordResult.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Sowjanya Kota + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.models.json.passwordResult + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonObject +data class PasswordResult( + @JsonField(name = ["passed"]) + var passed: Boolean?, + @JsonField(name = ["reason"]) + var reason: String? +) : Parcelable { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(null, null) +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/passwordResult/PasswordResultOCS.kt b/app/src/main/java/com/nextcloud/talk/models/json/passwordResult/PasswordResultOCS.kt new file mode 100644 index 00000000000..d967656a243 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/passwordResult/PasswordResultOCS.kt @@ -0,0 +1,26 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Sowjanya Kota + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.models.json.passwordResult + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import com.nextcloud.talk.models.json.generic.GenericMeta +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonObject +data class PasswordResultOCS( + @JsonField(name = ["meta"]) + var meta: GenericMeta?, + @JsonField(name = ["data"]) + var data: PasswordResult? +) : Parcelable { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(null, null) +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/passwordResult/PasswordResultOverall.kt b/app/src/main/java/com/nextcloud/talk/models/json/passwordResult/PasswordResultOverall.kt new file mode 100644 index 00000000000..5f202864c99 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/passwordResult/PasswordResultOverall.kt @@ -0,0 +1,23 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Sowjanya Kota + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.models.json.passwordResult + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonObject +data class PasswordResultOverall( + @JsonField(name = ["ocs"]) + var ocs: PasswordResultOCS? +) : Parcelable { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(null) +} diff --git a/app/src/main/res/layout/dialog_password.xml b/app/src/main/res/layout/dialog_password.xml deleted file mode 100644 index a4647154ae5..00000000000 --- a/app/src/main/res/layout/dialog_password.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bbe92cd498b..b641f2b44c9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1086,4 +1086,6 @@ How to translate with transifex: Turn on background blur Turn off background blur Account not found + Password is secure + Copy password From 3954359bb7abea855d1135a23cb74c4cf7428b5f Mon Sep 17 00:00:00 2001 From: rapterjet2004 Date: Thu, 3 Sep 2026 12:07:19 -0500 Subject: [PATCH 2/2] fix(guest-access): repair password-policy validation after rebase onto master The PR was rebased onto a master that had since migrated ConversationInfo to Jetpack Compose, which broke the build: the merge resurrected pre-migration files (GuestAccessHelper.kt, its DialogPasswordBinding/ActivityConversationInfoBinding dialog code) and dropped an unrelated master fix (@Serializable on ProvisioningCapability). - Delete GuestAccessHelper.kt (obsolete View/XML-binding helper no longer referenced since the Compose migration) and port its password-validation dialog into ConversationInfoActivity's Compose screen, replacing the stale XML showPasswordDialog(). - Remove dead, unrelated state left over in ConversationInfoViewModel from the old base branch (GetCapabilitiesStartState, CreateRoomUIState) and a duplicate ConversationCreationRepository import. - Restore @Serializable on ProvisioningCapability, lost in the merge. - Fix ConversationCreationActivity's password-set dialog not dismissing after a successful save, and drop an unused ktlint-failing import and a misapplied @Suppress in ConversationCreationViewModel. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: rapterjet2004 --- .../ConversationCreationActivity.kt | 1 + .../ConversationCreationViewModel.kt | 3 - .../ConversationInfoActivity.kt | 187 ++++++++-- .../conversationinfo/GuestAccessHelper.kt | 351 ------------------ .../viewmodel/ConversationInfoViewModel.kt | 15 - .../capabilities/ProvisioningCapability.kt | 3 +- 6 files changed, 160 insertions(+), 400 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/talk/conversationinfo/GuestAccessHelper.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 d382c05df88..c5353bf3179 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -934,6 +934,7 @@ fun ShowPasswordDialog( conversationCreationViewModel.updatePassword(password) conversationCreationViewModel.isPasswordEnabled(true) conversationCreationViewModel.resetPasswordViewState() + onDismiss() } } ) { 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 181ffebb16e..36101760102 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 @@ -18,9 +18,7 @@ import com.nextcloud.talk.conversationinfo.CreateRoomRequest 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.models.json.generic.GenericMeta import com.nextcloud.talk.models.json.passwordResult.PasswordResult -import com.nextcloud.talk.repositories.conversations.ConversationsRepositoryImpl.Companion.STATUS_CODE_OK import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ParticipantPermissions import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld @@ -116,7 +114,6 @@ class ConversationCreationViewModel @Inject constructor( _validPasswordViewState.value = ValidPasswordUiState.None } - @Suppress("Detekt.TooGenericExceptionCaught") fun updateConversationPreset(preset: String) { conversationPreset.value = preset when (preset) { diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt index bddda18c82e..8956357e064 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -7,19 +7,36 @@ package com.nextcloud.talk.conversationinfo import android.annotation.SuppressLint +import android.content.ClipData +import android.content.ClipboardManager import android.content.Intent import android.os.Bundle import android.util.Log -import android.view.LayoutInflater import androidx.activity.compose.setContent import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.AlertDialog as ComposeAlertDialog import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentTransaction import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -54,7 +71,6 @@ import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel import com.nextcloud.talk.conversationinfoedit.ConversationInfoEditActivity import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.DialogBanParticipantBinding -import com.nextcloud.talk.databinding.DialogPasswordBinding import com.nextcloud.talk.events.EventStatus import com.nextcloud.talk.extensions.getParcelableArrayListExtraProvider import com.nextcloud.talk.extensions.getParcelableExtraProvider @@ -118,6 +134,10 @@ class ConversationInfoActivity : BaseActivity() { private var startGroupChat: Boolean = false + private var securePasswordViewState: ConversationInfoViewModel.SecurePasswordViewState + by mutableStateOf(ConversationInfoViewModel.SecurePasswordViewState.None) + private var showPasswordDialog by mutableStateOf(false) + private val workerData: Data? get() { val user = conversationUser ?: return null @@ -194,6 +214,8 @@ class ConversationInfoActivity : BaseActivity() { } } + viewModel.securePasswordViewState.observe(this) { securePasswordViewState = it } + setupCompose() } @@ -243,14 +265,57 @@ class ConversationInfoActivity : BaseActivity() { ColoredStatusBar() ConversationInfoScreen( state = uiState, - callbacks = buildCallbacks() + callbacks = buildCallbacks(onShowPasswordDialog = { showPasswordDialog = true }) ) + GuestAccessPasswordDialogHost() } } } + @Composable + private fun GuestAccessPasswordDialogHost() { + if (!showPasswordDialog) return + GuestAccessPasswordDialog( + validationState = securePasswordViewState, + onPasswordChanged = ::onGuestPasswordChanged, + onDismiss = ::dismissGuestPasswordDialog, + onSave = ::onGuestPasswordSave + ) + } + + private fun onGuestPasswordChanged(password: String) { + val user = conversationUser ?: return + val validatePasswordUrl = user.capabilities?.passwordCapability?.api?.validatePasswordApi ?: "" + viewModel.securePassword(credentials, validatePasswordUrl, password) + } + + private fun onGuestPasswordSave(password: String, copyAfterSave: Boolean) { + val user = conversationUser ?: return + if (copyAfterSave) { + copyPasswordToClipboard(password) + } + val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1)) + viewModel.setPassword( + user = user, + url = ApiUtils.getUrlForRoomPassword(apiVersion, user.baseUrl!!, conversationToken), + password = password + ) + dismissGuestPasswordDialog() + } + + private fun dismissGuestPasswordDialog() { + showPasswordDialog = false + viewModel.resetSecurePasswordViewState() + } + + private fun copyPasswordToClipboard(password: String) { + val clipboardManager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager + val label = resources.getString(R.string.nc_app_product_name) + clipboardManager.setPrimaryClip(ClipData.newPlainText(label, password)) + } + @Suppress("LongMethod", "CyclomaticComplexMethod") - private fun buildCallbacks() = + private fun buildCallbacks(onShowPasswordDialog: () -> Unit) = ConversationInfoScreenCallbacks( onNavigateBack = { onBackPressedDispatcher.onBackPressed() }, onEditConversation = { @@ -287,7 +352,7 @@ class ConversationInfoActivity : BaseActivity() { password = "" ) } else { - showPasswordDialog(conversationToken) + onShowPasswordDialog() } }, onResendInvitationsClick = { @@ -613,31 +678,6 @@ class ConversationInfoActivity : BaseActivity() { } } - private fun showPasswordDialog(token: String) { - val user = conversationUser ?: return - val dialogPassword = DialogPasswordBinding.inflate(LayoutInflater.from(this)) - viewThemeUtils.platform.colorEditText(dialogPassword.password) - val builder = MaterialAlertDialogBuilder(this) - .setView(dialogPassword.root) - .setTitle(R.string.nc_guest_access_password_dialog_title) - .setPositiveButton(R.string.nc_ok) { _, _ -> - val apiVersion = - ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1)) - viewModel.setPassword( - user = user, - url = ApiUtils.getUrlForRoomPassword(apiVersion, user.baseUrl!!, token), - password = dialogPassword.password.text.toString() - ) - } - .setNegativeButton(R.string.nc_cancel, null) - viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, builder) - val dialog = builder.show() - viewThemeUtils.platform.colorTextButtons( - dialog.getButton(AlertDialog.BUTTON_POSITIVE), - dialog.getButton(AlertDialog.BUTTON_NEGATIVE) - ) - } - private fun toggleModeratorStatus(apiVersion: Int, participant: Participant) { val user = conversationUser ?: return val subscriber = participantActionObserver() @@ -847,3 +887,90 @@ class ConversationInfoActivity : BaseActivity() { private const val PARTICIPANT_TYPE_USER: Int = 3 } } + +@Composable +@Suppress("LongMethod") +private fun GuestAccessPasswordDialog( + validationState: ConversationInfoViewModel.SecurePasswordViewState, + onPasswordChanged: (String) -> Unit, + onDismiss: () -> Unit, + onSave: (password: String, copyAfterSave: Boolean) -> Unit +) { + var password by rememberSaveable { mutableStateOf("") } + val secureText = stringResource(R.string.nc_password_secure) + val warningMessage = passwordWarningMessage(validationState, secureText) + val isPasswordValid = password.isNotBlank() && warningMessage == secureText + + ComposeAlertDialog( + onDismissRequest = onDismiss, + title = { Text(text = stringResource(id = R.string.nc_guest_access_password_dialog_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = password, + onValueChange = { + password = it + onPasswordChanged(it) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { + Text(text = stringResource(id = R.string.nc_guest_access_password_dialog_hint)) + }, + supportingText = { + warningMessage?.let { + Text( + text = it, + color = if (!isPasswordValid) { + colorResource(R.color.nc_darkRed) + } else { + colorResource(R.color.nc_darkGreen) + } + ) + } + } + ) + } + }, + confirmButton = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton( + onClick = { onSave(password, true) }, + enabled = isPasswordValid + ) { + Text(text = stringResource(R.string.nc_copy_password)) + } + TextButton( + onClick = { onSave(password, false) }, + enabled = isPasswordValid + ) { + Text(text = stringResource(R.string.save)) + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(id = R.string.nc_cancel)) + } + } + ) +} + +@Composable +private fun passwordWarningMessage( + validationState: ConversationInfoViewModel.SecurePasswordViewState, + secureText: String +): String? = + when (validationState) { + is ConversationInfoViewModel.SecurePasswordViewState.Success -> { + validationState.result.passed?.let { passed -> + if (passed) secureText else validationState.result.reason + } + } + + is ConversationInfoViewModel.SecurePasswordViewState.Error -> { + stringResource(R.string.nc_common_error_sorry) + } + + ConversationInfoViewModel.SecurePasswordViewState.None -> "" + } diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/GuestAccessHelper.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/GuestAccessHelper.kt deleted file mode 100644 index 6b414ab8b62..00000000000 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/GuestAccessHelper.kt +++ /dev/null @@ -1,351 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2024 Marcel Hibbe - * SPDX-FileCopyrightText: 2023 Julius Linus - * SPDX-FileCopyrightText: 2022 Tim Krüger - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.conversationinfo - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context.CLIPBOARD_SERVICE -import android.util.Log -import android.view.View -import androidx.appcompat.app.AlertDialog -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.LifecycleOwner -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import com.nextcloud.talk.R -import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel -import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.databinding.ActivityConversationInfoBinding -import com.nextcloud.talk.models.domain.ConversationModel -import com.nextcloud.talk.models.json.capabilities.SpreedCapability -import com.nextcloud.talk.models.json.conversations.ConversationEnums -import com.nextcloud.talk.repositories.conversations.ConversationsRepository -import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.ConversationUtils -import io.reactivex.Observer -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.Disposable -import io.reactivex.schedulers.Schedulers - -class GuestAccessHelper( - private val activity: ConversationInfoActivity, - private val binding: ActivityConversationInfoBinding, - private val conversation: ConversationModel, - private val spreedCapabilities: SpreedCapability, - private val conversationUser: User, - private val viewModel: ConversationInfoViewModel, - private val lifecycleOwner: LifecycleOwner -) { - private val conversationsRepository = activity.conversationsRepository - private val context = activity.context - - private var shouldCopyPasswordAfterSet: Boolean = false - private var lastSetPassword: String = "" - private var passwordValidationState by mutableStateOf( - ConversationInfoViewModel.SecurePasswordViewState.None - ) - - fun setupGuestAccess() { - if (ConversationUtils.canModerate(conversation, spreedCapabilities)) { - binding.guestAccessView.guestAccessSettings.visibility = View.VISIBLE - } else { - binding.guestAccessView.guestAccessSettings.visibility = View.GONE - } - - if (conversation.type == ConversationEnums.ConversationType.ROOM_PUBLIC_CALL) { - binding.guestAccessView.allowGuestsSwitch.isChecked = true - showAllOptions() - if (conversation.hasPassword) { - binding.guestAccessView.passwordProtectionSwitch.isChecked = true - } - } else { - binding.guestAccessView.allowGuestsSwitch.isChecked = false - hideAllOptions() - } - - viewModel.allowGuestsViewState.observe(lifecycleOwner) { uiState -> - when (uiState) { - is ConversationInfoViewModel.AllowGuestsUIState.Success -> { - binding.guestAccessView.allowGuestsSwitch.isChecked = uiState.allow - if (uiState.allow) { - showAllOptions() - } else { - hideAllOptions() - } - } - - is ConversationInfoViewModel.AllowGuestsUIState.Error -> { - val exception = uiState.exception - val message = context.getString(R.string.nc_guest_access_allow_failed) - Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show() - Log.e(TAG, message, exception) - } - - ConversationInfoViewModel.AllowGuestsUIState.None -> Unit - } - } - - viewModel.securePasswordViewState.observe(lifecycleOwner) { uiState -> - passwordValidationState = uiState - } - - passwordObserver() - - binding.guestAccessView.guestAccessSettingsAllowGuest.setOnClickListener { - val isChecked = binding.guestAccessView.allowGuestsSwitch.isChecked - binding.guestAccessView.allowGuestsSwitch.isChecked = !isChecked - viewModel.allowGuests(conversationUser, conversation.token, !isChecked) - } - - binding.guestAccessView.guestAccessSettingsPasswordProtection.setOnClickListener { - val isChecked = binding.guestAccessView.passwordProtectionSwitch.isChecked - binding.guestAccessView.passwordProtectionSwitch.isChecked = !isChecked - if (isChecked) { - val apiVersion = ApiUtils.getConversationApiVersion( - conversationUser, - intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1) - ) - val url = ApiUtils.getUrlForRoomPassword( - apiVersion, - conversationUser.baseUrl!!, - conversation.token - ) - viewModel.setPassword(user = conversationUser, url = url, password = "") - } else { - showPasswordDialog() - } - } - - binding.guestAccessView.resendInvitationsButton.setOnClickListener { - val apiVersion = ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.API_V4)) - val url = ApiUtils.getUrlForParticipantsResendInvitations( - apiVersion, - conversationUser.baseUrl!!, - conversation.token - ) - - conversationsRepository.resendInvitations(user = conversationUser, url = url) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(ResendInvitationsObserver()) - } - } - - private fun passwordObserver() { - viewModel.passwordViewState.observe(lifecycleOwner) { uiState -> - when (uiState) { - is ConversationInfoViewModel.PasswordUiState.Success -> { - if (shouldCopyPasswordAfterSet && lastSetPassword.isNotEmpty()) { - val clipboardManager = activity.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager - val clipData = ClipData.newPlainText("Guest access password", lastSetPassword) - clipboardManager.setPrimaryClip(clipData) - } - shouldCopyPasswordAfterSet = false - lastSetPassword = "" - } - - is ConversationInfoViewModel.PasswordUiState.Error -> { - val exception = uiState.exception - val message = context.getString(R.string.nc_guest_access_password_failed) - Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show() - Log.e(TAG, message, exception) - } - - is ConversationInfoViewModel.PasswordUiState.None -> Unit - } - } - } - - private fun showPasswordDialog() { - val apiVersion = ApiUtils.getConversationApiVersion( - conversationUser, - intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1) - ) - val url = ApiUtils.getUrlForRoomPassword(apiVersion, conversationUser.baseUrl!!, conversation.token) - - val validPasswordUrl = conversationUser?.capabilities?.passwordCapability?.api?.validatePasswordApi ?: "" - passwordValidationState = ConversationInfoViewModel.SecurePasswordViewState.None - - val composeView = ComposeView(activity) - var materialDialog: AlertDialog? = null - val credentials = ApiUtils.getCredentials(conversationUser.username, conversationUser.token) - composeView.setContent { - GuestAccessPasswordDialog( - validationState = passwordValidationState, - onPasswordChanged = { password -> - viewModel.securePassword(credentials!!, validPasswordUrl, password) - }, - onDismiss = { - binding.guestAccessView.passwordProtectionSwitch.isChecked = false - materialDialog?.dismiss() - }, - onSave = { password, copyAfterSave -> - shouldCopyPasswordAfterSet = copyAfterSave - lastSetPassword = password - viewModel.setPassword(user = conversationUser, url = url, password = password) - materialDialog?.dismiss() - } - ) - } - - val builder = MaterialAlertDialogBuilder(activity) - .setView(composeView) - .setCancelable(true) - - materialDialog = builder.show() - } - - inner class ResendInvitationsObserver : Observer { - - private lateinit var resendInvitationsResult: ConversationsRepository.ResendInvitationsResult - - override fun onSubscribe(d: Disposable) = Unit - - override fun onNext(t: ConversationsRepository.ResendInvitationsResult) { - resendInvitationsResult = t - } - - override fun onError(e: Throwable) { - val message = context.getString(R.string.nc_guest_access_resend_invitations_failed) - Snackbar.make(binding.root, message, Snackbar.LENGTH_SHORT).show() - Log.e(TAG, message, e) - } - - override fun onComplete() { - if (resendInvitationsResult.successful) { - Snackbar.make( - binding.root, - R.string.nc_guest_access_resend_invitations_successful, - Snackbar.LENGTH_SHORT - ).show() - } - } - } - - private fun showAllOptions() { - binding.guestAccessView.guestAccessSettingsPasswordProtection.visibility = View.VISIBLE - if (conversationUser.capabilities?.spreedCapability?.features?.contains("sip-support") == true) { - binding.guestAccessView.resendInvitationsButton.visibility = View.VISIBLE - } - } - - private fun hideAllOptions() { - binding.guestAccessView.guestAccessSettingsPasswordProtection.visibility = View.GONE - binding.guestAccessView.resendInvitationsButton.visibility = View.GONE - } - - companion object { - private val TAG = GuestAccessHelper::class.simpleName - } -} - -@Composable -@Suppress("LongMethod") -private fun GuestAccessPasswordDialog( - validationState: ConversationInfoViewModel.SecurePasswordViewState, - onPasswordChanged: (String) -> Unit, - onDismiss: () -> Unit, - onSave: (password: String, copyAfterSave: Boolean) -> Unit -) { - var password by rememberSaveable { mutableStateOf("") } - val secureText = stringResource(R.string.nc_password_secure) - val warningMessage = passwordWarningMessage(validationState, secureText) - val isPasswordValid = - password.isNotBlank() && warningMessage == secureText - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(text = stringResource(id = R.string.nc_guest_access_password_dialog_title)) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = password, - onValueChange = { - password = it - onPasswordChanged(it) - }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - label = { - Text(text = stringResource(id = R.string.nc_guest_access_password_dialog_hint)) - }, - supportingText = { - warningMessage?.let { - Text( - text = it, - color = if (!isPasswordValid) { - colorResource(R.color.nc_darkRed) - } else { - colorResource(R.color.nc_darkGreen) - } - ) - } - } - ) - } - }, - confirmButton = { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TextButton( - onClick = { onSave(password, true) }, - enabled = isPasswordValid - ) { - Text(text = stringResource(R.string.nc_copy_password)) - } - TextButton( - onClick = { onSave(password, false) }, - enabled = isPasswordValid - ) { - Text(text = stringResource(R.string.save)) - } - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(text = stringResource(id = R.string.nc_cancel)) - } - } - ) -} - -@Composable -private fun passwordWarningMessage( - validationState: ConversationInfoViewModel.SecurePasswordViewState, - secureText: String -): String? = - when (validationState) { - is ConversationInfoViewModel.SecurePasswordViewState.Success -> { - validationState.result.passed?.let { passed -> - if (passed) secureText else validationState.result.reason - } - } - - is ConversationInfoViewModel.SecurePasswordViewState.Error -> { - stringResource(R.string.nc_common_error_sorry) - } - - ConversationInfoViewModel.SecurePasswordViewState.None -> "" - } diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/viewmodel/ConversationInfoViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/viewmodel/ConversationInfoViewModel.kt index ba33d21a843..9046d6fbdd6 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/viewmodel/ConversationInfoViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/viewmodel/ConversationInfoViewModel.kt @@ -17,7 +17,6 @@ import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource import com.nextcloud.talk.conversationcreation.data.ConversationCreationRepository -import com.nextcloud.talk.conversationcreation.ConversationCreationRepository import com.nextcloud.talk.conversationinfo.ConversationInfoUiEvent import com.nextcloud.talk.conversationinfo.ConversationInfoUiState import com.nextcloud.talk.conversationinfo.CreateRoomRequest @@ -180,10 +179,6 @@ class ConversationInfoViewModel @Inject constructor( private val _securePasswordViewState = MutableLiveData(SecurePasswordViewState.None) val securePasswordViewState: LiveData = _securePasswordViewState - private val _getCapabilitiesViewState: MutableLiveData = MutableLiveData(GetCapabilitiesStartState) - val getCapabilitiesViewState: LiveData - get() = _getCapabilitiesViewState - private val _clearChatHistoryViewState: MutableLiveData = MutableLiveData(ClearChatHistoryViewState.None) val clearChatHistoryViewState: LiveData @@ -206,10 +201,6 @@ class ConversationInfoViewModel @Inject constructor( val markAsUnimportantResult: LiveData get() = _markConversationAsUnimportantResult - private val _createRoomViewState = MutableLiveData(CreateRoomUIState.None) - val createRoomViewState: LiveData - get() = _createRoomViewState - object GetProfileErrorState : ViewState class GetProfileSuccessState(val profile: Profile) : ViewState private val _getProfileViewState = MutableLiveData() @@ -1055,12 +1046,6 @@ class ConversationInfoViewModel @Inject constructor( data class Error(val exception: Exception) : AllowGuestsUIState() } - sealed class CreateRoomUIState { - data object None : CreateRoomUIState() - data class Success(val room: RoomOverall) : CreateRoomUIState() - data class Error(val exception: Exception) : CreateRoomUIState() - } - sealed class PasswordUiState { data object None : PasswordUiState() data object Success : PasswordUiState() diff --git a/app/src/main/java/com/nextcloud/talk/models/json/capabilities/ProvisioningCapability.kt b/app/src/main/java/com/nextcloud/talk/models/json/capabilities/ProvisioningCapability.kt index 12c29d11305..0dfcede1eea 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/capabilities/ProvisioningCapability.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/capabilities/ProvisioningCapability.kt @@ -1,4 +1,3 @@ - /* * Nextcloud Talk - Android Client * @@ -12,9 +11,11 @@ import android.os.Parcelable import com.bluelinelabs.logansquare.annotation.JsonField import com.bluelinelabs.logansquare.annotation.JsonObject import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable @Parcelize @JsonObject +@Serializable data class ProvisioningCapability( @JsonField(name = ["AccountPropertyScopesVersion"]) var accountPropertyScopesVersion: Int?