android: confirm password when deleting/unhiding inactive hidden user profile (#2103)

This commit is contained in:
Evgeny Poberezkin
2023-03-30 09:02:57 +01:00
committed by GitHub
parent 935d826a21
commit 4351610eca
7 changed files with 154 additions and 67 deletions

View File

@@ -439,18 +439,18 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
suspend fun apiHideUser(userId: Long, viewPwd: String): User =
setUserPrivacy(CC.ApiHideUser(userId, viewPwd))
suspend fun apiUnhideUser(userId: Long, viewPwd: String?): User =
suspend fun apiUnhideUser(userId: Long, viewPwd: String): User =
setUserPrivacy(CC.ApiUnhideUser(userId, viewPwd))
suspend fun apiMuteUser(userId: Long, viewPwd: String?): User =
setUserPrivacy(CC.ApiMuteUser(userId, viewPwd))
suspend fun apiMuteUser(userId: Long): User =
setUserPrivacy(CC.ApiMuteUser(userId))
suspend fun apiUnmuteUser(userId: Long, viewPwd: String?): User =
setUserPrivacy(CC.ApiUnmuteUser(userId, viewPwd))
suspend fun apiUnmuteUser(userId: Long): User =
setUserPrivacy(CC.ApiUnmuteUser(userId))
private suspend fun setUserPrivacy(cmd: CC): User {
val r = sendCmd(cmd)
if (r is CR.UserPrivacy) return r.user
if (r is CR.UserPrivacy) return r.updatedUser
else throw Exception("Failed to change user privacy: ${r.responseType} ${r.details}")
}
@@ -1796,9 +1796,9 @@ sealed class CC {
class ListUsers: CC()
class ApiSetActiveUser(val userId: Long, val viewPwd: String?): CC()
class ApiHideUser(val userId: Long, val viewPwd: String): CC()
class ApiUnhideUser(val userId: Long, val viewPwd: String?): CC()
class ApiMuteUser(val userId: Long, val viewPwd: String?): CC()
class ApiUnmuteUser(val userId: Long, val viewPwd: String?): CC()
class ApiUnhideUser(val userId: Long, val viewPwd: String): CC()
class ApiMuteUser(val userId: Long): CC()
class ApiUnmuteUser(val userId: Long): CC()
class ApiDeleteUser(val userId: Long, val delSMPQueues: Boolean, val viewPwd: String?): CC()
class StartChat(val expire: Boolean): CC()
class ApiStopChat: CC()
@@ -1879,9 +1879,9 @@ sealed class CC {
is ListUsers -> "/users"
is ApiSetActiveUser -> "/_user $userId${maybePwd(viewPwd)}"
is ApiHideUser -> "/_hide user $userId ${json.encodeToString(viewPwd)}"
is ApiUnhideUser -> "/_unhide user $userId${maybePwd(viewPwd)}"
is ApiMuteUser -> "/_mute user $userId${maybePwd(viewPwd)}"
is ApiUnmuteUser -> "/_unmute user $userId${maybePwd(viewPwd)}"
is ApiUnhideUser -> "/_unhide user $userId ${json.encodeToString(viewPwd)}"
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 ApiStopChat -> "/_stop"
@@ -2052,9 +2052,7 @@ sealed class CC {
is ApiStorageEncryption -> ApiStorageEncryption(DBEncryptionConfig(obfuscate(config.currentKey), obfuscate(config.newKey)))
is ApiSetActiveUser -> ApiSetActiveUser(userId, obfuscateOrNull(viewPwd))
is ApiHideUser -> ApiHideUser(userId, obfuscate(viewPwd))
is ApiUnhideUser -> ApiUnhideUser(userId, obfuscateOrNull(viewPwd))
is ApiMuteUser -> ApiMuteUser(userId, obfuscateOrNull(viewPwd))
is ApiUnmuteUser -> ApiUnmuteUser(userId, obfuscateOrNull(viewPwd))
is ApiUnhideUser -> ApiUnhideUser(userId, obfuscate(viewPwd))
is ApiDeleteUser -> ApiDeleteUser(userId, delSMPQueues, obfuscateOrNull(viewPwd))
else -> this
}
@@ -2990,7 +2988,7 @@ sealed class CR {
@Serializable @SerialName("chatCleared") class ChatCleared(val user: User, val chatInfo: ChatInfo): CR()
@Serializable @SerialName("userProfileNoChange") class UserProfileNoChange(val user: User): CR()
@Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile): CR()
@Serializable @SerialName("userPrivacy") class UserPrivacy(val user: User): CR()
@Serializable @SerialName("userPrivacy") class UserPrivacy(val user: User, val updatedUser: User): CR()
@Serializable @SerialName("contactAliasUpdated") class ContactAliasUpdated(val user: User, val toContact: Contact): CR()
@Serializable @SerialName("connectionAliasUpdated") class ConnectionAliasUpdated(val user: User, val toConnection: PendingContactConnection): CR()
@Serializable @SerialName("contactPrefsUpdated") class ContactPrefsUpdated(val user: User, val fromContact: Contact, val toContact: Contact): CR()
@@ -3200,7 +3198,7 @@ sealed class CR {
is ChatCleared -> withUser(user, json.encodeToString(chatInfo))
is UserProfileNoChange -> withUser(user, noDetails())
is UserProfileUpdated -> withUser(user, json.encodeToString(toProfile))
is UserPrivacy -> withUser(user, "")
is UserPrivacy -> withUser(user, json.encodeToString(updatedUser))
is ContactAliasUpdated -> withUser(user, json.encodeToString(toContact))
is ConnectionAliasUpdated -> withUser(user, json.encodeToString(toConnection))
is ContactPrefsUpdated -> withUser(user, "fromContact: $fromContact\ntoContact: \n${json.encodeToString(toContact)}")

View File

@@ -69,11 +69,12 @@ private fun HiddenProfileLayout(
val hidePassword = rememberSaveable { mutableStateOf("") }
val confirmHidePassword = rememberSaveable { mutableStateOf("") }
val passwordValid by remember { derivedStateOf { hidePassword.value == hidePassword.value.trim() } }
val confirmValid by remember { derivedStateOf { confirmHidePassword.value == "" || hidePassword.value == confirmHidePassword.value } }
val saveDisabled by remember { derivedStateOf { hidePassword.value == "" || confirmHidePassword.value == "" || !confirmValid } }
val saveDisabled by remember { derivedStateOf { hidePassword.value == "" || !passwordValid || confirmHidePassword.value == "" || !confirmValid } }
SectionView(stringResource(R.string.hidden_profile_password).uppercase()) {
SectionItemView {
PassphraseField(hidePassword, generalGetString(R.string.password_to_show), isValid = { true }, showStrength = true)
PassphraseField(hidePassword, generalGetString(R.string.password_to_show), isValid = { passwordValid }, showStrength = true)
}
SectionDivider()
SectionItemView {

View File

@@ -2,9 +2,11 @@ package chat.simplex.app.views.usersettings
import SectionDivider
import SectionItemView
import SectionItemViewSpaceBetween
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.annotation.StringRes
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
@@ -24,6 +26,8 @@ import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.item.ItemAction
import chat.simplex.app.views.chatlist.UserProfilePickerItem
import chat.simplex.app.views.chatlist.UserProfileRow
import chat.simplex.app.views.database.PassphraseField
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.onboarding.CreateProfile
import kotlinx.coroutines.delay
@@ -93,15 +97,28 @@ fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden:
}
},
unhideUser = { user ->
setUserPrivacy(m) { m.controller.apiUnhideUser(user.userId, userViewPassword(user, searchTextOrPassword.value)) }
if (passwordEntryRequired(user, searchTextOrPassword.value)) {
ModalManager.shared.showModalCloseable(true) { close ->
ProfileActionView(UserProfileAction.UNHIDE, user) { pwd ->
withBGApi {
setUserPrivacy(m) { m.controller.apiUnhideUser(user.userId, pwd) }
close()
}
}
}
} else {
withBGApi { setUserPrivacy(m) { m.controller.apiUnhideUser(user.userId, searchTextOrPassword.value) } }
}
},
muteUser = { user ->
setUserPrivacy(m, onSuccess = { if (m.controller.appPrefs.showMuteProfileAlert.get()) showMuteProfileAlert(m.controller.appPrefs.showMuteProfileAlert) }) {
m.controller.apiMuteUser(user.userId, userViewPassword(user, searchTextOrPassword.value))
withBGApi {
setUserPrivacy(m, onSuccess = {
if (m.controller.appPrefs.showMuteProfileAlert.get()) showMuteProfileAlert(m.controller.appPrefs.showMuteProfileAlert)
}) { m.controller.apiMuteUser(user.userId) }
}
},
unmuteUser = { user ->
setUserPrivacy(m) { m.controller.apiUnmuteUser(user.userId, userViewPassword(user, searchTextOrPassword.value)) }
withBGApi { setUserPrivacy(m) { m.controller.apiUnmuteUser(user.userId) } }
},
showHiddenProfile = { user ->
ModalManager.shared.showModalCloseable(true) { close ->
@@ -235,60 +252,129 @@ private fun UserView(
}
}
enum class UserProfileAction {
DELETE,
UNHIDE
}
@Composable
private fun ProfileActionView(action: UserProfileAction, user: User, doAction: (String) -> Unit) {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(bottom = DEFAULT_BOTTOM_PADDING),
) {
val actionPassword = rememberSaveable { mutableStateOf("") }
val passwordValid by remember { derivedStateOf { actionPassword.value == actionPassword.value.trim() } }
val actionEnabled by remember { derivedStateOf { actionPassword.value != "" && passwordValid && correctPassword(user, actionPassword.value) } }
@Composable fun ActionHeader(@StringRes title: Int) {
AppBarTitle(stringResource(title))
SectionView(padding = PaddingValues(start = 8.dp, end = DEFAULT_PADDING)) {
UserProfileRow(user)
}
SectionSpacer()
}
@Composable fun PasswordAndAction(@StringRes label: Int, color: Color = MaterialTheme.colors.primary) {
SectionView() {
SectionItemView {
PassphraseField(actionPassword, generalGetString(R.string.profile_password), isValid = { passwordValid }, showStrength = true)
}
SectionItemViewSpaceBetween({ doAction(actionPassword.value) }, disabled = !actionEnabled, minHeight = TextFieldDefaults.MinHeight) {
Text(generalGetString(label), color = if (actionEnabled) color else HighOrLowlight)
}
}
}
when (action) {
UserProfileAction.DELETE -> {
ActionHeader(R.string.delete_profile)
PasswordAndAction(R.string.delete_chat_profile, color = Color.Red)
if (actionEnabled) {
SectionTextFooter(stringResource(R.string.users_delete_all_chats_deleted))
}
}
UserProfileAction.UNHIDE -> {
ActionHeader(R.string.unhide_profile)
PasswordAndAction(R.string.unhide_chat_profile)
}
}
}
}
private fun filteredUsers(m: ChatModel, searchTextOrPassword: String): List<User> {
val s = searchTextOrPassword.trim()
val lower = s.lowercase()
return m.users.filter { u ->
if ((u.user.activeUser || u.user.viewPwdHash == null) && (s == "" || u.user.chatViewName.lowercase().contains(lower))) {
if ((u.user.activeUser || !u.user.hidden) && (s == "" || u.user.chatViewName.lowercase().contains(lower))) {
true
} else if (u.user.viewPwdHash != null) {
s != "" && chatPasswordHash(s, u.user.viewPwdHash.salt) == u.user.viewPwdHash.hash
} else {
false
correctPassword(u.user, s)
}
}.map { it.user }
}
private fun visibleUsersCount(m: ChatModel): Int = m.users.filter { u -> !u.user.hidden }.size
private fun correctPassword(user: User, pwd: String): Boolean {
val ph = user.viewPwdHash
return ph != null && pwd != "" && chatPasswordHash(pwd, ph.salt) == ph.hash
}
private fun userViewPassword(user: User, searchTextOrPassword: String): String? =
if (user.activeUser || !user.hidden) null else searchTextOrPassword
if (user.hidden) searchTextOrPassword else null
private fun passwordEntryRequired(user: User, searchTextOrPassword: String): Boolean =
user.hidden && user.activeUser && !correctPassword(user, searchTextOrPassword)
private fun removeUser(m: ChatModel, user: User, users: List<User>, delSMPQueues: Boolean, searchTextOrPassword: String) {
if (users.size < 2) return
withBGApi {
suspend fun deleteUser(user: User) {
m.controller.apiDeleteUser(user.userId, delSMPQueues, userViewPassword(user, searchTextOrPassword))
m.removeUser(user)
}
try {
if (user.activeUser) {
val newActive = users.firstOrNull { u -> !u.activeUser && !u.hidden }
if (newActive != null) {
m.controller.changeActiveUser_(newActive.userId, null)
deleteUser(user.copy(activeUser = false))
if (passwordEntryRequired(user, searchTextOrPassword)) {
ModalManager.shared.showModalCloseable(true) { close ->
ProfileActionView(UserProfileAction.DELETE, user) { pwd ->
withBGApi {
doRemoveUser(m, user, users, delSMPQueues, pwd)
close()
}
} else {
deleteUser(user)
}
} catch (e: Exception) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_deleting_user), e.stackTraceToString())
}
} else {
withBGApi { doRemoveUser(m, user, users, delSMPQueues, userViewPassword(user, searchTextOrPassword)) }
}
}
private fun setUserPrivacy(m: ChatModel, onSuccess: (() -> Unit)? = null, api: suspend () -> User) {
withBGApi {
try {
m.updateUser(api())
onSuccess?.invoke()
} catch (e: Exception) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.error_updating_user_privacy),
text = e.stackTraceToString()
)
private suspend fun doRemoveUser(m: ChatModel, user: User, users: List<User>, delSMPQueues: Boolean, viewPwd: String?) {
if (users.size < 2) return
suspend fun deleteUser(user: User) {
m.controller.apiDeleteUser(user.userId, delSMPQueues, viewPwd)
m.removeUser(user)
}
try {
if (user.activeUser) {
val newActive = users.firstOrNull { u -> !u.activeUser && !u.hidden }
if (newActive != null) {
m.controller.changeActiveUser_(newActive.userId, null)
deleteUser(user.copy(activeUser = false))
}
} else {
deleteUser(user)
}
} catch (e: Exception) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_deleting_user), e.stackTraceToString())
}
}
private suspend fun setUserPrivacy(m: ChatModel, onSuccess: (() -> Unit)? = null, api: suspend () -> User) {
try {
m.updateUser(api())
onSuccess?.invoke()
} catch (e: Exception) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.error_updating_user_privacy),
text = e.stackTraceToString()
)
}
}

View File

@@ -1056,6 +1056,11 @@
<string name="dont_show_again">Don\'t show again</string>
<string name="muted_when_inactive">Muted when inactive!</string>
<string name="you_will_still_receive_calls_and_ntfs">You will still receive calls and notifications from muted profiles when they are active.</string>
<string name="delete_profile">Delete profile</string>
<string name="delete_chat_profile">Delete chat profile</string>
<string name="unhide_profile">Unhide profile</string>
<string name="unhide_chat_profile">Unhide chat profile</string>
<string name="profile_password">Profile password</string>
<!-- Incognito mode -->
<string name="incognito">Incognito</string>

View File

@@ -176,7 +176,7 @@ func apiUnmuteUser(_ userId: Int64) async throws -> User {
func setUserPrivacy_(_ cmd: ChatCommand) async throws -> User {
let r = await chatSendCmd(cmd)
if case let .userPrivacy(user) = r { return user }
if case let .userPrivacy(_, updatedUser) = r { return updatedUser }
throw r
}

View File

@@ -182,13 +182,10 @@ struct UserProfilesView: View {
let s = searchTextOrPassword.trimmingCharacters(in: .whitespaces)
let lower = s.localizedLowercase
return m.users.filter { u in
if (u.user.activeUser || u.user.viewPwdHash == nil) && (s == "" || u.user.chatViewName.localizedLowercase.contains(lower)) {
if (u.user.activeUser || !u.user.hidden) && (s == "" || u.user.chatViewName.localizedLowercase.contains(lower)) {
return true
}
if let ph = u.user.viewPwdHash {
return s != "" && chatPasswordHash(s, ph.salt) == ph.hash
}
return false
return correctPassword(u.user, s)
}
}
@@ -214,11 +211,11 @@ struct UserProfilesView: View {
List {
switch action {
case let .deleteUser(user, delSMPQueues):
actionHeader("Delete user", user)
actionHeader("Delete profile", user)
Section {
passwordField
settingsRow("trash") {
Button("Delete user", role: .destructive) {
Button("Delete chat profile", role: .destructive) {
profileAction = nil
Task { await removeUser(user, delSMPQueues, viewPwd: actionPassword) }
}
@@ -231,11 +228,11 @@ struct UserProfilesView: View {
}
}
case let .unhideUser(user):
actionHeader("Unhide user", user)
actionHeader("Unhide profile", user)
Section {
passwordField
settingsRow("lock.open") {
Button("Unhide user") {
Button("Unhide chat profile") {
profileAction = nil
setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: actionPassword) }
}

View File

@@ -389,7 +389,7 @@ public enum ChatResponse: Decodable, Error {
case chatCleared(user: User, chatInfo: ChatInfo)
case userProfileNoChange(user: User)
case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile)
case userPrivacy(user: User)
case userPrivacy(user: User, updatedUser: User)
case contactAliasUpdated(user: User, toContact: Contact)
case connectionAliasUpdated(user: User, toConnection: PendingContactConnection)
case contactPrefsUpdated(user: User, fromContact: Contact, toContact: Contact)
@@ -611,7 +611,7 @@ public enum ChatResponse: Decodable, Error {
case let .chatCleared(u, chatInfo): return withUser(u, String(describing: chatInfo))
case .userProfileNoChange: return noDetails
case let .userProfileUpdated(u, _, toProfile): return withUser(u, String(describing: toProfile))
case let .userPrivacy(u): return withUser(u, "")
case let .userPrivacy(u, updatedUser): return withUser(u, String(describing: updatedUser))
case let .contactAliasUpdated(u, toContact): return withUser(u, String(describing: toContact))
case let .connectionAliasUpdated(u, toConnection): return withUser(u, String(describing: toConnection))
case let .contactPrefsUpdated(u, fromContact, toContact): return withUser(u, "fromContact: \(String(describing: fromContact))\ntoContact: \(String(describing: toContact))")