From 04719ff8df89745618c8f6dc8744ccca1438f507 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 7 Oct 2022 12:29:13 +0300 Subject: [PATCH] android: Automatic message deletion (#1171) * android: Automatic message deletion * Disable changing TTL when this operation is already happening * corrections * update translations * afterSetCiTTL Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> --- .../java/chat/simplex/app/model/ChatModel.kt | 32 ++++++ .../java/chat/simplex/app/model/SimpleXAPI.kt | 39 ++++++- .../app/views/database/DatabaseView.kt | 108 +++++++++++++++++- .../app/src/main/res/values-de/strings.xml | 12 +- .../app/src/main/res/values-ru/strings.xml | 12 +- .../app/src/main/res/values/strings.xml | 12 +- 6 files changed, 204 insertions(+), 11 deletions(-) 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 277660d7d8..37741f3464 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 @@ -40,6 +40,7 @@ class ChatModel(val controller: ChatController) { val terminalItems = mutableStateListOf() val userAddress = mutableStateOf(null) val userSMPServers = mutableStateOf<(List)?>(null) + val chatItemTTL = mutableStateOf(ChatItemTTL.None) // set when app opened from external intent val clearOverlays = mutableStateOf(false) @@ -1554,3 +1555,34 @@ sealed class SndGroupEvent() { is GroupUpdated -> generalGetString(R.string.snd_group_event_group_profile_updated) } } + +sealed class ChatItemTTL: Comparable { + object Day: ChatItemTTL() + object Week: ChatItemTTL() + object Month: ChatItemTTL() + data class Seconds(val secs: Long): ChatItemTTL() + object None: ChatItemTTL() + + override fun compareTo(other: ChatItemTTL?): Int = (seconds ?: Long.MAX_VALUE).compareTo(other?.seconds ?: Long.MAX_VALUE) + + val seconds: Long? + get() = + when (this) { + is None -> null + is Day -> 86400L + is Week -> 7 * 86400L + is Month -> 30 * 86400L + is Seconds -> secs + } + + companion object { + fun fromSeconds(seconds: Long?): ChatItemTTL = + when (seconds) { + null -> None + 86400L -> Day + 7 * 86400L -> Week + 30 * 86400L -> Month + else -> Seconds(seconds) + } + } +} 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 f46b8b754b..e18b49644a 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 @@ -234,6 +234,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a apiSetIncognito(chatModel.incognito.value) chatModel.userAddress.value = apiGetUserAddress() chatModel.userSMPServers.value = getUserSMPServers() + chatModel.chatItemTTL.value = getChatItemTTL() val chats = apiGetChats() chatModel.updateChats(chats) chatModel.currentUser.value = user @@ -320,7 +321,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } suspend fun apiStartChat(): Boolean { - val r = sendCmd(CC.StartChat()) + val r = sendCmd(CC.StartChat(expire = true)) when (r) { is CR.ChatStarted -> return true is CR.ChatRunning -> return false @@ -373,7 +374,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a throw Exception("failed to set storage encryption: ${r.responseType} ${r.details}") } - private suspend fun apiGetChats(): List { + suspend fun apiGetChats(): List { val r = sendCmd(CC.ApiGetChats()) if (r is CR.ApiChats ) return r.chats throw Error("failed getting the list of chats: ${r.responseType} ${r.details}") @@ -436,6 +437,18 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } } + suspend fun getChatItemTTL(): ChatItemTTL { + val r = sendCmd(CC.APIGetChatItemTTL()) + 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 r = sendCmd(CC.APISetChatItemTTL(chatItemTTL.seconds)) + if (r is CR.CmdOk) return + throw Exception("failed to set chat item TTL: ${r.responseType} ${r.details}") + } + suspend fun apiGetNetworkConfig(): NetCfg? { val r = sendCmd(CC.APIGetNetworkConfig()) if (r is CR.NetworkConfig) return r.networkConfig @@ -1313,7 +1326,7 @@ sealed class CC { class Console(val cmd: String): CC() class ShowActiveUser: CC() class CreateActiveUser(val profile: Profile): CC() - class StartChat: CC() + class StartChat(val expire: Boolean): CC() class ApiStopChat: CC() class SetFilesFolder(val filesFolder: String): CC() class SetIncognito(val incognito: Boolean): CC() @@ -1336,6 +1349,8 @@ sealed class CC { class ApiUpdateGroupProfile(val groupId: Long, val groupProfile: GroupProfile): CC() class GetUserSMPServers: CC() class SetUserSMPServers(val smpServers: List): CC() + class APISetChatItemTTL(val seconds: Long?): CC() + class APIGetChatItemTTL: CC() class APISetNetworkConfig(val networkConfig: NetCfg): CC() class APIGetNetworkConfig: CC() class APISetChatSettings(val type: ChatType, val id: Long, val chatSettings: ChatSettings): CC() @@ -1369,10 +1384,10 @@ sealed class CC { is Console -> cmd is ShowActiveUser -> "/u" is CreateActiveUser -> "/u ${profile.displayName} ${profile.fullName}" - is StartChat -> "/_start" + is StartChat -> "/_start subscribe=on expire=${onOff(expire)}" is ApiStopChat -> "/_stop" is SetFilesFolder -> "/_files_folder $filesFolder" - is SetIncognito -> "/incognito ${if (incognito) "on" else "off"}" + is SetIncognito -> "/incognito ${onOff(incognito)}" is ApiExportArchive -> "/_db export ${json.encodeToString(config)}" is ApiImportArchive -> "/_db import ${json.encodeToString(config)}" is ApiDeleteStorage -> "/_db delete" @@ -1391,6 +1406,8 @@ sealed class CC { is ApiUpdateGroupProfile -> "/_group_profile #$groupId ${json.encodeToString(groupProfile)}" is GetUserSMPServers -> "/smp_servers" is SetUserSMPServers -> "/smp_servers ${smpServersStr(smpServers)}" + is APISetChatItemTTL -> "/_ttl ${chatItemTTLStr(seconds)}" + is APIGetChatItemTTL -> "/ttl" is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}" is APIGetNetworkConfig -> "/network" is APISetChatSettings -> "/_settings ${chatRef(type, id)} ${json.encodeToString(chatSettings)}" @@ -1447,6 +1464,8 @@ sealed class CC { is ApiUpdateGroupProfile -> "apiUpdateGroupProfile" is GetUserSMPServers -> "getUserSMPServers" is SetUserSMPServers -> "setUserSMPServers" + is APISetChatItemTTL -> "apiSetChatItemTTL" + is APIGetChatItemTTL -> "apiGetChatItemTTL" is APISetNetworkConfig -> "/apiSetNetworkConfig" is APIGetNetworkConfig -> "/apiGetNetworkConfig" is APISetChatSettings -> "/apiSetChatSettings" @@ -1479,6 +1498,11 @@ sealed class CC { class ItemRange(val from: Long, val to: Long) + fun chatItemTTLStr(seconds: Long?): String { + if (seconds == null) return "none" + return seconds.toString() + } + val obfuscated: CC get() = when (this) { is ApiStorageEncryption -> ApiStorageEncryption(DBEncryptionConfig(obfuscate(config.currentKey), obfuscate(config.newKey))) @@ -1487,6 +1511,8 @@ sealed class CC { private fun obfuscate(s: String): String = if (s.isEmpty()) "" else "***" + private fun onOff(b: Boolean): String = if (b) "on" else "off" + companion object { fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}" @@ -1637,6 +1663,7 @@ sealed class CR { @Serializable @SerialName("apiChats") class ApiChats(val chats: List): CR() @Serializable @SerialName("apiChat") class ApiChat(val chat: Chat): CR() @Serializable @SerialName("userSMPServers") class UserSMPServers(val smpServers: List): CR() + @Serializable @SerialName("chatItemTTL") class ChatItemTTL(val chatItemTTL: Long? = null): CR() @Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR() @Serializable @SerialName("contactInfo") class ContactInfo(val contact: Contact, val connectionStats: ConnectionStats, val customUserProfile: Profile? = null): CR() @Serializable @SerialName("groupMemberInfo") class GroupMemberInfo(val groupInfo: GroupInfo, val member: GroupMember, val connectionStats_: ConnectionStats?): CR() @@ -1726,6 +1753,7 @@ sealed class CR { is ApiChats -> "apiChats" is ApiChat -> "apiChat" is UserSMPServers -> "userSMPServers" + is ChatItemTTL -> "chatItemTTL" is NetworkConfig -> "networkConfig" is ContactInfo -> "contactInfo" is GroupMemberInfo -> "groupMemberInfo" @@ -1813,6 +1841,7 @@ sealed class CR { is ApiChats -> json.encodeToString(chats) is ApiChat -> json.encodeToString(chat) is UserSMPServers -> json.encodeToString(smpServers) + is ChatItemTTL -> json.encodeToString(chatItemTTL) is NetworkConfig -> json.encodeToString(networkConfig) is ContactInfo -> "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}" is GroupMemberInfo -> "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats_)}" diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt index c908760cbd..d5c10f8778 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt @@ -41,6 +41,7 @@ import kotlinx.datetime.* import java.io.* import java.text.SimpleDateFormat import java.util.* +import kotlin.collections.ArrayList @Composable fun DatabaseView( @@ -67,6 +68,7 @@ fun DatabaseView( LaunchedEffect(m.chatRunning) { runChat.value = m.chatRunning.value ?: true } + val chatItemTTL = remember { mutableStateOf(m.chatItemTTL.value) } Box( Modifier.fillMaxSize(), ) { @@ -82,11 +84,21 @@ fun DatabaseView( chatLastStart, chatDbDeleted.value, appFilesCountAndSize, + chatItemTTL, startChat = { startChat(m, runChat, chatLastStart, m.chatDbChanged) }, stopChatAlert = { stopChatAlert(m, runChat, context) }, exportArchive = { exportArchive(context, m, progressIndicator, chatArchiveName, chatArchiveTime, chatArchiveFile, saveArchiveLauncher) }, deleteChatAlert = { deleteChatAlert(m, progressIndicator) }, deleteAppFilesAndMedia = { deleteFilesAndMediaAlert(context, appFilesCountAndSize) }, + onChatItemTTLSelected = { + val oldValue = chatItemTTL.value + chatItemTTL.value = it + if (it < oldValue) { + setChatItemTTLAlert(m, chatItemTTL, progressIndicator, appFilesCountAndSize, context) + } else if (it != oldValue) { + setCiTTL(m, chatItemTTL, progressIndicator, appFilesCountAndSize, context) + } + }, showSettingsModal ) if (progressIndicator.value) { @@ -119,11 +131,13 @@ fun DatabaseLayout( chatLastStart: MutableState, chatDbDeleted: Boolean, appFilesCountAndSize: MutableState>, + chatItemTTL: MutableState, startChat: () -> Unit, stopChatAlert: () -> Unit, exportArchive: () -> Unit, deleteChatAlert: () -> Unit, deleteAppFilesAndMedia: () -> Unit, + onChatItemTTLSelected: (ChatItemTTL) -> Unit, showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit) ) { val stopped = !runChat @@ -204,7 +218,9 @@ fun DatabaseLayout( ) SectionSpacer() - SectionView(stringResource(R.string.files_section)) { + SectionView(stringResource(R.string.data_section)) { + SectionItemView { TtlOptions(chatItemTTL, rememberUpdatedState(!progressIndicator), onChatItemTTLSelected) } + SectionDivider() val deleteFilesDisabled = operationsDisabled || appFilesCountAndSize.value.first == 0 SectionItemView( deleteAppFilesAndMedia, @@ -227,6 +243,48 @@ fun DatabaseLayout( } } +private fun setChatItemTTLAlert( + m: ChatModel, selectedChatItemTTL: MutableState, + progressIndicator: MutableState, + appFilesCountAndSize: MutableState>, + context: Context +) { + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.enable_automatic_deletion_question), + text = generalGetString(R.string.enable_automatic_deletion_message), + confirmText = generalGetString(R.string.delete_messages), + onConfirm = { setCiTTL(m, selectedChatItemTTL, progressIndicator, appFilesCountAndSize, context) }, + onDismiss = { selectedChatItemTTL.value = m.chatItemTTL.value } + ) +} + +@Composable +private fun TtlOptions(current: State, enabled: State, onSelected: (ChatItemTTL) -> Unit) { + val values = remember { + val all: ArrayList = arrayListOf(ChatItemTTL.None, ChatItemTTL.Month, ChatItemTTL.Week, ChatItemTTL.Day) + if (current.value is ChatItemTTL.Seconds) { + all.add(current.value) + } + all.map { + when (it) { + is ChatItemTTL.None -> it to generalGetString(R.string.chat_item_ttl_none) + is ChatItemTTL.Day -> it to generalGetString(R.string.chat_item_ttl_day) + is ChatItemTTL.Week -> it to generalGetString(R.string.chat_item_ttl_week) + is ChatItemTTL.Month -> it to generalGetString(R.string.chat_item_ttl_month) + is ChatItemTTL.Seconds -> it to String.format(generalGetString(R.string.chat_item_ttl_seconds), it.secs) + } + } + } + ExposedDropDownSettingRow( + generalGetString(R.string.delete_messages_after), + values, + current, + icon = null, + enabled = enabled, + onSelected = onSelected + ) +} + @Composable fun RunChatSetting( runChat: Boolean, @@ -250,7 +308,7 @@ fun RunChatSetting( ) Spacer(Modifier.fillMaxWidth().weight(1f)) Switch( - enabled= !chatDbDeleted, + enabled = !chatDbDeleted, checked = runChat, onCheckedChange = { runChatSwitch -> if (runChatSwitch) { @@ -533,6 +591,48 @@ private fun deleteChat(m: ChatModel, progressIndicator: MutableState) { } } +private fun setCiTTL( + m: ChatModel, + chatItemTTL: MutableState, + progressIndicator: MutableState, + appFilesCountAndSize: MutableState>, + context: Context +) { + Log.d(TAG, "DatabaseView setChatItemTTL ${chatItemTTL.value.seconds ?: -1}") + progressIndicator.value = true + withApi { + try { + m.controller.setChatItemTTL(chatItemTTL.value) + // Update model on success + m.chatItemTTL.value = chatItemTTL.value + afterSetCiTTL(m, progressIndicator, appFilesCountAndSize, context) + } catch (e: Exception) { + // Rollback to model's value + chatItemTTL.value = m.chatItemTTL.value + afterSetCiTTL(m, progressIndicator, appFilesCountAndSize, context) + AlertManager.shared.showAlertMsg(generalGetString(R.string.error_changing_message_deletion), e.stackTraceToString()) + } + } +} + +private fun afterSetCiTTL( + m: ChatModel, + progressIndicator: MutableState, + appFilesCountAndSize: MutableState>, + context: Context +) { + progressIndicator.value = false + appFilesCountAndSize.value = directoryFileCountAndSize(getAppFilesDirectory(context)) + withApi { + try { + val chats = m.controller.apiGetChats() + m.updateChats(chats) + } catch (e: Exception) { + Log.e(TAG, "apiGetChats error: ${e.message}") + } + } +} + private fun deleteFilesAndMediaAlert(context: Context, appFilesCountAndSize: MutableState>) { AlertManager.shared.showAlertDialog( title = generalGetString(R.string.delete_files_and_media_question), @@ -575,12 +675,14 @@ fun PreviewDatabaseLayout() { chatLastStart = remember { mutableStateOf(Clock.System.now()) }, chatDbDeleted = false, appFilesCountAndSize = remember { mutableStateOf(0 to 0L) }, + chatItemTTL = remember { mutableStateOf(ChatItemTTL.None) }, startChat = {}, stopChatAlert = {}, exportArchive = {}, deleteChatAlert = {}, deleteAppFilesAndMedia = {}, - showSettingsModal = { {} } + showSettingsModal = { {} }, + onChatItemTTLSelected = {}, ) } } diff --git a/apps/android/app/src/main/res/values-de/strings.xml b/apps/android/app/src/main/res/values-de/strings.xml index bd3fa2531c..4d08fe1a64 100644 --- a/apps/android/app/src/main/res/values-de/strings.xml +++ b/apps/android/app/src/main/res/values-de/strings.xml @@ -593,12 +593,22 @@ Starten Sie die App neu, um ein neues Chat-Profil zu erstellen. Sie dürfen die neueste Version Ihrer Chat-Datenbank NUR auf einem Gerät verwenden, andernfalls erhalten Sie möglicherweise keine Nachrichten mehr von einigen Ihrer Kontakte. Chat beenden, um Datenbankaktionen zu erlauben. - DATEIEN + DATA Dateien \& Medien löschen Dateien und Medien löschen? Diese Aktion kann nicht rückgängig gemacht werden - alle empfangenen und gesendeten Dateien und Medien werden gelöscht. Bilder mit niedriger Auflösung bleiben erhalten. Keine empfangenen oder gesendeten Dateien %d Datei(en) mit einem Gesamtspeicherverbrauch von %s + no + 1 day + 1 week + 1 month + %s second(s) + Delete messages after + Enable automatic message deletion? + This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes. + Delete messages + Error changing setting Passwort im Keystore sichern diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index e59f1db679..cc195623ee 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -593,12 +593,22 @@ Перезапустите приложение, чтобы создать новый профиль. Используйте самую последнюю версию архива чата и ТОЛЬКО на одном устройстве, иначе вы можете перестать получать сообщения от некоторых контактов. Остановите чат, чтобы разблокировать операции с архивом чата. - ФАЙЛЫ + ДАННЫЕ Удалить файлы и медиа Удалить файлы и медиа? Это действие нельзя отменить — все полученные и отправленные файлы будут удалены. Изображения останутся в низком разрешении. Нет полученных или отправленных файлов %d файл(ов) общим размером %s + нет + 1 день + 1 неделю + 1 месяц + %s секунд + Удалять сообщения через + Включить автоматическое удаление сообщений? + Это действие нельзя отменить — все сообщения, отправленные или полученные раньше чем выбрано, будут удалены. Это может занять несколько минут. + Удалить сообщения + Ошибка при изменении настройки Сохранить пароль в Keystore diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 450c435505..f3236f0dda 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -593,12 +593,22 @@ Restart the app to create a new chat profile. You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts. Stop chat to enable database actions. - FILES + DATA Delete files \& media Delete files and media? This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain. No received or sent files %d file(s) with total size of %s + no + 1 day + 1 week + 1 month + %s second(s) + Delete messages after + Enable automatic message deletion? + This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes. + Delete messages + Error changing setting Save passphrase in Keystore