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..c34f3e513a 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -37,6 +37,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 @@ -56,12 +57,12 @@ 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 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 @@ -82,6 +83,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 @@ -101,8 +103,11 @@ import com.nextcloud.talk.conversationcreation.viewmodel.ConversationCreationVie 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.copyPasswordToClipboard import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.PickImage import com.nextcloud.talk.utils.bundle.BundleKeys @@ -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,20 @@ fun RoomCreationOptions(conversationCreationViewModel: ConversationCreationViewM ) } + if (isGuestsAllowed && isPasswordSet) { + ConversationOption( + icon = R.drawable.ic_content_copy, + text = R.string.nc_copy_password, + onClick = { + copyPasswordToClipboard( + context = context, + label = context.resources.getString(R.string.nc_app_product_name), + password = conversationCreationViewModel.password.value + ) + } + ) + } + ConversationOption( icon = R.drawable.baseline_format_list_bulleted_24, text = R.string.nc_open_conversation_to_registered_users, @@ -601,17 +621,20 @@ 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 = { 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 @@ -622,15 +645,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.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(16.dp)) Column( modifier = Modifier @@ -642,9 +665,10 @@ fun ShowChangePassword(onDismiss: () -> Unit, conversationCreationViewModel: Con TextButton( onClick = { conversationCreationViewModel.updatePassword(changedPassword) + conversationCreationViewModel.passwordValidation.reset() onDismiss() }, - enabled = changedPassword.isNotEmpty() && changedPassword.isNotBlank(), + enabled = changedPassword.isNotBlank() && passwordValidationState.isPasswordAccepted, contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) ) { Text(text = stringResource(id = R.string.nc_change_password)) @@ -653,6 +677,7 @@ fun ShowChangePassword(onDismiss: () -> Unit, conversationCreationViewModel: Con TextButton( onClick = { conversationCreationViewModel.updatePassword("") + conversationCreationViewModel.passwordValidation.reset() onDismiss() }, contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) @@ -664,7 +689,10 @@ fun ShowChangePassword(onDismiss: () -> Unit, conversationCreationViewModel: Con } Spacer(modifier = Modifier.height(4.dp)) TextButton( - onClick = { onDismiss() }, + onClick = { + conversationCreationViewModel.passwordValidation.reset() + onDismiss() + }, contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) ) { Text(text = stringResource(id = R.string.nc_cancel)) @@ -677,34 +705,41 @@ 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( containerColor = colorResource(id = R.color.dialog_background), onDismissRequest = onDismiss, title = { Text(text = stringResource(id = R.string.nc_set_password)) }, text = { - TextField( - value = password, - onValueChange = { + PasswordPolicyField( + password = password, + onPasswordChange = { password = it + conversationCreationViewModel.passwordValidation.validate(it) }, - label = { Text(text = stringResource(id = R.string.nc_guest_access_password_dialog_hint)) } + validationState = passwordValidationState, + label = stringResource(id = R.string.nc_guest_access_password_dialog_hint) ) }, confirmButton = { TextButton( + enabled = password.isNotBlank() && passwordValidationState.isPasswordAccepted, onClick = { - if (password.isNotEmpty() && password.isNotBlank()) { - conversationCreationViewModel.updatePassword(password) - onDismiss() - } + conversationCreationViewModel.updatePassword(password) + conversationCreationViewModel.passwordValidation.reset() + onDismiss() } ) { Text(text = stringResource(id = R.string.save)) } }, dismissButton = { - TextButton(onClick = { onDismiss() }) { + TextButton(onClick = { + conversationCreationViewModel.passwordValidation.reset() + onDismiss() + }) { Text(text = stringResource(id = R.string.nc_cancel)) } } 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/conversationcreation/viewmodel/ConversationCreationViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt index 33a8490f05..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,6 +26,8 @@ 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.passwordpolicy.PasswordPolicyValidator +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 @@ -40,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()) @@ -47,6 +50,8 @@ class ConversationCreationViewModel @Inject constructor( private val roomViewState = MutableStateFlow(RoomUIState.None) val creationState: StateFlow = roomViewState + val passwordValidation = PasswordPolicyValidator(passwordPolicyRepository, viewModelScope) { _currentUser } + private val _selectedImageUri = MutableStateFlow(null) val selectedImageUri: StateFlow = _selectedImageUri 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..fefb54ef85 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -10,16 +10,27 @@ import android.annotation.SuppressLint 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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.AlertDialog as ComposeAlertDialog import androidx.compose.material3.MaterialTheme 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.setValue +import androidx.compose.ui.Modifier +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 +65,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 @@ -70,6 +80,9 @@ import com.nextcloud.talk.models.json.participants.Participant 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 @@ -82,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 @@ -239,18 +253,57 @@ class ConversationInfoActivity : BaseActivity() { } } + var showPasswordDialog by remember { mutableStateOf(false) } + MaterialTheme(colorScheme = colorScheme) { ColoredStatusBar() ConversationInfoScreen( state = uiState, - callbacks = buildCallbacks() + callbacks = buildCallbacks(onShowPasswordDialog = { showPasswordDialog = true }) ) + 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() + } + ) + } } } } + private fun onGuestPasswordSave(password: String, copyAfterSave: Boolean) { + val user = conversationUser ?: return + if (copyAfterSave) { + copyPassword(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 + ) + } + + private fun copyPassword(password: String) { + copyPasswordToClipboard( + context = this, + label = resources.getString(R.string.nc_app_product_name), + password = password + ) + } + @Suppress("LongMethod", "CyclomaticComplexMethod") - private fun buildCallbacks() = + private fun buildCallbacks(onShowPasswordDialog: () -> Unit) = ConversationInfoScreenCallbacks( onNavigateBack = { onBackPressedDispatcher.onBackPressed() }, onEditConversation = { @@ -287,7 +340,7 @@ class ConversationInfoActivity : BaseActivity() { password = "" ) } else { - showPasswordDialog(conversationToken) + onShowPasswordDialog() } }, onResendInvitationsClick = { @@ -613,31 +666,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 +875,52 @@ class ConversationInfoActivity : BaseActivity() { private const val PARTICIPANT_TYPE_USER: Int = 3 } } + +@Composable +private fun GuestAccessPasswordDialog( + validationState: PasswordValidationState, + onPasswordChanged: (String) -> Unit, + onDismiss: () -> Unit, + onSave: (password: String, copyAfterSave: Boolean) -> Unit +) { + var password by remember { mutableStateOf("") } + val isPasswordValid = password.isNotBlank() && validationState.isPasswordAccepted + + ComposeAlertDialog( + onDismissRequest = onDismiss, + title = { Text(text = stringResource(id = R.string.nc_guest_access_password_dialog_title)) }, + text = { + 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)) { + 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)) + } + } + ) +} 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..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 @@ -37,8 +37,10 @@ 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.profile.Profile +import com.nextcloud.talk.passwordpolicy.PasswordPolicyValidator import com.nextcloud.talk.repositories.conversations.ConversationsRepository 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 @@ -74,7 +76,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 passwordPolicyRepository: PasswordPolicyRepository ) : ViewModel() { object LifeCycleObserver : DefaultLifecycleObserver { enum class LifeCycleFlag { @@ -108,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) @@ -168,6 +172,7 @@ class ConversationInfoViewModel @Inject constructor( } return uiItems } + fun getRoom(user: User, token: String) { currentUser = user currentToken = token @@ -224,6 +229,7 @@ class ConversationInfoViewModel @Inject constructor( } } } + private fun convertAutocompleteUserToParticipant(autocompleteUsers: List): Participants { val participants = Participants() autocompleteUsers.forEach { autocompleteUser -> 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/models/json/capabilities/Capabilities.kt b/app/src/main/java/com/nextcloud/talk/models/json/capabilities/Capabilities.kt index c0cad49e80..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 @@ -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 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) + constructor() : this(null, null, null, null, null, null, null, null) } 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..710b362d58 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordApi.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 PasswordApi( + @JsonField(name = ["validate"]) + var validatePasswordApi: String? +) : 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..d669bb3765 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/capabilities/PasswordPolicy.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 PasswordPolicy( + @JsonField(name = ["api"]) + var api: PasswordApi? +) : 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/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/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyField.kt b/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyField.kt new file mode 100644 index 0000000000..72d82eddcc --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyField.kt @@ -0,0 +1,67 @@ +/* + * 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.foundation.text.KeyboardOptions +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 androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +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, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + 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, 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 new file mode 100644 index 0000000000..fc4d959479 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordPolicyValidator.kt @@ -0,0 +1,82 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Sowjanya Kota + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +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 +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 + +/** + * 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 + + 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) { + _state.value = PasswordValidationState.NoPolicy + return + } + val credentials = ApiUtils.getCredentials(user.username, user.token) ?: "" + // 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) + ) + } 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 ?: "") + } + } + } + + fun reset() { + validationJob?.cancel() + _state.value = PasswordValidationState.None + } + + 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/passwordpolicy/PasswordValidationState.kt b/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordValidationState.kt new file mode 100644 index 0000000000..c88a2dca8f --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/passwordpolicy/PasswordValidationState.kt @@ -0,0 +1,32 @@ +/* + * 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 + + /** 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 +} + +/** + * Whether the server accepted the password that was last validated. + */ +val PasswordValidationState.isPasswordAccepted: Boolean + get() = when (this) { + is PasswordValidationState.Success -> result.passed == true + PasswordValidationState.NoPolicy -> true + PasswordValidationState.None, is PasswordValidationState.Error -> false + } 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..4a25a124d2 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/repositories/passwordpolicy/PasswordPolicyRepository.kt @@ -0,0 +1,20 @@ +/* + * 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.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): 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 new file mode 100644 index 0000000000..6e17aa191b --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/repositories/passwordpolicy/PasswordPolicyRepositoryImpl.kt @@ -0,0 +1,20 @@ +/* + * 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.PasswordResult +import javax.inject.Inject + +class PasswordPolicyRepositoryImpl @Inject constructor(private val ncApiCoroutines: NcApiCoroutines) : + PasswordPolicyRepository { + + 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") +} 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..cbb7fabe9f 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,16 @@ 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 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() +} 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 + ) } } 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