From a266bcbae70bbb738c8eab1f3b53aa8b21c76c04 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 25 Mar 2023 00:48:34 +0300 Subject: [PATCH] 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> --- apps/android/app/src/main/cpp/simplex-api.c | 11 + .../java/chat/simplex/app/MainActivity.kt | 4 +- .../main/java/chat/simplex/app/SimplexApp.kt | 1 + .../java/chat/simplex/app/model/ChatModel.kt | 44 +++- .../java/chat/simplex/app/model/NtfManager.kt | 10 +- .../java/chat/simplex/app/model/SimpleXAPI.kt | 171 +++++++----- .../simplex/app/views/call/CallManager.kt | 14 +- .../app/views/chatlist/ChatListView.kt | 2 +- .../simplex/app/views/chatlist/UserPicker.kt | 67 +++-- .../views/database/DatabaseEncryptionView.kt | 34 ++- .../app/views/database/DatabaseErrorView.kt | 2 +- .../app/views/helpers/CloseSheetBar.kt | 7 +- .../views/usersettings/HiddenProfileView.kt | 89 +++++++ .../app/views/usersettings/SettingsView.kt | 29 ++- .../views/usersettings/UserProfilesView.kt | 246 +++++++++++++++--- .../app/src/main/res/values/strings.xml | 5 +- 16 files changed, 577 insertions(+), 159 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HiddenProfileView.kt diff --git a/apps/android/app/src/main/cpp/simplex-api.c b/apps/android/app/src/main/cpp/simplex-api.c index a5264e335..e3f1c13cf 100644 --- a/apps/android/app/src/main/cpp/simplex-api.c +++ b/apps/android/app/src/main/cpp/simplex-api.c @@ -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; +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt index efe6b022c..734f41cbd 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt @@ -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 diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt index 923f71b97..dce371a35 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt @@ -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 diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index b573a1375..440bc6862 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -94,6 +94,32 @@ class ChatModel(val controller: ChatController) { val filesToDelete = mutableSetOf() 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, diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt index b6c3c1165..a4c4c8aea 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt @@ -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 = emptyList()) { + fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List = emptyList()) { + if (!user.showNotifications) return Log.d(TAG, "notifyMessageReceived $chatId") val now = Clock.System.now().toEpochMilliseconds() val recentNotification = (now - prevNtfTime.getOrDefault(chatId, 0) < msgNtfTimeoutMs) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 35b918776..b0d659bd4 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -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 { - 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>? { - 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): 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? { - 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 { 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? = 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 diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt index d5d440646..2a4b39784 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallManager.kt @@ -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) + } } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt index 8f3d7e685..f8501afbe 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt @@ -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) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/UserPicker.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/UserPicker.kt index 9b10062d3..b7d3185e3 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/UserPicker.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/UserPicker.kt @@ -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, switchingUsers: MutableState, 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 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) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseEncryptionView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseEncryptionView.kt index fa511e0b8..634028ce4 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseEncryptionView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseEncryptionView.kt @@ -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, placeholder: String, modifier: Modifier = Modifier, showStrength: Boolean = false, isValid: (String) -> Boolean, keyboardActions: KeyboardActions = KeyboardActions(), + dependsOn: MutableState? = 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/ diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseErrorView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseErrorView.kt index 1dc25440a..9f69ca279 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseErrorView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseErrorView.kt @@ -206,7 +206,7 @@ private fun restoreDb(restoreDbFromBackup: MutableState, prefs: AppPref @Composable private fun DatabaseKeyField(text: MutableState, enabled: Boolean, onClick: (() -> Unit)? = null) { - DatabaseKeyField( + PassphraseField( text, generalGetString(R.string.enter_passphrase), isValid = ::validKey, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt index 0d0d6675b..6a7ab8277 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt @@ -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() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HiddenProfileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HiddenProfileView.kt new file mode 100644 index 000000000..b9fa39b11 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HiddenProfileView.kt @@ -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)) + } +} \ No newline at end of file diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index c586aaf36..8e800ffb3 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -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) -> 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 = {}, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfilesView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfilesView.kt index 525e52123..e6db50de4 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfilesView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfilesView.kt @@ -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, profileHidden: MutableState) { + 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, + filteredUsers: List, + searchTextOrPassword: MutableState, + profileHidden: MutableState, + visibleUsersCount: Int, + showHiddenProfilesNotice: SharedPreference, 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, activateUser: (User) -> Unit, removeUser: (User) -> Unit) { +private fun UserView( + user: User, + users: List, + 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, 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, delSMPQueues: Boolean) { +private fun filteredUsers(m: ChatModel, searchTextOrPassword: String): List { + 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, 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) { + 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) + }, + ) +} \ No newline at end of file diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 1e0564e74..ff0cfced0 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -1016,7 +1016,6 @@ Update - Your chat profiles are stored locally, only on your device Add profile Delete chat profile? All chats and messages will be deleted - this cannot be undone! @@ -1027,13 +1026,13 @@ Unhide Mute Unmute - Enter password above to show! + Enter password in search Tap to activate profile. Can\'t delete user profile! There should be at least one visible user profile. There should be at least one user profile. Make profile private! - You can hide or mute a user profile - hold it for the menu.\nSimpleX Lock must be enabled. + You can hide or mute a user profile - hold it for the menu. Don\'t show again Muted when inactive! You will still receive calls and notifications from muted profiles when they are active.