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 a724d0fdaf..e0039a67bb 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 @@ -305,7 +305,8 @@ fun MainPage( if (chatModel.showCallView.value) ActiveCallView(chatModel) else { showAdvertiseLAAlert = true - if (chatModel.chatId.value == null) ChatListView(chatModel, setPerformLA = { setPerformLA(it) }) + val stopped = chatModel.chatRunning.value == false + if (chatModel.chatId.value == null) ChatListView(chatModel, setPerformLA, stopped) else ChatView(chatModel) } } 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 30df816f37..e0eded442a 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 @@ -67,7 +67,7 @@ class SimplexApp: Application(), LifecycleEventObserver { withApi { when (event) { Lifecycle.Event.ON_STOP -> - if (appPreferences.runServiceInBackground.get()) SimplexService.start(applicationContext) + if (appPreferences.runServiceInBackground.get() && chatModel.chatRunning.value != false) SimplexService.start(applicationContext) Lifecycle.Event.ON_START -> SimplexService.stop(applicationContext) Lifecycle.Event.ON_RESUME -> 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 25276fc6bf..b911edc4a0 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 @@ -23,6 +23,8 @@ class ChatModel(val controller: ChatController) { val onboardingStage = mutableStateOf(null) val currentUser = mutableStateOf(null) val userCreated = mutableStateOf(null) + val chatRunning = mutableStateOf(null) + val chatDbChanged = mutableStateOf(false) val chats = mutableStateListOf() val chatId = mutableStateOf(null) val chatItems = mutableStateListOf() 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 88225a0ae7..a9b9ec8aa7 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 @@ -81,6 +81,9 @@ class AppPreferences(val context: Context) { val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true) val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true) val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false) + val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null) + val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null) + val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null) private fun mkIntPreference(prefName: String, default: Int) = Preference( @@ -100,6 +103,15 @@ class AppPreferences(val context: Context) { set = fun(value) = sharedPreferences.edit().putString(prefName, value).apply() ) + private fun mkDatePreference(prefName: String, default: Instant?): Preference = + Preference( + get = { + val pref = sharedPreferences.getString(prefName, default?.toEpochMilliseconds()?.toString()) + pref?.let { Instant.fromEpochMilliseconds(pref.toLong()) } + }, + set = fun(value) = sharedPreferences.edit().putString(prefName, value?.toEpochMilliseconds()?.toString()).apply() + ) + companion object { private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS" private const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion" @@ -113,6 +125,9 @@ class AppPreferences(val context: Context) { private const val SHARED_PREFS_PRIVACY_ACCEPT_IMAGES = "PrivacyAcceptImages" private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews" private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls" + private const val SHARED_PREFS_CHAT_ARCHIVE_NAME = "ChatArchiveName" + private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime" + private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart" } } @@ -130,12 +145,12 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager suspend fun startChat(user: User) { Log.d(TAG, "user: $user") try { - val chatStarted = apiStartChat() + val justStarted = apiStartChat() apiSetFilesFolder(getAppFilesDirectory(appContext)) chatModel.userAddress.value = apiGetUserAddress() chatModel.userSMPServers.value = getUserSMPServers() val chats = apiGetChats() - if (chatStarted) { + if (justStarted) { chatModel.chats.clear() chatModel.chats.addAll(chats) } else { @@ -144,6 +159,8 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager chatModel.currentUser.value = user chatModel.userCreated.value = true chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete + chatModel.controller.appPrefs.chatLastStart.set(Clock.System.now()) + chatModel.chatRunning.value = true startReceiver() Log.d(TAG, "chat started") } catch (e: Error) { @@ -215,7 +232,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager throw Error("user not created ${r.responseType} ${r.details}") } - private suspend fun apiStartChat(): Boolean { + suspend fun apiStartChat(): Boolean { val r = sendCmd(CC.StartChat()) when (r) { is CR.ChatStarted -> return true @@ -224,12 +241,38 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager } } + suspend fun apiStopChat(): Boolean { + val r = sendCmd(CC.ApiStopChat()) + when (r) { + is CR.ChatStopped -> return true + else -> throw Error("failed stopping chat: ${r.responseType} ${r.details}") + } + } + private suspend fun apiSetFilesFolder(filesFolder: String) { val r = sendCmd(CC.SetFilesFolder(filesFolder)) if (r is CR.CmdOk) return throw Error("failed to set files folder: ${r.responseType} ${r.details}") } + suspend fun apiExportArchive(config: ArchiveConfig) { + val r = sendCmd(CC.ApiExportArchive(config)) + if (r is CR.CmdOk) return + throw Error("failed to export archive: ${r.responseType} ${r.details}") + } + + suspend fun apiImportArchive(config: ArchiveConfig) { + val r = sendCmd(CC.ApiImportArchive(config)) + if (r is CR.CmdOk) return + throw Error("failed to import archive: ${r.responseType} ${r.details}") + } + + suspend fun apiDeleteStorage() { + val r = sendCmd(CC.ApiDeleteStorage()) + if (r is CR.CmdOk) return + throw Error("failed to delete storage: ${r.responseType} ${r.details}") + } + private suspend fun apiGetChats(): List { val r = sendCmd(CC.ApiGetChats()) if (r is CR.ApiChats ) return r.chats @@ -844,7 +887,11 @@ sealed class CC { class ShowActiveUser: CC() class CreateActiveUser(val profile: Profile): CC() class StartChat: CC() + class ApiStopChat: CC() class SetFilesFolder(val filesFolder: String): CC() + class ApiExportArchive(val config: ArchiveConfig): CC() + class ApiImportArchive(val config: ArchiveConfig): CC() + class ApiDeleteStorage: CC() class ApiGetChats: CC() class ApiGetChat(val type: ChatType, val id: Long): CC() class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent): CC() @@ -878,7 +925,11 @@ sealed class CC { is ShowActiveUser -> "/u" is CreateActiveUser -> "/u ${profile.displayName} ${profile.fullName}" is StartChat -> "/_start" + is ApiStopChat -> "/_stop" is SetFilesFolder -> "/_files_folder $filesFolder" + is ApiExportArchive -> "/_db export ${json.encodeToString(config)}" + is ApiImportArchive -> "/_db import ${json.encodeToString(config)}" + is ApiDeleteStorage -> "/_db delete" is ApiGetChats -> "/_get chats pcc=on" is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100" is ApiSendMessage -> "/_send ${chatRef(type, id)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}" @@ -913,7 +964,11 @@ sealed class CC { is ShowActiveUser -> "showActiveUser" is CreateActiveUser -> "createActiveUser" is StartChat -> "startChat" + is ApiStopChat -> "apiStopChat" is SetFilesFolder -> "setFilesFolder" + is ApiExportArchive -> "apiExportArchive" + is ApiImportArchive -> "apiImportArchive" + is ApiDeleteStorage -> "apiDeleteStorage" is ApiGetChats -> "apiGetChats" is ApiGetChat -> "apiGetChat" is ApiSendMessage -> "apiSendMessage" @@ -955,6 +1010,9 @@ sealed class CC { @Serializable class ComposedMessage(val filePath: String?, val quotedItemId: Long?, val msgContent: MsgContent) +@Serializable +class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = null, val parentTempDirectory: String? = null) + val json = Json { prettyPrint = true ignoreUnknownKeys = true @@ -988,6 +1046,7 @@ sealed class CR { @Serializable @SerialName("activeUser") class ActiveUser(val user: User): CR() @Serializable @SerialName("chatStarted") class ChatStarted: CR() @Serializable @SerialName("chatRunning") class ChatRunning: CR() + @Serializable @SerialName("chatStopped") class ChatStopped: 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() @@ -1046,6 +1105,7 @@ sealed class CR { is ActiveUser -> "activeUser" is ChatStarted -> "chatStarted" is ChatRunning -> "chatRunning" + is ChatStopped -> "chatStopped" is ApiChats -> "apiChats" is ApiChat -> "apiChat" is UserSMPServers -> "userSMPServers" @@ -1105,6 +1165,7 @@ sealed class CR { is ActiveUser -> json.encodeToString(user) is ChatStarted -> noDetails() is ChatRunning -> noDetails() + is ChatStopped -> noDetails() is ApiChats -> json.encodeToString(chats) is ApiChat -> json.encodeToString(chat) is UserSMPServers -> json.encodeToString(smpServers) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt index 0fc8193047..db30a923c4 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt @@ -1,6 +1,7 @@ package chat.simplex.app.views.chatlist import android.content.res.Configuration +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -28,6 +29,7 @@ import kotlinx.datetime.Clock fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { val showMenu = remember { mutableStateOf(false) } var showMarkRead by remember { mutableStateOf(false) } + val stopped = chatModel.chatRunning.value == false LaunchedEffect(chat.id, chat.chatStats.unreadCount > 0) { showMenu.value = false delay(500L) @@ -36,31 +38,35 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { when (chat.chatInfo) { is ChatInfo.Direct -> ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat) }, + chatLinkPreview = { ChatPreviewView(chat, stopped) }, click = { openOrPendingChat(chat.chatInfo, chatModel) }, dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) }, - showMenu + showMenu, + stopped ) is ChatInfo.Group -> ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat) }, + chatLinkPreview = { ChatPreviewView(chat, stopped) }, click = { openOrPendingChat(chat.chatInfo, chatModel) }, dropdownMenuItems = { GroupMenuItems(chat, chatModel, showMenu, showMarkRead) }, - showMenu + showMenu, + stopped ) is ChatInfo.ContactRequest -> ChatListNavLinkLayout( chatLinkPreview = { ContactRequestView(chat.chatInfo) }, click = { contactRequestAlertDialog(chat.chatInfo, chatModel) }, dropdownMenuItems = { ContactRequestMenuItems(chat.chatInfo, chatModel, showMenu) }, - showMenu + showMenu, + stopped ) is ChatInfo.ContactConnection -> ChatListNavLinkLayout( chatLinkPreview = { ContactConnectionView(chat.chatInfo.contactConnection) }, click = { contactConnectionAlertDialog(chat.chatInfo.contactConnection, chatModel) }, dropdownMenuItems = { ContactConnectionMenuItems(chat.chatInfo, chatModel, showMenu) }, - showMenu + showMenu, + stopped ) } } @@ -286,17 +292,12 @@ fun ChatListNavLinkLayout( chatLinkPreview: @Composable () -> Unit, click: () -> Unit, dropdownMenuItems: (@Composable () -> Unit)?, - showMenu: MutableState + showMenu: MutableState, + stopped: Boolean ) { - Surface( - modifier = Modifier - .fillMaxWidth() - .combinedClickable( - onClick = click, - onLongClick = { showMenu.value = true } - ) - .height(88.dp) - ) { + var modifier = Modifier.fillMaxWidth().height(88.dp) + if (!stopped) modifier = modifier.combinedClickable(onClick = click, onLongClick = { showMenu.value = true }) + Surface(modifier) { Row( modifier = Modifier .fillMaxWidth() @@ -345,12 +346,14 @@ fun PreviewChatListNavLinkDirect() { ) ), chatStats = Chat.ChatStats() - ) + ), + stopped = false ) }, click = {}, dropdownMenuItems = null, - showMenu = remember { mutableStateOf(false) } + showMenu = remember { mutableStateOf(false) }, + stopped = false ) } } @@ -378,12 +381,14 @@ fun PreviewChatListNavLinkGroup() { ) ), chatStats = Chat.ChatStats() - ) + ), + stopped = false ) }, click = {}, dropdownMenuItems = null, - showMenu = remember { mutableStateOf(false) } + showMenu = remember { mutableStateOf(false) }, + stopped = false ) } } @@ -403,7 +408,8 @@ fun PreviewChatListNavLinkContactRequest() { }, click = {}, dropdownMenuItems = null, - showMenu = remember { mutableStateOf(false) } + showMenu = remember { mutableStateOf(false) }, + stopped = false ) } } 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 a5ff2008aa..a3fc3ada33 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 @@ -7,6 +7,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Report import androidx.compose.material.icons.outlined.Menu import androidx.compose.material.icons.outlined.PersonAdd import androidx.compose.runtime.* @@ -20,6 +21,8 @@ import chat.simplex.app.R import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.ToolbarDark import chat.simplex.app.ui.theme.ToolbarLight +import chat.simplex.app.views.helpers.AlertManager +import chat.simplex.app.views.helpers.generalGetString import chat.simplex.app.views.newchat.NewChatSheet import chat.simplex.app.views.onboarding.MakeConnection import chat.simplex.app.views.usersettings.SettingsView @@ -64,7 +67,7 @@ fun scaffoldController(): ScaffoldController { } @Composable -fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { +fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: Boolean) { val scaffoldCtrl = scaffoldController() LaunchedEffect(chatModel.clearOverlays.value) { if (chatModel.clearOverlays.value && scaffoldCtrl.expanded.value) scaffoldCtrl.collapse() @@ -82,7 +85,7 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { .fillMaxSize() .background(MaterialTheme.colors.background) ) { - ChatListToolbar(scaffoldCtrl) + ChatListToolbar(scaffoldCtrl, stopped) Divider() if (chatModel.chats.isNotEmpty()) { ChatList(chatModel) @@ -103,7 +106,7 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { } @Composable -fun ChatListToolbar(scaffoldCtrl: ScaffoldController) { +fun ChatListToolbar(scaffoldCtrl: ScaffoldController, stopped: Boolean) { Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, @@ -127,13 +130,24 @@ fun ChatListToolbar(scaffoldCtrl: ScaffoldController) { fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(5.dp) ) - IconButton(onClick = { scaffoldCtrl.toggleSheet() }) { - Icon( - Icons.Outlined.PersonAdd, - stringResource(R.string.add_contact), - tint = MaterialTheme.colors.primary, - modifier = Modifier.padding(10.dp) - ) + if (!stopped) { + IconButton(onClick = { scaffoldCtrl.toggleSheet() }) { + Icon( + Icons.Outlined.PersonAdd, + stringResource(R.string.add_contact), + tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(10.dp) + ) + } + } else { + IconButton(onClick = { AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_is_stopped_indication), generalGetString(R.string.you_can_start_chat_via_setting_or_by_restarting_the_app)) }) { + Icon( + Icons.Filled.Report, + generalGetString(R.string.chat_is_stopped_indication), + tint = Color.Red, + modifier = Modifier.padding(10.dp) + ) + } } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt index 4bbabd9143..1ce4177e25 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt @@ -26,7 +26,7 @@ import chat.simplex.app.views.helpers.ChatInfoImage import chat.simplex.app.views.helpers.badgeLayout @Composable -fun ChatPreviewView(chat: Chat) { +fun ChatPreviewView(chat: Chat, stopped: Boolean) { Row { val cInfo = chat.chatInfo ChatInfoImage(cInfo, size = 72.dp) @@ -80,7 +80,7 @@ fun ChatPreviewView(chat: Chat) { color = MaterialTheme.colors.onPrimary, fontSize = 11.sp, modifier = Modifier - .background(MaterialTheme.colors.primary, shape = CircleShape) + .background(if (stopped) HighOrLowlight else MaterialTheme.colors.primary, shape = CircleShape) .badgeLayout() .padding(horizontal = 3.dp) .padding(vertical = 1.dp) @@ -131,6 +131,6 @@ fun ChatStatusImage(chat: Chat) { @Composable fun PreviewChatPreviewView() { SimpleXTheme { - ChatPreviewView(Chat.sampleData) + ChatPreviewView(Chat.sampleData, stopped = false) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/database/ChatArchiveView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/database/ChatArchiveView.kt new file mode 100644 index 0000000000..f3c4fb5a66 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/database/ChatArchiveView.kt @@ -0,0 +1,145 @@ +package chat.simplex.app.views.database + +import android.content.Context +import android.content.res.Configuration +import android.net.Uri +import android.util.Log +import android.widget.Toast +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import chat.simplex.app.R +import chat.simplex.app.TAG +import chat.simplex.app.model.ChatModel +import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.usersettings.SettingsActionItem +import chat.simplex.app.views.usersettings.SettingsSectionView +import kotlinx.datetime.* +import java.io.BufferedOutputStream +import java.io.File +import java.text.SimpleDateFormat +import java.util.* + +@Composable +fun ChatArchiveView(m: ChatModel, title: String, archiveName: String, archiveTime: Instant) { + val context = LocalContext.current + val archivePath = "${getFilesDirectory(context)}/$archiveName" + val saveArchiveLauncher = rememberSaveArchiveLauncher(cxt = context, archivePath) + ChatArchiveLayout( + title, + archiveTime, + saveArchive = { saveArchiveLauncher.launch(archivePath.substringAfterLast("/")) }, + deleteArchiveAlert = { deleteArchiveAlert(m, archivePath) } + ) +} + +@Composable +fun ChatArchiveLayout( + title: String, + archiveTime: Instant, + saveArchive: () -> Unit, + deleteArchiveAlert: () -> Unit +) { + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + ) { + @Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp)) + Text( + title, + Modifier.padding(start = 16.dp, bottom = 24.dp), + style = MaterialTheme.typography.h1 + ) + + SettingsSectionView(stringResource(R.string.chat_database_section)) { + SettingsActionItem( + Icons.Outlined.IosShare, + stringResource(R.string.save_archive), + saveArchive, + textColor = MaterialTheme.colors.primary + ) + divider() + SettingsActionItem( + Icons.Outlined.Delete, + stringResource(R.string.delete_archive), + deleteArchiveAlert, + textColor = Color.Red + ) + } + val archiveTs = SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US).format(Date.from(archiveTime.toJavaInstant())) + SettingsSectionFooter( + String.format(generalGetString(R.string.archive_created_on_ts), archiveTs) + ) + } +} + +@Composable +private fun rememberSaveArchiveLauncher(cxt: Context, chatArchivePath: String): ManagedActivityResultLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument(), + onResult = { destination -> + try { + destination?.let { + val contentResolver = cxt.contentResolver + contentResolver.openOutputStream(destination)?.let { stream -> + val outputStream = BufferedOutputStream(stream) + val file = File(chatArchivePath) + outputStream.write(file.readBytes()) + outputStream.close() + Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show() + } + } + } catch (e: Error) { + Toast.makeText(cxt, generalGetString(R.string.error_saving_file), Toast.LENGTH_SHORT).show() + Log.e(TAG, "rememberSaveArchiveLauncher error saving archive $e") + } + } + ) + +private fun deleteArchiveAlert(m: ChatModel, archivePath: String) { + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.delete_chat_archive_question), + confirmText = generalGetString(R.string.delete_verb), + onConfirm = { + val fileDeleted = File(archivePath).delete() + if (fileDeleted) { + m.controller.appPrefs.chatArchiveName.set(null) + m.controller.appPrefs.chatArchiveTime.set(null) + ModalManager.shared.closeModal() + } else { + Log.e(TAG, "deleteArchiveAlert delete() error") + } + } + ) +} + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun PreviewChatArchiveLayout() { + SimpleXTheme { + ChatArchiveLayout( + title = "New database archive", + archiveTime = Clock.System.now(), + saveArchive = {}, + deleteArchiveAlert = {} + ) + } +} 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 new file mode 100644 index 0000000000..e3d55c164b --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt @@ -0,0 +1,471 @@ +package chat.simplex.app.views.database + +import android.content.Context +import android.content.res.Configuration +import android.net.Uri +import android.os.FileUtils +import android.util.Log +import android.widget.Toast +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Report +import androidx.compose.material.icons.outlined.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.R +import chat.simplex.app.TAG +import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.HighOrLowlight +import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.usersettings.* +import kotlinx.datetime.* +import java.io.* +import java.text.SimpleDateFormat +import java.util.* + +@Composable +fun DatabaseView( + m: ChatModel, + showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit) +) { + val context = LocalContext.current + val progressIndicator = remember { mutableStateOf(false) } + val runChat = remember { mutableStateOf(false) } + val prefs = m.controller.appPrefs + val chatArchiveName = remember { mutableStateOf(prefs.chatArchiveName.get()) } + val chatArchiveTime = remember { mutableStateOf(prefs.chatArchiveTime.get()) } + val chatLastStart = remember { mutableStateOf(prefs.chatLastStart.get()) } + val chatArchiveFile = remember { mutableStateOf(null) } + val saveArchiveLauncher = rememberSaveArchiveLauncher(cxt = context, chatArchiveFile) + val importArchiveLauncher = rememberGetContentLauncher { uri: Uri? -> + if (uri != null) { + importArchiveAlert(m, context, uri, progressIndicator) + } + } + LaunchedEffect(m.chatRunning) { + runChat.value = m.chatRunning.value ?: true + } + Box( + Modifier.fillMaxSize(), + ) { + DatabaseLayout( + progressIndicator.value, + runChat.value, + m.chatDbChanged.value, + importArchiveLauncher, + chatArchiveName, + chatArchiveTime, + chatLastStart, + startChat = { startChat(m, runChat) }, + stopChatAlert = { stopChatAlert(m, runChat) }, + exportArchive = { exportArchive(context, m, progressIndicator, chatArchiveName, chatArchiveTime, chatArchiveFile, saveArchiveLauncher) }, + deleteChatAlert = { deleteChatAlert(m, progressIndicator) }, + showSettingsModal + ) + if (progressIndicator.value) { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + Modifier + .padding(horizontal = 2.dp) + .size(30.dp), + color = HighOrLowlight, + strokeWidth = 2.5.dp + ) + } + } + } +} + +@Composable +fun DatabaseLayout( + progressIndicator: Boolean, + runChat: Boolean, + chatDbChanged: Boolean, + importArchiveLauncher: ManagedActivityResultLauncher, + chatArchiveName: MutableState, + chatArchiveTime: MutableState, + chatLastStart: MutableState, + startChat: () -> Unit, + stopChatAlert: () -> Unit, + exportArchive: () -> Unit, + deleteChatAlert: () -> Unit, + showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit) +) { + val stopped = !runChat + val operationsDisabled = !stopped || progressIndicator || chatDbChanged + + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + ) { + @Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp)) + Text( + stringResource(R.string.your_chat_database), + Modifier.padding(start = 16.dp, bottom = 24.dp), + style = MaterialTheme.typography.h1 + ) + + SettingsSectionView(stringResource(R.string.run_chat_section)) { + RunChatSetting(runChat, stopped, chatDbChanged, startChat, stopChatAlert) + } + Spacer(Modifier.height(30.dp)) + + SettingsSectionView(stringResource(R.string.chat_database_section)) { + SettingsActionItem( + Icons.Outlined.IosShare, + stringResource(R.string.export_database), + exportArchive, + textColor = MaterialTheme.colors.primary, + disabled = operationsDisabled + ) + divider() + SettingsActionItem( + Icons.Outlined.FileDownload, + stringResource(R.string.import_database), + { importArchiveLauncher.launch("application/zip") }, + textColor = Color.Red, + disabled = operationsDisabled + ) + divider() + val chatArchiveNameVal = chatArchiveName.value + val chatArchiveTimeVal = chatArchiveTime.value + val chatLastStartVal = chatLastStart.value + if (chatArchiveNameVal != null && chatArchiveTimeVal != null && chatLastStartVal != null) { + val title = chatArchiveTitle(chatArchiveTimeVal, chatLastStartVal) + SettingsActionItem( + Icons.Outlined.Inventory2, + title, + click = showSettingsModal { ChatArchiveView(it, title, chatArchiveNameVal, chatArchiveTimeVal) }, + disabled = operationsDisabled + ) + divider() + } + SettingsActionItem( + Icons.Outlined.DeleteForever, + stringResource(R.string.delete_database), + deleteChatAlert, + textColor = Color.Red, + disabled = operationsDisabled + ) + } + SettingsSectionFooter( + if (chatDbChanged) { + stringResource(R.string.restart_the_app_to_use_new_chat_database) + } else { + if (stopped) { + stringResource(R.string.you_must_use_the_most_recent_version_of_database) + } else { + stringResource(R.string.stop_chat_to_enable_database_actions) + } + } + ) + } +} + +@Composable +fun RunChatSetting( + runChat: Boolean, + stopped: Boolean, + chatDbChanged: Boolean, + startChat: () -> Unit, + stopChatAlert: () -> Unit +) { + SettingsItemView() { + Row(verticalAlignment = Alignment.CenterVertically) { + val chatRunningText = if (stopped) stringResource(R.string.chat_is_stopped) else stringResource(R.string.chat_is_running) + Icon( + if (stopped) Icons.Filled.Report else Icons.Filled.PlayArrow, + chatRunningText, + tint = if (chatDbChanged) HighOrLowlight else if (stopped) Color.Red else MaterialTheme.colors.primary + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + Text( + chatRunningText, + Modifier.padding(end = 24.dp), + color = if (chatDbChanged) HighOrLowlight else Color.Unspecified + ) + Spacer(Modifier.fillMaxWidth().weight(1f)) + Switch( + checked = runChat, + onCheckedChange = { runChatSwitch -> + if (runChatSwitch) { + startChat() + } else { + stopChatAlert() + } + }, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colors.primary, + uncheckedThumbColor = HighOrLowlight + ), + enabled = !chatDbChanged + ) + } + } +} + +@Composable +fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String { + return stringResource(if (chatArchiveTime < chatLastStart) R.string.old_database_archive else R.string.new_database_archive) +} + +@Composable +fun SettingsSectionFooter(text: String) { + Text(text, color = HighOrLowlight, modifier = Modifier.padding(start = 16.dp, top = 5.dp).fillMaxWidth(0.9F), fontSize = 12.sp) +} + +private fun startChat(m: ChatModel, runChat: MutableState) { + withApi { + try { + m.controller.apiStartChat() + runChat.value = true + m.chatRunning.value = true + m.controller.appPrefs.chatLastStart.set(Clock.System.now()) + } catch (e: Error) { + runChat.value = false + AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString()) + } + } +} + +private fun stopChatAlert(m: ChatModel, runChat: MutableState) { + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.stop_chat_question), + text = generalGetString(R.string.stop_chat_to_export_import_or_delete_chat_database), + confirmText = generalGetString(R.string.stop_chat_confirmation), + onConfirm = { stopChat(m, runChat) }, + onDismiss = { runChat.value = true } + ) +} + +private fun stopChat(m: ChatModel, runChat: MutableState) { + withApi { + try { + m.controller.apiStopChat() + runChat.value = false + m.chatRunning.value = false + } catch (e: Error) { + runChat.value = true + AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString()) + } + } +} + +private fun exportArchive( + context: Context, + m: ChatModel, + progressIndicator: MutableState, + chatArchiveName: MutableState, + chatArchiveTime: MutableState, + chatArchiveFile: MutableState, + saveArchiveLauncher: ManagedActivityResultLauncher +) { + progressIndicator.value = true + withApi { + try { + val archiveFile = exportChatArchive(m, context, chatArchiveName, chatArchiveTime, chatArchiveFile) + chatArchiveFile.value = archiveFile + saveArchiveLauncher.launch(archiveFile.substringAfterLast("/")) + progressIndicator.value = false + } catch (e: Error) { + AlertManager.shared.showAlertMsg(generalGetString(R.string.error_exporting_chat_database), e.toString()) + progressIndicator.value = false + } + } +} + +private suspend fun exportChatArchive( + m: ChatModel, + context: Context, + chatArchiveName: MutableState, + chatArchiveTime: MutableState, + chatArchiveFile: MutableState +): String { + val archiveTime = Clock.System.now() + val ts = SimpleDateFormat("yyyy-MM-dd'T'HHmmss", Locale.US).format(Date.from(archiveTime.toJavaInstant())) + val archiveName = "simplex-chat.$ts.zip" + val archivePath = "${getFilesDirectory(context)}/$archiveName" + val config = ArchiveConfig(archivePath, parentTempDirectory = context.cacheDir.toString()) + m.controller.apiExportArchive(config) + deleteOldArchive(m, context) + m.controller.appPrefs.chatArchiveName.set(archiveName) + chatArchiveName.value = archiveName + m.controller.appPrefs.chatArchiveTime.set(archiveTime) + chatArchiveTime.value = archiveTime + chatArchiveFile.value = archivePath + return archivePath +} + +private fun deleteOldArchive(m: ChatModel, context: Context) { + val chatArchiveName = m.controller.appPrefs.chatArchiveName.get() + if (chatArchiveName != null) { + val file = File("${getFilesDirectory(context)}/$chatArchiveName") + val fileDeleted = file.delete() + if (fileDeleted) { + m.controller.appPrefs.chatArchiveName.set(null) + m.controller.appPrefs.chatArchiveTime.set(null) + } else { + Log.e(TAG, "deleteOldArchive file.delete() error") + } + } +} + +@Composable +private fun rememberSaveArchiveLauncher(cxt: Context, chatArchiveFile: MutableState): ManagedActivityResultLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument(), + onResult = { destination -> + try { + destination?.let { + val filePath = chatArchiveFile.value + if (filePath != null) { + val contentResolver = cxt.contentResolver + contentResolver.openOutputStream(destination)?.let { stream -> + val outputStream = BufferedOutputStream(stream) + val file = File(filePath) + outputStream.write(file.readBytes()) + outputStream.close() + Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show() + } + } else { + Toast.makeText(cxt, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show() + } + } + } catch (e: Error) { + Toast.makeText(cxt, generalGetString(R.string.error_saving_file), Toast.LENGTH_SHORT).show() + Log.e(TAG, "rememberSaveArchiveLauncher error saving archive $e") + } finally { + chatArchiveFile.value = null + } + } + ) + +private fun importArchiveAlert(m: ChatModel, context: Context, importedArchiveUri: Uri, progressIndicator: MutableState) { + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.import_database_question), + text = generalGetString(R.string.your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one), + confirmText = generalGetString(R.string.import_database_confirmation), + onConfirm = { importArchive(m, context, importedArchiveUri, progressIndicator) } + ) +} + +private fun importArchive(m: ChatModel, context: Context, importedArchiveUri: Uri, progressIndicator: MutableState) { + progressIndicator.value = true + val archivePath = saveArchiveFromUri(context, importedArchiveUri) + if (archivePath != null) { + withApi { + try { + m.controller.apiDeleteStorage() + try { + val config = ArchiveConfig(archivePath, parentTempDirectory = context.cacheDir.toString()) + m.controller.apiImportArchive(config) + operationEnded(m, progressIndicator) { + AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_imported), generalGetString(R.string.restart_the_app_to_use_imported_chat_database)) + } + } catch (e: Error) { + operationEnded(m, progressIndicator) { + AlertManager.shared.showAlertMsg(generalGetString(R.string.error_importing_database), e.toString()) + } + } + } catch (e: Error) { + operationEnded(m, progressIndicator) { + AlertManager.shared.showAlertMsg(generalGetString(R.string.error_deleting_database), e.toString()) + } + } finally { + File(archivePath).delete() + } + } + } +} + +private fun saveArchiveFromUri(context: Context, importedArchiveUri: Uri): String? { + return try { + val inputStream = context.contentResolver.openInputStream(importedArchiveUri) + val archiveName = getFileName(context, importedArchiveUri) + if (inputStream != null && archiveName != null) { + val archivePath = "${context.cacheDir}/$archiveName" + val destFile = File(archivePath) + FileUtils.copy(inputStream, FileOutputStream(destFile)) + archivePath + } else { + Log.e(TAG, "saveArchiveFromUri null inputStream") + null + } + } catch (e: Exception) { + Log.e(TAG, "saveArchiveFromUri error: ${e.message}") + null + } +} + +private fun deleteChatAlert(m: ChatModel, progressIndicator: MutableState) { + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.delete_chat_profile_question), + text = generalGetString(R.string.delete_chat_profile_action_cannot_be_undone_warning), + confirmText = generalGetString(R.string.delete_verb), + onConfirm = { deleteChat(m, progressIndicator) } + ) +} + +private fun deleteChat(m: ChatModel, progressIndicator: MutableState) { + progressIndicator.value = true + withApi { + try { + m.controller.apiDeleteStorage() + operationEnded(m, progressIndicator) { + AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_deleted), generalGetString(R.string.restart_the_app_to_create_a_new_chat_profile)) + } + } catch (e: Error) { + operationEnded(m, progressIndicator) { + AlertManager.shared.showAlertMsg(generalGetString(R.string.error_deleting_database), e.toString()) + } + } + } +} + +private fun operationEnded(m: ChatModel, progressIndicator: MutableState, alert: () -> Unit) { + m.chatDbChanged.value = true + progressIndicator.value = false + alert.invoke() +} + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun PreviewDatabaseLayout() { + SimpleXTheme { + DatabaseLayout( + progressIndicator = false, + runChat = true, + chatDbChanged = false, + importArchiveLauncher = rememberGetContentLauncher {}, + chatArchiveName = remember { mutableStateOf("dummy_archive") }, + chatArchiveTime = remember { mutableStateOf(Clock.System.now()) }, + chatLastStart = remember { mutableStateOf(Clock.System.now()) }, + startChat = {}, + stopChatAlert = {}, + exportArchive = {}, + deleteChatAlert = {}, + showSettingsModal = { {} } + ) + } +} 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 582a387e9a..6e42d149ed 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 @@ -5,6 +5,7 @@ import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Report import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -23,12 +24,14 @@ import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.TerminalView +import chat.simplex.app.views.database.DatabaseView import chat.simplex.app.views.helpers.* import chat.simplex.app.views.onboarding.SimpleXInfo @Composable fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { val user = chatModel.currentUser.value + val stopped = chatModel.chatRunning.value == false fun setRunServiceInBackground(on: Boolean) { chatModel.controller.appPrefs.runServiceInBackground.set(on) @@ -42,6 +45,7 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { if (user != null) { SettingsLayout( profile = user.profile, + stopped, runServiceInBackground = chatModel.runServiceInBackground, setRunServiceInBackground = ::setRunServiceInBackground, setPerformLA = setPerformLA, @@ -65,6 +69,7 @@ val simplexTeamUri = @Composable fun SettingsLayout( profile: Profile, + stopped: Boolean, runServiceInBackground: MutableState, setRunServiceInBackground: (Boolean) -> Unit, setPerformLA: (Boolean) -> Unit, @@ -92,40 +97,42 @@ fun SettingsLayout( Spacer(Modifier.height(30.dp)) SettingsSectionView(stringResource(R.string.settings_section_title_you)) { - SettingsItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp) { - ProfilePreview(profile) + SettingsItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, disabled = stopped) { + ProfilePreview(profile, stopped = stopped) } divider() - SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { UserAddressView(it) }) + SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { UserAddressView(it) }, disabled = stopped) + divider() + DatabaseItem(showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped) } spacer() SettingsSectionView(stringResource(R.string.settings_section_title_settings)) { - SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it) }) + SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it) }, disabled = stopped) divider() - SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) }) + SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) }, disabled = stopped) divider() - PrivateNotificationsItem(runServiceInBackground, setRunServiceInBackground) + PrivateNotificationsItem(runServiceInBackground, setRunServiceInBackground, stopped) divider() - SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) }) + SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) }, disabled = stopped) } spacer() SettingsSectionView(stringResource(R.string.settings_section_title_help)) { - SettingsActionItem(Icons.Outlined.HelpOutline, stringResource(R.string.how_to_use_simplex_chat), showModal { HelpView(it) }) + SettingsActionItem(Icons.Outlined.HelpOutline, stringResource(R.string.how_to_use_simplex_chat), showModal { HelpView(it) }, disabled = stopped) divider() SettingsActionItem(Icons.Outlined.Info, stringResource(R.string.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) }) divider() SettingsActionItem(Icons.Outlined.TextFormat, stringResource(R.string.markdown_in_messages), showModal { MarkdownHelpView() }) divider() - SettingsActionItem(Icons.Outlined.Tag, stringResource(R.string.chat_with_the_founder), { uriHandler.openUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary) + SettingsActionItem(Icons.Outlined.Tag, stringResource(R.string.chat_with_the_founder), { uriHandler.openUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped) divider() SettingsActionItem(Icons.Outlined.Email, stringResource(R.string.send_us_an_email), { uriHandler.openUri("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary) } spacer() SettingsSectionView(stringResource(R.string.settings_section_title_develop)) { - ChatConsoleItem(showTerminal) + ChatConsoleItem(showTerminal, stopped) divider() InstallTerminalAppItem(uriHandler) divider() @@ -139,19 +146,49 @@ fun SettingsLayout( @Composable fun SettingsSectionView(title: String, content: (@Composable () -> Unit)) { Column { - Text(title, color = HighOrLowlight, style = MaterialTheme.typography.body2, - modifier = Modifier.padding(start = 16.dp, bottom = 5.dp), fontSize = 12.sp) + Text( + title, color = HighOrLowlight, style = MaterialTheme.typography.body2, + modifier = Modifier.padding(start = 16.dp, bottom = 5.dp), fontSize = 12.sp + ) Surface(color = if (isSystemInDarkTheme()) GroupDark else MaterialTheme.colors.background) { Column(Modifier.padding(horizontal = 6.dp)) { content() } } } } +@Composable private fun DatabaseItem(openDatabaseView: () -> Unit, stopped: Boolean) { + SettingsItemView(openDatabaseView) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row { + Icon( + Icons.Outlined.Archive, + contentDescription = stringResource(R.string.database_export_and_import), + tint = HighOrLowlight, + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + Text(stringResource(R.string.database_export_and_import)) + } + if (stopped) { + Icon( + Icons.Filled.Report, + contentDescription = stringResource(R.string.chat_is_stopped), + tint = Color.Red, + modifier = Modifier.padding(end = 6.dp) + ) + } + } + } +} + @Composable private fun PrivateNotificationsItem( runServiceInBackground: MutableState, - setRunServiceInBackground: (Boolean) -> Unit + setRunServiceInBackground: (Boolean) -> Unit, + stopped: Boolean ) { - SettingsItemView() { + SettingsItemView(disabled = stopped) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( Icons.Outlined.Bolt, @@ -164,7 +201,8 @@ fun SettingsLayout( Modifier .padding(end = 24.dp) .fillMaxWidth() - .weight(1f) + .weight(1f), + color = if (stopped) HighOrLowlight else Color.Unspecified ) Switch( checked = runServiceInBackground.value, @@ -173,7 +211,8 @@ fun SettingsLayout( checkedThumbColor = MaterialTheme.colors.primary, uncheckedThumbColor = HighOrLowlight ), - modifier = Modifier.padding(end = 6.dp) + modifier = Modifier.padding(end = 6.dp), + enabled = !stopped ) } } @@ -207,15 +246,18 @@ fun SettingsLayout( } } -@Composable private fun ChatConsoleItem(showTerminal: () -> Unit) { - SettingsItemView(showTerminal) { +@Composable private fun ChatConsoleItem(showTerminal: () -> Unit, stopped: Boolean) { + SettingsItemView(showTerminal, disabled = stopped) { Icon( painter = painterResource(id = R.drawable.ic_outline_terminal), contentDescription = stringResource(R.string.chat_console), tint = HighOrLowlight, ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text(stringResource(R.string.chat_console)) + Text( + stringResource(R.string.chat_console), + color = if (stopped) HighOrLowlight else Color.Unspecified + ) } } @@ -237,7 +279,7 @@ fun SettingsLayout( } } -@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, color: Color = MaterialTheme.colors.secondary) { +@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, color: Color = MaterialTheme.colors.secondary, stopped: Boolean = false) { ProfileImage(size = size, image = profileOf.image, color = color) Spacer(Modifier.padding(horizontal = 4.dp)) Column { @@ -245,19 +287,23 @@ fun SettingsLayout( profileOf.displayName, style = MaterialTheme.typography.caption, fontWeight = FontWeight.Bold, + color = if (stopped) HighOrLowlight else Color.Unspecified + ) + Text( + profileOf.fullName, + color = if (stopped) HighOrLowlight else Color.Unspecified ) - Text(profileOf.fullName) } } @Composable -fun SettingsItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, content: (@Composable () -> Unit)) { +fun SettingsItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, disabled: Boolean = false, content: (@Composable () -> Unit)) { val modifier = Modifier .padding(start = 8.dp) .fillMaxWidth() .height(height) Row( - if (click == null) modifier else modifier.clickable(onClick = click), + if (click == null || disabled) modifier else modifier.clickable(onClick = click), verticalAlignment = Alignment.CenterVertically ) { content() @@ -265,11 +311,11 @@ fun SettingsItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, content: ( } @Composable -fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified) { - SettingsItemView(click) { +fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified, disabled: Boolean = false) { + SettingsItemView(click, disabled = disabled) { Icon(icon, text, tint = HighOrLowlight) Spacer(Modifier.padding(horizontal = 4.dp)) - Text(text, color = textColor) + Text(text, color = if (disabled) HighOrLowlight else textColor) } } @@ -295,6 +341,7 @@ fun PreviewSettingsLayout() { SimpleXTheme { SettingsLayout( profile = Profile.sampleData, + stopped = false, runServiceInBackground = remember { mutableStateOf(true) }, setRunServiceInBackground = {}, setPerformLA = {}, 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 c6a916f6c3..0bd6e6901d 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -244,6 +244,7 @@ Настройки Ваш SimpleX адрес + Экспорт и импорт архива чата Информация о SimpleX Chat Как использовать Форматирование сообщений @@ -439,4 +440,47 @@ УСТРОЙСТВО ЧАТЫ Экспериментальные функции + + + Архив чата + ЗАПУСТИТЬ ЧАТ + Чат запущен + Чат остановлен + АРХИВ ЧАТА + Экспорт архива чата + Импорт архива чата + Новый архив чата + Старый архив чата + Удалить данные чата + Ошибка при запуске чата + Остановить чат? + Остановите чат, чтобы экспортировать или импортировать архив чата или удалить данные чата. Вы не сможете получать и отправлять сообщения, пока чат остановлен. + Остановить + Ошибка при остановке чата + Ошибка экспорта архива чата + Импортировать архив чата? + Текущие данные вашего чата будет УДАЛЕНЫ и ЗАМЕНЕНЫ импортированными.\nЭто действие нельзя отменить — ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны. + Импортировать + Ошибка при удалении данных чата + Ошибка при импорте архива чата + Архив чата импортирован + Перезапустите приложение, чтобы использовать импортированный архив. + Удалить профиль? + Это действие нельзя отменить — ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны. + Данные чата удалены + Перезапустите приложение, чтобы создать новый профиль. + Используйте самую последнюю версию архива чата и ТОЛЬКО на одном устройстве, иначе вы можете перестать получать сообщения от некоторых контактов. + Остановите чат, чтобы разблокировать операции с архивом. + Перезапустите приложение, чтобы использовать новый архив чата. + + + Чат остановлен + Вы можете стартовать чат через Настройки приложения или перезапустив приложение. + + + Архив чата + Сохранить архив + Удалить архив + Дата создания %1$s + Удалить архив чата? diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index e327f0febb..eb30fbb86c 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -250,6 +250,7 @@ Your settings Your SimpleX contact address + Database export & import About SimpleX Chat How to use it Markdown help @@ -441,4 +442,47 @@ DEVICE CHATS Experimental features + + + Your chat database + RUN CHAT + Chat is running + Chat is stopped + CHAT DATABASE + Export database + Import database + New database archive + Old database archive + Delete database + Error starting chat + Stop chat? + Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped. + Stop + Error stopping chat + Error exporting chat database + Import chat database? + Your current chat database will be DELETED and REPLACED with the imported one.\nThis action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. + Import + Error deleting chat database + Error importing chat database + Chat database imported + Restart the app to use imported chat database. + Delete chat profile? + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. + Chat database deleted + 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. + Restart the app to use new chat database. + + + Chat is stopped + You can start chat via app Settings / Database or by restarting the app. + + + Chat archive + Save archive + Delete archive + Created on %1$s + Delete chat archive? diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 317135dd97..b2622a539e 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -383,12 +383,10 @@ struct ComposedMessage: Encodable { public struct ArchiveConfig: Encodable { var archivePath: String var disableCompression: Bool? - var parentTempDirectory: String? - public init(archivePath: String, disableCompression: Bool? = nil, parentTempDirectory: String? = nil) { + public init(archivePath: String, disableCompression: Bool? = nil) { self.archivePath = archivePath self.disableCompression = disableCompression - self.parentTempDirectory = parentTempDirectory } } diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs index ce8f820f6e..8f7a92e675 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -24,48 +24,28 @@ archiveFilesFolder :: String archiveFilesFolder = "simplex_v1_files" exportArchive :: ChatMonad m => ArchiveConfig -> m () -exportArchive cfg@ArchiveConfig {archivePath, disableCompression} = do - liftIO . print $ "exportArchive 1" +exportArchive cfg@ArchiveConfig {archivePath, disableCompression} = withTempDir cfg "simplex-chat." $ \dir -> do - liftIO . print $ "exportArchive 2, dir = " <> dir StorageFiles {chatDb, agentDb, filesPath} <- storageFiles - liftIO . print $ "exportArchive 3" copyFile chatDb $ dir archiveChatDbFile - liftIO . print $ "exportArchive 4" copyFile agentDb $ dir archiveAgentDbFile - liftIO . print $ "exportArchive 5" - forM_ filesPath $ \fp -> do - liftIO . print $ "exportArchive 6, fp = " <> fp + forM_ filesPath $ \fp -> copyDirectoryFiles fp $ dir archiveFilesFolder - liftIO . print $ "exportArchive 7" let method = if disableCompression == Just True then Z.Store else Z.Deflate - liftIO . print $ "exportArchive 8, method = " <> show method Z.createArchive archivePath $ Z.packDirRecur method Z.mkEntrySelector dir - liftIO . print $ "exportArchive 9" importArchive :: ChatMonad m => ArchiveConfig -> m () -importArchive cfg@ArchiveConfig {archivePath} = do - liftIO . print $ "importArchive 1" +importArchive cfg@ArchiveConfig {archivePath} = withTempDir cfg "simplex-chat." $ \dir -> do - liftIO . print $ "importArchive 2, dir = " <> dir Z.withArchive archivePath $ Z.unpackInto dir - liftIO . print $ "importArchive 3" StorageFiles {chatDb, agentDb, filesPath} <- storageFiles - liftIO . print $ "importArchive 4" backup chatDb - liftIO . print $ "importArchive 5" backup agentDb - liftIO . print $ "importArchive 6" copyFile (dir archiveChatDbFile) chatDb - liftIO . print $ "importArchive 7" copyFile (dir archiveAgentDbFile) agentDb - liftIO . print $ "importArchive 8" let filesDir = dir archiveFilesFolder - liftIO . print $ "importArchive 9, filesDir = " <> filesDir - forM_ filesPath $ \fp -> do - liftIO . print $ "importArchive 10, fp = " <> fp - whenM (doesDirectoryExist filesDir) $ do - liftIO . print $ "importArchive 11" + forM_ filesPath $ \fp -> + whenM (doesDirectoryExist filesDir) $ copyDirectoryFiles filesDir fp where backup f = whenM (doesFileExist f) $ copyFile f $ f <> ".bak"