From e66f5d488bcc52b2725311d186d695aec12eb033 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 18 Apr 2023 13:11:00 +0300 Subject: [PATCH 1/3] android: more enhancements to layouts (#2196) * android: more enhancements to layouts * changes * unused code * unused code --- .../chat/simplex/app/views/WelcomeView.kt | 11 +- .../views/chat/group/AddGroupMembersView.kt | 12 + .../app/views/chat/group/GroupProfileView.kt | 88 ++++--- .../simplex/app/views/helpers/AlertManager.kt | 3 +- .../simplex/app/views/newchat/AddGroupView.kt | 17 +- .../app/views/onboarding/HowItWorks.kt | 5 + .../app/views/usersettings/UserProfileView.kt | 224 +++++++----------- 7 files changed, 169 insertions(+), 191 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt index 1d933ac762..cd066b9076 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt @@ -32,6 +32,8 @@ import chat.simplex.app.views.onboarding.OnboardingStage import chat.simplex.app.views.onboarding.ReadableText import com.google.accompanist.insets.navigationBarsWithImePadding import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch fun isValidDisplayName(name: String) : Boolean { return (name.firstOrNull { it.isWhitespace() }) == null && !name.startsWith("@") && !name.startsWith("#") @@ -162,7 +164,7 @@ fun ProfileNameField(name: MutableState, placeholder: String = "", isVal .onFocusChanged { focused = it.isFocused } TextField( value = name.value, - onValueChange = { name.value = it; valid = isValid(it) }, + onValueChange = { name.value = it }, modifier = if (focusRequester == null) modifier else modifier.focusRequester(focusRequester), textStyle = TextStyle(fontSize = 18.sp, color = colors.onBackground), keyboardOptions = KeyboardOptions( @@ -182,4 +184,11 @@ fun ProfileNameField(name: MutableState, placeholder: String = "", isVal errorIndicatorColor = Color.Unspecified ) ) + LaunchedEffect(Unit) { + snapshotFlow { name.value } + .distinctUntilChanged() + .collect { + valid = isValid(it) + } + } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt index 1ff21e6651..07b9318ab6 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt @@ -28,6 +28,7 @@ import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.chat.ChatInfoToolbarTitle import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.newchat.InfoAboutIncognito import chat.simplex.app.views.usersettings.SettingsActionItem @Composable @@ -37,6 +38,7 @@ fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, ch var allowModifyMembers by remember { mutableStateOf(true) } BackHandler(onBack = close) AddGroupMembersLayout( + chatModel.incognito.value, groupInfo = groupInfo, creatingGroup = creatingGroup, contactsToAdd = getContactsToAdd(chatModel), @@ -85,6 +87,7 @@ fun getContactsToAdd(chatModel: ChatModel): List { @Composable fun AddGroupMembersLayout( + chatModelIncognito: Boolean, groupInfo: GroupInfo, creatingGroup: Boolean, contactsToAdd: List, @@ -105,6 +108,14 @@ fun AddGroupMembersLayout( horizontalAlignment = Alignment.Start, ) { AppBarTitle(stringResource(R.string.button_add_members)) + InfoAboutIncognito( + chatModelIncognito, + false, + generalGetString(R.string.group_unsupported_incognito_main_profile_sent), + generalGetString(R.string.group_main_profile_sent), + true + ) + Spacer(Modifier.size(DEFAULT_PADDING)) Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center @@ -318,6 +329,7 @@ fun showProhibitedToInviteIncognitoAlertDialog() { fun PreviewAddGroupMembersLayout() { SimpleXTheme { AddGroupMembersLayout( + chatModelIncognito = false, groupInfo = GroupInfo.sampleData, creatingGroup = false, contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData), diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupProfileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupProfileView.kt index 89271baf2e..3ad913d431 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupProfileView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupProfileView.kt @@ -1,7 +1,6 @@ package chat.simplex.app.views.chat.group import android.content.res.Configuration -import android.graphics.Bitmap import android.net.Uri import androidx.compose.foundation.* import androidx.compose.foundation.layout.* @@ -24,6 +23,7 @@ import chat.simplex.app.ui.theme.* import chat.simplex.app.views.ProfileNameField import chat.simplex.app.views.helpers.* import chat.simplex.app.views.isValidDisplayName +import chat.simplex.app.views.onboarding.ReadableText import chat.simplex.app.views.usersettings.* import com.google.accompanist.insets.ProvideWindowInsets import com.google.accompanist.insets.navigationBarsWithImePadding @@ -61,7 +61,25 @@ fun GroupProfileLayout( val scope = rememberCoroutineScope() val scrollState = rememberScrollState() val focusRequester = remember { FocusRequester() } - + val dataUnchanged = + displayName.value == groupProfile.displayName && + fullName.value == groupProfile.fullName && + chosenImage.value == null + val closeWithAlert = { + if (dataUnchanged || !(displayName.value.isNotEmpty() && isValidDisplayName(displayName.value))) { + close() + } else { + showUnsavedChangesAlert({ + saveProfile( + groupProfile.copy( + displayName = displayName.value, + fullName = fullName.value, + image = profileImage.value + ) + ) + }, close) + } + } ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { ModalBottomSheetLayout( scrimColor = Color.Black.copy(alpha = 0.12F), @@ -77,23 +95,16 @@ fun GroupProfileLayout( sheetState = bottomSheetModalState, sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) ) { - ModalView(close = close) { + ModalView(close = closeWithAlert) { Column( Modifier .verticalScroll(scrollState) - .padding(horizontal = DEFAULT_PADDING), - horizontalAlignment = Alignment.Start ) { - Text( - stringResource(R.string.group_profile_is_stored_on_members_devices), - Modifier.padding(bottom = 24.dp), - color = MaterialTheme.colors.onBackground, - lineHeight = 22.sp - ) Column( - Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.Start + Modifier.fillMaxWidth() + .padding(horizontal = DEFAULT_PADDING) ) { + ReadableText(R.string.group_profile_is_stored_on_members_devices, TextAlign.Center) Box( Modifier .fillMaxWidth() @@ -102,7 +113,7 @@ fun GroupProfileLayout( ) { Box(contentAlignment = Alignment.TopEnd) { Box(contentAlignment = Alignment.Center) { - ProfileImage(192.dp, profileImage.value) + ProfileImage(108.dp, profileImage.value, color = HighOrLowlight.copy(alpha = 0.1f)) EditImageButton { scope.launch { bottomSheetModalState.show() } } } if (profileImage.value != null) { @@ -133,32 +144,29 @@ fun GroupProfileLayout( ) ProfileNameField(fullName) Spacer(Modifier.height(DEFAULT_PADDING)) - Row { - TextButton(stringResource(R.string.cancel_verb)) { - close.invoke() - } - Spacer(Modifier.padding(horizontal = 8.dp)) - val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value) - if (enabled) { - Text( - stringResource(R.string.save_group_profile), - modifier = Modifier.clickable { - saveProfile(groupProfile.copy( + val enabled = !dataUnchanged && displayName.value.isNotEmpty() && isValidDisplayName(displayName.value) + if (enabled) { + Text( + stringResource(R.string.save_group_profile), + modifier = Modifier.clickable { + saveProfile( + groupProfile.copy( displayName = displayName.value, fullName = fullName.value, image = profileImage.value - )) - }, - color = MaterialTheme.colors.primary - ) - } else { - Text( - stringResource(R.string.save_group_profile), - color = HighOrLowlight - ) - } + ) + ) + }, + color = MaterialTheme.colors.primary + ) + } else { + Text( + stringResource(R.string.save_group_profile), + color = HighOrLowlight + ) } } + Spacer(Modifier.height(DEFAULT_BOTTOM_BUTTON_PADDING)) LaunchedEffect(Unit) { @@ -171,6 +179,16 @@ fun GroupProfileLayout( } } +private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) { + AlertManager.shared.showAlertDialogStacked( + title = generalGetString(R.string.save_preferences_question), + confirmText = generalGetString(R.string.save_and_notify_group_members), + dismissText = generalGetString(R.string.exit_without_saving), + onConfirm = save, + onDismiss = revert, + ) +} + @Preview(showBackground = true) @Preview( uiMode = Configuration.UI_MODE_NIGHT_YES, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt index 03cabe4c83..97c4bfa51a 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.* import androidx.compose.ui.window.Dialog import chat.simplex.app.R @@ -134,7 +135,7 @@ class AlertManager { TextButton(onClick = { onConfirm?.invoke() hideAlert() - }) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) } + }) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified, textAlign = TextAlign.End) } } }, ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddGroupView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddGroupView.kt index a4b64107bb..2cf1a1d90e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddGroupView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddGroupView.kt @@ -1,6 +1,5 @@ package chat.simplex.app.views.newchat -import android.graphics.Bitmap import android.net.Uri import androidx.compose.foundation.* import androidx.compose.foundation.layout.* @@ -28,6 +27,7 @@ import chat.simplex.app.views.chat.group.AddGroupMembersView import chat.simplex.app.views.chatlist.setGroupMembers import chat.simplex.app.views.helpers.* import chat.simplex.app.views.isValidDisplayName +import chat.simplex.app.views.onboarding.ReadableText import chat.simplex.app.views.usersettings.DeleteImageButton import chat.simplex.app.views.usersettings.EditImageButton import com.google.accompanist.insets.ProvideWindowInsets @@ -38,7 +38,6 @@ import kotlinx.coroutines.launch @Composable fun AddGroupView(chatModel: ChatModel, close: () -> Unit) { AddGroupLayout( - chatModel.incognito.value, createGroup = { groupProfile -> withApi { val groupInfo = chatModel.controller.apiNewGroup(groupProfile) @@ -59,7 +58,7 @@ fun AddGroupView(chatModel: ChatModel, close: () -> Unit) { } @Composable -fun AddGroupLayout(chatModelIncognito: Boolean, createGroup: (GroupProfile) -> Unit, close: () -> Unit) { +fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val scope = rememberCoroutineScope() val displayName = rememberSaveable { mutableStateOf("") } @@ -91,14 +90,7 @@ fun AddGroupLayout(chatModelIncognito: Boolean, createGroup: (GroupProfile) -> U .padding(horizontal = DEFAULT_PADDING) ) { AppBarTitleCentered(stringResource(R.string.create_secret_group_title)) - Text(stringResource(R.string.group_is_decentralized), Modifier.fillMaxWidth(), textAlign = TextAlign.Center) - InfoAboutIncognito( - chatModelIncognito, - false, - generalGetString(R.string.group_unsupported_incognito_main_profile_sent), - generalGetString(R.string.group_main_profile_sent), - true - ) + ReadableText(R.string.group_is_decentralized, TextAlign.Center) Box( Modifier .fillMaxWidth() @@ -107,7 +99,7 @@ fun AddGroupLayout(chatModelIncognito: Boolean, createGroup: (GroupProfile) -> U ) { Box(contentAlignment = Alignment.TopEnd) { Box(contentAlignment = Alignment.Center) { - ProfileImage(size = 192.dp, image = profileImage.value) + ProfileImage(108.dp, image = profileImage.value) EditImageButton { scope.launch { bottomSheetModalState.show() } } } if (profileImage.value != null) { @@ -182,7 +174,6 @@ fun CreateGroupButton(color: Color, modifier: Modifier) { fun PreviewAddGroupLayout() { SimpleXTheme { AddGroupLayout( - chatModelIncognito = false, createGroup = {}, close = {} ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/HowItWorks.kt b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/HowItWorks.kt index 27f3200fe2..7df26e2d2d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/HowItWorks.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/HowItWorks.kt @@ -60,6 +60,11 @@ fun ReadableText(@StringRes stringResId: Int, textAlign: TextAlign = TextAlign.S Text(annotatedStringResource(stringResId), modifier = Modifier.padding(padding), textAlign = textAlign, lineHeight = 22.sp) } +@Composable +fun ReadableText(text: String, textAlign: TextAlign = TextAlign.Start, padding: PaddingValues = PaddingValues(bottom = 12.dp)) { + Text(text, modifier = Modifier.padding(padding), textAlign = textAlign, lineHeight = 22.sp) +} + @Preview(showBackground = true) @Preview( uiMode = Configuration.UI_MODE_NIGHT_YES, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt index a00eb73420..f02c48eadb 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt @@ -1,14 +1,10 @@ package chat.simplex.app.views.usersettings import android.content.res.Configuration -import android.graphics.Bitmap import android.net.Uri import androidx.compose.foundation.* import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.* @@ -19,8 +15,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -40,10 +34,8 @@ import kotlinx.coroutines.launch fun UserProfileView(chatModel: ChatModel, close: () -> Unit) { val user = chatModel.currentUser.value if (user != null) { - val editProfile = rememberSaveable { mutableStateOf(false) } var profile by remember { mutableStateOf(user.profile.toProfile()) } UserProfileLayout( - editProfile = editProfile, profile = profile, close, saveProfile = { displayName, fullName, image -> @@ -53,7 +45,7 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) { chatModel.updateCurrentUser(newProfile) profile = newProfile } - editProfile.value = false + close() } } ) @@ -62,7 +54,6 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) { @Composable fun UserProfileLayout( - editProfile: MutableState, profile: Profile, close: () -> Unit, saveProfile: (String, String, String?) -> Unit, @@ -92,7 +83,19 @@ fun UserProfileLayout( sheetState = bottomSheetModalState, sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) ) { - ModalView(close = close) { + val dataUnchanged = + displayName.value == profile.displayName && + fullName.value == profile.fullName && + chosenImage.value == null + + val closeWithAlert = { + if (dataUnchanged || !(displayName.value.isNotEmpty() && isValidDisplayName(displayName.value))) { + close() + } else { + showUnsavedChangesAlert({ saveProfile(displayName.value, fullName.value, profileImage.value) }, close) + } + } + ModalView(close = closeWithAlert) { Column( Modifier .verticalScroll(scrollState) @@ -100,99 +103,73 @@ fun UserProfileLayout( horizontalAlignment = Alignment.Start ) { AppBarTitleCentered(stringResource(R.string.your_current_profile)) - ReadableText(R.string.your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it, TextAlign.Center) - if (editProfile.value) { - Column( - Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.Start + val text = remember { + var t = generalGetString(R.string.your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it) + val index = t.indexOfFirst { it == '\n' } + if (index != -1) t = t.removeRange(index..index + 2) + t + } + ReadableText(text, TextAlign.Center) + Column( + Modifier + .fillMaxWidth() + ) { + Box( + Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + contentAlignment = Alignment.Center ) { - Box( - Modifier - .fillMaxWidth() - .padding(bottom = 24.dp), - contentAlignment = Alignment.Center - ) { - Box(contentAlignment = Alignment.TopEnd) { - Box(contentAlignment = Alignment.Center) { - ProfileImage(192.dp, profileImage.value) - EditImageButton { scope.launch { bottomSheetModalState.show() } } - } - if (profileImage.value != null) { - DeleteImageButton { profileImage.value = null } - } + Box(contentAlignment = Alignment.TopEnd) { + Box(contentAlignment = Alignment.Center) { + ProfileImage(108.dp, profileImage.value, color = HighOrLowlight.copy(alpha = 0.1f)) + EditImageButton { scope.launch { bottomSheetModalState.show() } } + } + if (profileImage.value != null) { + DeleteImageButton { profileImage.value = null } } } - Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text( - stringResource(R.string.display_name__field), - fontSize = 16.sp - ) - if (!isValidDisplayName(displayName.value)) { - Spacer(Modifier.size(DEFAULT_PADDING_HALF)) - Text( - stringResource(R.string.no_spaces), - fontSize = 16.sp, - color = Color.Red - ) - } - } - ProfileNameField(displayName, "", ::isValidDisplayName, focusRequester) - Spacer(Modifier.height(DEFAULT_PADDING)) + } + Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text( - stringResource(R.string.full_name__field), - fontSize = 16.sp, - modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF) + stringResource(R.string.display_name__field), + fontSize = 16.sp ) - ProfileNameField(fullName) - Spacer(Modifier.height(DEFAULT_PADDING)) - Row { - TextButton(stringResource(R.string.cancel_verb)) { - displayName.value = profile.displayName - fullName.value = profile.fullName - profileImage.value = profile.image - editProfile.value = false - } - Spacer(Modifier.padding(horizontal = 8.dp)) - val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value) - val saveModifier: Modifier - val saveColor: Color - if (enabled) { - saveModifier = Modifier - .clickable { saveProfile(displayName.value, fullName.value, profileImage.value) } - saveColor = MaterialTheme.colors.primary - } else { - saveModifier = Modifier - saveColor = HighOrLowlight - } + if (!isValidDisplayName(displayName.value)) { + Spacer(Modifier.size(DEFAULT_PADDING_HALF)) Text( - stringResource(R.string.save_and_notify_contacts), - modifier = saveModifier, - color = saveColor + stringResource(R.string.no_spaces), + fontSize = 16.sp, + color = Color.Red ) } } - } else { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.Start - ) { - Box( - Modifier - .fillMaxWidth() - .padding(bottom = 24.dp), contentAlignment = Alignment.Center - ) { - ProfileImage(192.dp, profile.image) - if (profile.image == null) { - EditImageButton { - editProfile.value = true - scope.launch { bottomSheetModalState.show() } - } - } - } - ProfileNameRow(stringResource(R.string.display_name__field), profile.displayName) - ProfileNameRow(stringResource(R.string.full_name__field), profile.fullName) - TextButton(stringResource(R.string.edit_verb)) { editProfile.value = true } + ProfileNameField(displayName, "", ::isValidDisplayName, focusRequester) + Spacer(Modifier.height(DEFAULT_PADDING)) + Text( + stringResource(R.string.full_name__field), + fontSize = 16.sp, + modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF) + ) + ProfileNameField(fullName) + + Spacer(Modifier.height(DEFAULT_PADDING)) + val enabled = !dataUnchanged && displayName.value.isNotEmpty() && isValidDisplayName(displayName.value) + val saveModifier: Modifier + val saveColor: Color + if (enabled) { + saveModifier = Modifier + .clickable { saveProfile(displayName.value, fullName.value, profileImage.value) } + saveColor = MaterialTheme.colors.primary + } else { + saveModifier = Modifier + saveColor = HighOrLowlight } + Text( + stringResource(R.string.save_and_notify_contacts), + modifier = saveModifier, + color = saveColor + ) } Spacer(Modifier.height(DEFAULT_BOTTOM_BUTTON_PADDING)) if (savedKeyboardState != keyboardState) { @@ -209,60 +186,17 @@ fun UserProfileLayout( } } -@Composable -fun ProfileNameTextField(name: MutableState) { - BasicTextField( - value = name.value, - onValueChange = { name.value = it }, - modifier = Modifier - .padding(bottom = 24.dp) - .padding(start = 28.dp) - .fillMaxWidth(), - textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground), - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrect = false - ), - singleLine = true - ) -} - -@Composable -fun ProfileNameRow(label: String, text: String) { - Row(Modifier.padding(bottom = 24.dp)) { - Text( - label, - color = MaterialTheme.colors.onBackground - ) - Spacer(Modifier.padding(horizontal = 4.dp)) - Text( - text, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colors.onBackground - ) - } -} - -@Composable -fun TextButton(text: String, click: () -> Unit) { - Text( - text, - color = MaterialTheme.colors.primary, - modifier = Modifier.clickable(onClick = click), - ) -} - @Composable fun EditImageButton(click: () -> Unit) { IconButton( onClick = click, - modifier = Modifier.background(Color(1f, 1f, 1f, 0.2f), shape = CircleShape) + modifier = Modifier.size(30.dp) ) { Icon( Icons.Outlined.PhotoCamera, contentDescription = stringResource(R.string.edit_image), tint = MaterialTheme.colors.primary, - modifier = Modifier.size(36.dp) + modifier = Modifier.size(30.dp) ) } } @@ -278,6 +212,16 @@ fun DeleteImageButton(click: () -> Unit) { } } +private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) { + AlertManager.shared.showAlertDialogStacked( + title = generalGetString(R.string.save_preferences_question), + confirmText = generalGetString(R.string.save_and_notify_contacts), + dismissText = generalGetString(R.string.exit_without_saving), + onConfirm = save, + onDismiss = revert, + ) +} + @Preview(showBackground = true) @Preview( uiMode = Configuration.UI_MODE_NIGHT_YES, @@ -290,7 +234,6 @@ fun PreviewUserProfileLayoutEditOff() { UserProfileLayout( profile = Profile.sampleData, close = {}, - editProfile = remember { mutableStateOf(false) }, saveProfile = { _, _, _ -> } ) } @@ -308,7 +251,6 @@ fun PreviewUserProfileLayoutEditOn() { UserProfileLayout( profile = Profile.sampleData, close = {}, - editProfile = remember { mutableStateOf(true) }, saveProfile = { _, _, _ -> } ) } From e51f7a51cc953de642c3c66071145e1f53609847 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 18 Apr 2023 18:51:14 +0400 Subject: [PATCH 2/3] core: prohibit SMP file handshake for XFTP files on sender side, except for images and voice messages (#2201) --- src/Simplex/Chat.hs | 23 +++++++++++++++++++---- src/Simplex/Chat/Controller.hs | 1 + src/Simplex/Chat/View.hs | 1 + 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index a3970eb4b0..7c960a0995 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -3380,6 +3380,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do xFileAcptInv ct sharedMsgId fileConnReq_ fName msgMeta = do checkIntegrityCreateItem (CDDirectRcv ct) msgMeta fileId <- withStore $ \db -> getDirectFileIdBySharedMsgId db user ct sharedMsgId + (AChatItem _ _ _ ci) <- withStore $ \db -> getChatItemByFileId db user fileId + assertSMPAcceptNotProhibited ci ft@FileTransferMeta {fileName, fileSize, fileInline, cancelled} <- withStore (\db -> getFileTransferMeta db user fileId) -- [async agent commands] no continuation needed, but command should be asynchronous for stability if fName == fileName @@ -3391,9 +3393,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- receiving inline _ -> do event <- withStore $ \db -> do - ci <- updateDirectCIFileStatus db user fileId $ CIFSSndTransfer 0 1 + ci' <- updateDirectCIFileStatus db user fileId $ CIFSSndTransfer 0 1 sft <- liftIO $ createSndDirectInlineFT db ct ft - pure $ CRSndFileStart user ci sft + pure $ CRSndFileStart user ci' sft toView event ifM (allowSendInline fileSize fileInline) @@ -3401,6 +3403,17 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do (messageError "x.file.acpt.inv: fileSize is bigger than allowed to send inline") else messageError "x.file.acpt.inv: fileName is different from expected" + assertSMPAcceptNotProhibited :: ChatItem c d -> m () + assertSMPAcceptNotProhibited ChatItem {file = Just CIFile {fileId, fileProtocol}, content} + | fileProtocol == FPXFTP && not (imageOrVoice content) = throwChatError $ CEFallbackToSMPProhibited fileId + | otherwise = pure () + where + imageOrVoice :: CIContent d -> Bool + imageOrVoice (CISndMsgContent (MCImage _ _)) = True + imageOrVoice (CISndMsgContent (MCVoice _ _)) = True + imageOrVoice _ = False + assertSMPAcceptNotProhibited _ = pure () + checkSndInlineFTComplete :: Connection -> AgentMsgId -> m () checkSndInlineFTComplete conn agentMsgId = do sft_ <- withStore' $ \db -> getSndFTViaMsgDelivery db user conn agentMsgId @@ -3463,6 +3476,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do xFileAcptInvGroup g@GroupInfo {groupId} m@GroupMember {activeConn} sharedMsgId fileConnReq_ fName msgMeta = do checkIntegrityCreateItem (CDGroupRcv g m) msgMeta fileId <- withStore $ \db -> getGroupFileIdBySharedMsgId db userId groupId sharedMsgId + (AChatItem _ _ _ ci) <- withStore $ \db -> getChatItemByFileId db user fileId + assertSMPAcceptNotProhibited ci -- TODO check that it's not already accepted ft@FileTransferMeta {fileName, fileSize, fileInline, cancelled} <- withStore (\db -> getFileTransferMeta db user fileId) if fName == fileName @@ -3475,9 +3490,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do (_, Just conn) -> do -- receiving inline event <- withStore $ \db -> do - ci <- updateDirectCIFileStatus db user fileId $ CIFSSndTransfer 0 1 + ci' <- updateDirectCIFileStatus db user fileId $ CIFSSndTransfer 0 1 sft <- liftIO $ createSndGroupInlineFT db m conn ft - pure $ CRSndFileStart user ci sft + pure $ CRSndFileStart user ci' sft toView event ifM (allowSendInline fileSize fileInline) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index c71a99ab9f..e90d6fcaca 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -795,6 +795,7 @@ data ChatErrorType | CEFileNotReceived {fileId :: FileTransferId} | CEXFTPRcvFile {fileId :: FileTransferId, agentRcvFileId :: AgentRcvFileId, agentError :: AgentErrorType} | CEXFTPSndFile {fileId :: FileTransferId, agentSndFileId :: AgentSndFileId, agentError :: AgentErrorType} + | CEFallbackToSMPProhibited {fileId :: FileTransferId} | CEInlineFileProhibited {fileId :: FileTransferId} | CEInvalidQuote | CEInvalidChatItemUpdate diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 06a775f9e9..93b946bf9b 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1305,6 +1305,7 @@ viewChatError logLevel = \case CEFileNotReceived fileId -> ["file " <> sShow fileId <> " not received"] CEXFTPRcvFile fileId aFileId e -> ["error receiving XFTP file " <> sShow fileId <> ", agent file id " <> sShow aFileId <> ": " <> sShow e | logLevel == CLLError] CEXFTPSndFile fileId aFileId e -> ["error sending XFTP file " <> sShow fileId <> ", agent file id " <> sShow aFileId <> ": " <> sShow e | logLevel == CLLError] + CEFallbackToSMPProhibited fileId -> ["recipient tried to accept file " <> sShow fileId <> " via old protocol, prohibited"] CEInlineFileProhibited _ -> ["A small file sent without acceptance - you can enable receiving such files with -f option."] CEInvalidQuote -> ["cannot reply to this message"] CEInvalidChatItemUpdate -> ["cannot update this item"] From ddd8e719efb86625636dceae70952c7d73f7e090 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 18 Apr 2023 19:43:16 +0400 Subject: [PATCH 3/3] core, ios: don't send/receive XFTP files in NSE (#2202) --- .../main/java/chat/simplex/app/model/SimpleXAPI.kt | 2 +- apps/ios/Shared/Model/SimpleXAPI.swift | 2 +- apps/ios/SimpleX NSE/NotificationService.swift | 5 ++++- apps/ios/SimpleXChat/APITypes.swift | 4 ++-- src/Simplex/Chat.hs | 14 +++++++------- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Core.hs | 2 +- 7 files changed, 17 insertions(+), 14 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index f79389e91e..c8eaccb572 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -1918,7 +1918,7 @@ sealed class CC { is ApiMuteUser -> "/_mute user $userId" is ApiUnmuteUser -> "/_unmute user $userId" is ApiDeleteUser -> "/_delete user $userId del_smp=${onOff(delSMPQueues)}${maybePwd(viewPwd)}" - is StartChat -> "/_start subscribe=on expire=${onOff(expire)}" + is StartChat -> "/_start subscribe=on expire=${onOff(expire)} xftp=on" is ApiStopChat -> "/_stop" is SetTempFolder -> "/_temp_folder $tempFolder" is SetFilesFolder -> "/_files_folder $filesFolder" diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index c8a3ba5316..5906c45b1f 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -187,7 +187,7 @@ func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) asyn } func apiStartChat() throws -> Bool { - let r = chatSendCmdSync(.startChat(subscribe: true, expire: true)) + let r = chatSendCmdSync(.startChat(subscribe: true, expire: true, xftp: true)) switch r { case .chatStarted: return true case .chatRunning: return false diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 5a015d9e5b..6a941c749c 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -273,18 +273,21 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? { } if case .image = cItem.content.msgContent { if let file = cItem.file, + file.fileProtocol == .smp, file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV, privacyAcceptImagesGroupDefault.get() { cItem = apiReceiveFile(fileId: file.fileId)?.chatItem ?? cItem } } else if case .video = cItem.content.msgContent { if let file = cItem.file, + file.fileProtocol == .smp, file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV, privacyAcceptImagesGroupDefault.get() { cItem = apiReceiveFile(fileId: file.fileId)?.chatItem ?? cItem } } else if case .voice = cItem.content.msgContent { // TODO check inlineFileMode != IFMSent if let file = cItem.file, + file.fileProtocol == .smp, file.fileSize <= MAX_IMAGE_SIZE, file.fileSize > MAX_VOICE_MESSAGE_SIZE_INLINE_SEND, privacyAcceptImagesGroupDefault.get() { @@ -343,7 +346,7 @@ func apiGetActiveUser() -> User? { } func apiStartChat() throws -> Bool { - let r = sendSimpleXCmd(.startChat(subscribe: false, expire: false)) + let r = sendSimpleXCmd(.startChat(subscribe: false, expire: false, xftp: false)) switch r { case .chatStarted: return true case .chatRunning: return false diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index a672081e37..c13f338332 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -22,7 +22,7 @@ public enum ChatCommand { case apiMuteUser(userId: Int64) case apiUnmuteUser(userId: Int64) case apiDeleteUser(userId: Int64, delSMPQueues: Bool, viewPwd: String?) - case startChat(subscribe: Bool, expire: Bool) + case startChat(subscribe: Bool, expire: Bool, xftp: Bool) case apiStopChat case apiActivateChat case apiSuspendChat(timeoutMicroseconds: Int) @@ -116,7 +116,7 @@ public enum ChatCommand { case let .apiMuteUser(userId): return "/_mute user \(userId)" case let .apiUnmuteUser(userId): return "/_unmute user \(userId)" case let .apiDeleteUser(userId, delSMPQueues, viewPwd): return "/_delete user \(userId) del_smp=\(onOff(delSMPQueues))\(maybePwd(viewPwd))" - case let .startChat(subscribe, expire): return "/_start subscribe=\(onOff(subscribe)) expire=\(onOff(expire))" + case let .startChat(subscribe, expire, xftp): return "/_start subscribe=\(onOff(subscribe)) expire=\(onOff(expire)) xftp=\(onOff(xftp))" case .apiStopChat: return "/_stop" case .apiActivateChat: return "/_app activate" case let .apiSuspendChat(timeoutMicroseconds): return "/_app suspend \(timeoutMicroseconds)" diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 7c960a0995..0efe7924c0 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -210,8 +210,8 @@ cfgServers = \case SPSMP -> smp SPXFTP -> xftp -startChatController :: forall m. ChatMonad' m => Bool -> Bool -> m (Async ()) -startChatController subConns enableExpireCIs = do +startChatController :: forall m. ChatMonad' m => Bool -> Bool -> Bool -> m (Async ()) +startChatController subConns enableExpireCIs startXFTPWorkers = do asks smpAgent >>= resumeAgentClient users <- fromRight [] <$> runExceptT (withStore' getUsers) restoreCalls @@ -225,7 +225,7 @@ startChatController subConns enableExpireCIs = do then Just <$> async (subscribeUsers users) else pure Nothing atomically . writeTVar s $ Just (a1, a2) - startXFTP + when startXFTPWorkers startXFTP startCleanupManager when enableExpireCIs $ startExpireCIs users pure a1 @@ -380,10 +380,10 @@ processChatCommand = \case checkDeleteChatUser user' withChatLock "deleteUser" . procCmd $ deleteChatUser user' delSMPQueues DeleteUser uName delSMPQueues viewPwd_ -> withUserName uName $ \userId -> APIDeleteUser userId delSMPQueues viewPwd_ - StartChat subConns enableExpireCIs -> withUser' $ \_ -> + StartChat subConns enableExpireCIs startXFTPWorkers -> withUser' $ \_ -> asks agentAsync >>= readTVarIO >>= \case Just _ -> pure CRChatRunning - _ -> checkStoreNotChanged $ startChatController subConns enableExpireCIs $> CRChatStarted + _ -> checkStoreNotChanged $ startChatController subConns enableExpireCIs startXFTPWorkers $> CRChatStarted APIStopChat -> do ask >>= stopChatController pure CRChatStopped @@ -4510,8 +4510,8 @@ chatCommandP = "/_delete user " *> (APIDeleteUser <$> A.decimal <* " del_smp=" <*> onOffP <*> optional (A.space *> jsonP)), "/delete user " *> (DeleteUser <$> displayName <*> pure True <*> optional (A.space *> pwdP)), ("/user" <|> "/u") $> ShowActiveUser, - "/_start subscribe=" *> (StartChat <$> onOffP <* " expire=" <*> onOffP), - "/_start" $> StartChat True True, + "/_start subscribe=" *> (StartChat <$> onOffP <* " expire=" <*> onOffP <* " xftp=" <*> onOffP), + "/_start" $> StartChat True True True, "/_stop" $> APIStopChat, "/_app activate" $> APIActivateChat, "/_app suspend " *> (APISuspendChat <$> A.decimal), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index e90d6fcaca..e345dcf478 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -194,7 +194,7 @@ data ChatCommand | UnmuteUser | APIDeleteUser UserId Bool (Maybe UserPwd) | DeleteUser UserName Bool (Maybe UserPwd) - | StartChat {subscribeConnections :: Bool, enableExpireChatItems :: Bool} + | StartChat {subscribeConnections :: Bool, enableExpireChatItems :: Bool, startXFTPWorkers :: Bool} | APIStopChat | APIActivateChat | APISuspendChat {suspendTimeout :: Int} diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index a62ab5642b..e23dbc5a96 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -35,7 +35,7 @@ runSimplexChat :: ChatOpts -> User -> ChatController -> (User -> ChatController runSimplexChat ChatOpts {maintenance} u cc chat | maintenance = wait =<< async (chat u cc) | otherwise = do - a1 <- runReaderT (startChatController True True) cc + a1 <- runReaderT (startChatController True True True) cc a2 <- async $ chat u cc waitEither_ a1 a2