From de49eabd97f6340fde7b5db73581b6db52d45d34 Mon Sep 17 00:00:00 2001 From: sowjanyakch Date: Wed, 4 Mar 2026 18:09:20 +0100 Subject: [PATCH 01/14] 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 | 114 +++++- .../data/ConversationCreationRepository.kt | 2 + .../ConversationCreationRepositoryImpl.kt | 10 + .../ConversationCreationViewModel.kt | 33 ++ .../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 | 1 + 19 files changed, 868 insertions(+), 45 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 b18a79c827..1836c89022 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt +++ b/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt @@ -26,6 +26,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 @@ -584,4 +585,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 3fbeb36966..f3c16aa420 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 @@ -37,6 +39,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 @@ -82,6 +85,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 @@ -98,6 +102,7 @@ import com.nextcloud.talk.conversationcreation.ui.CreationResultEffect import com.nextcloud.talk.conversationcreation.ui.ShareCreatedConversation import com.nextcloud.talk.conversationcreation.ui.openConversation import com.nextcloud.talk.conversationcreation.viewmodel.ConversationCreationViewModel +import com.nextcloud.talk.conversationcreation.viewmodel.ValidPasswordUiState import com.nextcloud.talk.extensions.getParcelableArrayListExtraProvider import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser @@ -478,6 +483,7 @@ fun AddParticipants( @Suppress("LongMethod") @Composable fun RoomCreationOptions(conversationCreationViewModel: ConversationCreationViewModel) { + val context = LocalContext.current val isGuestsAllowed = conversationCreationViewModel.isGuestsAllowed val isConversationAvailableForRegisteredUsers = conversationCreationViewModel .isConversationAvailableForRegisteredUsers @@ -524,6 +530,21 @@ fun RoomCreationOptions(conversationCreationViewModel: ConversationCreationViewM ) } + if (isGuestsAllowed && isPasswordSet) { + ConversationOption( + icon = R.drawable.ic_content_copy, + text = R.string.nc_copy_password, + onClick = { + 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) + } + ) + } + ConversationOption( icon = R.drawable.baseline_format_list_bulleted_24, text = R.string.nc_open_conversation_to_registered_users, @@ -602,16 +623,18 @@ fun ConversationOption( @Composable fun ShowChangePassword(onDismiss: () -> Unit, conversationCreationViewModel: ConversationCreationViewModel) { var changedPassword by rememberSaveable { mutableStateOf("") } + val passwordValidationState by conversationCreationViewModel.validPasswordViewState.collectAsStateWithLifecycle() Dialog(onDismissRequest = { onDismiss() }) { 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 @@ -620,17 +643,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 @@ -639,12 +668,17 @@ 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.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)) @@ -653,6 +687,7 @@ fun ShowChangePassword(onDismiss: () -> Unit, conversationCreationViewModel: Con TextButton( onClick = { conversationCreationViewModel.updatePassword("") + conversationCreationViewModel.resetPasswordViewState() onDismiss() }, contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) @@ -664,7 +699,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)) @@ -678,24 +717,40 @@ fun ShowChangePassword(onDismiss: () -> Unit, conversationCreationViewModel: Con @Composable fun ShowPasswordDialog(onDismiss: () -> Unit, conversationCreationViewModel: ConversationCreationViewModel) { var password by rememberSaveable { mutableStateOf("") } + val passwordValidationState by conversationCreationViewModel.validPasswordViewState.collectAsStateWithLifecycle() + 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.resetPasswordViewState() onDismiss() } } @@ -704,13 +759,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 isCreatingRoom by conversationCreationViewModel.isCreatingRoom.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 8bbe420631..3f47adc8e5 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 @@ -15,6 +15,7 @@ import com.nextcloud.talk.models.json.conversations.ConversationPreset 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 { @@ -46,4 +47,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 b7e60ff9ea..7130277a26 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 @@ -16,6 +16,7 @@ import com.nextcloud.talk.models.json.conversations.ConversationPreset 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 @@ -144,4 +145,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 33a8490f05..c8c9e04dd7 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 @@ -26,6 +26,7 @@ import com.nextcloud.talk.conversationcreation.parametersOf 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.passwordResult.PasswordResult import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.SpreedFeatures @@ -47,6 +48,9 @@ class ConversationCreationViewModel @Inject constructor( private val roomViewState = MutableStateFlow(RoomUIState.None) val creationState: StateFlow = roomViewState + private val _validPasswordViewState = MutableStateFlow(ValidPasswordUiState.None) + val validPasswordViewState: StateFlow = _validPasswordViewState + private val _selectedImageUri = MutableStateFlow(null) val selectedImageUri: StateFlow = _selectedImageUri @@ -155,6 +159,29 @@ class ConversationCreationViewModel @Inject constructor( _conversationDescription.value = conversationDescription } + fun resetPasswordViewState() { + _validPasswordViewState.value = ValidPasswordUiState.None + } + + @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") fun updateConversationPreset(preset: String) { conversationPreset.value = preset val loaded = (_presets.value as? PresetsUiState.Success)?.presets.orEmpty() @@ -281,3 +308,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 0000000000..6b414ab8b6 --- /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 dae28789b4..ba33d21a84 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 c0cad49e80..16245f465f 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 0000000000..27d01245db --- /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 0000000000..9ba832ac21 --- /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 0000000000..aabc10a719 --- /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 0000000000..98b47cc495 --- /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 0000000000..8bbc86fd14 --- /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 0cd08fef1a..12c29d1130 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 0000000000..cc45672141 --- /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 0000000000..d967656a24 --- /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 0000000000..5f202864c9 --- /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 a4647154ae..0000000000 --- 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 97df98a3bc..6773ab65e2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1106,4 +1106,5 @@ How to translate with transifex: Turn on background blur Turn off background blur Account not found + Password is secure From ee1fa8377ff1946b44c2872ba7f83768b80cd98e Mon Sep 17 00:00:00 2001 From: rapterjet2004 Date: Thu, 3 Sep 2026 12:07:19 -0500 Subject: [PATCH 02/14] 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 --- .../ConversationInfoActivity.kt | 187 ++++++++-- .../conversationinfo/GuestAccessHelper.kt | 351 ------------------ .../viewmodel/ConversationInfoViewModel.kt | 15 - .../capabilities/ProvisioningCapability.kt | 3 +- 4 files changed, 159 insertions(+), 397 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/talk/conversationinfo/GuestAccessHelper.kt 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 bddda18c82..8956357e06 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 6b414ab8b6..0000000000 --- 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 ba33d21a84..9046d6fbdd 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 12c29d1130..0dfcede1ee 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? From 377a9c8079098460d419dda8c6f6b705b7487cd8 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 8 Sep 2026 18:45:09 +0200 Subject: [PATCH 03/14] refactor(conversation-info): remove the view states resurrected by the rebase An earlier rebase of this branch reintroduced view-model state that master had deleted during the ConversationInfo Compose migration: eight sealed view-state classes and the LiveData exposing them, plus the profile view state. Nothing in the app reads or writes any of it. Keep only SecurePasswordViewState, which the password-policy validation this branch adds actually uses. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger --- .../viewmodel/ConversationInfoViewModel.kt | 92 ------------------- 1 file changed, 92 deletions(-) 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 9046d6fbdd..0861dcb8c5 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 @@ -172,53 +172,9 @@ 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 _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 - - 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 @@ -1016,54 +972,6 @@ class ConversationInfoViewModel @Inject constructor( } } - 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 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() From b009acbe8380ad9e34a6ad441ec04f50772190d2 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 8 Sep 2026 18:45:39 +0200 Subject: [PATCH 04/14] refactor(capabilities): reduce the password-policy models to what is read PasswordCapability, PasswordPolicies and PasswordAccount were referenced nowhere, and of PasswordPolicy only api.validate is ever read - the minLength and enforce* mirrors of the server capability, the nested policies object and api.generate were all dead. Drop the three unused classes and the unread fields, and rename Capabilities.passwordCapability to passwordPolicy so the property matches both its type and the password_policy key it maps. Also correct a typo in the SPDX contributor address on the two files that carried it. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger --- .../ConversationCreationActivity.kt | 4 +-- .../ConversationInfoActivity.kt | 2 +- .../models/json/capabilities/Capabilities.kt | 2 +- .../json/capabilities/PasswordAccount.kt | 33 ------------------- .../models/json/capabilities/PasswordApi.kt | 5 +-- .../json/capabilities/PasswordCapability.kt | 23 ------------- .../json/capabilities/PasswordPolicies.kt | 23 ------------- .../json/capabilities/PasswordPolicy.kt | 18 ++-------- 8 files changed, 8 insertions(+), 102 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordAccount.kt delete mode 100644 app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordCapability.kt delete mode 100644 app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordPolicies.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 f3c16aa420..3bd808950f 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -644,7 +644,7 @@ fun ShowChangePassword(onDismiss: () -> Unit, conversationCreationViewModel: Con horizontalAlignment = Alignment.CenterHorizontally ) { val validatePasswordUrl = conversationCreationViewModel - .currentUser.capabilities?.passwordCapability?.api?.validatePasswordApi + .currentUser.capabilities?.passwordPolicy?.api?.validatePasswordApi Text(text = stringResource(id = R.string.nc_set_new_password), fontWeight = FontWeight.SemiBold) Spacer(modifier = Modifier.height(16.dp)) OutlinedTextField( @@ -719,7 +719,7 @@ fun ShowPasswordDialog(onDismiss: () -> Unit, conversationCreationViewModel: Con var password by rememberSaveable { mutableStateOf("") } val passwordValidationState by conversationCreationViewModel.validPasswordViewState.collectAsStateWithLifecycle() val validatePasswordUrl = conversationCreationViewModel - .currentUser.capabilities?.passwordCapability?.api?.validatePasswordApi + .currentUser.capabilities?.passwordPolicy?.api?.validatePasswordApi AlertDialog( containerColor = colorResource(id = R.color.dialog_background), onDismissRequest = onDismiss, 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 8956357e06..802abcc3db 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -285,7 +285,7 @@ class ConversationInfoActivity : BaseActivity() { private fun onGuestPasswordChanged(password: String) { val user = conversationUser ?: return - val validatePasswordUrl = user.capabilities?.passwordCapability?.api?.validatePasswordApi ?: "" + val validatePasswordUrl = user.capabilities?.passwordPolicy?.api?.validatePasswordApi ?: "" viewModel.securePassword(credentials, validatePasswordUrl, password) } 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 16245f465f..021d81d9d2 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 @@ -31,7 +31,7 @@ data class Capabilities( @JsonField(name = ["user_status"]) var userStatusCapability: UserStatusCapability?, @JsonField(name = ["password_policy"]) - var passwordCapability: PasswordPolicy? + var passwordPolicy: PasswordPolicy? ) : Parcelable { // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' 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 deleted file mode 100644 index 27d01245db..0000000000 --- a/app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordAccount.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * 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 index 9ba832ac21..710b362d58 100644 --- 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 @@ -15,12 +15,9 @@ 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) + constructor() : this(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 deleted file mode 100644 index aabc10a719..0000000000 --- a/app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordCapability.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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 deleted file mode 100644 index 98b47cc495..0000000000 --- a/app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordPolicies.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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 index 8bbc86fd14..d669bb3765 100644 --- 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 @@ -1,7 +1,7 @@ /* * Nextcloud Talk - Android Client * - * SPDX-FileCopyrightText: 2026 Sowjanya Kota + * SPDX-FileCopyrightText: 2026 Sowjanya Kota * SPDX-License-Identifier: GPL-3.0-or-later */ @@ -16,20 +16,8 @@ import kotlinx.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 + var api: PasswordApi? ) : Parcelable { // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' - constructor() : this(null, null, 0, false, false, false, false) + constructor() : this(null) } From 18288fd51337731316ed27d58445d0e5afbc2455 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 8 Sep 2026 18:45:59 +0200 Subject: [PATCH 05/14] chore: drop the changes unrelated to password validation Three leftovers from the earlier rebase that have nothing to do with this feature: a whitespace-only edit to ProvisioningCapability, a TooGenericExceptionCaught suppression on updateConversationPreset (which catches nothing), and a stray blank line in the change-password dialog. Also drop the LongMethod suppression from validatePassword; the function is thirteen lines and only the catch needs suppressing. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger --- .../talk/conversationcreation/ConversationCreationActivity.kt | 1 - .../viewmodel/ConversationCreationViewModel.kt | 3 +-- .../talk/models/json/capabilities/ProvisioningCapability.kt | 1 + 3 files changed, 2 insertions(+), 3 deletions(-) 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 3bd808950f..996e831522 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -703,7 +703,6 @@ fun ShowChangePassword(onDismiss: () -> Unit, conversationCreationViewModel: Con conversationCreationViewModel.resetPasswordViewState() onDismiss() }, - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) ) { Text(text = stringResource(id = R.string.nc_cancel)) 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 c8c9e04dd7..76d941cf1f 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 @@ -163,7 +163,7 @@ class ConversationCreationViewModel @Inject constructor( _validPasswordViewState.value = ValidPasswordUiState.None } - @Suppress("Detekt.TooGenericExceptionCaught", "LongMethod") + @Suppress("Detekt.TooGenericExceptionCaught") fun validatePassword(url: String, password: String) { val credentials = ApiUtils.getCredentials(_currentUser.username, _currentUser.token) ?: "" viewModelScope.launch { @@ -181,7 +181,6 @@ class ConversationCreationViewModel @Inject constructor( } } - @Suppress("Detekt.TooGenericExceptionCaught") fun updateConversationPreset(preset: String) { conversationPreset.value = preset val loaded = (_presets.value as? PresetsUiState.Success)?.presets.orEmpty() 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 0dfcede1ee..0cd08fef1a 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,3 +1,4 @@ + /* * Nextcloud Talk - Android Client * From 3e5515e939f67257aa168a2755716aac2071de18 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 8 Sep 2026 18:50:57 +0200 Subject: [PATCH 06/14] refactor(password-policy): move validation into its own repository ConversationInfoViewModel injected ConversationCreationRepository purely to reach validatePassword, coupling the conversation-info feature to conversation creation's data layer for an endpoint that belongs to neither. Password-policy validation is an account-level concern, so give it a PasswordPolicyRepository of its own and let both view models depend on that instead. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger --- .../data/ConversationCreationRepository.kt | 2 -- .../ConversationCreationRepositoryImpl.kt | 10 --------- .../ConversationCreationViewModel.kt | 4 +++- .../viewmodel/ConversationInfoViewModel.kt | 6 ++--- .../talk/dagger/modules/RepositoryModule.kt | 6 +++++ .../PasswordPolicyRepository.kt | 19 ++++++++++++++++ .../PasswordPolicyRepositoryImpl.kt | 22 +++++++++++++++++++ .../talk/utils/preview/ComposePreviewUtils.kt | 8 ++++++- 8 files changed, 60 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/repositories/passwordpolicy/PasswordPolicyRepository.kt create mode 100644 app/src/main/java/com/nextcloud/talk/repositories/passwordpolicy/PasswordPolicyRepositoryImpl.kt 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 3f47adc8e5..8bbe420631 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 @@ -15,7 +15,6 @@ import com.nextcloud.talk.models.json.conversations.ConversationPreset 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 { @@ -47,5 +46,4 @@ 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 7130277a26..b7e60ff9ea 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 @@ -16,7 +16,6 @@ import com.nextcloud.talk.models.json.conversations.ConversationPreset 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 @@ -145,13 +144,4 @@ 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 76d941cf1f..62c2ba93b2 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 @@ -27,6 +27,7 @@ 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.passwordResult.PasswordResult +import com.nextcloud.talk.repositories.passwordpolicy.PasswordPolicyRepository import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.SpreedFeatures @@ -41,6 +42,7 @@ import javax.inject.Inject class ConversationCreationViewModel @Inject constructor( private val repository: ConversationCreationRepository, private val conversationCreator: ConversationCreator, + private val passwordPolicyRepository: PasswordPolicyRepository, private val currentUserProvider: CurrentUserProviderOld ) : ViewModel() { private val _selectedParticipants = MutableStateFlow>(emptyList()) @@ -168,7 +170,7 @@ class ConversationCreationViewModel @Inject constructor( val credentials = ApiUtils.getCredentials(_currentUser.username, _currentUser.token) ?: "" viewModelScope.launch { try { - val passwordResult = repository.validatePassword( + val passwordResult = passwordPolicyRepository.validatePassword( credentials, url, password 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 0861dcb8c5..53cc1b1a6e 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,7 +16,6 @@ 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.conversationinfo.ConversationInfoUiEvent import com.nextcloud.talk.conversationinfo.ConversationInfoUiState import com.nextcloud.talk.conversationinfo.CreateRoomRequest @@ -40,6 +39,7 @@ 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.passwordpolicy.PasswordPolicyRepository import com.nextcloud.talk.repositories.conversations.ConversationsRepository.ResendInvitationsResult import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ApiUtils.getUrlForRooms @@ -77,7 +77,7 @@ class ConversationInfoViewModel @Inject constructor( private val chatNetworkDataSource: ChatNetworkDataSource, private val conversationsRepository: ConversationsRepository, private val ncApi: NcApi, - private val conversationCreationRepository: ConversationCreationRepository + private val passwordPolicyRepository: PasswordPolicyRepository ) : ViewModel() { object LifeCycleObserver : DefaultLifecycleObserver { enum class LifeCycleFlag { @@ -240,7 +240,7 @@ class ConversationInfoViewModel @Inject constructor( fun securePassword(credentials: String, url: String, password: String) { viewModelScope.launch { try { - val passwordResult = conversationCreationRepository.validatePassword( + val passwordResult = passwordPolicyRepository.validatePassword( credentials, url, password diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt index dbbd3ef400..60d8a423d2 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt @@ -59,6 +59,8 @@ import com.nextcloud.talk.repositories.callrecording.CallRecordingRepository import com.nextcloud.talk.repositories.callrecording.CallRecordingRepositoryImpl import com.nextcloud.talk.repositories.conversations.ConversationsRepository import com.nextcloud.talk.repositories.conversations.ConversationsRepositoryImpl +import com.nextcloud.talk.repositories.passwordpolicy.PasswordPolicyRepository +import com.nextcloud.talk.repositories.passwordpolicy.PasswordPolicyRepositoryImpl import com.nextcloud.talk.repositories.reactions.ReactionsRepository import com.nextcloud.talk.repositories.reactions.ReactionsRepositoryImpl import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository @@ -222,6 +224,10 @@ class RepositoryModule { fun provideConversationCreationRepository(ncApiCoroutines: NcApiCoroutines): ConversationCreationRepository = ConversationCreationRepositoryImpl(ncApiCoroutines) + @Provides + fun providePasswordPolicyRepository(ncApiCoroutines: NcApiCoroutines): PasswordPolicyRepository = + PasswordPolicyRepositoryImpl(ncApiCoroutines) + @Provides fun provideThreadsRepository(ncApiCoroutines: NcApiCoroutines): ThreadsRepository = ThreadsRepositoryImpl(ncApiCoroutines) diff --git a/app/src/main/java/com/nextcloud/talk/repositories/passwordpolicy/PasswordPolicyRepository.kt b/app/src/main/java/com/nextcloud/talk/repositories/passwordpolicy/PasswordPolicyRepository.kt new file mode 100644 index 0000000000..ecd8fa5f53 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/repositories/passwordpolicy/PasswordPolicyRepository.kt @@ -0,0 +1,19 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Sowjanya Kota + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.repositories.passwordpolicy + +import com.nextcloud.talk.models.json.passwordResult.PasswordResultOverall + +interface PasswordPolicyRepository { + /** + * Asks the server whether [password] satisfies the password policy it advertises. + * + * @param url the validation endpoint taken from the password_policy capability + */ + suspend fun validatePassword(credentials: String, url: String, password: String): PasswordResultOverall +} diff --git a/app/src/main/java/com/nextcloud/talk/repositories/passwordpolicy/PasswordPolicyRepositoryImpl.kt b/app/src/main/java/com/nextcloud/talk/repositories/passwordpolicy/PasswordPolicyRepositoryImpl.kt new file mode 100644 index 0000000000..2d6ff11f93 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/repositories/passwordpolicy/PasswordPolicyRepositoryImpl.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Sowjanya Kota + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.repositories.passwordpolicy + +import com.nextcloud.talk.api.NcApiCoroutines +import com.nextcloud.talk.models.json.passwordResult.PasswordResultOverall +import javax.inject.Inject + +class PasswordPolicyRepositoryImpl @Inject constructor(private val ncApiCoroutines: NcApiCoroutines) : + PasswordPolicyRepository { + + override suspend fun validatePassword( + credentials: String, + url: String, + password: String + ): PasswordResultOverall = ncApiCoroutines.validatePassword(credentials, url, password) +} 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 6da5e49c3b..0cbbc4fcf8 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 @@ -44,6 +44,7 @@ import com.nextcloud.talk.data.network.NetworkMonitorImpl import com.nextcloud.talk.data.user.UsersDao import com.nextcloud.talk.data.user.UsersRepository import com.nextcloud.talk.data.user.UsersRepositoryImpl +import com.nextcloud.talk.repositories.passwordpolicy.PasswordPolicyRepositoryImpl import com.nextcloud.talk.repositories.reactions.ReactionsRepository import com.nextcloud.talk.repositories.reactions.ReactionsRepositoryImpl import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository @@ -245,6 +246,11 @@ class ComposePreviewUtils private constructor(context: Context) { val conversationCreationViewModel: ConversationCreationViewModel get() = ConversationCreationRepositoryImpl(ncApiCoroutines).let { repository -> - ConversationCreationViewModel(repository, ConversationCreator(repository), userProvider) + ConversationCreationViewModel( + repository, + ConversationCreator(repository), + PasswordPolicyRepositoryImpl(ncApiCoroutines), + userProvider + ) } } From 1b29c2fe89634a4cc79b3857dcacff8209bc86a8 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 8 Sep 2026 18:52:36 +0200 Subject: [PATCH 07/14] refactor(password-policy): resolve the endpoint and credentials in the view model The composables dug the validation endpoint out of currentUser.capabilities and handed it, and in the conversation-info case the credentials too, back to a view model that already holds the user. Give CapabilitiesUtil a getPasswordValidationUrl accessor next to the other capability lookups and let each view model resolve both itself, so validatePassword and securePassword now take only the password. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger --- .../ConversationCreationActivity.kt | 12 ++---------- .../viewmodel/ConversationCreationViewModel.kt | 3 ++- .../conversationinfo/ConversationInfoActivity.kt | 4 +--- .../viewmodel/ConversationInfoViewModel.kt | 5 ++++- .../com/nextcloud/talk/utils/CapabilitiesUtil.kt | 11 +++++++++++ 5 files changed, 20 insertions(+), 15 deletions(-) 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 996e831522..277f81c07a 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -643,17 +643,13 @@ fun ShowChangePassword(onDismiss: () -> Unit, conversationCreationViewModel: Con verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - val validatePasswordUrl = conversationCreationViewModel - .currentUser.capabilities?.passwordPolicy?.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) - } + conversationCreationViewModel.validatePassword(it) }, label = { Text(text = stringResource(id = R.string.nc_password)) }, singleLine = true @@ -717,8 +713,6 @@ fun ShowChangePassword(onDismiss: () -> Unit, conversationCreationViewModel: Con fun ShowPasswordDialog(onDismiss: () -> Unit, conversationCreationViewModel: ConversationCreationViewModel) { var password by rememberSaveable { mutableStateOf("") } val passwordValidationState by conversationCreationViewModel.validPasswordViewState.collectAsStateWithLifecycle() - val validatePasswordUrl = conversationCreationViewModel - .currentUser.capabilities?.passwordPolicy?.api?.validatePasswordApi AlertDialog( containerColor = colorResource(id = R.color.dialog_background), onDismissRequest = onDismiss, @@ -729,9 +723,7 @@ fun ShowPasswordDialog(onDismiss: () -> Unit, conversationCreationViewModel: Con value = password, onValueChange = { password = it - if (validatePasswordUrl != null) { - conversationCreationViewModel.validatePassword(validatePasswordUrl, it) - } + conversationCreationViewModel.validatePassword(it) }, label = { Text(text = stringResource(id = R.string.nc_guest_access_password_dialog_hint)) } ) 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 62c2ba93b2..4d00a29d0f 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 @@ -166,7 +166,8 @@ class ConversationCreationViewModel @Inject constructor( } @Suppress("Detekt.TooGenericExceptionCaught") - fun validatePassword(url: String, password: String) { + fun validatePassword(password: String) { + val url = CapabilitiesUtil.getPasswordValidationUrl(_currentUser) ?: return val credentials = ApiUtils.getCredentials(_currentUser.username, _currentUser.token) ?: "" viewModelScope.launch { try { 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 802abcc3db..2b5594bf54 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -284,9 +284,7 @@ class ConversationInfoActivity : BaseActivity() { } private fun onGuestPasswordChanged(password: String) { - val user = conversationUser ?: return - val validatePasswordUrl = user.capabilities?.passwordPolicy?.api?.validatePasswordApi ?: "" - viewModel.securePassword(credentials, validatePasswordUrl, password) + viewModel.securePassword(password) } private fun onGuestPasswordSave(password: String, copyAfterSave: Boolean) { 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 53cc1b1a6e..1d0a629f66 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 @@ -237,7 +237,10 @@ class ConversationInfoViewModel @Inject constructor( } @Suppress("Detekt.TooGenericExceptionCaught") - fun securePassword(credentials: String, url: String, password: String) { + fun securePassword(password: String) { + val user = currentUser ?: return + val url = CapabilitiesUtil.getPasswordValidationUrl(user) ?: return + val credentials = ApiUtils.getCredentials(user.username, user.token) ?: "" viewModelScope.launch { try { val passwordResult = passwordPolicyRepository.validatePassword( 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 879b9a35eb..58a52d6ef1 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt @@ -107,6 +107,17 @@ object CapabilitiesUtil { // endregion + //region PasswordPolicyCapabilities + + /** + * The endpoint that validates a password against the server's policy, or null when the server + * does not advertise the password_policy capability. + */ + fun getPasswordValidationUrl(user: User?): String? = + user?.capabilities?.passwordPolicy?.api?.validatePasswordApi + + // endregion + //region SpreedCapabilities @JvmStatic From cec9fe84fac47b62b9386a1608ef9390e613f322 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 8 Sep 2026 19:03:15 +0200 Subject: [PATCH 08/14] refactor(password-policy): share one validator and one field between both screens Conversation creation and conversation info each carried their own copy of the feature: two UI state types (ValidPasswordUiState and SecurePasswordViewState) with the same three cases, two view-model methods with the same body, two reset methods, and two composables rendering the same message with different colour rules - one of which decided validity by string-comparing the rendered message against the localized "password is secure" label. Replace all of it with a PasswordValidationState both view models expose, a PasswordPolicyValidator that owns the request and the state, and a PasswordPolicyField that renders the input together with the server's verdict. Validity now reads result.passed through isPasswordAccepted. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger --- .../ConversationCreationActivity.kt | 103 +++++------------- .../ConversationCreationViewModel.kt | 34 +----- .../ConversationInfoActivity.kt | 82 ++++---------- .../viewmodel/ConversationInfoViewModel.kt | 38 +------ .../passwordpolicy/PasswordPolicyField.kt | 62 +++++++++++ .../passwordpolicy/PasswordPolicyValidator.kt | 54 +++++++++ .../passwordpolicy/PasswordValidationState.kt | 25 +++++ .../PasswordPolicyRepositoryImpl.kt | 7 +- .../nextcloud/talk/utils/CapabilitiesUtil.kt | 3 +- 9 files changed, 202 insertions(+), 206 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyField.kt create mode 100644 app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyValidator.kt create mode 100644 app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordValidationState.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 277f81c07a..c929fcb192 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -59,7 +59,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TextField import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -102,10 +101,11 @@ import com.nextcloud.talk.conversationcreation.ui.CreationResultEffect import com.nextcloud.talk.conversationcreation.ui.ShareCreatedConversation import com.nextcloud.talk.conversationcreation.ui.openConversation import com.nextcloud.talk.conversationcreation.viewmodel.ConversationCreationViewModel -import com.nextcloud.talk.conversationcreation.viewmodel.ValidPasswordUiState import com.nextcloud.talk.extensions.getParcelableArrayListExtraProvider import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser +import com.nextcloud.talk.passwordpolicy.PasswordPolicyField +import com.nextcloud.talk.passwordpolicy.isPasswordAccepted import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.DisplayUtils @@ -623,7 +623,8 @@ fun ConversationOption( @Composable fun ShowChangePassword(onDismiss: () -> Unit, conversationCreationViewModel: ConversationCreationViewModel) { var changedPassword by rememberSaveable { mutableStateOf("") } - val passwordValidationState by conversationCreationViewModel.validPasswordViewState.collectAsStateWithLifecycle() + val passwordValidationState by conversationCreationViewModel.passwordValidation.state + .collectAsStateWithLifecycle() Dialog(onDismissRequest = { onDismiss() }) { @@ -645,17 +646,15 @@ fun ShowChangePassword(onDismiss: () -> Unit, conversationCreationViewModel: Con ) { Text(text = stringResource(id = R.string.nc_set_new_password), fontWeight = FontWeight.SemiBold) Spacer(modifier = Modifier.height(16.dp)) - OutlinedTextField( - value = changedPassword, - onValueChange = { + PasswordPolicyField( + password = changedPassword, + onPasswordChange = { changedPassword = it - conversationCreationViewModel.validatePassword(it) + conversationCreationViewModel.passwordValidation.validate(it) }, - label = { Text(text = stringResource(id = R.string.nc_password)) }, - singleLine = true + validationState = passwordValidationState, + label = stringResource(id = R.string.nc_password) ) - Spacer(modifier = Modifier.height(8.dp)) - PasswordValidationMessage(passwordValidationState) Column( modifier = Modifier @@ -664,17 +663,13 @@ 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.resetPasswordViewState() + conversationCreationViewModel.passwordValidation.reset() onDismiss() }, - enabled = changedPassword.isNotEmpty() && - changedPassword.isNotBlank() && - securePassword, + enabled = changedPassword.isNotBlank() && passwordValidationState.isPasswordAccepted, contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) ) { Text(text = stringResource(id = R.string.nc_change_password)) @@ -683,7 +678,7 @@ fun ShowChangePassword(onDismiss: () -> Unit, conversationCreationViewModel: Con TextButton( onClick = { conversationCreationViewModel.updatePassword("") - conversationCreationViewModel.resetPasswordViewState() + conversationCreationViewModel.passwordValidation.reset() onDismiss() }, contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) @@ -696,7 +691,7 @@ fun ShowChangePassword(onDismiss: () -> Unit, conversationCreationViewModel: Con Spacer(modifier = Modifier.height(4.dp)) TextButton( onClick = { - conversationCreationViewModel.resetPasswordViewState() + conversationCreationViewModel.passwordValidation.reset() onDismiss() }, contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) @@ -712,38 +707,30 @@ fun ShowChangePassword(onDismiss: () -> Unit, conversationCreationViewModel: Con @Composable fun ShowPasswordDialog(onDismiss: () -> Unit, conversationCreationViewModel: ConversationCreationViewModel) { var password by rememberSaveable { mutableStateOf("") } - val passwordValidationState by conversationCreationViewModel.validPasswordViewState.collectAsStateWithLifecycle() + val passwordValidationState by conversationCreationViewModel.passwordValidation.state + .collectAsStateWithLifecycle() AlertDialog( containerColor = colorResource(id = R.color.dialog_background), onDismissRequest = onDismiss, title = { Text(text = stringResource(id = R.string.nc_set_password)) }, text = { - Column { - TextField( - value = password, - onValueChange = { - password = it - conversationCreationViewModel.validatePassword(it) - }, - label = { Text(text = stringResource(id = R.string.nc_guest_access_password_dialog_hint)) } - ) - Spacer(modifier = Modifier.height(8.dp)) - PasswordValidationMessage(passwordValidationState) - } + PasswordPolicyField( + password = password, + onPasswordChange = { + password = it + conversationCreationViewModel.passwordValidation.validate(it) + }, + validationState = passwordValidationState, + label = stringResource(id = R.string.nc_guest_access_password_dialog_hint) + ) }, confirmButton = { - val securePassword = (passwordValidationState as? ValidPasswordUiState.Success)?.result?.passed - ?: false TextButton( + enabled = password.isNotBlank() && passwordValidationState.isPasswordAccepted, onClick = { - if (password.isNotEmpty() && - password.isNotBlank() && - securePassword - ) { - conversationCreationViewModel.updatePassword(password) - conversationCreationViewModel.resetPasswordViewState() - onDismiss() - } + conversationCreationViewModel.updatePassword(password) + conversationCreationViewModel.passwordValidation.reset() + onDismiss() } ) { Text(text = stringResource(id = R.string.save)) @@ -751,7 +738,7 @@ fun ShowPasswordDialog(onDismiss: () -> Unit, conversationCreationViewModel: Con }, dismissButton = { TextButton(onClick = { - conversationCreationViewModel.resetPasswordViewState() + conversationCreationViewModel.passwordValidation.reset() onDismiss() }) { Text(text = stringResource(id = R.string.nc_cancel)) @@ -760,36 +747,6 @@ fun ShowPasswordDialog(onDismiss: () -> Unit, conversationCreationViewModel: Con ) } -@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 isCreatingRoom by conversationCreationViewModel.isCreatingRoom.collectAsState() 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 4d00a29d0f..08288b51a9 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 @@ -26,7 +26,7 @@ import com.nextcloud.talk.conversationcreation.parametersOf 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.passwordResult.PasswordResult +import com.nextcloud.talk.passwordpolicy.PasswordPolicyValidator import com.nextcloud.talk.repositories.passwordpolicy.PasswordPolicyRepository import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil @@ -50,8 +50,7 @@ class ConversationCreationViewModel @Inject constructor( private val roomViewState = MutableStateFlow(RoomUIState.None) val creationState: StateFlow = roomViewState - private val _validPasswordViewState = MutableStateFlow(ValidPasswordUiState.None) - val validPasswordViewState: StateFlow = _validPasswordViewState + val passwordValidation = PasswordPolicyValidator(passwordPolicyRepository, viewModelScope) { _currentUser } private val _selectedImageUri = MutableStateFlow(null) val selectedImageUri: StateFlow = _selectedImageUri @@ -161,29 +160,6 @@ class ConversationCreationViewModel @Inject constructor( _conversationDescription.value = conversationDescription } - fun resetPasswordViewState() { - _validPasswordViewState.value = ValidPasswordUiState.None - } - - @Suppress("Detekt.TooGenericExceptionCaught") - fun validatePassword(password: String) { - val url = CapabilitiesUtil.getPasswordValidationUrl(_currentUser) ?: return - val credentials = ApiUtils.getCredentials(_currentUser.username, _currentUser.token) ?: "" - viewModelScope.launch { - try { - val passwordResult = passwordPolicyRepository.validatePassword( - credentials, - url, - password - ) - - _validPasswordViewState.value = ValidPasswordUiState.Success(passwordResult.ocs?.data!!) - } catch (exception: Exception) { - _validPasswordViewState.value = ValidPasswordUiState.Error(exception.message ?: "") - } - } - } - fun updateConversationPreset(preset: String) { conversationPreset.value = preset val loaded = (_presets.value as? PresetsUiState.Success)?.presets.orEmpty() @@ -310,9 +286,3 @@ 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/ConversationInfoActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt index 2b5594bf54..5ffcfd1cbe 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -17,12 +17,10 @@ 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 @@ -34,7 +32,6 @@ 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 @@ -83,6 +80,9 @@ import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.models.json.converters.EnumActorTypeConverter import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.participants.Participant +import com.nextcloud.talk.passwordpolicy.PasswordPolicyField +import com.nextcloud.talk.passwordpolicy.PasswordValidationState +import com.nextcloud.talk.passwordpolicy.isPasswordAccepted import com.nextcloud.talk.models.json.participants.Participant.ActorType.CIRCLES import com.nextcloud.talk.models.json.participants.Participant.ActorType.GROUPS import com.nextcloud.talk.models.json.upcomingEvents.UpcomingEvent @@ -134,8 +134,8 @@ class ConversationInfoActivity : BaseActivity() { private var startGroupChat: Boolean = false - private var securePasswordViewState: ConversationInfoViewModel.SecurePasswordViewState - by mutableStateOf(ConversationInfoViewModel.SecurePasswordViewState.None) + private var passwordValidationState: PasswordValidationState + by mutableStateOf(PasswordValidationState.None) private var showPasswordDialog by mutableStateOf(false) private val workerData: Data? @@ -214,7 +214,9 @@ class ConversationInfoActivity : BaseActivity() { } } - viewModel.securePasswordViewState.observe(this) { securePasswordViewState = it } + lifecycleScope.launch { + viewModel.passwordValidation.state.collect { passwordValidationState = it } + } setupCompose() } @@ -276,7 +278,7 @@ class ConversationInfoActivity : BaseActivity() { private fun GuestAccessPasswordDialogHost() { if (!showPasswordDialog) return GuestAccessPasswordDialog( - validationState = securePasswordViewState, + validationState = passwordValidationState, onPasswordChanged = ::onGuestPasswordChanged, onDismiss = ::dismissGuestPasswordDialog, onSave = ::onGuestPasswordSave @@ -284,7 +286,7 @@ class ConversationInfoActivity : BaseActivity() { } private fun onGuestPasswordChanged(password: String) { - viewModel.securePassword(password) + viewModel.passwordValidation.validate(password) } private fun onGuestPasswordSave(password: String, copyAfterSave: Boolean) { @@ -303,7 +305,7 @@ class ConversationInfoActivity : BaseActivity() { private fun dismissGuestPasswordDialog() { showPasswordDialog = false - viewModel.resetSecurePasswordViewState() + viewModel.passwordValidation.reset() } private fun copyPasswordToClipboard(password: String) { @@ -887,48 +889,29 @@ class ConversationInfoActivity : BaseActivity() { } @Composable -@Suppress("LongMethod") private fun GuestAccessPasswordDialog( - validationState: ConversationInfoViewModel.SecurePasswordViewState, + validationState: PasswordValidationState, 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 + val isPasswordValid = password.isNotBlank() && validationState.isPasswordAccepted 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) - } - ) - } - } - ) - } + PasswordPolicyField( + password = password, + onPasswordChange = { + password = it + onPasswordChanged(it) + }, + validationState = validationState, + label = stringResource(id = R.string.nc_guest_access_password_dialog_hint), + modifier = Modifier.fillMaxWidth() + ) }, confirmButton = { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -953,22 +936,3 @@ private fun GuestAccessPasswordDialog( } ) } - -@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 1d0a629f66..94f402bb43 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 @@ -36,11 +36,11 @@ 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.passwordpolicy.PasswordPolicyValidator import com.nextcloud.talk.repositories.conversations.ConversationsRepository -import com.nextcloud.talk.repositories.passwordpolicy.PasswordPolicyRepository import com.nextcloud.talk.repositories.conversations.ConversationsRepository.ResendInvitationsResult +import com.nextcloud.talk.repositories.passwordpolicy.PasswordPolicyRepository import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ApiUtils.getUrlForRooms import com.nextcloud.talk.utils.CapabilitiesUtil @@ -111,6 +111,7 @@ class ConversationInfoViewModel @Inject constructor( private var currentUser: User? = null private var currentToken: String = "" private var databaseStorageModule: DatabaseStorageModule? = null + val passwordValidation = PasswordPolicyValidator(passwordPolicyRepository, viewModelScope) { currentUser } private val _uiState = MutableStateFlow(ConversationInfoUiState()) val uiState: StateFlow = _uiState.asStateFlow() private val _uiEvent = MutableSharedFlow(extraBufferCapacity = 1) @@ -172,9 +173,6 @@ class ConversationInfoViewModel @Inject constructor( return uiItems } - private val _securePasswordViewState = MutableLiveData(SecurePasswordViewState.None) - val securePasswordViewState: LiveData = _securePasswordViewState - fun getRoom(user: User, token: String) { currentUser = user currentToken = token @@ -188,10 +186,6 @@ class ConversationInfoViewModel @Inject constructor( ?.subscribe(GetRoomObserver()) } - fun resetSecurePasswordViewState() { - _securePasswordViewState.value = SecurePasswordViewState.None - } - @Suppress("Detekt.TooGenericExceptionCaught") fun createRoomFromOneToOne( user: User, @@ -236,26 +230,6 @@ class ConversationInfoViewModel @Inject constructor( } } - @Suppress("Detekt.TooGenericExceptionCaught") - fun securePassword(password: String) { - val user = currentUser ?: return - val url = CapabilitiesUtil.getPasswordValidationUrl(user) ?: return - val credentials = ApiUtils.getCredentials(user.username, user.token) ?: "" - viewModelScope.launch { - try { - val passwordResult = passwordPolicyRepository.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 -> @@ -974,10 +948,4 @@ class ConversationInfoViewModel @Inject constructor( return DisplayUtils.ellipsize(conversationName, MAX_ROOM_NAME_LENGTH) } } - - 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/passwordpolicy/PasswordPolicyField.kt b/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyField.kt new file mode 100644 index 0000000000..cbdbce1016 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyField.kt @@ -0,0 +1,62 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Sowjanya Kota + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.passwordpolicy + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import com.nextcloud.talk.R + +/** + * A password input that reports what the server made of the password as it is typed. + */ +@Composable +fun PasswordPolicyField( + password: String, + onPasswordChange: (String) -> Unit, + validationState: PasswordValidationState, + label: String, + modifier: Modifier = Modifier +) { + OutlinedTextField( + value = password, + onValueChange = onPasswordChange, + modifier = modifier, + label = { Text(text = label) }, + singleLine = true, + isError = validationState is PasswordValidationState.Error, + supportingText = { PasswordPolicyFeedback(validationState) } + ) +} + +@Composable +private fun PasswordPolicyFeedback(validationState: PasswordValidationState) { + when (validationState) { + is PasswordValidationState.Success -> Text( + text = validationState.result.reason ?: stringResource(R.string.nc_password_secure), + color = if (validationState.isPasswordAccepted) { + colorResource(id = R.color.nc_darkGreen) + } else { + colorResource(id = R.color.nc_darkRed) + }, + style = MaterialTheme.typography.bodySmall + ) + + is PasswordValidationState.Error -> Text( + text = stringResource(R.string.nc_common_error_sorry), + color = colorResource(id = R.color.nc_darkRed), + style = MaterialTheme.typography.bodySmall + ) + + PasswordValidationState.None -> Unit + } +} diff --git a/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyValidator.kt b/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyValidator.kt new file mode 100644 index 0000000000..dc77e02c7a --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyValidator.kt @@ -0,0 +1,54 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Sowjanya Kota + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.passwordpolicy + +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.repositories.passwordpolicy.PasswordPolicyRepository +import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.CapabilitiesUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +/** + * Checks passwords against the server's policy on behalf of a screen, and holds the outcome for it + * to render. + * + * @param scope the owning view model's scope; validation is cancelled with it + * @param userProvider the account to validate against, read at validation time because a screen may + * learn about its user only after it is created + */ +class PasswordPolicyValidator( + private val repository: PasswordPolicyRepository, + private val scope: CoroutineScope, + private val userProvider: () -> User? +) { + private val _state = MutableStateFlow(PasswordValidationState.None) + val state: StateFlow = _state + + @Suppress("Detekt.TooGenericExceptionCaught") + fun validate(password: String) { + val user = userProvider() ?: return + val url = CapabilitiesUtil.getPasswordValidationUrl(user) ?: return + val credentials = ApiUtils.getCredentials(user.username, user.token) ?: "" + scope.launch { + try { + _state.value = PasswordValidationState.Success( + repository.validatePassword(credentials, url, password).ocs?.data!! + ) + } catch (exception: Exception) { + _state.value = PasswordValidationState.Error(exception.message ?: "") + } + } + } + + fun reset() { + _state.value = PasswordValidationState.None + } +} diff --git a/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordValidationState.kt b/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordValidationState.kt new file mode 100644 index 0000000000..eaad9fab6e --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordValidationState.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.passwordpolicy + +import com.nextcloud.talk.models.json.passwordResult.PasswordResult + +/** + * Outcome of checking a password against the server's password policy. + */ +sealed interface PasswordValidationState { + data object None : PasswordValidationState + data class Success(val result: PasswordResult) : PasswordValidationState + data class Error(val message: String) : PasswordValidationState +} + +/** + * Whether the server accepted the password that was last validated. + */ +val PasswordValidationState.isPasswordAccepted: Boolean + get() = this is PasswordValidationState.Success && result.passed == true diff --git a/app/src/main/java/com/nextcloud/talk/repositories/passwordpolicy/PasswordPolicyRepositoryImpl.kt b/app/src/main/java/com/nextcloud/talk/repositories/passwordpolicy/PasswordPolicyRepositoryImpl.kt index 2d6ff11f93..4a60e47598 100644 --- a/app/src/main/java/com/nextcloud/talk/repositories/passwordpolicy/PasswordPolicyRepositoryImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/repositories/passwordpolicy/PasswordPolicyRepositoryImpl.kt @@ -14,9 +14,6 @@ import javax.inject.Inject class PasswordPolicyRepositoryImpl @Inject constructor(private val ncApiCoroutines: NcApiCoroutines) : PasswordPolicyRepository { - override suspend fun validatePassword( - credentials: String, - url: String, - password: String - ): PasswordResultOverall = ncApiCoroutines.validatePassword(credentials, url, password) + override suspend fun validatePassword(credentials: String, url: String, password: String): PasswordResultOverall = + ncApiCoroutines.validatePassword(credentials, url, password) } 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 58a52d6ef1..cbb7fabe9f 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt @@ -113,8 +113,7 @@ object CapabilitiesUtil { * The endpoint that validates a password against the server's policy, or null when the server * does not advertise the password_policy capability. */ - fun getPasswordValidationUrl(user: User?): String? = - user?.capabilities?.passwordPolicy?.api?.validatePasswordApi + fun getPasswordValidationUrl(user: User?): String? = user?.capabilities?.passwordPolicy?.api?.validatePasswordApi // endregion From 4fdbd6e47f09f435cd224a5d73f43c88c51aa686 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 8 Sep 2026 19:04:52 +0200 Subject: [PATCH 09/14] fix(password-policy): let a password be set when the server has no policy Save was gated on a successful validation round-trip, so on a server that does not advertise the password_policy capability no request was ever made, the state stayed None and the button never enabled - the guest password could not be set at all. Conversation info made it worse by passing an empty string as the endpoint, firing a doomed request at the base URL and settling on Error. Report the missing capability as its own NoPolicy state, which counts as accepted: without a policy to check against there is nothing to fail. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger --- .../nextcloud/talk/passwordpolicy/PasswordPolicyField.kt | 2 +- .../talk/passwordpolicy/PasswordPolicyValidator.kt | 6 +++++- .../talk/passwordpolicy/PasswordValidationState.kt | 9 ++++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyField.kt b/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyField.kt index cbdbce1016..668c9fdfb2 100644 --- a/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyField.kt +++ b/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyField.kt @@ -57,6 +57,6 @@ private fun PasswordPolicyFeedback(validationState: PasswordValidationState) { style = MaterialTheme.typography.bodySmall ) - PasswordValidationState.None -> Unit + PasswordValidationState.None, PasswordValidationState.NoPolicy -> Unit } } diff --git a/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyValidator.kt b/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyValidator.kt index dc77e02c7a..66077a8c49 100644 --- a/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyValidator.kt +++ b/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyValidator.kt @@ -35,7 +35,11 @@ class PasswordPolicyValidator( @Suppress("Detekt.TooGenericExceptionCaught") fun validate(password: String) { val user = userProvider() ?: return - val url = CapabilitiesUtil.getPasswordValidationUrl(user) ?: return + val url = CapabilitiesUtil.getPasswordValidationUrl(user) + if (url == null) { + _state.value = PasswordValidationState.NoPolicy + return + } val credentials = ApiUtils.getCredentials(user.username, user.token) ?: "" scope.launch { try { diff --git a/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordValidationState.kt b/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordValidationState.kt index eaad9fab6e..c88a2dca8f 100644 --- a/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordValidationState.kt +++ b/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordValidationState.kt @@ -14,6 +14,9 @@ import com.nextcloud.talk.models.json.passwordResult.PasswordResult */ sealed interface PasswordValidationState { data object None : PasswordValidationState + + /** The server advertises no password policy, so there is nothing to check against. */ + data object NoPolicy : PasswordValidationState data class Success(val result: PasswordResult) : PasswordValidationState data class Error(val message: String) : PasswordValidationState } @@ -22,4 +25,8 @@ sealed interface PasswordValidationState { * Whether the server accepted the password that was last validated. */ val PasswordValidationState.isPasswordAccepted: Boolean - get() = this is PasswordValidationState.Success && result.passed == true + get() = when (this) { + is PasswordValidationState.Success -> result.passed == true + PasswordValidationState.NoPolicy -> true + PasswordValidationState.None, is PasswordValidationState.Error -> false + } From 34eedef1dc135a86b2b953ddcf5579bfbd678e1c Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 8 Sep 2026 19:06:25 +0200 Subject: [PATCH 10/14] fix(password-policy): validate once the user stops typing Every keystroke fired its own request, so typing a ten-character password sent ten of them - each a prefix of the password - and whichever response happened to land last decided whether Save was enabled, regardless of what the field then held. Debounce by half a second and cancel the request in flight, so only the last password typed is sent and only its verdict can be written. Clear the verdict as soon as the password changes, so the previous password's result cannot stand in for one that has not been checked yet. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger --- .../passwordpolicy/PasswordPolicyValidator.kt | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyValidator.kt b/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyValidator.kt index 66077a8c49..99d9241251 100644 --- a/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyValidator.kt +++ b/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyValidator.kt @@ -11,7 +11,10 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.repositories.passwordpolicy.PasswordPolicyRepository import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -32,8 +35,15 @@ class PasswordPolicyValidator( private val _state = MutableStateFlow(PasswordValidationState.None) val state: StateFlow = _state + private var validationJob: Job? = null + @Suppress("Detekt.TooGenericExceptionCaught") fun validate(password: String) { + validationJob?.cancel() + if (password.isEmpty()) { + _state.value = PasswordValidationState.None + return + } val user = userProvider() ?: return val url = CapabilitiesUtil.getPasswordValidationUrl(user) if (url == null) { @@ -41,11 +51,17 @@ class PasswordPolicyValidator( return } val credentials = ApiUtils.getCredentials(user.username, user.token) ?: "" - scope.launch { + // Until the new password has its own verdict, the previous one's must not stand for it. + _state.value = PasswordValidationState.None + validationJob = scope.launch { + // Wait for a pause in typing so that a password is not sent one prefix at a time. + delay(DEBOUNCE_MILLIS) try { _state.value = PasswordValidationState.Success( repository.validatePassword(credentials, url, password).ocs?.data!! ) + } catch (e: CancellationException) { + throw e } catch (exception: Exception) { _state.value = PasswordValidationState.Error(exception.message ?: "") } @@ -53,6 +69,11 @@ class PasswordPolicyValidator( } fun reset() { + validationJob?.cancel() _state.value = PasswordValidationState.None } + + companion object { + private const val DEBOUNCE_MILLIS = 500L + } } From 597b99d95537ee9c7dd9704dad2d663be3a9fd71 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 8 Sep 2026 19:07:33 +0200 Subject: [PATCH 11/14] fix(password-policy): report a malformed validation response as an error The result was unwrapped with ocs?.data!!, so a well-formed response that carried no result threw a NullPointerException into the surrounding catch and surfaced as an error whose message was whatever the NPE said. Unwrap it in the repository, which is the layer that knows the wire format, and fail with a described IllegalStateException. Log what went wrong on the way past, since the dialog only tells the user that something did. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger --- .../talk/passwordpolicy/PasswordPolicyValidator.kt | 5 ++++- .../passwordpolicy/PasswordPolicyRepository.kt | 5 +++-- .../passwordpolicy/PasswordPolicyRepositoryImpl.kt | 7 ++++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyValidator.kt b/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyValidator.kt index 99d9241251..fc4d959479 100644 --- a/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyValidator.kt +++ b/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyValidator.kt @@ -7,6 +7,7 @@ package com.nextcloud.talk.passwordpolicy +import android.util.Log import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.repositories.passwordpolicy.PasswordPolicyRepository import com.nextcloud.talk.utils.ApiUtils @@ -58,11 +59,12 @@ class PasswordPolicyValidator( delay(DEBOUNCE_MILLIS) try { _state.value = PasswordValidationState.Success( - repository.validatePassword(credentials, url, password).ocs?.data!! + repository.validatePassword(credentials, url, password) ) } catch (e: CancellationException) { throw e } catch (exception: Exception) { + Log.e(TAG, "Failed to validate the password against the server policy", exception) _state.value = PasswordValidationState.Error(exception.message ?: "") } } @@ -74,6 +76,7 @@ class PasswordPolicyValidator( } companion object { + private val TAG = PasswordPolicyValidator::class.java.simpleName private const val DEBOUNCE_MILLIS = 500L } } diff --git a/app/src/main/java/com/nextcloud/talk/repositories/passwordpolicy/PasswordPolicyRepository.kt b/app/src/main/java/com/nextcloud/talk/repositories/passwordpolicy/PasswordPolicyRepository.kt index ecd8fa5f53..4a25a124d2 100644 --- a/app/src/main/java/com/nextcloud/talk/repositories/passwordpolicy/PasswordPolicyRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/repositories/passwordpolicy/PasswordPolicyRepository.kt @@ -7,13 +7,14 @@ package com.nextcloud.talk.repositories.passwordpolicy -import com.nextcloud.talk.models.json.passwordResult.PasswordResultOverall +import com.nextcloud.talk.models.json.passwordResult.PasswordResult interface PasswordPolicyRepository { /** * Asks the server whether [password] satisfies the password policy it advertises. * * @param url the validation endpoint taken from the password_policy capability + * @throws IllegalStateException if the server answers without a result */ - suspend fun validatePassword(credentials: String, url: String, password: String): PasswordResultOverall + suspend fun validatePassword(credentials: String, url: String, password: String): PasswordResult } diff --git a/app/src/main/java/com/nextcloud/talk/repositories/passwordpolicy/PasswordPolicyRepositoryImpl.kt b/app/src/main/java/com/nextcloud/talk/repositories/passwordpolicy/PasswordPolicyRepositoryImpl.kt index 4a60e47598..6e17aa191b 100644 --- a/app/src/main/java/com/nextcloud/talk/repositories/passwordpolicy/PasswordPolicyRepositoryImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/repositories/passwordpolicy/PasswordPolicyRepositoryImpl.kt @@ -8,12 +8,13 @@ package com.nextcloud.talk.repositories.passwordpolicy import com.nextcloud.talk.api.NcApiCoroutines -import com.nextcloud.talk.models.json.passwordResult.PasswordResultOverall +import com.nextcloud.talk.models.json.passwordResult.PasswordResult import javax.inject.Inject class PasswordPolicyRepositoryImpl @Inject constructor(private val ncApiCoroutines: NcApiCoroutines) : PasswordPolicyRepository { - override suspend fun validatePassword(credentials: String, url: String, password: String): PasswordResultOverall = - ncApiCoroutines.validatePassword(credentials, url, password) + override suspend fun validatePassword(credentials: String, url: String, password: String): PasswordResult = + ncApiCoroutines.validatePassword(credentials, url, password).ocs?.data + ?: throw IllegalStateException("The password validation response carried no result") } From 5fc2aa29e316c5ce8d27d8752b0cf2f881b4b563 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 8 Sep 2026 19:10:23 +0200 Subject: [PATCH 12/14] fix(password-policy): mask the password as it is typed The XML dialog the Compose one replaced set inputType="textPassword". Its replacement set nothing, so the guest password was drawn in clear text and went through the keyboard's suggestion and learning path. The two dialogs on the conversation creation screen never masked it either. Mask it in the shared field, and hold it in remember rather than rememberSaveable so the plain text is not written into the saved instance state bundle. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger --- .../conversationcreation/ConversationCreationActivity.kt | 5 +++-- .../talk/conversationinfo/ConversationInfoActivity.kt | 3 +-- .../com/nextcloud/talk/passwordpolicy/PasswordPolicyField.kt | 5 +++++ 3 files changed, 9 insertions(+), 4 deletions(-) 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 c929fcb192..9922f275a4 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -64,6 +64,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState 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.Alignment @@ -622,7 +623,7 @@ fun ConversationOption( @Suppress("LongMethod") @Composable fun ShowChangePassword(onDismiss: () -> Unit, conversationCreationViewModel: ConversationCreationViewModel) { - var changedPassword by rememberSaveable { mutableStateOf("") } + var changedPassword by remember { mutableStateOf("") } val passwordValidationState by conversationCreationViewModel.passwordValidation.state .collectAsStateWithLifecycle() Dialog(onDismissRequest = { @@ -706,7 +707,7 @@ fun ShowChangePassword(onDismiss: () -> Unit, conversationCreationViewModel: Con @Composable fun ShowPasswordDialog(onDismiss: () -> Unit, conversationCreationViewModel: ConversationCreationViewModel) { - var password by rememberSaveable { mutableStateOf("") } + var password by remember { mutableStateOf("") } val passwordValidationState by conversationCreationViewModel.passwordValidation.state .collectAsStateWithLifecycle() AlertDialog( 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 5ffcfd1cbe..460dfc5343 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -29,7 +29,6 @@ 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.stringResource @@ -895,7 +894,7 @@ private fun GuestAccessPasswordDialog( onDismiss: () -> Unit, onSave: (password: String, copyAfterSave: Boolean) -> Unit ) { - var password by rememberSaveable { mutableStateOf("") } + var password by remember { mutableStateOf("") } val isPasswordValid = password.isNotBlank() && validationState.isPasswordAccepted ComposeAlertDialog( diff --git a/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyField.kt b/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyField.kt index 668c9fdfb2..72d82eddcc 100644 --- a/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyField.kt +++ b/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyField.kt @@ -7,6 +7,7 @@ package com.nextcloud.talk.passwordpolicy +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -14,6 +15,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation import com.nextcloud.talk.R /** @@ -33,6 +36,8 @@ fun PasswordPolicyField( modifier = modifier, label = { Text(text = label) }, singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), isError = validationState is PasswordValidationState.Error, supportingText = { PasswordPolicyFeedback(validationState) } ) From 8494863125bd14e6247664350efb47f8a1eaf8d1 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 8 Sep 2026 19:13:27 +0200 Subject: [PATCH 13/14] fix(guest-access): mark copied passwords as sensitive CreatedConversation already flagged the clipboard entry with EXTRA_IS_SENSITIVE and confirmed the copy with a toast, but the two copy actions this branch adds did neither, so on Android 13 and later the password appeared in the clipboard preview and nothing told the user it had been copied. Lift that private helper into ClipboardUtils and use it at all three sites. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger --- .../ConversationCreationActivity.kt | 12 +++---- .../ui/CreatedConversation.kt | 20 ++--------- .../ConversationInfoActivity.kt | 21 ++++++------ .../nextcloud/talk/utils/ClipboardUtils.kt | 33 +++++++++++++++++++ 4 files changed, 51 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/utils/ClipboardUtils.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 9922f275a4..c34f3e513a 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -11,8 +11,6 @@ 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 @@ -109,6 +107,7 @@ import com.nextcloud.talk.passwordpolicy.PasswordPolicyField import com.nextcloud.talk.passwordpolicy.isPasswordAccepted import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil +import com.nextcloud.talk.utils.copyPasswordToClipboard import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.PickImage import com.nextcloud.talk.utils.bundle.BundleKeys @@ -536,12 +535,11 @@ fun RoomCreationOptions(conversationCreationViewModel: ConversationCreationViewM icon = R.drawable.ic_content_copy, text = R.string.nc_copy_password, onClick = { - 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 + copyPasswordToClipboard( + context = context, + label = context.resources.getString(R.string.nc_app_product_name), + password = conversationCreationViewModel.password.value ) - clipboardManager.setPrimaryClip(clip) } ) } diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ui/CreatedConversation.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ui/CreatedConversation.kt index f5701792cb..eea87d7b0e 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ui/CreatedConversation.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ui/CreatedConversation.kt @@ -8,14 +8,9 @@ package com.nextcloud.talk.conversationcreation.ui import android.app.Activity -import android.content.ClipData -import android.content.ClipDescription -import android.content.ClipboardManager import android.content.Context import android.content.Intent -import android.os.Build import android.os.Bundle -import android.os.PersistableBundle import android.widget.Toast import androidx.compose.foundation.layout.Column import androidx.compose.material3.AlertDialog @@ -32,6 +27,7 @@ import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.ShareUtils import com.nextcloud.talk.utils.bundle.BundleKeys +import com.nextcloud.talk.utils.copyPasswordToClipboard /** * Reacts to the outcome of creating a conversation: reports what could not be done, hands public @@ -95,7 +91,7 @@ fun ShareCreatedConversation( Column { Text(text = stringResource(R.string.nc_conversation_created_public)) if (!password.isNullOrEmpty()) { - TextButton(onClick = { copyPassword(context, roomToken, password) }) { + TextButton(onClick = { copyPasswordToClipboard(context, roomToken, password) }) { Text(text = stringResource(R.string.nc_copy_password)) } } @@ -133,15 +129,3 @@ fun openConversation(context: Context, roomToken: String) { chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) context.startActivity(chatIntent) } - -private fun copyPassword(context: Context, roomToken: String, password: String) { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText(roomToken, password) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - clip.description.extras = PersistableBundle().apply { - putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) - } - } - clipboard.setPrimaryClip(clip) - Toast.makeText(context, context.getString(R.string.nc_password_copied), Toast.LENGTH_SHORT).show() -} 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 460dfc5343..afb402260b 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -7,8 +7,6 @@ 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 @@ -79,12 +77,12 @@ import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.models.json.converters.EnumActorTypeConverter import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.participants.Participant -import com.nextcloud.talk.passwordpolicy.PasswordPolicyField -import com.nextcloud.talk.passwordpolicy.PasswordValidationState -import com.nextcloud.talk.passwordpolicy.isPasswordAccepted import com.nextcloud.talk.models.json.participants.Participant.ActorType.CIRCLES import com.nextcloud.talk.models.json.participants.Participant.ActorType.GROUPS import com.nextcloud.talk.models.json.upcomingEvents.UpcomingEvent +import com.nextcloud.talk.passwordpolicy.PasswordPolicyField +import com.nextcloud.talk.passwordpolicy.PasswordValidationState +import com.nextcloud.talk.passwordpolicy.isPasswordAccepted import com.nextcloud.talk.shareditems.activities.SharedItemsActivity import com.nextcloud.talk.threadsoverview.ThreadsOverviewActivity import com.nextcloud.talk.ui.dialog.DialogBanListFragment @@ -97,6 +95,7 @@ import com.nextcloud.talk.utils.ShareUtils import com.nextcloud.talk.utils.ShortcutManagerHelper import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN +import com.nextcloud.talk.utils.copyPasswordToClipboard import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable @@ -291,7 +290,7 @@ class ConversationInfoActivity : BaseActivity() { private fun onGuestPasswordSave(password: String, copyAfterSave: Boolean) { val user = conversationUser ?: return if (copyAfterSave) { - copyPasswordToClipboard(password) + copyPassword(password) } val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1)) viewModel.setPassword( @@ -307,10 +306,12 @@ class ConversationInfoActivity : BaseActivity() { viewModel.passwordValidation.reset() } - 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)) + private fun copyPassword(password: String) { + copyPasswordToClipboard( + context = this, + label = resources.getString(R.string.nc_app_product_name), + password = password + ) } @Suppress("LongMethod", "CyclomaticComplexMethod") diff --git a/app/src/main/java/com/nextcloud/talk/utils/ClipboardUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/ClipboardUtils.kt new file mode 100644 index 0000000000..eaaaab6746 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/ClipboardUtils.kt @@ -0,0 +1,33 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.utils + +import android.content.ClipData +import android.content.ClipDescription +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import android.os.PersistableBundle +import android.widget.Toast +import com.nextcloud.talk.R + +/** + * Copies [password] to the clipboard, flagged so that Android 13 and later keep it out of the + * clipboard preview, and tells the user it was copied. + */ +fun copyPasswordToClipboard(context: Context, label: String, password: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(label, password) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + clip.description.extras = PersistableBundle().apply { + putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) + } + } + clipboard.setPrimaryClip(clip) + Toast.makeText(context, context.getString(R.string.nc_password_copied), Toast.LENGTH_SHORT).show() +} From 2a0ba13d6fc9b418366bb40a6b4a0c9a89e24606 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 8 Sep 2026 19:14:52 +0200 Subject: [PATCH 14/14] refactor(conversation-info): keep the password dialog's state in the composition Dialog visibility and the validation state were activity fields written from an observer, while the rest of the screen already collects its state inside setContent. Hold both in the composition, collecting the validator with collectAsStateWithLifecycle like uiState next to it, so the dialog stops outliving the composition that shows it. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger --- .../ConversationInfoActivity.kt | 48 +++++++------------ 1 file changed, 18 insertions(+), 30 deletions(-) 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 afb402260b..fefb54ef85 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -132,10 +132,6 @@ class ConversationInfoActivity : BaseActivity() { private var startGroupChat: Boolean = false - private var passwordValidationState: PasswordValidationState - by mutableStateOf(PasswordValidationState.None) - private var showPasswordDialog by mutableStateOf(false) - private val workerData: Data? get() { val user = conversationUser ?: return null @@ -212,10 +208,6 @@ class ConversationInfoActivity : BaseActivity() { } } - lifecycleScope.launch { - viewModel.passwordValidation.state.collect { passwordValidationState = it } - } - setupCompose() } @@ -261,32 +253,34 @@ class ConversationInfoActivity : BaseActivity() { } } + var showPasswordDialog by remember { mutableStateOf(false) } + MaterialTheme(colorScheme = colorScheme) { ColoredStatusBar() ConversationInfoScreen( state = uiState, callbacks = buildCallbacks(onShowPasswordDialog = { showPasswordDialog = true }) ) - GuestAccessPasswordDialogHost() + if (showPasswordDialog) { + val validationState by viewModel.passwordValidation.state.collectAsStateWithLifecycle() + GuestAccessPasswordDialog( + validationState = validationState, + onPasswordChanged = viewModel.passwordValidation::validate, + onDismiss = { + showPasswordDialog = false + viewModel.passwordValidation.reset() + }, + onSave = { password, copyAfterSave -> + onGuestPasswordSave(password, copyAfterSave) + showPasswordDialog = false + viewModel.passwordValidation.reset() + } + ) + } } } } - @Composable - private fun GuestAccessPasswordDialogHost() { - if (!showPasswordDialog) return - GuestAccessPasswordDialog( - validationState = passwordValidationState, - onPasswordChanged = ::onGuestPasswordChanged, - onDismiss = ::dismissGuestPasswordDialog, - onSave = ::onGuestPasswordSave - ) - } - - private fun onGuestPasswordChanged(password: String) { - viewModel.passwordValidation.validate(password) - } - private fun onGuestPasswordSave(password: String, copyAfterSave: Boolean) { val user = conversationUser ?: return if (copyAfterSave) { @@ -298,12 +292,6 @@ class ConversationInfoActivity : BaseActivity() { url = ApiUtils.getUrlForRoomPassword(apiVersion, user.baseUrl!!, conversationToken), password = password ) - dismissGuestPasswordDialog() - } - - private fun dismissGuestPasswordDialog() { - showPasswordDialog = false - viewModel.passwordValidation.reset() } private fun copyPassword(password: String) {