android: hidden and muted user profiles (#2069)

* android: hidden and muted user profiles

* swap buttons

* smaller delay

* remove unused type

* some fixes of issues

* small visual changes

* removed delay

* re-appeared calls

* update icons and colors

* disable all notifications for muted users

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
Stanislav Dmitrenko
2023-03-25 00:48:34 +03:00
committed by GitHub
parent b665dce383
commit a266bcbae7
16 changed files with 577 additions and 159 deletions

View File

@@ -30,6 +30,7 @@ extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated
extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
extern char *chat_parse_markdown(const char *str);
extern char *chat_parse_server(const char *str);
extern char *chat_password_hash(const char *pwd, const char *salt);
JNIEXPORT jobjectArray JNICALL
Java_chat_simplex_app_SimplexAppKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey) {
@@ -85,3 +86,13 @@ Java_chat_simplex_app_SimplexAppKt_chatParseServer(JNIEnv *env, __unused jclass
(*env)->ReleaseStringUTFChars(env, str, _str);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_app_SimplexAppKt_chatPasswordHash(JNIEnv *env, __unused jclass clazz, jstring pwd, jstring salt) {
const char *_pwd = (*env)->GetStringUTFChars(env, pwd, JNI_FALSE);
const char *_salt = (*env)->GetStringUTFChars(env, salt, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_password_hash(_pwd, _salt));
(*env)->ReleaseStringUTFChars(env, pwd, _pwd);
(*env)->ReleaseStringUTFChars(env, salt, _salt);
return res;
}

View File

@@ -402,7 +402,7 @@ fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
if (chatId != null) {
withBGApi {
if (userId != null && userId != chatModel.currentUser.value?.userId) {
chatModel.controller.changeActiveUser(userId)
chatModel.controller.changeActiveUser(userId, null)
}
val cInfo = chatModel.getChat(chatId)?.chatInfo
chatModel.clearOverlays.value = true
@@ -414,7 +414,7 @@ fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
Log.d(TAG, "processNotificationIntent: ShowChatsAction")
withBGApi {
if (userId != null && userId != chatModel.currentUser.value?.userId) {
chatModel.controller.changeActiveUser(userId)
chatModel.controller.changeActiveUser(userId, null)
}
chatModel.chatId.value = null
chatModel.clearOverlays.value = true

View File

@@ -32,6 +32,7 @@ external fun chatRecvMsg(ctrl: ChatCtrl): String
external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
external fun chatParseMarkdown(str: String): String
external fun chatParseServer(str: String): String
external fun chatPasswordHash(pwd: String, salt: String): String
class SimplexApp: Application(), LifecycleEventObserver {
lateinit var chatController: ChatController

View File

@@ -94,6 +94,32 @@ class ChatModel(val controller: ChatController) {
val filesToDelete = mutableSetOf<File>()
val simplexLinkMode = mutableStateOf(controller.appPrefs.simplexLinkMode.get())
fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) {
currentUser.value
} else {
users.firstOrNull { it.user.userId == userId }?.user
}
private fun getUserIndex(user: User): Int =
users.indexOfFirst { it.user.userId == user.userId }
fun updateUser(user: User) {
val i = getUserIndex(user)
if (i != -1) {
users[i] = users[i].copy(user = user)
}
if (currentUser.value?.userId == user.userId) {
currentUser.value = user
}
}
fun removeUser(user: User) {
val i = getUserIndex(user)
if (i != -1 && users[i].user.userId != currentUser.value?.userId) {
users.removeAt(i)
}
}
fun hasChat(id: String): Boolean = chats.firstOrNull { it.id == id } != null
fun getChat(id: String): Chat? = chats.firstOrNull { it.id == id }
fun getContactChat(contactId: Long): Chat? = chats.firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId }
@@ -422,13 +448,19 @@ data class User(
val localDisplayName: String,
val profile: LocalProfile,
val fullPreferences: FullChatPreferences,
val activeUser: Boolean
val activeUser: Boolean,
val showNtfs: Boolean,
val viewPwdHash: UserPwdHash?
): NamedChat {
override val displayName: String get() = profile.displayName
override val fullName: String get() = profile.fullName
override val image: String? get() = profile.image
override val localAlias: String = ""
val hidden: Boolean = viewPwdHash != null
val showNotifications: Boolean = activeUser || showNtfs
companion object {
val sampleData = User(
userId = 1,
@@ -436,11 +468,19 @@ data class User(
localDisplayName = "alice",
profile = LocalProfile.sampleData,
fullPreferences = FullChatPreferences.sampleData,
activeUser = true
activeUser = true,
showNtfs = true,
viewPwdHash = null,
)
}
}
@Serializable
data class UserPwdHash(
val hash: String,
val salt: String
)
@Serializable
data class UserInfo(
val user: User,

View File

@@ -80,7 +80,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
}
fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest) {
notifyMessageReceived(
displayNotification(
user = user,
chatId = cInfo.id,
displayName = cInfo.displayName,
@@ -91,7 +91,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
}
fun notifyContactConnected(user: User, contact: Contact) {
notifyMessageReceived(
displayNotification(
user = user,
chatId = contact.id,
displayName = contact.displayName,
@@ -101,11 +101,11 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) {
if (!cInfo.ntfsEnabled) return
notifyMessageReceived(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
displayNotification(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
}
fun notifyMessageReceived(user: User, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List<NotificationAction> = emptyList()) {
fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List<NotificationAction> = emptyList()) {
if (!user.showNotifications) return
Log.d(TAG, "notifyMessageReceived $chatId")
val now = Clock.System.now().toEpochMilliseconds()
val recentNotification = (now - prevNtfTime.getOrDefault(chatId, 0) < msgNtfTimeoutMs)

View File

@@ -133,6 +133,8 @@ class AppPreferences(val context: Context) {
val incognito = mkBoolPreference(SHARED_PREFS_INCOGNITO, false)
val connectViaLinkTab = mkStrPreference(SHARED_PREFS_CONNECT_VIA_LINK_TAB, ConnectViaLinkTab.SCAN.name)
val liveMessageAlertShown = mkBoolPreference(SHARED_PREFS_LIVE_MESSAGE_ALERT_SHOWN, false)
val showHiddenProfilesNotice = mkBoolPreference(SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE, true)
val showMuteProfileAlert = mkBoolPreference(SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT, true)
val appLanguage = mkStrPreference(SHARED_PREFS_APP_LANGUAGE, null)
val storeDBPassphrase = mkBoolPreference(SHARED_PREFS_STORE_DB_PASSPHRASE, true)
@@ -233,6 +235,8 @@ class AppPreferences(val context: Context) {
private const val SHARED_PREFS_INCOGNITO = "Incognito"
private const val SHARED_PREFS_CONNECT_VIA_LINK_TAB = "ConnectViaLinkTab"
private const val SHARED_PREFS_LIVE_MESSAGE_ALERT_SHOWN = "LiveMessageAlertShown"
private const val SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE = "ShowHiddenProfilesNotice"
private const val SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT = "ShowMuteProfileAlert"
private const val SHARED_PREFS_STORE_DB_PASSPHRASE = "StoreDBPassphrase"
private const val SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE = "InitialRandomDBPassphrase"
private const val SHARED_PREFS_ENCRYPTED_DB_PASSPHRASE = "EncryptedDBPassphrase"
@@ -261,6 +265,12 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
chatModel.incognito.value = appPrefs.incognito.get()
}
private fun currentUserId(funcName: String): Long {
val error = "$funcName: no current user"
Log.e(TAG, error)
return chatModel.currentUser.value?.userId ?: throw Exception(error)
}
suspend fun startChat(user: User) {
Log.d(TAG, "user: $user")
try {
@@ -292,21 +302,26 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
}
suspend fun changeActiveUser(toUserId: Long) {
suspend fun changeActiveUser(toUserId: Long, viewPwd: String?) {
try {
changeActiveUser_(toUserId)
changeActiveUser_(toUserId, viewPwd)
} catch (e: Exception) {
Log.e(TAG, "Unable to set active user: ${e.stackTraceToString()}")
AlertManager.shared.showAlertMsg(generalGetString(R.string.failed_to_active_user_title), e.stackTraceToString())
}
}
suspend fun changeActiveUser_(toUserId: Long) {
chatModel.currentUser.value = apiSetActiveUser(toUserId)
suspend fun changeActiveUser_(toUserId: Long, viewPwd: String?) {
val currentUser = apiSetActiveUser(toUserId, viewPwd)
chatModel.currentUser.value = currentUser
val users = listUsers()
chatModel.users.clear()
chatModel.users.addAll(users)
getUserChatData()
val invitation = chatModel.callInvitations.values.firstOrNull { inv -> inv.user.userId == toUserId }
if (invitation != null) {
chatModel.callManager.reportNewIncomingCall(invitation.copy(user = currentUser))
}
}
suspend fun getUserChatData() {
@@ -403,15 +418,33 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
throw Exception("failed to list users ${r.responseType} ${r.details}")
}
suspend fun apiSetActiveUser(userId: Long): User {
val r = sendCmd(CC.ApiSetActiveUser(userId))
suspend fun apiSetActiveUser(userId: Long, viewPwd: String?): User {
val r = sendCmd(CC.ApiSetActiveUser(userId, viewPwd))
if (r is CR.ActiveUser) return r.user
Log.d(TAG, "apiSetActiveUser: ${r.responseType} ${r.details}")
throw Exception("failed to set the user as active ${r.responseType} ${r.details}")
}
suspend fun apiDeleteUser(userId: Long, delSMPQueues: Boolean) {
val r = sendCmd(CC.ApiDeleteUser(userId, delSMPQueues))
suspend fun apiHideUser(userId: Long, viewPwd: String): User =
setUserPrivacy(CC.ApiHideUser(userId, viewPwd))
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 apiUnmuteUser(userId: Long, viewPwd: String?): User =
setUserPrivacy(CC.ApiUnmuteUser(userId, viewPwd))
private suspend fun setUserPrivacy(cmd: CC): User {
val r = sendCmd(cmd)
if (r is CR.UserPrivacy) return r.user
else throw Exception("Failed to change user privacy: ${r.responseType} ${r.details}")
}
suspend fun apiDeleteUser(userId: Long, delSMPQueues: Boolean, viewPwd: String?) {
val r = sendCmd(CC.ApiDeleteUser(userId, delSMPQueues, viewPwd))
if (r is CR.CmdOk) return
Log.d(TAG, "apiDeleteUser: ${r.responseType} ${r.details}")
throw Exception("failed to delete the user ${r.responseType} ${r.details}")
@@ -472,10 +505,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
suspend fun apiGetChats(): List<Chat> {
val userId = chatModel.currentUser.value?.userId ?: run {
Log.e(TAG, "apiGetChats: no current user")
return emptyList()
}
val userId = kotlin.runCatching { currentUserId("apiGetChats") }.getOrElse { return emptyList() }
val r = sendCmd(CC.ApiGetChats(userId))
if (r is CR.ApiChats) return r.chats
Log.e(TAG, "failed getting the list of chats: ${r.responseType} ${r.details}")
@@ -527,10 +557,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
private suspend fun getUserSMPServers(): Pair<List<ServerCfg>, List<String>>? {
val userId = chatModel.currentUser.value?.userId ?: run {
Log.e(TAG, "getUserSMPServers: no current user")
return null
}
val userId = kotlin.runCatching { currentUserId("getUserSMPServers") }.getOrElse { return null }
val r = sendCmd(CC.APIGetUserSMPServers(userId))
if (r is CR.UserSMPServers) return r.smpServers to r.presetSMPServers
Log.e(TAG, "getUserSMPServers bad response: ${r.responseType} ${r.details}")
@@ -538,10 +565,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
suspend fun setUserSMPServers(smpServers: List<ServerCfg>): Boolean {
val userId = chatModel.currentUser.value?.userId ?: run {
Log.e(TAG, "setUserSMPServers: no current user")
return false
}
val userId = kotlin.runCatching { currentUserId("setUserSMPServers") }.getOrElse { return false }
val r = sendCmd(CC.APISetUserSMPServers(userId, smpServers))
return when (r) {
is CR.CmdOk -> true
@@ -557,7 +581,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
suspend fun testSMPServer(smpServer: String): SMPTestFailure? {
val userId = chatModel.currentUser.value?.userId ?: run { throw Exception("testSMPServer: no current user") }
val userId = currentUserId("testSMPServer")
val r = sendCmd(CC.APITestSMPServer(userId, smpServer))
return when (r) {
is CR.SmpTestResult -> r.smpTestFailure
@@ -569,14 +593,14 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
suspend fun getChatItemTTL(): ChatItemTTL {
val userId = chatModel.currentUser.value?.userId ?: run { throw Exception("getChatItemTTL: no current user") }
val userId = currentUserId("getChatItemTTL")
val r = sendCmd(CC.APIGetChatItemTTL(userId))
if (r is CR.ChatItemTTL) return ChatItemTTL.fromSeconds(r.chatItemTTL)
throw Exception("failed to get chat item TTL: ${r.responseType} ${r.details}")
}
suspend fun setChatItemTTL(chatItemTTL: ChatItemTTL) {
val userId = chatModel.currentUser.value?.userId ?: run { throw Exception("setChatItemTTL: no current user") }
val userId = currentUserId("setChatItemTTL")
val r = sendCmd(CC.APISetChatItemTTL(userId, chatItemTTL.seconds))
if (r is CR.CmdOk) return
throw Exception("failed to set chat item TTL: ${r.responseType} ${r.details}")
@@ -760,10 +784,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
suspend fun apiListContacts(): List<Contact>? {
val userId = chatModel.currentUser.value?.userId ?: run {
Log.e(TAG, "apiListContacts: no current user")
return null
}
val userId = kotlin.runCatching { currentUserId("apiListContacts") }.getOrElse { return null }
val r = sendCmd(CC.ApiListContacts(userId))
if (r is CR.ContactsList) return r.contacts
Log.e(TAG, "apiListContacts bad response: ${r.responseType} ${r.details}")
@@ -771,10 +792,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
suspend fun apiUpdateProfile(profile: Profile): Profile? {
val userId = chatModel.currentUser.value?.userId ?: run {
Log.e(TAG, "apiUpdateProfile: no current user")
return null
}
val userId = kotlin.runCatching { currentUserId("apiUpdateProfile") }.getOrElse { return null }
val r = sendCmd(CC.ApiUpdateProfile(userId, profile))
if (r is CR.UserProfileNoChange) return profile
if (r is CR.UserProfileUpdated) return r.toProfile
@@ -804,10 +822,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
suspend fun apiCreateUserAddress(): String? {
val userId = chatModel.currentUser.value?.userId ?: run {
Log.e(TAG, "apiCreateUserAddress: no current user")
return null
}
val userId = kotlin.runCatching { currentUserId("apiCreateUserAddress") }.getOrElse { return null }
val r = sendCmd(CC.ApiCreateMyAddress(userId))
return when (r) {
is CR.UserContactLinkCreated -> r.connReqContact
@@ -821,10 +836,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
suspend fun apiDeleteUserAddress(): Boolean {
val userId = chatModel.currentUser.value?.userId ?: run {
Log.e(TAG, "apiDeleteUserAddress: no current user")
return false
}
val userId = kotlin.runCatching { currentUserId("apiDeleteUserAddress") }.getOrElse { return false }
val r = sendCmd(CC.ApiDeleteMyAddress(userId))
if (r is CR.UserContactLinkDeleted) return true
Log.e(TAG, "apiDeleteUserAddress bad response: ${r.responseType} ${r.details}")
@@ -832,10 +844,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
private suspend fun apiGetUserAddress(): UserContactLinkRec? {
val userId = chatModel.currentUser.value?.userId ?: run {
Log.e(TAG, "apiGetUserAddress: no current user")
return null
}
val userId = kotlin.runCatching { currentUserId("apiGetUserAddress") }.getOrElse { return null }
val r = sendCmd(CC.ApiShowMyAddress(userId))
if (r is CR.UserContactLink) return r.contactLink
if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore
@@ -847,10 +856,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
suspend fun userAddressAutoAccept(autoAccept: AutoAccept?): UserContactLinkRec? {
val userId = chatModel.currentUser.value?.userId ?: run {
Log.e(TAG, "userAddressAutoAccept: no current user")
return null
}
val userId = kotlin.runCatching { currentUserId("userAddressAutoAccept") }.getOrElse { return null }
val r = sendCmd(CC.ApiAddressAutoAccept(userId, autoAccept))
if (r is CR.UserContactLinkUpdated) return r.contactLink
if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore
@@ -970,10 +976,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
}
suspend fun apiNewGroup(p: GroupProfile): GroupInfo? {
val userId = chatModel.currentUser.value?.userId ?: run {
Log.e(TAG, "apiNewGroup: no current user")
return null
}
val userId = kotlin.runCatching { currentUserId("apiNewGroup") }.getOrElse { return null }
val r = sendCmd(CC.ApiNewGroup(userId, p))
if (r is CR.GroupCreated) return r.groupInfo
Log.e(TAG, "apiNewGroup bad response: ${r.responseType} ${r.details}")
@@ -1206,7 +1209,11 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
val contactRequest = r.contactRequest
val cInfo = ChatInfo.ContactRequest(contactRequest)
if (active(r.user)) {
chatModel.addChat(Chat(chatInfo = cInfo, chatItems = listOf()))
if (chatModel.hasChat(contactRequest.id)) {
chatModel.updateChatInfo(cInfo)
} else {
chatModel.addChat(Chat(chatInfo = cInfo, chatItems = listOf()))
}
}
ntfManager.notifyContactRequestReceived(r.user, cInfo)
}
@@ -1292,7 +1299,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
val isLastChatItem = chatModel.getChat(cInfo.id)?.chatItems?.lastOrNull()?.id == cItem.id
if (isLastChatItem && ntfManager.hasNotificationsForChat(cInfo.id)) {
ntfManager.cancelNotificationsForChat(cInfo.id)
ntfManager.notifyMessageReceived(
ntfManager.displayNotification(
r.user,
cInfo.id,
cInfo.displayName,
@@ -1387,8 +1394,9 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
removeFile(appContext, fileName)
}
}
is CR.CallInvitation ->
is CR.CallInvitation -> {
chatModel.callManager.reportNewIncomingCall(r.callInvitation)
}
is CR.CallOffer -> {
// TODO askConfirmation?
// TODO check encryption is compatible
@@ -1754,8 +1762,12 @@ sealed class CC {
class ShowActiveUser: CC()
class CreateActiveUser(val profile: Profile): CC()
class ListUsers: CC()
class ApiSetActiveUser(val userId: Long): CC()
class ApiDeleteUser(val userId: Long, val delSMPQueues: Boolean): 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 ApiDeleteUser(val userId: Long, val delSMPQueues: Boolean, val viewPwd: String?): CC()
class StartChat(val expire: Boolean): CC()
class ApiStopChat: CC()
class SetFilesFolder(val filesFolder: String): CC()
@@ -1831,8 +1843,12 @@ sealed class CC {
is ShowActiveUser -> "/u"
is CreateActiveUser -> "/create user ${profile.displayName} ${profile.fullName}"
is ListUsers -> "/users"
is ApiSetActiveUser -> "/_user $userId"
is ApiDeleteUser -> "/_delete user $userId del_smp=${onOff(delSMPQueues)}"
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 ApiDeleteUser -> "/_delete user $userId del_smp=${onOff(delSMPQueues)}${maybePwd(viewPwd)}"
is StartChat -> "/_start subscribe=on expire=${onOff(expire)}"
is ApiStopChat -> "/_stop"
is SetFilesFolder -> "/_files_folder $filesFolder"
@@ -1910,6 +1926,10 @@ sealed class CC {
is CreateActiveUser -> "createActiveUser"
is ListUsers -> "listUsers"
is ApiSetActiveUser -> "apiSetActiveUser"
is ApiHideUser -> "apiHideUser"
is ApiUnhideUser -> "apiUnhideUser"
is ApiMuteUser -> "apiMuteUser"
is ApiUnmuteUser -> "apiUnmuteUser"
is ApiDeleteUser -> "apiDeleteUser"
is StartChat -> "startChat"
is ApiStopChat -> "apiStopChat"
@@ -1992,13 +2012,28 @@ sealed class CC {
val obfuscated: CC
get() = when (this) {
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 ApiDeleteUser -> ApiDeleteUser(userId, delSMPQueues, obfuscateOrNull(viewPwd))
else -> this
}
private fun obfuscate(s: String): String = if (s.isEmpty()) "" else "***"
private fun obfuscateOrNull(s: String?): String? =
if (s != null) {
obfuscate(s)
} else {
null
}
private fun onOff(b: Boolean): String = if (b) "on" else "off"
private fun maybePwd(pwd: String?): String = if (pwd == "" || pwd == null) "" else " " + json.encodeToString(pwd)
companion object {
fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}"
@@ -2851,6 +2886,13 @@ class APIResponse(val resp: CR, val corr: String? = null) {
resp = CR.ApiChat(user, chat),
corr = data["corr"]?.toString()
)
} else if (type == "chatCmdError") {
val userObject = resp["user_"]?.jsonObject
val user = runCatching<User?> { json.decodeFromJsonElement(userObject!!) }.getOrNull()
return APIResponse(
resp = CR.ChatCmdError(user, ChatError.ChatErrorInvalidJSON(json.encodeToString(resp["chatCmdError"]))),
corr = data["corr"]?.toString()
)
}
} catch (e: Exception) {
Log.e(TAG, "Error while parsing chat(s): " + e.stackTraceToString())
@@ -2907,6 +2949,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("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()
@@ -2980,8 +3023,8 @@ sealed class CR {
@Serializable @SerialName("versionInfo") class VersionInfo(val versionInfo: CoreVersionInfo): CR()
@Serializable @SerialName("apiParsedMarkdown") class ParsedMarkdown(val formattedText: List<FormattedText>? = null): CR()
@Serializable @SerialName("cmdOk") class CmdOk(val user: User?): CR()
@Serializable @SerialName("chatCmdError") class ChatCmdError(val user: User?, val chatError: ChatError): CR()
@Serializable @SerialName("chatError") class ChatRespError(val user: User?, val chatError: ChatError): CR()
@Serializable @SerialName("chatCmdError") class ChatCmdError(val user_: User?, val chatError: ChatError): CR()
@Serializable @SerialName("chatError") class ChatRespError(val user_: User?, val chatError: ChatError): CR()
@Serializable class Response(val type: String, val json: String): CR()
@Serializable class Invalid(val str: String): CR()
@@ -3010,6 +3053,7 @@ sealed class CR {
is ChatCleared -> "chatCleared"
is UserProfileNoChange -> "userProfileNoChange"
is UserProfileUpdated -> "userProfileUpdated"
is UserPrivacy -> "userPrivacy"
is ContactAliasUpdated -> "contactAliasUpdated"
is ConnectionAliasUpdated -> "connectionAliasUpdated"
is ContactPrefsUpdated -> "contactPrefsUpdated"
@@ -3111,6 +3155,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 ContactAliasUpdated -> withUser(user, json.encodeToString(toContact))
is ConnectionAliasUpdated -> withUser(user, json.encodeToString(toConnection))
is ContactPrefsUpdated -> withUser(user, "fromContact: $fromContact\ntoContact: \n${json.encodeToString(toContact)}")
@@ -3181,8 +3226,8 @@ sealed class CR {
is ContactConnectionDeleted -> withUser(user, json.encodeToString(connection))
is VersionInfo -> json.encodeToString(versionInfo)
is CmdOk -> withUser(user, noDetails())
is ChatCmdError -> withUser(user, chatError.string)
is ChatRespError -> withUser(user, chatError.string)
is ChatCmdError -> withUser(user_, chatError.string)
is ChatRespError -> withUser(user_, chatError.string)
is Response -> json
is Invalid -> str
}
@@ -3254,11 +3299,13 @@ sealed class ChatError {
is ChatErrorAgent -> "agent ${agentError.string}"
is ChatErrorStore -> "store ${storeError.string}"
is ChatErrorDatabase -> "database ${databaseError.string}"
is ChatErrorInvalidJSON -> "invalid json ${json}"
}
@Serializable @SerialName("error") class ChatErrorChat(val errorType: ChatErrorType): ChatError()
@Serializable @SerialName("errorAgent") class ChatErrorAgent(val agentError: AgentErrorType): ChatError()
@Serializable @SerialName("errorStore") class ChatErrorStore(val storeError: StoreError): ChatError()
@Serializable @SerialName("errorDatabase") class ChatErrorDatabase(val databaseError: DatabaseError): ChatError()
@Serializable @SerialName("invalidJSON") class ChatErrorInvalidJSON(val json: String): ChatError()
}
@Serializable

View File

@@ -13,12 +13,14 @@ class CallManager(val chatModel: ChatModel) {
Log.d(TAG, "CallManager.reportNewIncomingCall")
with (chatModel) {
callInvitations[invitation.contact.id] = invitation
if (Clock.System.now() - invitation.callTs <= 3.minutes) {
activeCallInvitation.value = invitation
controller.ntfManager.notifyCallInvitation(invitation)
} else {
val contact = invitation.contact
controller.ntfManager.notifyMessageReceived(user = invitation.user, chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText)
if (invitation.user.showNotifications) {
if (Clock.System.now() - invitation.callTs <= 3.minutes) {
activeCallInvitation.value = invitation
controller.ntfManager.notifyCallInvitation(invitation)
} else {
val contact = invitation.contact
controller.ntfManager.displayNotification(user = invitation.user, chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText)
}
}
}
}

View File

@@ -210,7 +210,7 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user
} else {
val users by remember { derivedStateOf { chatModel.users.toList() } }
val allRead = users
.filter { !it.user.activeUser }
.filter { u -> !u.user.activeUser && !u.user.hidden }
.all { u -> u.unreadCount == 0 }
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
if (users.size == 1) {

View File

@@ -9,11 +9,12 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
@@ -36,7 +37,13 @@ import kotlin.math.roundToInt
fun UserPicker(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedViewState>, switchingUsers: MutableState<Boolean>, openSettings: () -> Unit) {
val scope = rememberCoroutineScope()
var newChat by remember { mutableStateOf(userPickerState.value) }
val users by remember { derivedStateOf { chatModel.users.sortedByDescending { it.user.activeUser } } }
val users by remember {
derivedStateOf {
chatModel.users
.filter { u -> u.user.activeUser || !u.user.hidden }
.sortedByDescending { it.user.activeUser }
}
}
val animatedFloat = remember { Animatable(if (newChat.isVisible()) 0f else 1f) }
LaunchedEffect(Unit) {
launch {
@@ -104,13 +111,12 @@ fun UserPicker(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedV
}) {
userPickerState.value = AnimatedViewState.HIDING
if (!u.user.activeUser) {
chatModel.chats.clear()
scope.launch {
val job = launch {
delay(500)
switchingUsers.value = true
}
chatModel.controller.changeActiveUser(u.user.userId)
chatModel.controller.changeActiveUser(u.user.userId, null)
job.cancel()
switchingUsers.value = false
}
@@ -144,33 +150,19 @@ fun UserProfilePickerItem(u: User, unreadCount: Int = 0, onLongClick: () -> Unit
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
Modifier
.widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.7f)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
ProfileImage(
image = u.image,
size = 54.dp
)
Text(
u.displayName,
modifier = Modifier
.padding(start = 8.dp, end = 8.dp),
fontWeight = if (u.activeUser) FontWeight.Medium else FontWeight.Normal
)
}
UserProfileRow(u)
if (u.activeUser) {
Icon(Icons.Filled.Done, null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
Icon(Icons.Filled.Done, null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
} else if (u.hidden) {
Icon(Icons.Outlined.Lock, null, Modifier.size(20.dp), tint = HighOrLowlight)
} else if (unreadCount > 0) {
Row {
Text(
unreadCountStr(unreadCount),
color = MaterialTheme.colors.onPrimary,
color = Color.White,
fontSize = 11.sp,
modifier = Modifier
.background(MaterialTheme.colors.primary, shape = CircleShape)
.background(if (u.showNtfs) MaterialTheme.colors.primary else HighOrLowlight, shape = CircleShape)
.sizeIn(minWidth = 20.dp, minHeight = 20.dp)
.padding(horizontal = 3.dp)
.padding(vertical = 1.dp),
@@ -179,12 +171,35 @@ fun UserProfilePickerItem(u: User, unreadCount: Int = 0, onLongClick: () -> Unit
)
Spacer(Modifier.width(2.dp))
}
} else {
} else if (!u.showNtfs) {
Icon(Icons.Outlined.NotificationsOff, null, Modifier.size(20.dp), tint = HighOrLowlight)
} else {
Box(Modifier.size(20.dp))
}
}
}
@Composable
fun UserProfileRow(u: User) {
Row(
Modifier
.widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.7f)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
ProfileImage(
image = u.image,
size = 54.dp
)
Text(
u.displayName,
modifier = Modifier
.padding(start = 8.dp, end = 8.dp),
fontWeight = if (u.activeUser) FontWeight.Medium else FontWeight.Normal
)
}
}
@Composable
private fun SettingsPickerItem(onClick: () -> Unit) {
SectionItemViewSpaceBetween(onClick, minHeight = 68.dp) {

View File

@@ -1,5 +1,6 @@
package chat.simplex.app.views.database
import SectionDivider
import SectionItemView
import SectionItemViewSpaceBetween
import SectionTextFooter
@@ -25,13 +26,13 @@ import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.*
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.*
import chat.simplex.app.R
import chat.simplex.app.SimplexApp
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.datetime.Clock
import kotlin.math.log2
@@ -161,7 +162,9 @@ fun DatabaseEncryptionLayout(
}
if (!initialRandomDBPassphrase.value && chatDbEncrypted == true) {
DatabaseKeyField(
SectionDivider()
PassphraseField(
currentKey,
generalGetString(R.string.current_passphrase),
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
@@ -170,7 +173,9 @@ fun DatabaseEncryptionLayout(
)
}
DatabaseKeyField(
SectionDivider()
PassphraseField(
newKey,
generalGetString(R.string.new_passphrase),
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
@@ -201,7 +206,9 @@ fun DatabaseEncryptionLayout(
!validKey(newKey.value) ||
progressIndicator.value
DatabaseKeyField(
SectionDivider()
PassphraseField(
confirmNewKey,
generalGetString(R.string.confirm_new_passphrase),
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
@@ -212,7 +219,9 @@ fun DatabaseEncryptionLayout(
}),
)
SectionItemViewSpaceBetween(onClickUpdate, disabled = disabled) {
SectionDivider()
SectionItemViewSpaceBetween(onClickUpdate, disabled = disabled, minHeight = TextFieldDefaults.MinHeight) {
Text(generalGetString(R.string.update_database_passphrase), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
}
}
@@ -285,9 +294,10 @@ fun SavePassphraseSetting(
initialRandomDBPassphrase: Boolean,
storedKey: Boolean,
progressIndicator: Boolean,
minHeight: Dp = TextFieldDefaults.MinHeight,
onCheckedChange: (Boolean) -> Unit,
) {
SectionItemView {
SectionItemView(minHeight = minHeight) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (storedKey) Icons.Filled.VpnKey else Icons.Filled.VpnKeyOff,
@@ -349,13 +359,14 @@ private fun operationEnded(m: ChatModel, progressIndicator: MutableState<Boolean
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun DatabaseKeyField(
fun PassphraseField(
key: MutableState<String>,
placeholder: String,
modifier: Modifier = Modifier,
showStrength: Boolean = false,
isValid: (String) -> Boolean,
keyboardActions: KeyboardActions = KeyboardActions(),
dependsOn: MutableState<String>? = null,
) {
var valid by remember { mutableStateOf(validKey(key.value)) }
var showKey by remember { mutableStateOf(false) }
@@ -436,6 +447,13 @@ fun DatabaseKeyField(
)
}
)
LaunchedEffect(Unit) {
snapshotFlow { dependsOn?.value }
.distinctUntilChanged()
.collect {
valid = isValid(state.value.text)
}
}
}
// based on https://generatepasswords.org/how-to-calculate-entropy/

View File

@@ -206,7 +206,7 @@ private fun restoreDb(restoreDbFromBackup: MutableState<Boolean>, prefs: AppPref
@Composable
private fun DatabaseKeyField(text: MutableState<String>, enabled: Boolean, onClick: (() -> Unit)? = null) {
DatabaseKeyField(
PassphraseField(
text,
generalGetString(R.string.enter_passphrase),
isValid = ::validKey,

View File

@@ -4,6 +4,7 @@ import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@@ -22,7 +23,11 @@ fun CloseSheetBar(close: () -> Unit, endButtons: @Composable RowScope.() -> Unit
Modifier
.padding(top = 4.dp), // Like in DefaultAppBar
content = {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Row(
Modifier.fillMaxWidth().height(TextFieldDefaults.MinHeight),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
NavigationButtonBack(close)
Row {
endButtons()

View File

@@ -0,0 +1,89 @@
package chat.simplex.app.views.usersettings
import SectionDivider
import SectionItemView
import SectionItemViewSpaceBetween
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.User
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chatlist.UserProfileRow
import chat.simplex.app.views.database.PassphraseField
import chat.simplex.app.views.helpers.*
@Composable
fun HiddenProfileView(
m: ChatModel,
user: User,
close: () -> Unit,
) {
HiddenProfileLayout(
user,
saveProfilePassword = { hidePassword ->
withBGApi {
try {
val u = m.controller.apiHideUser(user.userId, hidePassword)
m.updateUser(u)
close()
} catch (e: Exception) {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.error_saving_user_password),
text = e.stackTraceToString()
)
}
}
}
)
}
@Composable
private fun HiddenProfileLayout(
user: User,
saveProfilePassword: (String) -> Unit
) {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(bottom = DEFAULT_BOTTOM_PADDING),
) {
AppBarTitle(stringResource(R.string.hide_profile))
SectionView(padding = PaddingValues(start = 8.dp, end = DEFAULT_PADDING)) {
UserProfileRow(user)
}
SectionSpacer()
val hidePassword = rememberSaveable { mutableStateOf("") }
val confirmHidePassword = rememberSaveable { mutableStateOf("") }
val confirmValid by remember { derivedStateOf { confirmHidePassword.value == "" || hidePassword.value == confirmHidePassword.value } }
val saveDisabled by remember { derivedStateOf { hidePassword.value == "" || confirmHidePassword.value == "" || !confirmValid } }
SectionView(stringResource(R.string.hidden_profile_password).uppercase()) {
SectionItemView {
PassphraseField(hidePassword, generalGetString(R.string.password_to_show), isValid = { true }, showStrength = true)
}
SectionDivider()
SectionItemView {
PassphraseField(confirmHidePassword, stringResource(R.string.confirm_password), isValid = { confirmValid }, dependsOn = hidePassword)
}
SectionDivider()
SectionItemViewSpaceBetween({ saveProfilePassword(hidePassword.value) }, disabled = saveDisabled, minHeight = TextFieldDefaults.MinHeight) {
Text(generalGetString(R.string.save_profile_password), color = if (saveDisabled) HighOrLowlight else MaterialTheme.colors.primary)
}
}
SectionTextFooter(stringResource(R.string.to_reveal_profile_enter_password))
}
}

View File

@@ -6,6 +6,7 @@ import SectionSpacer
import SectionView
import android.content.Context
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
@@ -13,6 +14,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -20,7 +22,9 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.*
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.*
@@ -58,6 +62,26 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
setPerformLA = setPerformLA,
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
showSettingsModal = { modalView -> { ModalManager.shared.showModal(true) { modalView(chatModel) } } },
showSettingsModalWithSearch = { modalView ->
ModalManager.shared.showCustomModal { close ->
val search = mutableStateOf("")
var showSearch by remember { mutableStateOf(false) }
ModalView(
{ if (showSearch) { showSearch = false } else close() },
if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight,
endButtons = {
if (!showSearch) {
IconButton({ showSearch = true }) {
Icon(Icons.Outlined.Search, stringResource(android.R.string.search_go).capitalize(Locale.current), tint = MaterialTheme.colors.primary)
}
} else {
BackHandler { showSearch = false }
SearchTextField(Modifier.fillMaxWidth(), stringResource(android.R.string.search_go)) { search.value = it }
}
},
content = { modalView(chatModel, search) })
}
},
showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } },
showVersion = {
withApi {
@@ -115,6 +139,7 @@ fun SettingsLayout(
setPerformLA: (Boolean) -> Unit,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showSettingsModalWithSearch: (@Composable (ChatModel, MutableState<String>) -> Unit) -> Unit,
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
showVersion: () -> Unit,
withAuth: (block: () -> Unit) -> Unit
@@ -141,7 +166,8 @@ fun SettingsLayout(
ProfilePreview(profile, stopped = stopped)
}
SectionDivider()
SettingsActionItem(Icons.Outlined.ManageAccounts, stringResource(R.string.your_chat_profiles), { withAuth { showSettingsModal { UserProfilesView(it) }() } }, disabled = stopped)
val profileHidden = rememberSaveable { mutableStateOf(false) }
SettingsActionItem(Icons.Outlined.ManageAccounts, stringResource(R.string.your_chat_profiles), { withAuth { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden) } } }, disabled = stopped)
SectionDivider()
SettingsIncognitoActionItem(incognitoPref, incognito, stopped) { showModal { IncognitoView() }() }
SectionDivider()
@@ -531,6 +557,7 @@ fun PreviewSettingsLayout() {
setPerformLA = {},
showModal = { {} },
showSettingsModal = { {} },
showSettingsModalWithSearch = { },
showCustomModal = { {} },
showVersion = {},
withAuth = {},

View File

@@ -2,6 +2,7 @@ package chat.simplex.app.views.usersettings
import SectionDivider
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
@@ -10,6 +11,7 @@ import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
@@ -17,18 +19,28 @@ import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
import chat.simplex.app.chatPasswordHash
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.helpers.*
import chat.simplex.app.views.onboarding.CreateProfile
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun UserProfilesView(m: ChatModel) {
fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden: MutableState<Boolean>) {
val searchTextOrPassword = rememberSaveable { search }
val users by remember { derivedStateOf { m.users.map { it.user } } }
val filteredUsers by remember { derivedStateOf { filteredUsers(m, searchTextOrPassword.value) } }
UserProfilesView(
users = users,
filteredUsers = filteredUsers,
profileHidden = profileHidden,
searchTextOrPassword = searchTextOrPassword,
showHiddenProfilesNotice = m.controller.appPrefs.showHiddenProfilesNotice,
visibleUsersCount = visibleUsersCount(m),
addUser = {
ModalManager.shared.showModalCloseable { close ->
CreateProfile(m, close)
@@ -36,38 +48,72 @@ fun UserProfilesView(m: ChatModel) {
},
activateUser = { user ->
withBGApi {
m.controller.changeActiveUser(user.userId)
m.controller.changeActiveUser(user.userId, userViewPassword(user, searchTextOrPassword.value))
}
},
removeUser = { user ->
val text = buildAnnotatedString {
append(generalGetString(R.string.users_delete_all_chats_deleted) + "\n\n" + generalGetString(R.string.users_delete_profile_for) + " ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(user.displayName)
if (m.users.size > 1 && (user.hidden || visibleUsersCount(m) > 1)) {
val text = buildAnnotatedString {
append(generalGetString(R.string.users_delete_all_chats_deleted) + "\n\n" + generalGetString(R.string.users_delete_profile_for) + " ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(user.displayName)
}
append(":")
}
append(":")
}
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(R.string.users_delete_question),
text = text,
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeUser(m, user, users, true)
}) {
Text(stringResource(R.string.users_delete_with_connections), color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
removeUser(m, user, users, false)
}
) {
Text(stringResource(R.string.users_delete_data_only), color = Color.Red)
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(R.string.users_delete_question),
text = text,
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeUser(m, user, users, true, searchTextOrPassword.value)
}) {
Text(stringResource(R.string.users_delete_with_connections), color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
removeUser(m, user, users, false, searchTextOrPassword.value)
}
) {
Text(stringResource(R.string.users_delete_data_only), color = Color.Red)
}
}
}
)
} else {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.cant_delete_user_profile),
text = if (m.users.size > 1) {
generalGetString(R.string.should_be_at_least_one_visible_profile)
} else {
generalGetString(R.string.should_be_at_least_one_profile)
}
)
}
},
unhideUser = { user ->
setUserPrivacy(m) { m.controller.apiUnhideUser(user.userId, userViewPassword(user, 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))
}
},
unmuteUser = { user ->
setUserPrivacy(m) { m.controller.apiUnmuteUser(user.userId, userViewPassword(user, searchTextOrPassword.value)) }
},
showHiddenProfile = { user ->
ModalManager.shared.showModalCloseable(true) { close ->
HiddenProfileView(m, user) {
profileHidden.value = true
withBGApi {
delay(10_000)
profileHidden.value = false
}
close()
}
)
}
}
)
}
@@ -75,9 +121,18 @@ fun UserProfilesView(m: ChatModel) {
@Composable
private fun UserProfilesView(
users: List<User>,
filteredUsers: List<User>,
searchTextOrPassword: MutableState<String>,
profileHidden: MutableState<Boolean>,
visibleUsersCount: Int,
showHiddenProfilesNotice: SharedPreference<Boolean>,
addUser: () -> Unit,
activateUser: (User) -> Unit,
removeUser: (User) -> Unit,
unhideUser: (User) -> Unit,
muteUser: (User) -> Unit,
unmuteUser: (User) -> Unit,
showHiddenProfile: (User) -> Unit,
) {
Column(
Modifier
@@ -85,25 +140,59 @@ private fun UserProfilesView(
.verticalScroll(rememberScrollState())
.padding(bottom = DEFAULT_PADDING),
) {
if (profileHidden.value) {
SectionView {
SettingsActionItem(Icons.Outlined.LockOpen, stringResource(R.string.enter_password_to_show), click = {
profileHidden.value = false
}
)
}
SectionSpacer()
}
AppBarTitle(stringResource(R.string.your_chat_profiles))
SectionView {
for (user in users) {
UserView(user, users, activateUser, removeUser)
for (user in filteredUsers) {
UserView(user, users, visibleUsersCount, activateUser, removeUser, unhideUser, muteUser, unmuteUser, showHiddenProfile)
SectionDivider()
}
SectionItemView(addUser, minHeight = 68.dp) {
Icon(Icons.Outlined.Add, stringResource(R.string.users_add), tint = MaterialTheme.colors.primary)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(stringResource(R.string.users_add), color = MaterialTheme.colors.primary)
if (searchTextOrPassword.value.isEmpty()) {
SectionItemView(addUser, minHeight = 68.dp) {
Icon(Icons.Outlined.Add, stringResource(R.string.users_add), tint = MaterialTheme.colors.primary)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(stringResource(R.string.users_add), color = MaterialTheme.colors.primary)
}
}
}
SectionTextFooter(stringResource(R.string.tap_to_activate_profile))
LaunchedEffect(Unit) {
if (showHiddenProfilesNotice.state.value && users.size > 1) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.make_profile_private),
text = generalGetString(R.string.you_can_hide_or_mute_user_profile),
confirmText = generalGetString(R.string.ok),
dismissText = generalGetString(R.string.dont_show_again),
onDismiss = {
showHiddenProfilesNotice.set(false)
},
)
}
}
SectionTextFooter(stringResource(R.string.your_chat_profiles_stored_locally))
}
}
@Composable
private fun UserView(user: User, users: List<User>, activateUser: (User) -> Unit, removeUser: (User) -> Unit) {
private fun UserView(
user: User,
users: List<User>,
visibleUsersCount: Int,
activateUser: (User) -> Unit,
removeUser: (User) -> Unit,
unhideUser: (User) -> Unit,
muteUser: (User) -> Unit,
unmuteUser: (User) -> Unit,
showHiddenProfile: (User) -> Unit,
) {
var showDropdownMenu by remember { mutableStateOf(false) }
UserProfilePickerItem(user, onLongClick = { if (users.size > 1) showDropdownMenu = true }) {
activateUser(user)
@@ -114,28 +203,103 @@ private fun UserView(user: User, users: List<User>, activateUser: (User) -> Unit
onDismissRequest = { showDropdownMenu = false },
Modifier.width(220.dp)
) {
if (user.hidden) {
ItemAction(stringResource(R.string.user_unhide), Icons.Outlined.LockOpen, onClick = {
showDropdownMenu = false
unhideUser(user)
})
} else {
if (visibleUsersCount > 1) {
ItemAction(stringResource(R.string.user_hide), Icons.Outlined.Lock, onClick = {
showDropdownMenu = false
showHiddenProfile(user)
})
}
if (user.showNtfs) {
ItemAction(stringResource(R.string.user_mute), Icons.Outlined.Notifications, onClick = {
showDropdownMenu = false
muteUser(user)
})
} else {
ItemAction(stringResource(R.string.user_unmute), Icons.Outlined.NotificationsOff, onClick = {
showDropdownMenu = false
unmuteUser(user)
})
}
}
ItemAction(stringResource(R.string.delete_verb), Icons.Outlined.Delete, color = Color.Red, onClick = {
removeUser(user)
showDropdownMenu = false
}
)
})
}
}
}
private fun removeUser(m: ChatModel, user: User, users: List<User>, delSMPQueues: Boolean) {
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))) {
true
} else if (u.user.viewPwdHash != null) {
s != "" && chatPasswordHash(s, u.user.viewPwdHash.salt) == u.user.viewPwdHash.hash
} else {
false
}
}.map { it.user }
}
private fun visibleUsersCount(m: ChatModel): Int = m.users.filter { u -> !u.user.hidden }.size
private fun userViewPassword(user: User, searchTextOrPassword: String): String? =
if (user.activeUser || !user.hidden) null else 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.first { !it.activeUser }
m.controller.changeActiveUser_(newActive.userId)
val newActive = users.firstOrNull { u -> !u.activeUser && !u.hidden }
if (newActive != null) {
m.controller.changeActiveUser_(newActive.userId, null)
deleteUser(user)
}
} else {
deleteUser(user)
}
m.controller.apiDeleteUser(user.userId, delSMPQueues)
m.users.removeAll { it.user.userId == user.userId }
} catch (e: Exception) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_deleting_user), e.stackTraceToString())
}
}
}
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 fun showMuteProfileAlert(showMuteProfileAlert: SharedPreference<Boolean>) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.muted_when_inactive),
text = generalGetString(R.string.you_will_still_receive_calls_and_ntfs),
confirmText = generalGetString(R.string.ok),
dismissText = generalGetString(R.string.dont_show_again),
onDismiss = {
showMuteProfileAlert.set(false)
},
)
}

View File

@@ -1016,7 +1016,6 @@
<string name="update_network_settings_confirmation">Update</string>
<!-- UserProfilesView.kt -->
<string name="your_chat_profiles_stored_locally">Your chat profiles are stored locally, only on your device</string>
<string name="users_add">Add profile</string>
<string name="users_delete_question">Delete chat profile?</string>
<string name="users_delete_all_chats_deleted">All chats and messages will be deleted - this cannot be undone!</string>
@@ -1027,13 +1026,13 @@
<string name="user_unhide">Unhide</string>
<string name="user_mute">Mute</string>
<string name="user_unmute">Unmute</string>
<string name="enter_password_to_show">Enter password above to show!</string>
<string name="enter_password_to_show">Enter password in search</string>
<string name="tap_to_activate_profile">Tap to activate profile.</string>
<string name="cant_delete_user_profile">Can\'t delete user profile!</string>
<string name="should_be_at_least_one_visible_profile">There should be at least one visible user profile.</string>
<string name="should_be_at_least_one_profile">There should be at least one user profile.</string>
<string name="make_profile_private">Make profile private!</string>
<string name="you_can_hide_or_mute_user_profile">You can hide or mute a user profile - hold it for the menu.\nSimpleX Lock must be enabled.</string>
<string name="you_can_hide_or_mute_user_profile">You can hide or mute a user profile - hold it for the menu.</string>
<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>