android: confirm password when deleting/unhiding inactive hidden user profile (#2103)
This commit is contained in:
committed by
GitHub
parent
935d826a21
commit
4351610eca
@@ -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)}")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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))")
|
||||
|
||||
Reference in New Issue
Block a user