multiplatform: migration via link (#3854)

* multiplatform: migration via link

* onion screen

* unused code

* changes

* migrate from device

* changes

* don't allow going back on Archiving step

* changes

* correction

* correction

* change

* font

* changes

* changes

* changes

* show NEVER text for onion when socks is disabled

* onion setup

* no check

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
This commit is contained in:
Stanislav Dmitrenko
2024-03-11 17:18:58 +00:00
committed by GitHub
co-authored by spaced4ndy
parent a56bc6760b
commit c9df591e52
23 changed files with 2185 additions and 179 deletions
@@ -1,10 +1,16 @@
package chat.simplex.app
import android.annotation.SuppressLint
import android.app.*
import android.content.Context
import chat.simplex.common.platform.Log
import android.content.Intent
import android.content.pm.ActivityInfo
import android.media.AudioManager
import android.os.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.*
import androidx.work.*
import chat.simplex.app.model.NtfManager
@@ -18,8 +24,7 @@ import chat.simplex.common.model.ChatModel.updatingChatsMutex
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.CurrentColors
import chat.simplex.common.ui.theme.DefaultTheme
import chat.simplex.common.views.call.RcvCallInvitation
import chat.simplex.common.views.call.activeCallDestroyWebView
import chat.simplex.common.views.call.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage
import com.jakewharton.processphoenix.ProcessPhoenix
@@ -65,7 +70,11 @@ class SimplexApp: Application(), LifecycleEventObserver {
tmpDir.deleteRecursively()
tmpDir.mkdir()
if (DatabaseUtils.ksSelfDestructPassword.get() == null) {
// Present screen for continue migration if it wasn't finished yet
if (chatModel.migrationState.value != null) {
// It's important, otherwise, user may be locked in undefined state
appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
} else if (DatabaseUtils.ksAppPassword.get() == null || DatabaseUtils.ksSelfDestructPassword.get() == null) {
initChatControllerAndRunMigrations()
}
ProcessLifecycleOwner.get().lifecycle.addObserver(this@SimplexApp)
@@ -282,6 +291,21 @@ class SimplexApp: Application(), LifecycleEventObserver {
activeCallDestroyWebView()
}
@SuppressLint("SourceLockedOrientationActivity")
@Composable
override fun androidLockPortraitOrientation() {
val context = LocalContext.current
DisposableEffect(Unit) {
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
// Lock orientation to portrait in order to have good experience with calls
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
onDispose {
// Unlock orientation
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
}
override suspend fun androidAskToAllowBackgroundCalls(): Boolean {
if (SimplexService.isBackgroundRestricted()) {
val userChoice: CompletableDeferred<Boolean> = CompletableDeferred()
@@ -2,6 +2,7 @@ package chat.simplex.common.views.database
import SectionItemView
import SectionTextFooter
import TextIconSpaced
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
@@ -22,8 +23,9 @@ actual fun SavePassphraseSetting(
useKeychain: Boolean,
initialRandomDBPassphrase: Boolean,
storedKey: Boolean,
progressIndicator: Boolean,
minHeight: Dp,
enabled: Boolean,
smallPadding: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
SectionItemView(minHeight = minHeight) {
@@ -33,7 +35,11 @@ actual fun SavePassphraseSetting(
stringResource(MR.strings.save_passphrase_in_keychain),
tint = if (storedKey) SimplexGreen else MaterialTheme.colors.secondary
)
Spacer(Modifier.padding(horizontal = 4.dp))
if (smallPadding) {
Spacer(Modifier.padding(horizontal = 4.dp))
} else {
TextIconSpaced(false)
}
Text(
stringResource(MR.strings.save_passphrase_in_keychain),
Modifier.padding(end = 24.dp),
@@ -43,7 +49,7 @@ actual fun SavePassphraseSetting(
DefaultSwitch(
checked = useKeychain,
onCheckedChange = onCheckedChange,
enabled = !initialRandomDBPassphrase && !progressIndicator
enabled = enabled
)
}
}
@@ -55,13 +61,14 @@ actual fun DatabaseEncryptionFooter(
chatDbEncrypted: Boolean?,
storedKey: MutableState<Boolean>,
initialRandomDBPassphrase: MutableState<Boolean>,
migration: Boolean,
) {
if (chatDbEncrypted == false) {
SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted))
} else if (useKeychain.value) {
if (storedKey.value) {
SectionTextFooter(generalGetString(MR.strings.keychain_is_storing_securely))
if (initialRandomDBPassphrase.value) {
if (initialRandomDBPassphrase.value && !migration) {
SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase))
} else {
SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase))
@@ -110,6 +110,13 @@ fun MainScreen() {
val localUserCreated = chatModel.localUserCreated.value
var showInitializationView by remember { mutableStateOf(false) }
when {
onboarding == OnboardingStage.Step1_SimpleXInfo && chatModel.migrationState.value != null -> {
// In migration process. Nothing should interrupt it, that's why it's the first branch in when()
SimpleXInfo(chatModel, onboarding = true)
if (appPlatform.isDesktop) {
ModalManager.fullscreen.showInView()
}
}
chatModel.dbMigrationInProgress.value -> DefaultProgressView(stringResource(MR.strings.database_migration_in_progress))
chatModel.chatDbStatus.value == null && showInitializationView -> DefaultProgressView(stringResource(MR.strings.opening_database))
showChatDatabaseError -> {
@@ -13,6 +13,7 @@ import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
import chat.simplex.common.views.chat.ComposeState
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.migration.MigrationFromAnotherDeviceState
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.StringResource
@@ -104,6 +105,8 @@ object ChatModel {
// currently showing invitation
val showingInvitation = mutableStateOf(null as ShowingInvitation?)
val migrationState: MutableState<MigrationFromAnotherDeviceState?> by lazy { mutableStateOf(MigrationFromAnotherDeviceState.transform()) }
var draft = mutableStateOf(null as ComposeState?)
var draftChatId = mutableStateOf(null as String?)
@@ -2973,10 +2976,17 @@ enum class FormatColor(val color: String) {
class SndFileTransfer() {}
@Serializable
class RcvFileTransfer() {}
data class RcvFileTransfer(
val fileId: Long,
)
@Serializable
class FileTransferMeta() {}
data class FileTransferMeta(
val fileId: Long,
val fileName: String,
val filePath: String,
val fileSize: Long,
)
@Serializable
enum class CICallStatus {
@@ -4,12 +4,15 @@ import chat.simplex.common.views.helpers.*
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import chat.simplex.common.model.ChatController.getNetCfg
import chat.simplex.common.model.ChatController.setNetCfg
import chat.simplex.common.model.ChatModel.updatingChatsMutex
import chat.simplex.common.model.ChatModel.changingActiveUserMutex
import dev.icerock.moko.resources.compose.painterResource
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
import chat.simplex.common.views.migration.MigrationFileLinkData
import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.common.views.usersettings.*
import com.charleskorn.kaml.Yaml
@@ -144,6 +147,7 @@ class AppPreferences {
val appLanguage = mkStrPreference(SHARED_PREFS_APP_LANGUAGE, null)
val onboardingStage = mkEnumPreference(SHARED_PREFS_ONBOARDING_STAGE, OnboardingStage.OnboardingComplete) { OnboardingStage.values().firstOrNull { it.name == this } }
val migrationStage = mkStrPreference(SHARED_PREFS_MIGRATION_STAGE, null)
val storeDBPassphrase = mkBoolPreference(SHARED_PREFS_STORE_DB_PASSPHRASE, true)
val initialRandomDBPassphrase = mkBoolPreference(SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE, false)
val encryptedDBPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_DB_PASSPHRASE, null)
@@ -177,6 +181,11 @@ class AppPreferences {
val offerRemoteMulticast = mkBoolPreference(SHARED_PREFS_OFFER_REMOTE_MULTICAST, true)
val desktopWindowState = mkStrPreference(SHARED_PREFS_DESKTOP_WINDOW_STATE, null)
val iosCallKitEnabled = mkBoolPreference(SHARED_PREFS_IOS_CALL_KIT_ENABLED, true)
val iosCallKitCallsInRecents = mkBoolPreference(SHARED_PREFS_IOS_CALL_KIT_CALLS_IN_RECENTS, false)
private fun mkIntPreference(prefName: String, default: Int) =
SharedPreference(
@@ -277,6 +286,7 @@ class AppPreferences {
private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime"
private const val SHARED_PREFS_APP_LANGUAGE = "AppLanguage"
private const val SHARED_PREFS_ONBOARDING_STAGE = "OnboardingStage"
const val SHARED_PREFS_MIGRATION_STAGE = "MigrationStage"
private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart"
private const val SHARED_PREFS_CHAT_STOPPED = "ChatStopped"
private const val SHARED_PREFS_DEVELOPER_TOOLS = "DeveloperTools"
@@ -326,6 +336,9 @@ class AppPreferences {
private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto"
private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast"
private const val SHARED_PREFS_DESKTOP_WINDOW_STATE = "DesktopWindowState"
private const val SHARED_PREFS_IOS_CALL_KIT_ENABLED = "iOSCallKitEnabled"
private const val SHARED_PREFS_IOS_CALL_KIT_CALLS_IN_RECENTS = "iOSCallKitCallsInRecents"
}
}
@@ -402,6 +415,16 @@ object ChatController {
}
}
suspend fun startChatWithTemporaryDatabase(ctrl: ChatCtrl, netCfg: NetCfg): User? {
Log.d(TAG, "startChatWithTemporaryDatabase")
val migrationActiveUser = apiGetActiveUser(null, ctrl) ?: apiCreateActiveUser(null, Profile(displayName = "Temp", fullName = ""), ctrl = ctrl)
apiSetNetworkConfig(netCfg, ctrl)
apiSetTempFolder(getMigrationTempFilesDirectory().absolutePath, ctrl)
apiSetFilesFolder(getMigrationTempFilesDirectory().absolutePath, ctrl)
apiStartChat(ctrl)
return migrationActiveUser
}
suspend fun changeActiveUser(rhId: Long?, toUserId: Long, viewPwd: String?) {
try {
changeActiveUser_(rhId, toUserId, viewPwd)
@@ -478,8 +501,8 @@ object ChatController {
}
}
suspend fun sendCmd(rhId: Long?, cmd: CC): CR {
val ctrl = ctrl ?: throw Exception("Controller is not initialized")
suspend fun sendCmd(rhId: Long?, cmd: CC, otherCtrl: ChatCtrl? = null): CR {
val ctrl = otherCtrl ?: ctrl ?: throw Exception("Controller is not initialized")
return withContext(Dispatchers.IO) {
val c = cmd.cmdString
@@ -496,7 +519,7 @@ object ChatController {
}
}
private fun recvMsg(ctrl: ChatCtrl): APIResponse? {
fun recvMsg(ctrl: ChatCtrl): APIResponse? {
val json = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT)
return if (json == "") {
null
@@ -509,8 +532,8 @@ object ChatController {
}
}
suspend fun apiGetActiveUser(rh: Long?): User? {
val r = sendCmd(rh, CC.ShowActiveUser())
suspend fun apiGetActiveUser(rh: Long?, ctrl: ChatCtrl? = null): User? {
val r = sendCmd(rh, CC.ShowActiveUser(), ctrl)
if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh)
Log.d(TAG, "apiGetActiveUser: ${r.responseType} ${r.details}")
if (rh == null) {
@@ -519,8 +542,8 @@ object ChatController {
return null
}
suspend fun apiCreateActiveUser(rh: Long?, p: Profile?, sameServers: Boolean = false, pastTimestamp: Boolean = false): User? {
val r = sendCmd(rh, CC.CreateActiveUser(p, sameServers = sameServers, pastTimestamp = pastTimestamp))
suspend fun apiCreateActiveUser(rh: Long?, p: Profile?, sameServers: Boolean = false, pastTimestamp: Boolean = false, ctrl: ChatCtrl? = null): User? {
val r = sendCmd(rh, CC.CreateActiveUser(p, sameServers = sameServers, pastTimestamp = pastTimestamp), ctrl)
if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh)
else if (
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.DuplicateName ||
@@ -598,8 +621,8 @@ object ChatController {
throw Exception("failed to delete the user ${r.responseType} ${r.details}")
}
suspend fun apiStartChat(): Boolean {
val r = sendCmd(null, CC.StartChat(mainApp = true))
suspend fun apiStartChat(ctrl: ChatCtrl? = null): Boolean {
val r = sendCmd(null, CC.StartChat(mainApp = true), ctrl)
when (r) {
is CR.ChatStarted -> return true
is CR.ChatRunning -> return false
@@ -615,14 +638,14 @@ object ChatController {
}
}
suspend fun apiSetTempFolder(tempFolder: String) {
val r = sendCmd(null, CC.SetTempFolder(tempFolder))
suspend fun apiSetTempFolder(tempFolder: String, ctrl: ChatCtrl? = null) {
val r = sendCmd(null, CC.SetTempFolder(tempFolder), ctrl)
if (r is CR.CmdOk) return
throw Error("failed to set temp folder: ${r.responseType} ${r.details}")
}
suspend fun apiSetFilesFolder(filesFolder: String) {
val r = sendCmd(null, CC.SetFilesFolder(filesFolder))
suspend fun apiSetFilesFolder(filesFolder: String, ctrl: ChatCtrl? = null) {
val r = sendCmd(null, CC.SetFilesFolder(filesFolder), ctrl)
if (r is CR.CmdOk) return
throw Error("failed to set files folder: ${r.responseType} ${r.details}")
}
@@ -635,6 +658,18 @@ object ChatController {
suspend fun apiSetEncryptLocalFiles(enable: Boolean) = sendCommandOkResp(null, CC.ApiSetEncryptLocalFiles(enable))
suspend fun apiSaveAppSettings(settings: AppSettings) {
val r = sendCmd(null, CC.ApiSaveSettings(settings))
if (r is CR.CmdOk) return
throw Error("failed to set app settings: ${r.responseType} ${r.details}")
}
suspend fun apiGetAppSettings(settings: AppSettings): AppSettings {
val r = sendCmd(null, CC.ApiGetSettings(settings))
if (r is CR.AppSettingsR) return r.appSettings
throw Error("failed to get app settings: ${r.responseType} ${r.details}")
}
suspend fun apiSetPQEncryption(enable: Boolean) = sendCommandOkResp(null, CC.ApiSetPQEncryption(enable))
suspend fun apiSetContactPQ(rh: Long?, contactId: Long, enable: Boolean): Contact? {
@@ -669,6 +704,11 @@ object ChatController {
throw Exception("failed to set storage encryption: ${r.responseType} ${r.details}")
}
suspend fun testStorageEncryption(key: String, ctrl: ChatCtrl? = null): Boolean {
val r = sendCmd(null, CC.TestStorageEncryption(key), ctrl)
return r is CR.CmdOk
}
suspend fun apiGetChats(rh: Long?): List<Chat> {
val userId = kotlin.runCatching { currentUserId("apiGetChats") }.getOrElse { return emptyList() }
val r = sendCmd(rh, CC.ApiGetChats(userId))
@@ -805,8 +845,8 @@ object ChatController {
throw Exception("failed to set chat item TTL: ${r.responseType} ${r.details}")
}
suspend fun apiSetNetworkConfig(cfg: NetCfg): Boolean {
val r = sendCmd(null, CC.APISetNetworkConfig(cfg))
suspend fun apiSetNetworkConfig(cfg: NetCfg, ctrl: ChatCtrl? = null): Boolean {
val r = sendCmd(null, CC.APISetNetworkConfig(cfg), ctrl)
return when (r) {
is CR.CmdOk -> true
else -> {
@@ -1236,6 +1276,36 @@ object ChatController {
return false
}
suspend fun uploadStandaloneFile(user: UserLike, file: CryptoFile, ctrl: ChatCtrl? = null): Pair<FileTransferMeta?, String?> {
val r = sendCmd(null, CC.ApiUploadStandaloneFile(user.userId, file), ctrl)
return if (r is CR.SndStandaloneFileCreated) {
r.fileTransferMeta to null
} else {
Log.e(TAG, "uploadStandaloneFile error: $r")
null to r.toString()
}
}
suspend fun downloadStandaloneFile(user: UserLike, url: String, file: CryptoFile, ctrl: ChatCtrl? = null): Pair<RcvFileTransfer?, String?> {
val r = sendCmd(null, CC.ApiDownloadStandaloneFile(user.userId, url, file), ctrl)
return if (r is CR.RcvStandaloneFileCreated) {
r.rcvFileTransfer to null
} else {
Log.e(TAG, "downloadStandaloneFile error: $r")
null to r.toString()
}
}
suspend fun standaloneFileInfo(url: String, ctrl: ChatCtrl? = null): MigrationFileLinkData? {
val r = sendCmd(null, CC.ApiStandaloneFileInfo(url), ctrl)
return if (r is CR.StandaloneFileInfo) {
r.fileMeta
} else {
Log.e(TAG, "standaloneFileInfo error: $r")
null
}
}
suspend fun apiReceiveFile(rh: Long?, fileId: Long, encrypted: Boolean, inline: Boolean? = null, auto: Boolean = false): AChatItem? {
// -1 here is to override default behavior of providing current remote host id because file can be asked by local device while remote is connected
val r = sendCmd(rh, CC.ReceiveFile(fileId, encrypted, inline))
@@ -1274,11 +1344,11 @@ object ChatController {
}
}
suspend fun apiCancelFile(rh: Long?, fileId: Long): AChatItem? {
val r = sendCmd(rh, CC.CancelFile(fileId))
suspend fun apiCancelFile(rh: Long?, fileId: Long, ctrl: ChatCtrl? = null): AChatItem? {
val r = sendCmd(rh, CC.CancelFile(fileId), ctrl)
return when (r) {
is CR.SndFileCancelled -> r.chatItem
is CR.RcvFileCancelled -> r.chatItem
is CR.SndFileCancelled -> r.chatItem_
is CR.RcvFileCancelled -> r.chatItem_
else -> {
Log.d(TAG, "apiCancelFile bad response: ${r.responseType} ${r.details}")
null
@@ -1565,8 +1635,8 @@ object ChatController {
suspend fun deleteRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(null, CC.DeleteRemoteCtrl(rcId))
private suspend fun sendCommandOkResp(rh: Long?, cmd: CC): Boolean {
val r = sendCmd(rh, cmd)
private suspend fun sendCommandOkResp(rh: Long?, cmd: CC, ctrl: ChatCtrl? = null): Boolean {
val r = sendCmd(rh, cmd, ctrl)
val ok = r is CR.CmdOk
if (!ok) apiErrorAlert(cmd.cmdType, generalGetString(MR.strings.error_alert_title), r)
return ok
@@ -1856,11 +1926,16 @@ object ChatController {
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
cleanupFile(r.chatItem)
}
is CR.RcvFileProgressXFTP ->
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
is CR.RcvFileProgressXFTP -> {
if (r.chatItem_ != null) {
chatItemSimpleUpdate(rhId, r.user, r.chatItem_)
}
}
is CR.RcvFileError -> {
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
cleanupFile(r.chatItem)
if (r.chatItem_ != null) {
chatItemSimpleUpdate(rhId, r.user, r.chatItem_)
cleanupFile(r.chatItem_)
}
}
is CR.SndFileStart ->
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
@@ -1869,18 +1944,25 @@ object ChatController {
cleanupDirectFile(r.chatItem)
}
is CR.SndFileRcvCancelled -> {
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
cleanupDirectFile(r.chatItem)
if (r.chatItem_ != null) {
chatItemSimpleUpdate(rhId, r.user, r.chatItem_)
cleanupDirectFile(r.chatItem_)
}
}
is CR.SndFileProgressXFTP -> {
if (r.chatItem_ != null) {
chatItemSimpleUpdate(rhId, r.user, r.chatItem_)
}
}
is CR.SndFileProgressXFTP ->
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
is CR.SndFileCompleteXFTP -> {
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
cleanupFile(r.chatItem)
}
is CR.SndFileError -> {
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
cleanupFile(r.chatItem)
if (r.chatItem_ != null) {
chatItemSimpleUpdate(rhId, r.user, r.chatItem_)
cleanupFile(r.chatItem_)
}
}
is CR.CallInvitation -> {
chatModel.callManager.reportNewIncomingCall(r.callInvitation.copy(remoteHostId = rhId))
@@ -2249,21 +2331,13 @@ object ChatController {
class SharedPreference<T>(val get: () -> T, set: (T) -> Unit) {
val set: (T) -> Unit
private val _state: MutableState<T> by lazy { mutableStateOf(get()) }
val state: State<T> by lazy { _state }
private val _state: MutableState<T> = mutableStateOf(get())
val state: State<T> = _state
init {
this.set = { value ->
set(value)
try {
_state.value = value
} catch (e: IllegalStateException) {
// Can be `Reading a state that was created after the snapshot was taken or in a snapshot that has not yet been applied`
Log.i(TAG, e.stackTraceToString())
withApi {
_state.value = value
}
}
_state.value = value
}
}
}
@@ -2295,6 +2369,9 @@ sealed class CC {
class ApiImportArchive(val config: ArchiveConfig): CC()
class ApiDeleteStorage: CC()
class ApiStorageEncryption(val config: DBEncryptionConfig): CC()
class TestStorageEncryption(val key: String): CC()
class ApiSaveSettings(val settings: AppSettings): CC()
class ApiGetSettings(val settings: AppSettings): CC()
class ApiGetChats(val userId: Long): CC()
class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC()
class ApiGetChatItemInfo(val type: ChatType, val id: Long, val itemId: Long): CC()
@@ -2388,6 +2465,9 @@ sealed class CC {
class ListRemoteCtrls(): CC()
class StopRemoteCtrl(): CC()
class DeleteRemoteCtrl(val remoteCtrlId: Long): CC()
class ApiUploadStandaloneFile(val userId: Long, val file: CryptoFile): CC()
class ApiDownloadStandaloneFile(val userId: Long, val url: String, val file: CryptoFile): CC()
class ApiStandaloneFileInfo(val url: String): CC()
// misc
class ShowVersion(): CC()
@@ -2426,6 +2506,9 @@ sealed class CC {
is ApiImportArchive -> "/_db import ${json.encodeToString(config)}"
is ApiDeleteStorage -> "/_db delete"
is ApiStorageEncryption -> "/_db encryption ${json.encodeToString(config)}"
is TestStorageEncryption -> "/db test key $key"
is ApiSaveSettings -> "/_save app settings ${json.encodeToString(settings)}"
is ApiGetSettings -> "/_get app settings ${json.encodeToString(settings)}"
is ApiGetChats -> "/_get chats $userId pcc=on"
is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search")
is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id)} $itemId"
@@ -2533,6 +2616,9 @@ sealed class CC {
is ListRemoteCtrls -> "/list remote ctrls"
is StopRemoteCtrl -> "/stop remote ctrl"
is DeleteRemoteCtrl -> "/delete remote ctrl $remoteCtrlId"
is ApiUploadStandaloneFile -> "/_upload $userId ${file.filePath}"
is ApiDownloadStandaloneFile -> "/_download $userId $url ${file.filePath}"
is ApiStandaloneFileInfo -> "/_download info $url"
is ShowVersion -> "/version"
}
@@ -2562,6 +2648,9 @@ sealed class CC {
is ApiImportArchive -> "apiImportArchive"
is ApiDeleteStorage -> "apiDeleteStorage"
is ApiStorageEncryption -> "apiStorageEncryption"
is TestStorageEncryption -> "testStorageEncryption"
is ApiSaveSettings -> "apiSaveSettings"
is ApiGetSettings -> "apiGetSettings"
is ApiGetChats -> "apiGetChats"
is ApiGetChat -> "apiGetChat"
is ApiGetChatItemInfo -> "apiGetChatItemInfo"
@@ -2654,6 +2743,9 @@ sealed class CC {
is ListRemoteCtrls -> "listRemoteCtrls"
is StopRemoteCtrl -> "stopRemoteCtrl"
is DeleteRemoteCtrl -> "deleteRemoteCtrl"
is ApiUploadStandaloneFile -> "apiUploadStandaloneFile"
is ApiDownloadStandaloneFile -> "apiDownloadStandaloneFile"
is ApiStandaloneFileInfo -> "apiStandaloneFileInfo"
is ShowVersion -> "showVersion"
}
@@ -2671,6 +2763,7 @@ sealed class CC {
is ApiHideUser -> ApiHideUser(userId, obfuscate(viewPwd))
is ApiUnhideUser -> ApiUnhideUser(userId, obfuscate(viewPwd))
is ApiDeleteUser -> ApiDeleteUser(userId, delSMPQueues, obfuscateOrNull(viewPwd))
is TestStorageEncryption -> TestStorageEncryption(obfuscate(key))
else -> this
}
@@ -3796,6 +3889,13 @@ val json = Json {
explicitNulls = false
}
val jsonShort = Json {
prettyPrint = false
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
val yaml = Yaml(configuration = YamlConfiguration(
strictMode = false,
encodeDefaults = false,
@@ -3985,20 +4085,28 @@ sealed class CR {
// receiving file events
@Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val user: UserRef, val chatItem: AChatItem): CR()
@Serializable @SerialName("rcvFileAcceptedSndCancelled") class RcvFileAcceptedSndCancelled(val user: UserRef, val rcvFileTransfer: RcvFileTransfer): CR()
@Serializable @SerialName("rcvFileStart") class RcvFileStart(val user: UserRef, val chatItem: AChatItem): CR()
@Serializable @SerialName("standaloneFileInfo") class StandaloneFileInfo(val fileMeta: MigrationFileLinkData?): CR()
@Serializable @SerialName("rcvStandaloneFileCreated") class RcvStandaloneFileCreated(val user: UserRef, val rcvFileTransfer: RcvFileTransfer): CR()
@Serializable @SerialName("rcvFileStart") class RcvFileStart(val user: UserRef, val chatItem: AChatItem): CR() // send by chats
@Serializable @SerialName("rcvFileProgressXFTP") class RcvFileProgressXFTP(val user: UserRef, val chatItem_: AChatItem?, val receivedSize: Long, val totalSize: Long, val rcvFileTransfer: RcvFileTransfer): CR()
@Serializable @SerialName("rcvFileComplete") class RcvFileComplete(val user: UserRef, val chatItem: AChatItem): CR()
@Serializable @SerialName("rcvFileCancelled") class RcvFileCancelled(val user: UserRef, val chatItem: AChatItem, val rcvFileTransfer: RcvFileTransfer): CR()
@Serializable @SerialName("rcvStandaloneFileComplete") class RcvStandaloneFileComplete(val user: UserRef, val targetPath: String, val rcvFileTransfer: RcvFileTransfer): CR()
@Serializable @SerialName("rcvFileCancelled") class RcvFileCancelled(val user: UserRef, val chatItem_: AChatItem?, val rcvFileTransfer: RcvFileTransfer): CR()
@Serializable @SerialName("rcvFileSndCancelled") class RcvFileSndCancelled(val user: UserRef, val chatItem: AChatItem, val rcvFileTransfer: RcvFileTransfer): CR()
@Serializable @SerialName("rcvFileProgressXFTP") class RcvFileProgressXFTP(val user: UserRef, val chatItem: AChatItem, val receivedSize: Long, val totalSize: Long): CR()
@Serializable @SerialName("rcvFileError") class RcvFileError(val user: UserRef, val chatItem: AChatItem): CR()
@Serializable @SerialName("rcvFileError") class RcvFileError(val user: UserRef, val chatItem_: AChatItem?, val rcvFileTransfer: RcvFileTransfer): CR()
// sending file events
@Serializable @SerialName("sndFileStart") class SndFileStart(val user: UserRef, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
@Serializable @SerialName("sndFileComplete") class SndFileComplete(val user: UserRef, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
@Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val user: UserRef, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List<SndFileTransfer>): CR()
@Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val user: UserRef, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
@Serializable @SerialName("sndFileProgressXFTP") class SndFileProgressXFTP(val user: UserRef, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sentSize: Long, val totalSize: Long): CR()
@Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val user: UserRef, val chatItem_: AChatItem?, val sndFileTransfer: SndFileTransfer): CR()
@Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val user: UserRef, val chatItem_: AChatItem?, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List<SndFileTransfer>): CR()
@Serializable @SerialName("sndStandaloneFileCreated") class SndStandaloneFileCreated(val user: UserRef, val fileTransferMeta: FileTransferMeta): CR() // returned by _upload
@Serializable @SerialName("sndFileStartXFTP") class SndFileStartXFTP(val user: UserRef, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta): CR() // not used
@Serializable @SerialName("sndFileProgressXFTP") class SndFileProgressXFTP(val user: UserRef, val chatItem_: AChatItem?, val fileTransferMeta: FileTransferMeta, val sentSize: Long, val totalSize: Long): CR()
@Serializable @SerialName("sndFileRedirectStartXFTP") class SndFileRedirectStartXFTP(val user: UserRef, val fileTransferMeta: FileTransferMeta, val redirectMeta: FileTransferMeta): CR()
@Serializable @SerialName("sndFileCompleteXFTP") class SndFileCompleteXFTP(val user: UserRef, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta): CR()
@Serializable @SerialName("sndFileError") class SndFileError(val user: UserRef, val chatItem: AChatItem): CR()
@Serializable @SerialName("sndStandaloneFileComplete") class SndStandaloneFileComplete(val user: UserRef, val fileTransferMeta: FileTransferMeta, val rcvURIs: List<String>): CR()
@Serializable @SerialName("sndFileCancelledXFTP") class SndFileCancelledXFTP(val user: UserRef, val chatItem_: AChatItem?, val fileTransferMeta: FileTransferMeta): CR()
@Serializable @SerialName("sndFileError") class SndFileError(val user: UserRef, val chatItem_: AChatItem?, val fileTransferMeta: FileTransferMeta): CR()
// call events
@Serializable @SerialName("callInvitation") class CallInvitation(val callInvitation: RcvCallInvitation): CR()
@Serializable @SerialName("callInvitations") class CallInvitations(val callInvitations: List<RcvCallInvitation>): CR()
@@ -4032,6 +4140,7 @@ sealed class CR {
@Serializable @SerialName("chatCmdError") class ChatCmdError(val user_: UserRef?, val chatError: ChatError): CR()
@Serializable @SerialName("chatError") class ChatRespError(val user_: UserRef?, val chatError: ChatError): CR()
@Serializable @SerialName("archiveImported") class ArchiveImported(val archiveErrors: List<ArchiveError>): CR()
@Serializable @SerialName("appSettings") class AppSettingsR(val appSettings: AppSettings): CR()
// general
@Serializable class Response(val type: String, val json: String): CR()
@Serializable class Invalid(val str: String): CR()
@@ -4141,19 +4250,27 @@ sealed class CR {
is NewMemberContactSentInv -> "newMemberContactSentInv"
is NewMemberContactReceivedInv -> "newMemberContactReceivedInv"
is RcvFileAcceptedSndCancelled -> "rcvFileAcceptedSndCancelled"
is StandaloneFileInfo -> "standaloneFileInfo"
is RcvStandaloneFileCreated -> "rcvStandaloneFileCreated"
is RcvFileAccepted -> "rcvFileAccepted"
is RcvFileStart -> "rcvFileStart"
is RcvFileComplete -> "rcvFileComplete"
is RcvStandaloneFileComplete -> "rcvStandaloneFileComplete"
is RcvFileCancelled -> "rcvFileCancelled"
is SndStandaloneFileCreated -> "sndStandaloneFileCreated"
is SndFileStartXFTP -> "sndFileStartXFTP"
is RcvFileSndCancelled -> "rcvFileSndCancelled"
is RcvFileProgressXFTP -> "rcvFileProgressXFTP"
is SndFileRedirectStartXFTP -> "sndFileRedirectStartXFTP"
is RcvFileError -> "rcvFileError"
is SndFileCancelled -> "sndFileCancelled"
is SndFileStart -> "sndFileStart"
is SndFileComplete -> "sndFileComplete"
is SndFileRcvCancelled -> "sndFileRcvCancelled"
is SndFileStart -> "sndFileStart"
is SndFileCancelled -> "sndFileCancelled"
is SndFileProgressXFTP -> "sndFileProgressXFTP"
is SndFileCompleteXFTP -> "sndFileCompleteXFTP"
is SndStandaloneFileComplete -> "sndStandaloneFileComplete"
is SndFileCancelledXFTP -> "sndFileCancelledXFTP"
is SndFileError -> "sndFileError"
is CallInvitations -> "callInvitations"
is CallInvitation -> "callInvitation"
@@ -4183,6 +4300,7 @@ sealed class CR {
is ChatCmdError -> "chatCmdError"
is ChatRespError -> "chatError"
is ArchiveImported -> "archiveImported"
is AppSettingsR -> "appSettings"
is Response -> "* $type"
is Invalid -> "* invalid json"
}
@@ -4195,7 +4313,7 @@ sealed class CR {
is ChatStopped -> noDetails()
is ApiChats -> withUser(user, json.encodeToString(chats))
is ApiChat -> withUser(user, json.encodeToString(chat))
is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(AChatItem)}\n${json.encodeToString(chatItemInfo)}")
is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}")
is UserProtoServers -> withUser(user, "servers: ${json.encodeToString(servers)}")
is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}")
is ChatItemTTL -> withUser(user, json.encodeToString(chatItemTTL))
@@ -4292,20 +4410,28 @@ sealed class CR {
is NewMemberContactSentInv -> withUser(user, "contact: $contact\ngroupInfo: $groupInfo\nmember: $member")
is NewMemberContactReceivedInv -> withUser(user, "contact: $contact\ngroupInfo: $groupInfo\nmember: $member")
is RcvFileAcceptedSndCancelled -> withUser(user, noDetails())
is StandaloneFileInfo -> json.encodeToString(fileMeta)
is RcvStandaloneFileCreated -> noDetails()
is RcvFileAccepted -> withUser(user, json.encodeToString(chatItem))
is RcvFileStart -> withUser(user, json.encodeToString(chatItem))
is RcvFileComplete -> withUser(user, json.encodeToString(chatItem))
is RcvFileCancelled -> withUser(user, json.encodeToString(chatItem))
is RcvFileCancelled -> withUser(user, json.encodeToString(chatItem_))
is RcvFileSndCancelled -> withUser(user, json.encodeToString(chatItem))
is RcvFileProgressXFTP -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\nreceivedSize: $receivedSize\ntotalSize: $totalSize")
is RcvFileError -> withUser(user, json.encodeToString(chatItem))
is SndFileCancelled -> json.encodeToString(chatItem)
is RcvFileProgressXFTP -> withUser(user, "chatItem: ${json.encodeToString(chatItem_)}\nreceivedSize: $receivedSize\ntotalSize: $totalSize")
is RcvStandaloneFileComplete -> withUser(user, targetPath)
is RcvFileError -> withUser(user, json.encodeToString(chatItem_))
is SndFileCancelled -> json.encodeToString(chatItem_)
is SndStandaloneFileCreated -> noDetails()
is SndFileStartXFTP -> withUser(user, json.encodeToString(chatItem))
is SndFileComplete -> withUser(user, json.encodeToString(chatItem))
is SndFileRcvCancelled -> withUser(user, json.encodeToString(chatItem))
is SndFileRcvCancelled -> withUser(user, json.encodeToString(chatItem_))
is SndFileStart -> withUser(user, json.encodeToString(chatItem))
is SndFileProgressXFTP -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\nsentSize: $sentSize\ntotalSize: $totalSize")
is SndFileProgressXFTP -> withUser(user, "chatItem: ${json.encodeToString(chatItem_)}\nsentSize: $sentSize\ntotalSize: $totalSize")
is SndFileRedirectStartXFTP -> withUser(user, json.encodeToString(redirectMeta))
is SndFileCompleteXFTP -> withUser(user, json.encodeToString(chatItem))
is SndFileError -> withUser(user, json.encodeToString(chatItem))
is SndStandaloneFileComplete -> withUser(user, rcvURIs.size.toString())
is SndFileCancelledXFTP -> withUser(user, json.encodeToString(chatItem_))
is SndFileError -> withUser(user, json.encodeToString(chatItem_))
is CallInvitations -> "callInvitations: ${json.encodeToString(callInvitations)}"
is CallInvitation -> "contact: ${callInvitation.contact.id}\ncallType: $callInvitation.callType\nsharedKey: ${callInvitation.sharedKey ?: ""}"
is CallOffer -> withUser(user, "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}")
@@ -4351,6 +4477,7 @@ sealed class CR {
is ChatCmdError -> withUser(user_, chatError.string)
is ChatRespError -> withUser(user_, chatError.string)
is ArchiveImported -> "${archiveErrors.map { it.string } }"
is AppSettingsR -> json.encodeToString(appSettings)
is Response -> json
is Invalid -> str
}
@@ -4764,6 +4891,7 @@ sealed class StoreError {
is FileIdNotFoundBySharedMsgId -> "fileIdNotFoundBySharedMsgId"
is SndFileNotFoundXFTP -> "sndFileNotFoundXFTP"
is RcvFileNotFoundXFTP -> "rcvFileNotFoundXFTP"
is ExtraFileDescrNotFoundXFTP -> "extraFileDescrNotFoundXFTP"
is ConnectionNotFound -> "connectionNotFound"
is ConnectionNotFoundById -> "connectionNotFoundById"
is ConnectionNotFoundByMemberId -> "connectionNotFoundByMemberId"
@@ -4822,6 +4950,7 @@ sealed class StoreError {
@Serializable @SerialName("fileIdNotFoundBySharedMsgId") class FileIdNotFoundBySharedMsgId(val sharedMsgId: String): StoreError()
@Serializable @SerialName("sndFileNotFoundXFTP") class SndFileNotFoundXFTP(val agentSndFileId: String): StoreError()
@Serializable @SerialName("rcvFileNotFoundXFTP") class RcvFileNotFoundXFTP(val agentRcvFileId: String): StoreError()
@Serializable @SerialName("extraFileDescrNotFoundXFTP") class ExtraFileDescrNotFoundXFTP(val fileId: Long): StoreError()
@Serializable @SerialName("connectionNotFound") class ConnectionNotFound(val agentConnId: String): StoreError()
@Serializable @SerialName("connectionNotFoundById") class ConnectionNotFoundById(val connId: Long): StoreError()
@Serializable @SerialName("connectionNotFoundByMemberId") class ConnectionNotFoundByMemberId(val groupMemberId: Long): StoreError()
@@ -5167,3 +5296,205 @@ enum class NotificationsMode() {
val default: NotificationsMode = SERVICE
}
}
@Serializable
data class AppSettings(
var networkConfig: NetCfg? = null,
var privacyEncryptLocalFiles: Boolean? = null,
var privacyAcceptImages: Boolean? = null,
var privacyLinkPreviews: Boolean? = null,
var privacyShowChatPreviews: Boolean? = null,
var privacySaveLastDraft: Boolean? = null,
var privacyProtectScreen: Boolean? = null,
var notificationMode: AppSettingsNotificationMode? = null,
var notificationPreviewMode: AppSettingsNotificationPreviewMode? = null,
var webrtcPolicyRelay: Boolean? = null,
var webrtcICEServers: List<String>? = null,
var confirmRemoteSessions: Boolean? = null,
var connectRemoteViaMulticast: Boolean? = null,
var connectRemoteViaMulticastAuto: Boolean? = null,
var developerTools: Boolean? = null,
var confirmDBUpgrades: Boolean? = null,
var androidCallOnLockScreen: AppSettingsLockScreenCalls? = null,
var iosCallKitEnabled: Boolean? = null,
var iosCallKitCallsInRecents: Boolean? = null,
) {
fun prepareForExport(): AppSettings {
val empty = AppSettings()
val def = defaults
if (networkConfig != def.networkConfig) { empty.networkConfig = networkConfig }
if (privacyEncryptLocalFiles != def.privacyEncryptLocalFiles) { empty.privacyEncryptLocalFiles = privacyEncryptLocalFiles }
if (privacyAcceptImages != def.privacyAcceptImages) { empty.privacyAcceptImages = privacyAcceptImages }
if (privacyLinkPreviews != def.privacyLinkPreviews) { empty.privacyLinkPreviews = privacyLinkPreviews }
if (privacyShowChatPreviews != def.privacyShowChatPreviews) { empty.privacyShowChatPreviews = privacyShowChatPreviews }
if (privacySaveLastDraft != def.privacySaveLastDraft) { empty.privacySaveLastDraft = privacySaveLastDraft }
if (privacyProtectScreen != def.privacyProtectScreen) { empty.privacyProtectScreen = privacyProtectScreen }
if (notificationMode != def.notificationMode) { empty.notificationMode = notificationMode }
if (notificationPreviewMode != def.notificationPreviewMode) { empty.notificationPreviewMode = notificationPreviewMode }
if (webrtcPolicyRelay != def.webrtcPolicyRelay) { empty.webrtcPolicyRelay = webrtcPolicyRelay }
if (webrtcICEServers != def.webrtcICEServers) { empty.webrtcICEServers = webrtcICEServers }
if (confirmRemoteSessions != def.confirmRemoteSessions) { empty.confirmRemoteSessions = confirmRemoteSessions }
if (connectRemoteViaMulticast != def.connectRemoteViaMulticast) { empty.connectRemoteViaMulticast = connectRemoteViaMulticast }
if (connectRemoteViaMulticastAuto != def.connectRemoteViaMulticastAuto) { empty.connectRemoteViaMulticastAuto = connectRemoteViaMulticastAuto }
if (developerTools != def.developerTools) { empty.developerTools = developerTools }
if (confirmDBUpgrades != def.confirmDBUpgrades) { empty.confirmDBUpgrades = confirmDBUpgrades }
if (androidCallOnLockScreen != def.androidCallOnLockScreen) { empty.androidCallOnLockScreen = androidCallOnLockScreen }
if (iosCallKitEnabled != def.iosCallKitEnabled) { empty.iosCallKitEnabled = iosCallKitEnabled }
if (iosCallKitCallsInRecents != def.iosCallKitCallsInRecents) { empty.iosCallKitCallsInRecents = iosCallKitCallsInRecents }
return empty
}
fun importIntoApp() {
val def = appPreferences
var net = networkConfig?.copy()
if (net != null) {
// migrating from iOS BUT shouldn't be here ever because it should be changed on migration stage
if (net.hostMode == HostMode.Onion) {
net = net.copy(hostMode = HostMode.Public, requiredHostMode = true)
}
setNetCfg(net)
}
privacyEncryptLocalFiles?.let { def.privacyEncryptLocalFiles.set(it) }
privacyAcceptImages?.let { def.privacyAcceptImages.set(it) }
privacyLinkPreviews?.let { def.privacyLinkPreviews.set(it) }
privacyShowChatPreviews?.let { def.privacyShowChatPreviews.set(it) }
privacySaveLastDraft?.let { def.privacySaveLastDraft.set(it) }
privacyProtectScreen?.let { def.privacyProtectScreen.set(it) }
notificationMode?.let { def.notificationsMode.set(it.toNotificationsMode()) }
notificationPreviewMode?.let { def.notificationPreviewMode.set(it.toNotificationPreviewMode().name) }
webrtcPolicyRelay?.let { def.webrtcPolicyRelay.set(it) }
webrtcICEServers?.let { def.webrtcIceServers.set(it.joinToString(separator = "\n")) }
confirmRemoteSessions?.let { def.confirmRemoteSessions.set(it) }
connectRemoteViaMulticast?.let { def.connectRemoteViaMulticast.set(it) }
connectRemoteViaMulticastAuto?.let { def.connectRemoteViaMulticastAuto.set(it) }
developerTools?.let { def.developerTools.set(it) }
confirmDBUpgrades?.let { def.confirmDBUpgrades.set(it) }
androidCallOnLockScreen?.let { def.callOnLockScreen.set(it.toCallOnLockScreen()) }
iosCallKitEnabled?.let { def.iosCallKitEnabled.set(it) }
iosCallKitCallsInRecents?.let { def.iosCallKitCallsInRecents.set(it) }
}
companion object {
val defaults: AppSettings
get() = AppSettings(
networkConfig = NetCfg.defaults,
privacyEncryptLocalFiles = true,
privacyAcceptImages = true,
privacyLinkPreviews = true,
privacyShowChatPreviews = true,
privacySaveLastDraft = true,
privacyProtectScreen = false,
notificationMode = AppSettingsNotificationMode.INSTANT,
notificationPreviewMode = AppSettingsNotificationPreviewMode.MESSAGE,
webrtcPolicyRelay = true,
webrtcICEServers = emptyList(),
confirmRemoteSessions = false,
connectRemoteViaMulticast = true,
connectRemoteViaMulticastAuto = true,
developerTools = false,
confirmDBUpgrades = false,
androidCallOnLockScreen = AppSettingsLockScreenCalls.SHOW,
iosCallKitEnabled = true,
iosCallKitCallsInRecents = false
)
val current: AppSettings
get() {
val def = appPreferences
return defaults.copy(
networkConfig = getNetCfg(),
privacyEncryptLocalFiles = def.privacyEncryptLocalFiles.get(),
privacyAcceptImages = def.privacyAcceptImages.get(),
privacyLinkPreviews = def.privacyLinkPreviews.get(),
privacyShowChatPreviews = def.privacyShowChatPreviews.get(),
privacySaveLastDraft = def.privacySaveLastDraft.get(),
privacyProtectScreen = def.privacyProtectScreen.get(),
notificationMode = AppSettingsNotificationMode.from(def.notificationsMode.get()),
notificationPreviewMode = AppSettingsNotificationPreviewMode.from(NotificationPreviewMode.valueOf(def.notificationPreviewMode.get()!!)),
webrtcPolicyRelay = def.webrtcPolicyRelay.get(),
webrtcICEServers = def.webrtcIceServers.get()?.lines(),
confirmRemoteSessions = def.confirmRemoteSessions.get(),
connectRemoteViaMulticast = def.connectRemoteViaMulticast.get(),
connectRemoteViaMulticastAuto = def.connectRemoteViaMulticastAuto.get(),
developerTools = def.developerTools.get(),
confirmDBUpgrades = def.confirmDBUpgrades.get(),
androidCallOnLockScreen = AppSettingsLockScreenCalls.from(def.callOnLockScreen.get()),
iosCallKitEnabled = def.iosCallKitEnabled.get(),
iosCallKitCallsInRecents = def.iosCallKitCallsInRecents.get(),
)
}
}
}
@Serializable
enum class AppSettingsNotificationMode {
@SerialName("off") OFF,
@SerialName("periodic") PERIODIC,
@SerialName("instant") INSTANT;
fun toNotificationsMode(): NotificationsMode =
when (this) {
INSTANT -> NotificationsMode.SERVICE
PERIODIC -> NotificationsMode.PERIODIC
OFF -> NotificationsMode.OFF
}
companion object {
fun from(mode: NotificationsMode): AppSettingsNotificationMode =
when (mode) {
NotificationsMode.SERVICE -> INSTANT
NotificationsMode.PERIODIC -> PERIODIC
NotificationsMode.OFF -> OFF
}
}
}
@Serializable
enum class AppSettingsNotificationPreviewMode {
@SerialName("message") MESSAGE,
@SerialName("contact") CONTACT,
@SerialName("hidden") HIDDEN;
fun toNotificationPreviewMode(): NotificationPreviewMode =
when (this) {
MESSAGE -> NotificationPreviewMode.MESSAGE
CONTACT -> NotificationPreviewMode.CONTACT
HIDDEN -> NotificationPreviewMode.HIDDEN
}
companion object {
val default: AppSettingsNotificationPreviewMode = MESSAGE
fun from(mode: NotificationPreviewMode): AppSettingsNotificationPreviewMode =
when (mode) {
NotificationPreviewMode.MESSAGE -> MESSAGE
NotificationPreviewMode.CONTACT -> CONTACT
NotificationPreviewMode.HIDDEN -> HIDDEN
}
}
}
@Serializable
enum class AppSettingsLockScreenCalls {
@SerialName("disable") DISABLE,
@SerialName("show") SHOW,
@SerialName("accept") ACCEPT;
fun toCallOnLockScreen(): CallOnLockScreen =
when (this) {
DISABLE -> CallOnLockScreen.DISABLE
SHOW -> CallOnLockScreen.SHOW
ACCEPT -> CallOnLockScreen.ACCEPT
}
companion object {
val default = SHOW
fun from(mode: CallOnLockScreen): AppSettingsLockScreenCalls =
when (mode) {
CallOnLockScreen.DISABLE -> DISABLE
CallOnLockScreen.SHOW -> SHOW
CallOnLockScreen.ACCEPT -> ACCEPT
}
}
}
@@ -5,10 +5,12 @@ import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel.currentUser
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.helpers.DatabaseUtils.ksDatabasePassword
import chat.simplex.common.views.helpers.DatabaseUtils.randomDatabasePassword
import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.res.MR
import kotlinx.coroutines.*
import kotlinx.serialization.decodeFromString
import java.io.File
import java.nio.ByteBuffer
// ghc's rts
@@ -137,6 +139,33 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
}
}
fun chatInitTemporaryDatabase(dbPath: String, key: String? = null, confirmation: MigrationConfirmation = MigrationConfirmation.Error): Pair<DBMigrationResult, ChatCtrl?> {
val dbKey = key ?: randomDatabasePassword()
Log.d(TAG, "chatInitTemporaryDatabase path: $dbPath")
val migrated = chatMigrateInit(dbPath, dbKey, confirmation.value)
val res = runCatching {
json.decodeFromString<DBMigrationResult>(migrated[0] as String)
}.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) }
return res to migrated[1] as ChatCtrl
}
fun chatInitControllerRemovingDatabases() {
val dbPath = dbAbsolutePrefixPath
val dbKey = randomDatabasePassword()
Log.d(TAG, "chatInitControllerRemovingDatabases path: $dbPath")
val migrated = chatMigrateInit(dbPath, dbKey, MigrationConfirmation.Error.value)
val res = runCatching {
json.decodeFromString<DBMigrationResult>(migrated[0] as String)
}.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) }
val ctrl = migrated[1] as Long
chatController.ctrl = ctrl
// We need only controller, not databases
File(dbPath + "_chat.db").delete()
File(dbPath + "_agent.db").delete()
}
fun showStartChatAfterRestartAlert(): CompletableDeferred<Boolean> {
val deferred = CompletableDeferred<Boolean>()
AlertManager.shared.showAlertDialog(
@@ -66,6 +66,8 @@ fun copyBytesToFile(bytes: ByteArrayInputStream, to: URI, finally: () -> Unit) {
}
}
fun getMigrationTempFilesDirectory(): File = File(dataDir, "migration_temp_files")
fun getAppFilePath(fileName: String): String {
val rh = chatModel.currentRemoteHost.value
val s = File.separator
@@ -1,5 +1,6 @@
package chat.simplex.common.platform
import androidx.compose.runtime.Composable
import chat.simplex.common.model.ChatId
import chat.simplex.common.model.NotificationsMode
@@ -16,6 +17,7 @@ interface PlatformInterface {
fun androidStartCallActivity(acceptCall: Boolean, remoteHostId: Long? = null, chatId: ChatId? = null) {}
fun androidPictureInPictureAllowed(): Boolean = true
fun androidCallEnded() {}
@Composable fun androidLockPortraitOrientation() {}
suspend fun androidAskToAllowBackgroundCalls(): Boolean = true
}
/**
@@ -1,9 +1,8 @@
package chat.simplex.common.views.database
import SectionBottomSpacer
import SectionItemView
import SectionItemViewSpaceBetween
import SectionTextFooter
import SectionSpacer
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -24,20 +23,22 @@ import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.*
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.appPreferences
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.views.usersettings.SettingsActionItem
import chat.simplex.res.MR
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.datetime.Clock
import kotlin.math.log2
@Composable
fun DatabaseEncryptionView(m: ChatModel) {
fun DatabaseEncryptionView(m: ChatModel, migration: Boolean) {
val progressIndicator = remember { mutableStateOf(false) }
val prefs = m.controller.appPrefs
val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
@@ -61,9 +62,10 @@ fun DatabaseEncryptionView(m: ChatModel) {
storedKey,
initialRandomDBPassphrase,
progressIndicator,
migration,
onConfirmEncrypt = {
withLongRunningApi {
encryptDatabase(currentKey, newKey, confirmNewKey, initialRandomDBPassphrase, useKeychain, storedKey, progressIndicator)
encryptDatabase(currentKey, newKey, confirmNewKey, initialRandomDBPassphrase, useKeychain, storedKey, progressIndicator, migration)
}
}
)
@@ -95,24 +97,34 @@ fun DatabaseEncryptionLayout(
storedKey: MutableState<Boolean>,
initialRandomDBPassphrase: MutableState<Boolean>,
progressIndicator: MutableState<Boolean>,
migration: Boolean,
onConfirmEncrypt: () -> Unit,
) {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
if (!migration) Modifier.fillMaxWidth().verticalScroll(rememberScrollState()) else Modifier.fillMaxWidth(),
) {
AppBarTitle(stringResource(MR.strings.database_passphrase))
SectionView(null) {
SavePassphraseSetting(useKeychain.value, initialRandomDBPassphrase.value, storedKey.value, progressIndicator.value) { checked ->
if (!migration) {
AppBarTitle(stringResource(MR.strings.database_passphrase))
} else {
ChatStoppedView()
SectionSpacer()
}
SectionView(if (migration) generalGetString(MR.strings.database_passphrase).uppercase() else null) {
SavePassphraseSetting(
useKeychain.value,
initialRandomDBPassphrase.value,
storedKey.value,
enabled = (!initialRandomDBPassphrase.value && !progressIndicator.value) || migration
) { checked ->
if (checked) {
setUseKeychain(true, useKeychain, prefs)
} else if (storedKey.value) {
setUseKeychain(true, useKeychain, prefs, migration)
} else if (storedKey.value && !migration) {
// Don't show in migration process since it will remove the key after successful encryption
removePassphraseAlert {
DatabaseUtils.ksDatabasePassword.remove()
setUseKeychain(false, useKeychain, prefs)
storedKey.value = false
removePassphraseFromKeyChain(useKeychain, prefs, storedKey, false)
}
} else {
setUseKeychain(false, useKeychain, prefs)
setUseKeychain(false, useKeychain, prefs, migration)
}
}
@@ -169,12 +181,12 @@ fun DatabaseEncryptionLayout(
)
SectionItemViewSpaceBetween(onClickUpdate, disabled = disabled, minHeight = TextFieldDefaults.MinHeight) {
Text(generalGetString(MR.strings.update_database_passphrase), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
Text(generalGetString(if (migration) MR.strings.set_passphrase else MR.strings.update_database_passphrase), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
}
}
Column {
DatabaseEncryptionFooter(useKeychain, chatDbEncrypted, storedKey, initialRandomDBPassphrase)
DatabaseEncryptionFooter(useKeychain, chatDbEncrypted, storedKey, initialRandomDBPassphrase, migration)
}
SectionBottomSpacer()
}
@@ -211,8 +223,9 @@ expect fun SavePassphraseSetting(
useKeychain: Boolean,
initialRandomDBPassphrase: Boolean,
storedKey: Boolean,
progressIndicator: Boolean,
minHeight: Dp = TextFieldDefaults.MinHeight,
enabled: Boolean,
smallPadding: Boolean = true,
onCheckedChange: (Boolean) -> Unit,
)
@@ -222,8 +235,18 @@ expect fun DatabaseEncryptionFooter(
chatDbEncrypted: Boolean?,
storedKey: MutableState<Boolean>,
initialRandomDBPassphrase: MutableState<Boolean>,
migration: Boolean,
)
@Composable
fun ChatStoppedView() {
SettingsActionItem(
icon = painterResource(MR.images.ic_report_filled),
text = stringResource(MR.strings.chat_is_stopped),
iconColor = Color.Red,
)
}
fun resetFormAfterEncryption(
m: ChatModel,
initialRandomDBPassphrase: MutableState<Boolean>,
@@ -242,9 +265,18 @@ fun resetFormAfterEncryption(
m.controller.appPrefs.initialRandomDBPassphrase.set(false)
}
fun setUseKeychain(value: Boolean, useKeychain: MutableState<Boolean>, prefs: AppPreferences) {
fun setUseKeychain(value: Boolean, useKeychain: MutableState<Boolean>, prefs: AppPreferences, migration: Boolean) {
useKeychain.value = value
prefs.storeDBPassphrase.set(value)
// Postpone it when migrating to the end of encryption process
if (!migration) {
prefs.storeDBPassphrase.set(value)
}
}
private fun removePassphraseFromKeyChain(useKeychain: MutableState<Boolean>, prefs: AppPreferences, storedKey: MutableState<Boolean>, migration: Boolean) {
DatabaseUtils.ksDatabasePassword.remove()
setUseKeychain(false, useKeychain, prefs, migration)
storedKey.value = false
}
fun storeSecurelySaved() = generalGetString(MR.strings.store_passphrase_securely)
@@ -267,6 +299,7 @@ fun PassphraseField(
isValid: (String) -> Boolean,
keyboardActions: KeyboardActions = KeyboardActions(),
dependsOn: State<Any?>? = null,
requestFocus: Boolean = false,
) {
var valid by remember { mutableStateOf(validKey(key.value)) }
var showKey by remember { mutableStateOf(false) }
@@ -295,6 +328,7 @@ fun PassphraseField(
val color = MaterialTheme.colors.onBackground
val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize)
val interactionSource = remember { MutableInteractionSource() }
val focusRequester = remember { FocusRequester() }
BasicTextField(
value = state.value,
modifier = modifier
@@ -304,7 +338,8 @@ fun PassphraseField(
.defaultMinSize(
minWidth = TextFieldDefaults.MinWidth,
minHeight = TextFieldDefaults.MinHeight
),
)
.focusRequester(focusRequester),
onValueChange = {
state.value = it
key.value = it.text
@@ -347,6 +382,12 @@ fun PassphraseField(
)
}
)
LaunchedEffect(Unit) {
if (requestFocus) {
delay(200)
focusRequester.requestFocus()
}
}
LaunchedEffect(Unit) {
snapshotFlow { dependsOn?.value }
.distinctUntilChanged()
@@ -363,13 +404,17 @@ suspend fun encryptDatabase(
initialRandomDBPassphrase: MutableState<Boolean>,
useKeychain: MutableState<Boolean>,
storedKey: MutableState<Boolean>,
progressIndicator: MutableState<Boolean>
progressIndicator: MutableState<Boolean>,
migration: Boolean,
): Boolean {
val m = ChatModel
val prefs = ChatController.appPrefs
progressIndicator.value = true
return try {
prefs.encryptionStartedAt.set(Clock.System.now())
if (!m.chatDbChanged.value) {
m.controller.apiSaveAppSettings(AppSettings.current.prepareForExport())
}
val error = m.controller.apiStorageEncryption(currentKey.value, newKey.value)
prefs.encryptionStartedAt.set(null)
val sqliteError = ((error?.chatError as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError
@@ -393,9 +438,14 @@ suspend fun encryptDatabase(
}
else -> {
val new = newKey.value
if (migration) {
appPreferences.storeDBPassphrase.set(useKeychain.value)
}
resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value)
if (useKeychain.value) {
DatabaseUtils.ksDatabasePassword.set(new)
} else if (migration) {
removePassphraseFromKeyChain(useKeychain, prefs, storedKey, true)
}
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.database_encrypted))
@@ -474,6 +524,7 @@ fun PreviewDatabaseEncryptionLayout() {
storedKey = remember { mutableStateOf(true) },
initialRandomDBPassphrase = remember { mutableStateOf(true) },
progressIndicator = remember { mutableStateOf(false) },
migration = false,
onConfirmEncrypt = {},
)
}
@@ -206,6 +206,14 @@ private fun runChat(
is DBMigrationResult.OK -> {
platform.androidChatStartedAfterBeingOff()
}
null -> {}
else -> showErrorOnMigrationIfNeeded(status)
}
}
fun showErrorOnMigrationIfNeeded(status: DBMigrationResult) =
when (status) {
is DBMigrationResult.OK -> {}
is DBMigrationResult.ErrorNotADatabase ->
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.wrong_passphrase_title), generalGetString(MR.strings.enter_correct_passphrase))
is DBMigrationResult.ErrorSQL ->
@@ -217,9 +225,7 @@ private fun runChat(
is DBMigrationResult.InvalidConfirmation ->
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.invalid_migration_confirmation))
is DBMigrationResult.ErrorMigration -> {}
null -> {}
}
}
private fun shouldShowRestoreDbButton(prefs: AppPreferences): Boolean {
val startedAt = prefs.encryptionStartedAt.get() ?: return false
@@ -246,7 +252,7 @@ private fun restoreDb(restoreDbFromBackup: MutableState<Boolean>, prefs: AppPref
}
}
private fun mtrErrorDescription(err: MTRError): String =
fun mtrErrorDescription(err: MTRError): String =
when (err) {
is MTRError.NoDown ->
String.format(generalGetString(MR.strings.mtr_error_no_down_migration), err.dbMigrations.joinToString(", "))
@@ -211,7 +211,7 @@ fun DatabaseLayout(
if (unencrypted) painterResource(MR.images.ic_lock_open_right) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled)
else painterResource(MR.images.ic_lock),
stringResource(MR.strings.database_passphrase),
click = showSettingsModal() { DatabaseEncryptionView(it) },
click = showSettingsModal() { DatabaseEncryptionView(it, false) },
iconColor = if (unencrypted || (appPlatform.isDesktop && passphraseSaved)) WarningOrange else MaterialTheme.colors.secondary,
disabled = operationsDisabled
)
@@ -486,6 +486,7 @@ fun deleteChatDatabaseFilesAndState() {
filesDir.mkdir()
remoteHostsDir.deleteRecursively()
tmpDir.deleteRecursively()
getMigrationTempFilesDirectory().deleteRecursively()
tmpDir.mkdir()
DatabaseUtils.ksDatabasePassword.remove()
controller.appPrefs.storeDBPassphrase.set(true)
@@ -509,7 +510,7 @@ private fun exportArchive(
progressIndicator.value = true
withLongRunningApi {
try {
val archiveFile = exportChatArchive(m, chatArchiveName, chatArchiveTime, chatArchiveFile)
val archiveFile = exportChatArchive(m, null, chatArchiveName, chatArchiveTime, chatArchiveFile)
chatArchiveFile.value = archiveFile
saveArchiveLauncher.launch(archiveFile.substringAfterLast(File.separator))
progressIndicator.value = false
@@ -520,8 +521,9 @@ private fun exportArchive(
}
}
private suspend fun exportChatArchive(
suspend fun exportChatArchive(
m: ChatModel,
storagePath: File?,
chatArchiveName: MutableState<String?>,
chatArchiveTime: MutableState<Instant?>,
chatArchiveFile: MutableState<String?>
@@ -529,13 +531,19 @@ private suspend fun exportChatArchive(
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 = "${filesDir.absolutePath}${File.separator}$archiveName"
val archivePath = "${(storagePath ?: filesDir).absolutePath}${File.separator}$archiveName"
val config = ArchiveConfig(archivePath, parentTempDirectory = databaseExportDir.toString())
// Settings should be saved before changing a passphrase, otherwise the database needs to be migrated first
if (!m.chatDbChanged.value) {
controller.apiSaveAppSettings(AppSettings.current.prepareForExport())
}
m.controller.apiExportArchive(config)
deleteOldArchive(m)
m.controller.appPrefs.chatArchiveName.set(archiveName)
if (storagePath == null) {
deleteOldArchive(m)
m.controller.appPrefs.chatArchiveName.set(archiveName)
m.controller.appPrefs.chatArchiveTime.set(archiveTime)
}
chatArchiveName.value = archiveName
m.controller.appPrefs.chatArchiveTime.set(archiveTime)
chatArchiveTime.value = archiveTime
chatArchiveFile.value = archivePath
return archivePath
@@ -64,7 +64,7 @@ object DatabaseUtils {
return dbKey
}
private fun randomDatabasePassword(): String {
fun randomDatabasePassword(): String {
val s = ByteArray(32)
SecureRandom().nextBytes(s)
return s.toBase64StringForPassphrase().replace("\n", "")
@@ -15,7 +15,7 @@ fun DefaultProgressView(description: String?) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(
Modifier
.padding(bottom = DEFAULT_PADDING)
.padding(bottom = if (description != null) DEFAULT_PADDING else 0.dp)
.size(30.dp),
color = MaterialTheme.colors.secondary,
strokeWidth = 2.5.dp
@@ -19,17 +19,18 @@ import kotlin.math.min
fun ModalView(
close: () -> Unit,
showClose: Boolean = true,
enableClose: Boolean = true,
background: Color = MaterialTheme.colors.background,
modifier: Modifier = Modifier,
endButtons: @Composable RowScope.() -> Unit = {},
content: @Composable () -> Unit,
) {
if (showClose) {
BackHandler(onBack = close)
BackHandler(enabled = enableClose, onBack = close)
}
Surface(Modifier.fillMaxSize(), contentColor = LocalContentColor.current) {
Column(if (background != MaterialTheme.colors.background) Modifier.background(background) else Modifier.themedBackground()) {
CloseSheetBar(close, showClose, endButtons = endButtons)
CloseSheetBar(if (enableClose) close else null, showClose, endButtons = endButtons)
Box(modifier) { content() }
}
}
@@ -0,0 +1,721 @@
package chat.simplex.common.views.migration
import SectionBottomSpacer
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import chat.simplex.common.model.*
import chat.simplex.common.model.AppPreferences.Companion.SHARED_PREFS_MIGRATION_STAGE
import chat.simplex.common.model.ChatController.getNetCfg
import chat.simplex.common.model.ChatController.startChat
import chat.simplex.common.model.ChatCtrl
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.database.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.helpers.DatabaseUtils.ksDatabasePassword
import chat.simplex.common.views.newchat.QRCodeScanner
import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.common.views.usersettings.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
import kotlinx.datetime.Clock
import kotlinx.datetime.toJavaInstant
import kotlinx.serialization.*
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.max
@Serializable
sealed class MigrationFromAnotherDeviceState {
@Serializable @SerialName("onion") data class Onion(val link: String, val socksProxy: String?, val hostMode: HostMode, val requiredHostMode: Boolean): MigrationFromAnotherDeviceState()
@Serializable @SerialName("downloadProgress") data class DownloadProgress(val link: String, val archiveName: String, val netCfg: NetCfg): MigrationFromAnotherDeviceState()
@Serializable @SerialName("archiveImport") data class ArchiveImport(val archiveName: String, val netCfg: NetCfg): MigrationFromAnotherDeviceState()
@Serializable @SerialName("passphrase") data class Passphrase(val netCfg: NetCfg): MigrationFromAnotherDeviceState()
companion object {
// Here we check whether it's needed to show migration process after app restart or not
// It's important to NOT show the process when archive was corrupted/not fully downloaded
fun transform(): MigrationFromAnotherDeviceState? {
val stage = settings.getStringOrNull(SHARED_PREFS_MIGRATION_STAGE)
var state: MigrationFromAnotherDeviceState? = if (stage != null) json.decodeFromString(stage) else null
if (state is DownloadProgress) {
// No migration happens at the moment actually since archive were not downloaded fully
Log.e(TAG, "MigrateFromDevice: archive wasn't fully downloaded, removed broken file")
state = null
} else if (state is Onion) {
state = null
} else if (state is ArchiveImport && !File(getMigrationTempFilesDirectory(), state.archiveName).exists()) {
Log.e(TAG, "MigrateFromDevice: archive was removed unintentionally or state is broken, dropping migration")
state = null
}
if (state == null) {
settings.remove(SHARED_PREFS_MIGRATION_STAGE)
getMigrationTempFilesDirectory().deleteRecursively()
}
return state
}
fun save(state: MigrationFromAnotherDeviceState?) {
if (state != null) {
appPreferences.migrationStage.set(json.encodeToString(state))
} else {
appPreferences.migrationStage.set(null)
}
chatModel.migrationState.value = state
}
}
}
@Serializable
private sealed class MigrationState {
@Serializable object PasteOrScanLink: MigrationState()
@Serializable data class Onion(val link: String, val socksProxy: String?, val hostMode: HostMode, val requiredHostMode: Boolean): MigrationState()
@Serializable data class DatabaseInit(val link: String, val netCfg: NetCfg): MigrationState()
@Serializable data class LinkDownloading(val link: String, val ctrl: ChatCtrl, val user: User, val archivePath: String, val netCfg: NetCfg): MigrationState()
@Serializable data class DownloadProgress(val downloadedBytes: Long, val totalBytes: Long, val fileId: Long, val link: String, val archivePath: String, val netCfg: NetCfg, val ctrl: ChatCtrl?): MigrationState()
@Serializable data class DownloadFailed(val totalBytes: Long, val link: String, val archivePath: String, val netCfg: NetCfg): MigrationState()
@Serializable data class ArchiveImport(val archivePath: String, val netCfg: NetCfg): MigrationState()
@Serializable data class ArchiveImportFailed(val archivePath: String, val netCfg: NetCfg): MigrationState()
@Serializable data class Passphrase(val passphrase: String, val netCfg: NetCfg): MigrationState()
@Serializable data class MigrationConfirmation(val status: DBMigrationResult, val passphrase: String, val useKeychain: Boolean, val netCfg: NetCfg): MigrationState()
@Serializable data class Migration(val passphrase: String, val confirmation: chat.simplex.common.views.helpers.MigrationConfirmation, val useKeychain: Boolean, val netCfg: NetCfg): MigrationState()
}
private var MutableState<MigrationState>.state: MigrationState
get() = value
set(v) { value = v }
@Composable
fun ModalData.MigrateFromAnotherDeviceView(state: MigrationFromAnotherDeviceState? = null, close: () -> Unit) {
val migrationState = rememberSaveable(stateSaver = serializableSaver()) {
mutableStateOf(
when (state) {
null -> MigrationState.PasteOrScanLink
is MigrationFromAnotherDeviceState.Onion -> {
MigrationState.Onion(state.link, state.socksProxy, state.hostMode, state.requiredHostMode)
}
is MigrationFromAnotherDeviceState.DownloadProgress -> {
val archivePath = File(getMigrationTempFilesDirectory(), state.archiveName)
// SHOULDN'T BE HERE because the app checks this before opening migration screen and will not open it in this case.
// See analyzeMigrationState()
MigrationState.DownloadFailed(totalBytes = 0, link = state.link, archivePath = archivePath.absolutePath, state.netCfg)
}
is MigrationFromAnotherDeviceState.ArchiveImport -> {
val archivePath = File(getMigrationTempFilesDirectory(), state.archiveName)
MigrationState.ArchiveImportFailed(archivePath.absolutePath, state.netCfg)
}
is MigrationFromAnotherDeviceState.Passphrase -> {
MigrationState.Passphrase("", state.netCfg)
}
}
)
}
// Prevent from hiding the view until migration is finished or app deleted
val backDisabled = remember {
derivedStateOf {
val s = chatModel.migrationState.value
s is MigrationFromAnotherDeviceState.ArchiveImport ||
s is MigrationFromAnotherDeviceState.Passphrase ||
migrationState.value is MigrationState.DatabaseInit
}
}
val chatReceiver = remember { mutableStateOf(null as MigrationFromChatReceiver?) }
ModalView(
enableClose = !backDisabled.value,
close = {
withBGApi {
migrationState.cleanUpOnBack(chatReceiver.value)
close()
}
},
) {
MigrateFromAnotherDeviceLayout(
migrationState = migrationState,
chatReceiver = chatReceiver,
close = close,
)
}
}
@Composable
private fun ModalData.MigrateFromAnotherDeviceLayout(
migrationState: MutableState<MigrationState>,
chatReceiver: MutableState<MigrationFromChatReceiver?>,
close: () -> Unit,
) {
val tempDatabaseFile = rememberSaveable { mutableStateOf(fileForTemporaryDatabase()) }
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).height(IntrinsicSize.Max),
) {
AppBarTitle(stringResource(MR.strings.migrate_here))
SectionByState(migrationState, tempDatabaseFile.value, chatReceiver, close)
SectionBottomSpacer()
}
platform.androidLockPortraitOrientation()
}
@Composable
private fun ModalData.SectionByState(
migrationState: MutableState<MigrationState>,
tempDatabaseFile: File,
chatReceiver: MutableState<MigrationFromChatReceiver?>,
close: () -> Unit
) {
when (val s = migrationState.value) {
is MigrationState.PasteOrScanLink -> migrationState.PasteOrScanLinkView()
is MigrationState.Onion -> OnionView(s.link, s.socksProxy, s.hostMode, s.requiredHostMode, migrationState)
is MigrationState.DatabaseInit -> migrationState.DatabaseInitView(s.link, tempDatabaseFile, s.netCfg)
is MigrationState.LinkDownloading -> migrationState.LinkDownloadingView(s.link, s.ctrl, s.user, s.archivePath, tempDatabaseFile, chatReceiver, s.netCfg)
is MigrationState.DownloadProgress -> DownloadProgressView(s.downloadedBytes, totalBytes = s.totalBytes)
is MigrationState.DownloadFailed -> migrationState.DownloadFailedView(s.link, chatReceiver.value, s.archivePath, s.netCfg)
is MigrationState.ArchiveImport -> migrationState.ArchiveImportView(s.archivePath, s.netCfg)
is MigrationState.ArchiveImportFailed -> migrationState.ArchiveImportFailedView(s.archivePath, s.netCfg)
is MigrationState.Passphrase -> migrationState.PassphraseEnteringView(currentKey = s.passphrase, s.netCfg)
is MigrationState.MigrationConfirmation -> migrationState.MigrationConfirmationView(s.status, s.passphrase, s.useKeychain, s.netCfg)
is MigrationState.Migration -> MigrationView(s.passphrase, s.confirmation, s.useKeychain, s.netCfg, close)
}
}
@Composable
private fun MutableState<MigrationState>.PasteOrScanLinkView() {
if (appPlatform.isAndroid) {
SectionView(stringResource(MR.strings.scan_QR_code).replace('\n', ' ').uppercase()) {
QRCodeScanner(showQRCodeScanner = remember { mutableStateOf(true) }) { text ->
withBGApi { checkUserLink(text) }
}
}
SectionSpacer()
}
if (appPlatform.isDesktop || appPreferences.developerTools.get()) {
SectionView(stringResource(if (appPlatform.isAndroid) MR.strings.or_paste_archive_link else MR.strings.paste_archive_link).uppercase()) {
PasteLinkView()
}
}
}
@Composable
private fun MutableState<MigrationState>.PasteLinkView() {
val clipboard = LocalClipboardManager.current
SectionItemView({
val str = clipboard.getText()?.text ?: return@SectionItemView
withBGApi { checkUserLink(str) }
}) {
Text(stringResource(MR.strings.tap_to_paste_link))
}
}
@Composable
private fun ModalData.OnionView(link: String, socksProxy: String?, hostMode: HostMode, requiredHostMode: Boolean, state: MutableState<MigrationState>) {
val onionHosts = remember { stateGetOrPut("onionHosts") {
getNetCfg().copy(socksProxy = socksProxy, hostMode = hostMode, requiredHostMode = requiredHostMode).onionHosts
} }
val networkUseSocksProxy = remember { stateGetOrPut("networkUseSocksProxy") { socksProxy != null } }
val sessionMode = remember { stateGetOrPut("sessionMode") { TransportSessionMode.User} }
val networkProxyHostPort = remember { stateGetOrPut("networkHostProxyPort") {
var proxy = (socksProxy ?: chatModel.controller.appPrefs.networkProxyHostPort.get())
if (proxy?.startsWith(":") == true) proxy = "localhost$proxy"
proxy
}
}
val proxyPort = remember { derivedStateOf { networkProxyHostPort.value?.split(":")?.lastOrNull()?.toIntOrNull() ?: 9050 } }
val netCfg = rememberSaveable(stateSaver = serializableSaver()) {
mutableStateOf(getNetCfg().withOnionHosts(onionHosts.value).copy(socksProxy = socksProxy, sessionMode = sessionMode.value))
}
SectionView(stringResource(MR.strings.migration_from_device_confirm_network_settings).uppercase()) {
SettingsActionItemWithContent(
icon = painterResource(MR.images.ic_check),
text = stringResource(MR.strings.migration_from_device_apply_onion),
textColor = MaterialTheme.colors.primary,
click = {
val updated = netCfg.value
.withOnionHosts(onionHosts.value)
.withHostPort(if (networkUseSocksProxy.value) networkProxyHostPort.value else null, null)
.copy(
sessionMode = sessionMode.value
)
withBGApi {
state.value = MigrationState.DatabaseInit(link, updated)
}
}
){}
SectionTextFooter(stringResource(MR.strings.migration_from_device_confirm_network_settings_footer))
}
SectionSpacer()
val networkProxyHostPortPref = SharedPreference(get = { networkProxyHostPort.value }, set = {
networkProxyHostPort.value = it
})
SectionView(stringResource(MR.strings.network_settings_title).uppercase()) {
OnionRelatedLayout(
appPreferences.developerTools.get(),
networkUseSocksProxy,
onionHosts,
sessionMode,
networkProxyHostPortPref,
proxyPort,
toggleSocksProxy = { enable ->
networkUseSocksProxy.value = enable
},
useOnion = {
onionHosts.value = it
},
updateSessionMode = {
sessionMode.value = it
}
)
}
}
@Composable
private fun MutableState<MigrationState>.DatabaseInitView(link: String, tempDatabaseFile: File, netCfg: NetCfg) {
Box {
SectionView(stringResource(MR.strings.migration_from_device_database_init).uppercase()) {}
ProgressView()
}
LaunchedEffect(Unit) {
prepareDatabase(link, tempDatabaseFile, netCfg)
}
}
@Composable
private fun MutableState<MigrationState>.LinkDownloadingView(
link: String,
ctrl: ChatCtrl,
user: User,
archivePath: String,
tempDatabaseFile: File,
chatReceiver: MutableState<MigrationFromChatReceiver?>,
netCfg: NetCfg
) {
Box {
SectionView(stringResource(MR.strings.migration_from_device_downloading_details).uppercase()) {}
ProgressView()
}
LaunchedEffect(Unit) {
startDownloading(0, ctrl, user, tempDatabaseFile, chatReceiver, link, archivePath, netCfg)
}
}
@Composable
private fun DownloadProgressView(downloadedBytes: Long, totalBytes: Long) {
Box {
SectionView(stringResource(MR.strings.migration_from_device_downloading_archive).uppercase()) {
val ratio = downloadedBytes.toFloat() / max(totalBytes, 1)
LargeProgressView(ratio, "${(ratio * 100).toInt()}%", stringResource(MR.strings.migration_from_device_bytes_downloaded).format(formatBytes(downloadedBytes)))
}
}
}
@Composable
private fun MutableState<MigrationState>.DownloadFailedView(link: String, chatReceiver: MigrationFromChatReceiver?, archivePath: String, netCfg: NetCfg) {
SectionView(stringResource(MR.strings.migration_from_device_download_failed).uppercase()) {
SettingsActionItemWithContent(
icon = painterResource(MR.images.ic_download),
text = stringResource(MR.strings.migration_from_device_repeat_download),
textColor = MaterialTheme.colors.primary,
click = {
state = MigrationState.DatabaseInit(link, netCfg)
}
) {}
SectionTextFooter(stringResource(MR.strings.migration_from_device_try_again))
}
LaunchedEffect(Unit) {
chatReceiver?.stopAndCleanUp()
File(archivePath).delete()
MigrationFromAnotherDeviceState.save(null)
}
}
@Composable
private fun MutableState<MigrationState>.ArchiveImportView(archivePath: String, netCfg: NetCfg) {
Box {
SectionView(stringResource(MR.strings.migration_from_device_importing_archive).uppercase()) {}
ProgressView()
}
LaunchedEffect(Unit) {
importArchive(archivePath, netCfg)
}
}
@Composable
private fun MutableState<MigrationState>.ArchiveImportFailedView(archivePath: String, netCfg: NetCfg) {
SectionView(stringResource(MR.strings.migration_from_device_import_failed).uppercase()) {
SettingsActionItemWithContent(
icon = painterResource(MR.images.ic_download),
text = stringResource(MR.strings.migration_from_device_repeat_import),
textColor = MaterialTheme.colors.primary,
click = {
state = MigrationState.ArchiveImport(archivePath, netCfg)
}
) {}
SectionTextFooter(stringResource(MR.strings.migration_from_device_try_again))
}
}
@Composable
private fun MutableState<MigrationState>.PassphraseEnteringView(currentKey: String, netCfg: NetCfg) {
val currentKey = rememberSaveable { mutableStateOf(currentKey) }
val verifyingPassphrase = rememberSaveable { mutableStateOf(false) }
val useKeychain = rememberSaveable { mutableStateOf(appPreferences.storeDBPassphrase.get()) }
Box {
val view = LocalMultiplatformView()
SectionView(stringResource(MR.strings.migration_from_device_enter_passphrase).uppercase()) {
SavePassphraseSetting(
useKeychain.value,
false,
false,
enabled = !verifyingPassphrase.value,
smallPadding = false
) { checked -> useKeychain.value = checked }
PassphraseField(currentKey, placeholder = stringResource(MR.strings.current_passphrase), Modifier.padding(horizontal = DEFAULT_PADDING), isValid = ::validKey, requestFocus = true)
SettingsActionItemWithContent(
icon = painterResource(MR.images.ic_vpn_key_filled),
text = stringResource(MR.strings.open_chat),
textColor = MaterialTheme.colors.primary,
disabled = verifyingPassphrase.value || currentKey.value.isEmpty(),
click = {
verifyingPassphrase.value = true
hideKeyboard(view)
withBGApi {
val (status, _) = chatInitTemporaryDatabase(dbAbsolutePrefixPath, key = currentKey.value, confirmation = MigrationConfirmation.YesUp)
val success = status == DBMigrationResult.OK || status == DBMigrationResult.InvalidConfirmation
if (success) {
state = MigrationState.Migration(currentKey.value, MigrationConfirmation.YesUp, useKeychain.value, netCfg)
} else if (status is DBMigrationResult.ErrorMigration) {
state = MigrationState.MigrationConfirmation(status, currentKey.value, useKeychain.value, netCfg)
} else {
showErrorOnMigrationIfNeeded(status)
}
verifyingPassphrase.value = false
}
}
) {}
DatabaseEncryptionFooter(useKeychain, chatDbEncrypted = true, remember { mutableStateOf(false) }, remember { mutableStateOf(false) }, true)
}
if (verifyingPassphrase.value) {
ProgressView()
}
}
}
@Composable
private fun MutableState<MigrationState>.MigrationConfirmationView(status: DBMigrationResult, passphrase: String, useKeychain: Boolean, netCfg: NetCfg) {
data class Tuple4<A,B,C,D>(val a: A, val b: B, val c: C, val d: D)
val (header: String, button: String?, footer: String, confirmation: MigrationConfirmation?) = when (status) {
is DBMigrationResult.ErrorMigration -> when (val err = status.migrationError) {
is MigrationError.Upgrade ->
Tuple4(
generalGetString(MR.strings.database_upgrade),
generalGetString(MR.strings.upgrade_and_open_chat),
"",
MigrationConfirmation.YesUp
)
is MigrationError.Downgrade ->
Tuple4(
generalGetString(MR.strings.database_downgrade),
generalGetString(MR.strings.downgrade_and_open_chat),
generalGetString(MR.strings.database_downgrade_warning),
MigrationConfirmation.YesUpDown
)
is MigrationError.Error ->
Tuple4(
generalGetString(MR.strings.incompatible_database_version),
null,
mtrErrorDescription(err.mtrError),
null
)
}
else -> Tuple4(generalGetString(MR.strings.error), null, generalGetString(MR.strings.unknown_error), null)
}
SectionView(header.uppercase()) {
if (button != null && confirmation != null) {
SettingsActionItemWithContent(
icon = painterResource(MR.images.ic_download),
text = button,
textColor = MaterialTheme.colors.primary,
click = {
state = MigrationState.Migration(passphrase, confirmation, useKeychain, netCfg)
}
) {}
}
SectionTextFooter(footer)
}
}
@Composable
private fun MigrationView(passphrase: String, confirmation: MigrationConfirmation, useKeychain: Boolean, netCfg: NetCfg, close: () -> Unit) {
Box {
SectionView(stringResource(MR.strings.migration_from_device_migrating).uppercase()) {}
ProgressView()
}
LaunchedEffect(Unit) {
startChat(passphrase, confirmation, useKeychain, netCfg, close)
}
}
@Composable
private fun ProgressView() {
DefaultProgressView(null)
}
private suspend fun MutableState<MigrationState>.checkUserLink(link: String) {
if (strHasSimplexFileLink(link.trim())) {
val data = MigrationFileLinkData.readFromLink(link)
val hasOnionConfigured = data?.networkConfig?.hasOnionConfigured() ?: false
val networkConfig = data?.networkConfig?.transformToPlatformSupported()
// If any of iOS or Android had onion enabled, show onion screen
if (hasOnionConfigured && networkConfig?.hostMode != null && networkConfig.requiredHostMode != null) {
state = MigrationState.Onion(link.trim(), networkConfig.socksProxy, networkConfig.hostMode, networkConfig.requiredHostMode)
MigrationFromAnotherDeviceState.save(MigrationFromAnotherDeviceState.Onion(link.trim(), networkConfig.socksProxy, networkConfig.hostMode, networkConfig.requiredHostMode))
} else {
val current = getNetCfg()
state = MigrationState.DatabaseInit(link.trim(), current.copy(
socksProxy = networkConfig?.socksProxy,
hostMode = networkConfig?.hostMode ?: current.hostMode,
requiredHostMode = networkConfig?.requiredHostMode ?: current.requiredHostMode
))
}
} else {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.invalid_file_link),
text = generalGetString(MR.strings.the_text_you_pasted_is_not_a_link)
)
}
}
private fun MutableState<MigrationState>.prepareDatabase(
link: String,
tempDatabaseFile: File,
netCfg: NetCfg,
) {
withLongRunningApi {
val ctrlAndUser = initTemporaryDatabase(tempDatabaseFile, netCfg)
if (ctrlAndUser == null) {
state = MigrationState.DownloadFailed(0, link, archivePath(), netCfg)
return@withLongRunningApi
}
val (ctrl, user) = ctrlAndUser
state = MigrationState.LinkDownloading(link, ctrl, user, archivePath(), netCfg)
}
}
private fun MutableState<MigrationState>.startDownloading(
totalBytes: Long,
ctrl: ChatCtrl,
user: User,
tempDatabaseFile: File,
chatReceiver: MutableState<MigrationFromChatReceiver?>,
link: String,
archivePath: String,
netCfg: NetCfg,
) {
withBGApi {
chatReceiver.value = MigrationFromChatReceiver(ctrl, tempDatabaseFile) { msg ->
when (msg) {
is CR.RcvFileProgressXFTP -> {
state = MigrationState.DownloadProgress(msg.receivedSize, msg.totalSize, msg.rcvFileTransfer.fileId, link, archivePath, netCfg, ctrl)
MigrationFromAnotherDeviceState.save(MigrationFromAnotherDeviceState.DownloadProgress(link, File(archivePath).name, netCfg))
}
is CR.RcvStandaloneFileComplete -> {
delay(500)
state = MigrationState.ArchiveImport(archivePath, netCfg)
MigrationFromAnotherDeviceState.save(MigrationFromAnotherDeviceState.ArchiveImport(File(archivePath).name, netCfg))
}
is CR.RcvFileError -> {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.migration_from_device_download_failed),
generalGetString(MR.strings.migration_from_device_file_delete_or_link_invalid)
)
state = MigrationState.DownloadFailed(totalBytes, link, archivePath, netCfg)
}
else -> Log.d(TAG, "unsupported event: ${msg.responseType}")
}
}
chatReceiver.value?.start()
val (res, error) = controller.downloadStandaloneFile(user, link, CryptoFile.plain(File(archivePath).path), ctrl)
if (res == null) {
state = MigrationState.DownloadFailed(totalBytes, link, archivePath, netCfg)
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.migration_from_device_error_downloading_archive),
error
)
}
}
}
private fun MutableState<MigrationState>.importArchive(archivePath: String, netCfg: NetCfg) {
withLongRunningApi {
try {
if (ChatController.ctrl == null || ChatController.ctrl == -1L) {
chatInitControllerRemovingDatabases()
}
controller.apiDeleteStorage()
try {
val config = ArchiveConfig(archivePath, parentTempDirectory = databaseExportDir.toString())
val archiveErrors = controller.apiImportArchive(config)
if (archiveErrors.isNotEmpty()) {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.chat_database_imported),
generalGetString(MR.strings.non_fatal_errors_occured_during_import)
)
}
state = MigrationState.Passphrase("", netCfg)
MigrationFromAnotherDeviceState.save(MigrationFromAnotherDeviceState.Passphrase(netCfg))
} catch (e: Exception) {
state = MigrationState.ArchiveImportFailed(archivePath, netCfg)
AlertManager.shared.showAlertMsg (generalGetString(MR.strings.error_importing_database), e.stackTraceToString())
}
} catch (e: Exception) {
state = MigrationState.ArchiveImportFailed(archivePath, netCfg)
AlertManager.shared.showAlertMsg (generalGetString(MR.strings.error_deleting_database), e.stackTraceToString())
}
}
}
private suspend fun stopArchiveDownloading(fileId: Long, ctrl: ChatCtrl) {
controller.apiCancelFile(null, fileId, ctrl)
}
private fun startChat(passphrase: String, confirmation: MigrationConfirmation, useKeychain: Boolean, netCfg: NetCfg, close: () -> Unit) {
if (useKeychain) {
ksDatabasePassword.set(passphrase)
} else {
ksDatabasePassword.remove()
}
appPreferences.storeDBPassphrase.set(useKeychain)
appPreferences.initialRandomDBPassphrase.set(false)
withBGApi {
try {
initChatController(useKey = passphrase, confirmMigrations = confirmation) { CompletableDeferred(false) }
val appSettings = controller.apiGetAppSettings(AppSettings.current.prepareForExport()).copy(
networkConfig = netCfg
)
finishMigration(appSettings, close)
} catch (e: Exception) {
hideView(close)
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_starting_chat), e.stackTraceToString())
}
}
}
private suspend fun finishMigration(appSettings: AppSettings, close: () -> Unit) {
try {
getMigrationTempFilesDirectory().deleteRecursively()
appSettings.importIntoApp()
val user = chatModel.currentUser.value
if (user != null) {
startChat(user)
}
hideView(close)
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.migration_from_device_chat_migrated), generalGetString(MR.strings.migration_from_device_finalize_migration))
} catch (e: Exception) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_starting_chat), e.stackTraceToString())
}
MigrationFromAnotherDeviceState.save(null)
}
private fun hideView(close: () -> Unit) {
appPreferences.onboardingStage.set(OnboardingStage.OnboardingComplete)
close()
}
private suspend fun MutableState<MigrationState>.cleanUpOnBack(chatReceiver: MigrationFromChatReceiver?) {
val state = state
if (state is MigrationState.ArchiveImportFailed) {
// Original database is not exist, nothing is set up correctly for showing to a user yet. Return to clean state
deleteChatDatabaseFilesAndState()
initChatControllerAndRunMigrations()
} else if (state is MigrationState.DownloadProgress && state.ctrl != null) {
stopArchiveDownloading(state.fileId, state.ctrl)
}
chatReceiver?.stopAndCleanUp()
getMigrationTempFilesDirectory().deleteRecursively()
MigrationFromAnotherDeviceState.save(null)
}
private fun strHasSimplexFileLink(text: String): Boolean =
text.startsWith("simplex:/file") || text.startsWith("https://simplex.chat/file")
private fun fileForTemporaryDatabase(): File =
File(getMigrationTempFilesDirectory(), generateNewFileName("migration", "db", getMigrationTempFilesDirectory()))
private fun archivePath(): 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 = File(getMigrationTempFilesDirectory(), archiveName)
return archivePath.absolutePath
}
private class MigrationFromChatReceiver(
val ctrl: ChatCtrl,
val databaseUrl: File,
var receiveMessages: Boolean = true,
val processReceivedMsg: suspend (CR) -> Unit
) {
fun start() {
Log.d(TAG, "MigrationChatReceiver startReceiver")
CoroutineScope(Dispatchers.IO).launch {
while (receiveMessages) {
try {
val msg = ChatController.recvMsg(ctrl)
if (msg != null && receiveMessages) {
val r = msg.resp
val rhId = msg.remoteHostId
Log.d(TAG, "processReceivedMsg: ${r.responseType}")
chatModel.addTerminalItem(TerminalItem.resp(rhId, r))
val finishedWithoutTimeout = withTimeoutOrNull(60_000L) {
processReceivedMsg(r)
}
if (finishedWithoutTimeout == null) {
Log.e(TAG, "Timeout reached while processing received message: " + msg.resp.responseType)
if (appPreferences.developerTools.get() && appPreferences.showSlowApiCalls.get()) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.possible_slow_function_title),
text = generalGetString(MR.strings.possible_slow_function_desc).format(60, msg.resp.responseType + "\n" + Exception().stackTraceToString()),
shareText = true
)
}
}
}
} catch (e: Exception) {
Log.e(TAG, "MigrationChatReceiver recvMsg/processReceivedMsg exception: " + e.stackTraceToString())
} catch (e: Exception) {
Log.e(TAG, "MigrationChatReceiver recvMsg/processReceivedMsg throwable: " + e.stackTraceToString())
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.stackTraceToString())
}
}
}
}
fun stopAndCleanUp() {
Log.d(TAG, "MigrationChatReceiver.stop")
receiveMessages = false
chatCloseStore(ctrl)
File(databaseUrl.absolutePath + "_chat.db").delete()
File(databaseUrl.absolutePath + "_agent.db").delete()
}
}
@@ -0,0 +1,683 @@
package chat.simplex.common.views.migration
import SectionBottomSpacer
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
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.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.getNetCfg
import chat.simplex.common.model.ChatController.startChat
import chat.simplex.common.model.ChatController.startChatWithTemporaryDatabase
import chat.simplex.common.model.ChatCtrl
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.database.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.LinkTextView
import chat.simplex.common.views.newchat.SimpleXLinkQRCode
import chat.simplex.common.views.usersettings.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
import kotlinx.datetime.*
import kotlinx.serialization.*
import java.io.File
import java.net.URLEncoder
import kotlin.math.max
@Serializable
data class MigrationFileLinkData(
val networkConfig: NetworkConfig?,
) {
@Serializable
data class NetworkConfig(
val socksProxy: String?,
val hostMode: HostMode?,
val requiredHostMode: Boolean?
) {
fun hasOnionConfigured(): Boolean = socksProxy != null || hostMode == HostMode.Onion
fun transformToPlatformSupported(): NetworkConfig {
return if (hostMode != null && requiredHostMode != null) {
NetworkConfig(
socksProxy = if (hostMode == HostMode.Onion) socksProxy ?: NetCfg.proxyDefaults.socksProxy else socksProxy,
hostMode = if (hostMode == HostMode.Onion) HostMode.OnionViaSocks else hostMode,
requiredHostMode = requiredHostMode
)
} else this
}
}
fun addToLink(link: String) = link + "&data=" + URLEncoder.encode(jsonShort.encodeToString(this), "UTF-8")
companion object {
suspend fun readFromLink(link: String): MigrationFileLinkData? =
try {
// val data = link.substringAfter("&data=").substringBefore("&")
// json.decodeFromString(URLDecoder.decode(data, "UTF-8"))
controller.standaloneFileInfo(link)
} catch (e: Exception) {
null
}
}
}
@Serializable
private sealed class MigrationToState {
@Serializable object ChatStopInProgress: MigrationToState()
@Serializable data class ChatStopFailed(val reason: String): MigrationToState()
@Serializable object PassphraseNotSet: MigrationToState()
@Serializable object PassphraseConfirmation: MigrationToState()
@Serializable object UploadConfirmation: MigrationToState()
@Serializable object Archiving: MigrationToState()
@Serializable data class DatabaseInit(val totalBytes: Long, val archivePath: String): MigrationToState()
@Serializable data class UploadProgress(val uploadedBytes: Long, val totalBytes: Long, val fileId: Long, val archivePath: String, val ctrl: ChatCtrl, val user: User): MigrationToState()
@Serializable data class UploadFailed(val totalBytes: Long, val archivePath: String): MigrationToState()
@Serializable object LinkCreation: MigrationToState()
@Serializable data class LinkShown(val fileId: Long, val link: String, val ctrl: ChatCtrl): MigrationToState()
@Serializable data class Finished(val chatDeletion: Boolean): MigrationToState()
}
private var MutableState<MigrationToState>.state: MigrationToState
get() = value
set(v) { value = v }
@Composable
fun MigrateToAnotherDeviceView(close: () -> Unit) {
val migrationState = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf<MigrationToState>(MigrationToState.ChatStopInProgress) }
// Prevent from hiding the view until migration is finished or app deleted
val backDisabled = remember {
derivedStateOf {
migrationState.value is MigrationToState.DatabaseInit ||
migrationState.value is MigrationToState.Archiving ||
migrationState.value is MigrationToState.LinkCreation ||
migrationState.value is MigrationToState.LinkShown ||
migrationState.value is MigrationToState.Finished
}
}
val chatReceiver = remember { mutableStateOf(null as MigrationToChatReceiver?) }
ModalView(
enableClose = !backDisabled.value,
close = {
withBGApi {
migrationState.cleanUpOnBack(chatReceiver.value)
}
close()
},
) {
MigrateToAnotherDeviceLayout(
migrationState = migrationState,
chatReceiver = chatReceiver
)
}
}
@Composable
private fun MigrateToAnotherDeviceLayout(
migrationState: MutableState<MigrationToState>,
chatReceiver: MutableState<MigrationToChatReceiver?>
) {
val tempDatabaseFile = rememberSaveable { mutableStateOf(fileForTemporaryDatabase()) }
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).height(IntrinsicSize.Max),
) {
AppBarTitle(stringResource(MR.strings.migrate_to_device))
SectionByState(migrationState, tempDatabaseFile.value, chatReceiver)
SectionBottomSpacer()
}
platform.androidLockPortraitOrientation()
}
@Composable
private fun SectionByState(
migrationState: MutableState<MigrationToState>,
tempDatabaseFile: File,
chatReceiver: MutableState<MigrationToChatReceiver?>
) {
when (val s = migrationState.value) {
is MigrationToState.ChatStopInProgress -> migrationState.ChatStopInProgressView()
is MigrationToState.ChatStopFailed -> migrationState.ChatStopFailedView(s.reason)
is MigrationToState.PassphraseNotSet -> migrationState.PassphraseNotSetView()
is MigrationToState.PassphraseConfirmation -> migrationState.PassphraseConfirmationView()
is MigrationToState.UploadConfirmation -> migrationState.UploadConfirmationView()
is MigrationToState.Archiving -> migrationState.ArchivingView()
is MigrationToState.DatabaseInit -> migrationState.DatabaseInitView(tempDatabaseFile, s.totalBytes, s.archivePath)
is MigrationToState.UploadProgress -> migrationState.UploadProgressView(s.uploadedBytes, s.totalBytes, s.ctrl, s.user, tempDatabaseFile, chatReceiver, s.archivePath)
is MigrationToState.UploadFailed -> migrationState.UploadFailedView(s.totalBytes, s.archivePath, chatReceiver.value)
is MigrationToState.LinkCreation -> LinkCreationView()
is MigrationToState.LinkShown -> migrationState.LinkShownView(s.fileId, s.link, s.ctrl)
is MigrationToState.Finished -> migrationState.FinishedView(s.chatDeletion)
}
}
@Composable
private fun MutableState<MigrationToState>.ChatStopInProgressView() {
Box {
SectionView(stringResource(MR.strings.migration_to_device_stopping_chat).uppercase()) {}
ProgressView()
}
LaunchedEffect(Unit) {
stopChat()
}
}
@Composable
private fun MutableState<MigrationToState>.ChatStopFailedView(reason: String) {
SectionView(stringResource(MR.strings.error_stopping_chat).uppercase()) {
Text(reason)
SectionSpacer()
SettingsActionItemWithContent(
icon = painterResource(MR.images.ic_report_filled),
text = stringResource(MR.strings.auth_stop_chat),
textColor = MaterialTheme.colors.error,
click = ::stopChat
){}
SectionTextFooter(stringResource(MR.strings.migration_to_device_chat_should_be_stopped))
}
}
@Composable
private fun MutableState<MigrationToState>.PassphraseNotSetView() {
DatabaseEncryptionView(chatModel, true)
KeyChangeEffect(appPreferences.initialRandomDBPassphrase.state.value) {
if (!appPreferences.initialRandomDBPassphrase.get()) {
state = MigrationToState.UploadConfirmation
}
}
}
@Composable
private fun MutableState<MigrationToState>.PassphraseConfirmationView() {
val useKeychain = remember { appPreferences.storeDBPassphrase.get() }
val currentKey = rememberSaveable { mutableStateOf("") }
val verifyingPassphrase = rememberSaveable { mutableStateOf(false) }
Box {
val view = LocalMultiplatformView()
Column {
ChatStoppedView()
SectionSpacer()
SectionView(stringResource(MR.strings.migration_to_device_verify_database_passphrase).uppercase()) {
PassphraseField(currentKey, placeholder = stringResource(MR.strings.current_passphrase), Modifier.padding(horizontal = DEFAULT_PADDING), isValid = ::validKey, requestFocus = true)
SettingsActionItemWithContent(
icon = painterResource(if (useKeychain) MR.images.ic_vpn_key_filled else MR.images.ic_lock),
text = stringResource(MR.strings.migration_to_device_verify_passphrase),
textColor = MaterialTheme.colors.primary,
disabled = verifyingPassphrase.value || currentKey.value.isEmpty(),
click = {
verifyingPassphrase.value = true
hideKeyboard(view)
withBGApi {
verifyDatabasePassphrase(currentKey.value)
verifyingPassphrase.value = false
}
}
) {}
SectionTextFooter(stringResource(MR.strings.migration_to_device_confirm_you_remember_passphrase))
}
}
if (verifyingPassphrase.value) {
ProgressView()
}
}
}
@Composable
private fun MutableState<MigrationToState>.UploadConfirmationView() {
SectionView(stringResource(MR.strings.migration_to_device_confirm_upload).uppercase()) {
SettingsActionItemWithContent(
icon = painterResource(MR.images.ic_ios_share),
text = stringResource(MR.strings.migration_to_device_archive_and_upload),
textColor = MaterialTheme.colors.primary,
click = { state = MigrationToState.Archiving }
){}
SectionTextFooter(stringResource(MR.strings.migration_to_device_all_data_will_be_uploaded))
}
}
@Composable
private fun MutableState<MigrationToState>.ArchivingView() {
Box {
SectionView(stringResource(MR.strings.migration_to_device_archiving_database).uppercase()) {}
ProgressView()
}
LaunchedEffect(Unit) {
exportArchive()
}
}
@Composable
private fun MutableState<MigrationToState>.DatabaseInitView(tempDatabaseFile: File, totalBytes: Long, archivePath: String) {
Box {
SectionView(stringResource(MR.strings.migration_to_device_database_init).uppercase()) {}
ProgressView()
}
LaunchedEffect(Unit) {
prepareDatabase(tempDatabaseFile, totalBytes, archivePath)
}
}
@Composable
private fun MutableState<MigrationToState>.UploadProgressView(
uploadedBytes: Long,
totalBytes: Long,
ctrl: ChatCtrl,
user: User,
tempDatabaseFile: File,
chatReceiver: MutableState<MigrationToChatReceiver?>,
archivePath: String,
) {
Box {
SectionView(stringResource(MR.strings.migration_to_device_uploading_archive).uppercase()) {
val ratio = uploadedBytes.toFloat() / max(totalBytes, 1)
LargeProgressView(ratio, "${(ratio * 100).toInt()}%", stringResource(MR.strings.migration_to_device_bytes_uploaded).format(formatBytes(uploadedBytes)))
}
}
LaunchedEffect(Unit) {
startUploading(totalBytes, ctrl, user, tempDatabaseFile, chatReceiver, archivePath)
}
}
@Composable
private fun MutableState<MigrationToState>.UploadFailedView(totalBytes: Long, archivePath: String, chatReceiver: MigrationToChatReceiver?) {
SectionView(stringResource(MR.strings.migration_to_device_upload_failed).uppercase()) {
SettingsActionItemWithContent(
icon = painterResource(MR.images.ic_ios_share),
text = stringResource(MR.strings.migration_to_device_repeat_upload),
textColor = MaterialTheme.colors.primary,
click = {
state = MigrationToState.DatabaseInit(totalBytes, archivePath)
}
) {}
SectionTextFooter(stringResource(MR.strings.migration_to_device_try_again))
}
LaunchedEffect(Unit) {
chatReceiver?.stopAndCleanUp()
}
}
@Composable
private fun LinkCreationView() {
Box {
SectionView(stringResource(MR.strings.migration_to_device_creating_archive_link).uppercase()) {}
ProgressView()
}
}
@Composable
private fun MutableState<MigrationToState>.LinkShownView(fileId: Long, link: String, ctrl: ChatCtrl) {
SectionView {
SettingsActionItemWithContent(
icon = painterResource(MR.images.ic_close),
text = stringResource(MR.strings.migration_to_device_cancel_migration),
textColor = MaterialTheme.colors.error,
click = {
cancelMigration(fileId, ctrl)
}
) {}
SettingsActionItemWithContent(
icon = painterResource(MR.images.ic_check),
text = stringResource(MR.strings.migration_to_device_finalize_migration),
textColor = MaterialTheme.colors.primary,
click = {
finishMigration(fileId, ctrl)
}
) {}
SectionTextFooter(annotatedStringResource(MR.strings.migration_to_device_choose_migrate_from_another_device))
}
SectionSpacer()
SectionView(stringResource(MR.strings.show_QR_code).uppercase()) {
SimpleXLinkQRCode(link, onShare = {})
}
SectionSpacer()
SectionView(stringResource(MR.strings.migration_to_device_or_share_this_file_link).uppercase()) {
LinkTextView(link, true)
}
}
@Composable
private fun MutableState<MigrationToState>.FinishedView(chatDeletion: Boolean) {
Box {
SectionView(stringResource(MR.strings.migration_to_device_migration_complete).uppercase()) {
SettingsActionItemWithContent(
icon = painterResource(MR.images.ic_delete_forever),
text = stringResource(MR.strings.migration_to_device_delete_database_from_device),
textColor = MaterialTheme.colors.primary,
click = {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.delete_chat_profile_question),
text = generalGetString(MR.strings.delete_chat_profile_action_cannot_be_undone_warning),
confirmText = generalGetString(MR.strings.delete_verb),
onConfirm = {
deleteChatAndDismiss()
}
)
}
) {}
SettingsActionItemWithContent(
icon = painterResource(MR.images.ic_play_arrow_filled),
text = stringResource(MR.strings.migration_to_device_start_chat),
textColor = MaterialTheme.colors.error,
click = {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.start_chat_question),
text = generalGetString(MR.strings.migration_to_device_starting_chat_on_multiple_devices_unsupported),
confirmText = generalGetString(MR.strings.migration_to_device_start_chat),
onConfirm = {
withLongRunningApi { startChatAndDismiss() }
}
)
}
) {}
SectionTextFooter(annotatedStringResource(MR.strings.migration_to_device_you_must_not_start_database_on_two_device))
SectionTextFooter(annotatedStringResource(MR.strings.migration_to_device_using_on_two_device_breaks_encryption))
}
if (chatDeletion) {
ProgressView()
}
}
}
@Composable
private fun ProgressView() {
DefaultProgressView(null)
}
@Composable
fun LargeProgressView(value: Float, title: String, description: String) {
Box(Modifier.padding(DEFAULT_PADDING).fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(
progress = value,
(if (appPlatform.isDesktop) Modifier.size(DEFAULT_START_MODAL_WIDTH) else Modifier.size(windowWidth() - DEFAULT_PADDING * 2))
.rotate(-90f),
color = MaterialTheme.colors.primary,
strokeWidth = 25.dp
)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(description, color = Color.Transparent)
Text(title, style = MaterialTheme.typography.h1.copy(fontSize = 50.sp, fontWeight = FontWeight.Bold), color = MaterialTheme.colors.primary)
Text(description, style = MaterialTheme.typography.subtitle1)
}
}
}
private fun MutableState<MigrationToState>.stopChat() {
withBGApi {
try {
stopChatAsync(chatModel)
try {
controller.apiSaveAppSettings(AppSettings.current.prepareForExport())
state = if (appPreferences.initialRandomDBPassphrase.get()) MigrationToState.PassphraseNotSet else MigrationToState.PassphraseConfirmation
} catch (e: Exception) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.migrate_to_device_error_saving_settings),
text = e.stackTraceToString()
)
state = MigrationToState.ChatStopFailed(reason = generalGetString(MR.strings.migrate_to_device_error_saving_settings))
}
} catch (e: Exception) {
state = MigrationToState.ChatStopFailed(reason = e.stackTraceToString().take(10))
}
}
}
private suspend fun MutableState<MigrationToState>.verifyDatabasePassphrase(dbKey: String) {
if (controller.testStorageEncryption(dbKey)) {
state = MigrationToState.UploadConfirmation
} else {
showErrorOnMigrationIfNeeded(DBMigrationResult.ErrorNotADatabase(""))
}
}
private fun MutableState<MigrationToState>.exportArchive() {
withLongRunningApi {
try {
getMigrationTempFilesDirectory().mkdir()
val archivePath = exportChatArchive(chatModel, getMigrationTempFilesDirectory(), mutableStateOf(""), mutableStateOf(Instant.DISTANT_PAST), mutableStateOf(""))
val totalBytes = File(archivePath).length()
if (totalBytes > 0L) {
state = MigrationToState.DatabaseInit(totalBytes, archivePath)
} else {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.migrate_to_device_exported_file_doesnt_exist))
state = MigrationToState.UploadConfirmation
}
} catch (e: Exception) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.migrate_to_device_error_exporting_archive),
text = e.stackTraceToString()
)
state = MigrationToState.UploadConfirmation
}
}
}
suspend fun initTemporaryDatabase(tempDatabaseFile: File, netCfg: NetCfg): Pair<ChatCtrl, User>? {
val (status, ctrl) = chatInitTemporaryDatabase(tempDatabaseFile.absolutePath)
showErrorOnMigrationIfNeeded(status)
try {
if (ctrl != null) {
val user = startChatWithTemporaryDatabase(ctrl, netCfg)
return if (user != null) ctrl to user else null
}
} catch (e: Throwable) {
Log.e(TAG, "Error while starting chat in temporary database: ${e.stackTraceToString()}")
}
return null
}
private fun MutableState<MigrationToState>.prepareDatabase(
tempDatabaseFile: File,
totalBytes: Long,
archivePath: String,
) {
withLongRunningApi {
val ctrlAndUser = initTemporaryDatabase(tempDatabaseFile, getNetCfg())
if (ctrlAndUser == null) {
state = MigrationToState.UploadFailed(totalBytes, archivePath)
return@withLongRunningApi
}
val (ctrl, user) = ctrlAndUser
state = MigrationToState.UploadProgress(0L, totalBytes, 0L, archivePath, ctrl, user)
}
}
private fun MutableState<MigrationToState>.startUploading(
totalBytes: Long,
ctrl: ChatCtrl,
user: User,
tempDatabaseFile: File,
chatReceiver: MutableState<MigrationToChatReceiver?>,
archivePath: String,
) {
withBGApi {
chatReceiver.value = MigrationToChatReceiver(ctrl, tempDatabaseFile) { msg ->
when (msg) {
is CR.SndFileProgressXFTP -> {
val s = state
if (s is MigrationToState.UploadProgress && s.uploadedBytes != s.totalBytes) {
state = MigrationToState.UploadProgress(msg.sentSize, msg.totalSize, msg.fileTransferMeta.fileId, archivePath, ctrl, user)
}
}
is CR.SndFileRedirectStartXFTP -> {
delay(500)
state = MigrationToState.LinkCreation
}
is CR.SndStandaloneFileComplete -> {
delay(500)
val cfg = getNetCfg()
val data = MigrationFileLinkData(
networkConfig = MigrationFileLinkData.NetworkConfig(
socksProxy = cfg.socksProxy,
hostMode = cfg.hostMode,
requiredHostMode = cfg.requiredHostMode
)
)
state = MigrationToState.LinkShown(msg.fileTransferMeta.fileId, data.addToLink(msg.rcvURIs[0]), ctrl)
}
else -> {
Log.d(TAG, "unsupported event: ${msg.responseType}")
}
}
}
chatReceiver.value?.start()
val (res, error) = controller.uploadStandaloneFile(user, CryptoFile.plain(File(archivePath).name), ctrl)
if (res == null) {
state = MigrationToState.UploadFailed(totalBytes, archivePath)
return@withBGApi AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.migration_to_device_error_uploading_archive),
error
)
}
state = MigrationToState.UploadProgress(0, res.fileSize, res.fileId, archivePath, ctrl, user)
}
}
private suspend fun cancelUploadedArchive(fileId: Long, ctrl: ChatCtrl) {
controller.apiCancelFile(null, fileId, ctrl)
}
private fun cancelMigration(fileId: Long, ctrl: ChatCtrl) {
withBGApi {
cancelUploadedArchive(fileId, ctrl)
startChatAndDismiss()
}
}
private fun MutableState<MigrationToState>.finishMigration(fileId: Long, ctrl: ChatCtrl) {
withBGApi {
cancelUploadedArchive(fileId, ctrl)
state = MigrationToState.Finished(false)
}
}
private fun MutableState<MigrationToState>.deleteChatAndDismiss() {
withBGApi {
try {
deleteChatAsync(chatModel)
chatModel.chatDbChanged.value = true
state = MigrationToState.Finished(true)
try {
initChatController(startChat = { CompletableDeferred(false) })
chatModel.chatDbChanged.value = false
ModalManager.fullscreen.closeModals()
} catch (e: Exception) {
throw Exception(generalGetString(MR.strings.error_starting_chat) + "\n" + e.stackTraceToString())
}
} catch (e: Exception) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.migration_to_device_error_deleting_database),
text = e.stackTraceToString()
)
}
}
}
private suspend fun startChatAndDismiss(dismiss: Boolean = true) {
try {
val user = chatModel.currentUser.value
if (chatModel.chatDbChanged.value) {
initChatController()
chatModel.chatDbChanged.value = false
} else if (user != null) {
startChat(user)
}
} catch (e: Exception) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.error_starting_chat),
text = e.stackTraceToString()
)
}
// Hide settings anyway if chatDbStatus is not ok, probably passphrase needs to be entered
if (dismiss || chatModel.chatDbStatus.value != DBMigrationResult.OK) {
ModalManager.fullscreen.closeModals()
}
}
private suspend fun MutableState<MigrationToState>.cleanUpOnBack(chatReceiver: MigrationToChatReceiver?) {
val s = state
if (s !is MigrationToState.LinkShown && s !is MigrationToState.Finished) {
chatModel.switchingUsersAndHosts.value = true
startChatAndDismiss(false)
chatModel.switchingUsersAndHosts.value = false
}
if (s is MigrationToState.UploadProgress) {
cancelUploadedArchive(s.fileId, s.ctrl)
}
chatReceiver?.stopAndCleanUp()
getMigrationTempFilesDirectory().deleteRecursively()
}
private fun fileForTemporaryDatabase(): File =
File(getMigrationTempFilesDirectory(), generateNewFileName("migration", "db", getMigrationTempFilesDirectory()))
private class MigrationToChatReceiver(
val ctrl: ChatCtrl,
val databaseUrl: File,
var receiveMessages: Boolean = true,
val processReceivedMsg: suspend (CR) -> Unit
) {
fun start() {
Log.d(TAG, "MigrationChatReceiver startReceiver")
CoroutineScope(Dispatchers.IO).launch {
while (receiveMessages) {
try {
val msg = ChatController.recvMsg(ctrl)
if (msg != null && receiveMessages) {
val r = msg.resp
val rhId = msg.remoteHostId
Log.d(TAG, "processReceivedMsg: ${r.responseType}")
chatModel.addTerminalItem(TerminalItem.resp(rhId, r))
val finishedWithoutTimeout = withTimeoutOrNull(60_000L) {
processReceivedMsg(r)
}
if (finishedWithoutTimeout == null) {
Log.e(TAG, "Timeout reached while processing received message: " + msg.resp.responseType)
if (appPreferences.developerTools.get() && appPreferences.showSlowApiCalls.get()) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.possible_slow_function_title),
text = generalGetString(MR.strings.possible_slow_function_desc).format(60, msg.resp.responseType + "\n" + Exception().stackTraceToString()),
shareText = true
)
}
}
}
} catch (e: Exception) {
Log.e(TAG, "MigrationChatReceiver recvMsg/processReceivedMsg exception: " + e.stackTraceToString())
} catch (e: Exception) {
Log.e(TAG, "MigrationChatReceiver recvMsg/processReceivedMsg throwable: " + e.stackTraceToString())
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.stackTraceToString())
}
}
}
}
fun stopAndCleanUp() {
Log.d(TAG, "MigrationChatReceiver.stop")
receiveMessages = false
chatCloseStore(ctrl)
File(databaseUrl.absolutePath + "_chat.db").delete()
File(databaseUrl.absolutePath + "_agent.db").delete()
}
}
@@ -301,7 +301,7 @@ private fun PasteLinkView(rhId: Long?, pastedLink: MutableState<String>, showQRC
}
@Composable
private fun LinkTextView(link: String, share: Boolean) {
fun LinkTextView(link: String, share: Boolean) {
val clipboard = LocalClipboardManager.current
Row(Modifier.fillMaxWidth().heightIn(min = 46.dp).padding(horizontal = DEFAULT_PADDING), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.weight(1f).clickable {
@@ -58,7 +58,7 @@ fun SetupDatabasePassphrase(m: ChatModel) {
prefs.storeDBPassphrase.set(false)
val newKeyValue = newKey.value
val success = encryptDatabase(currentKey, newKey, confirmNewKey, mutableStateOf(true), saveInPreferences, mutableStateOf(true), progressIndicator)
val success = encryptDatabase(currentKey, newKey, confirmNewKey, mutableStateOf(true), saveInPreferences, mutableStateOf(true), progressIndicator, false)
if (success) {
startChat(newKeyValue)
nextStep()
@@ -5,7 +5,7 @@ import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -16,8 +16,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.chatModel
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.migration.MigrateFromAnotherDeviceView
import chat.simplex.res.MR
import dev.icerock.moko.resources.StringResource
@@ -62,17 +64,32 @@ fun SimpleXInfoLayout(
OnboardingActionButton(user, onboardingStage)
}
Spacer(Modifier.fillMaxHeight().weight(1f))
Box(
Modifier
.fillMaxWidth()
.padding(top = DEFAULT_PADDING), contentAlignment = Alignment.Center
) {
SimpleButtonDecorated(text = stringResource(MR.strings.migrate_from_another_device), icon = painterResource(MR.images.ic_download),
click = { ModalManager.fullscreen.showCustomModal { close -> MigrateFromAnotherDeviceView(chatModel.migrationState.value, close) } })
}
}
Box(
Modifier
.fillMaxWidth()
.padding(bottom = DEFAULT_PADDING.times(1.5f), top = DEFAULT_PADDING), contentAlignment = Alignment.Center
.padding(bottom = DEFAULT_PADDING.times(1.5f), top = if (onboardingStage == null) DEFAULT_PADDING else 0.dp), contentAlignment = Alignment.Center
) {
SimpleButtonDecorated(text = stringResource(MR.strings.how_it_works), icon = painterResource(MR.images.ic_info),
click = showModal { HowItWorks(user, onboardingStage) })
}
}
LaunchedEffect(Unit) {
val state = chatModel.migrationState.value
if (state != null && !ModalManager.fullscreen.hasModalsOpen()) {
ModalManager.fullscreen.showCustomModal(animated = false) { close -> MigrateFromAnotherDeviceView(state, close) }
}
}
}
@Composable
@@ -33,12 +33,7 @@ import chat.simplex.common.views.helpers.annotatedStringResource
import chat.simplex.res.MR
@Composable
fun NetworkAndServersView(
chatModel: ChatModel,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showCustomModal: (@Composable ModalData.(ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
) {
fun NetworkAndServersView() {
val currentRemoteHost by remember { chatModel.currentRemoteHost }
// It's not a state, just a one-time value. Shouldn't be used in any state-related situations
val netCfg = remember { chatModel.controller.getNetCfg() }
@@ -55,9 +50,6 @@ fun NetworkAndServersView(
onionHosts = onionHosts,
sessionMode = sessionMode,
proxyPort = proxyPort,
showModal = showModal,
showSettingsModal = showSettingsModal,
showCustomModal = showCustomModal,
toggleSocksProxy = { enable ->
if (enable) {
AlertManager.shared.showAlertDialog(
@@ -154,13 +146,11 @@ fun NetworkAndServersView(
onionHosts: MutableState<OnionHosts>,
sessionMode: MutableState<TransportSessionMode>,
proxyPort: State<Int>,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showCustomModal: (@Composable ModalData.(ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
toggleSocksProxy: (Boolean) -> Unit,
useOnion: (OnionHosts) -> Unit,
updateSessionMode: (TransportSessionMode) -> Unit,
) {
val m = chatModel
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
@@ -168,17 +158,18 @@ fun NetworkAndServersView(
AppBarTitle(stringResource(MR.strings.network_and_servers))
if (!chatModel.desktopNoUserNoRemote) {
SectionView(generalGetString(MR.strings.settings_section_title_messages)) {
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.smp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.SMP, close) })
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.smp_servers), { ModalManager.start.showCustomModal { close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.SMP, close) } })
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.xftp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) })
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.xftp_servers), { ModalManager.start.showCustomModal { close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) } })
if (currentRemoteHost == null) {
UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showSettingsModal)
UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion)
val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.start.showModal(content = it) }
UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showModal, chatModel.controller.appPrefs.networkProxyHostPort, false)
UseOnionHosts(onionHosts, networkUseSocksProxy, showModal, useOnion)
if (developerTools) {
SessionModePicker(sessionMode, showSettingsModal, updateSessionMode)
SessionModePicker(sessionMode, showModal, updateSessionMode)
}
SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) })
SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), { ModalManager.start.showModal { AdvancedNetworkSettingsView(m) } })
}
}
}
@@ -196,18 +187,39 @@ fun NetworkAndServersView(
}
SectionView(generalGetString(MR.strings.settings_section_title_calls)) {
SettingsActionItem(painterResource(MR.images.ic_electrical_services), stringResource(MR.strings.webrtc_ice_servers), showModal { RTCServersView(it) })
SettingsActionItem(painterResource(MR.images.ic_electrical_services), stringResource(MR.strings.webrtc_ice_servers), { ModalManager.start.showModal { RTCServersView(m) } })
}
SectionBottomSpacer()
}
}
@Composable fun OnionRelatedLayout(
developerTools: Boolean,
networkUseSocksProxy: MutableState<Boolean>,
onionHosts: MutableState<OnionHosts>,
sessionMode: MutableState<TransportSessionMode>,
networkProxyHostPort: SharedPreference<String?>,
proxyPort: State<Int>,
toggleSocksProxy: (Boolean) -> Unit,
useOnion: (OnionHosts) -> Unit,
updateSessionMode: (TransportSessionMode) -> Unit,
) {
val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.fullscreen.showModal(content = it) }
UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showModal, networkProxyHostPort, true)
UseOnionHosts(onionHosts, networkUseSocksProxy, showModal, useOnion)
if (developerTools) {
SessionModePicker(sessionMode, showModal, updateSessionMode)
}
}
@Composable
fun UseSocksProxySwitch(
networkUseSocksProxy: MutableState<Boolean>,
proxyPort: State<Int>,
toggleSocksProxy: (Boolean) -> Unit,
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
showModal: (@Composable ModalData.() -> Unit) -> Unit,
networkProxyHostPort: SharedPreference<String?> = chatModel.controller.appPrefs.networkProxyHostPort,
migration: Boolean = false,
) {
Row(
Modifier.fillMaxWidth().padding(end = DEFAULT_PADDING),
@@ -227,8 +239,11 @@ fun UseSocksProxySwitch(
val text = buildAnnotatedString {
append(generalGetString(MR.strings.network_socks_toggle_use_socks_proxy) + " (")
val style = SpanStyle(color = MaterialTheme.colors.primary)
val disabledStyle = SpanStyle(color = MaterialTheme.colors.onBackground)
withAnnotation(tag = "PORT", annotation = generalGetString(MR.strings.network_proxy_port).format(proxyPort.value)) {
withStyle(style) { append(generalGetString(MR.strings.network_proxy_port).format(proxyPort.value)) }
withStyle(if (networkUseSocksProxy.value || !migration) style else disabledStyle) {
append(generalGetString(MR.strings.network_proxy_port).format(proxyPort.value))
}
}
append(")")
}
@@ -238,7 +253,9 @@ fun UseSocksProxySwitch(
onClick = { offset ->
text.getStringAnnotations(tag = "PORT", start = offset, end = offset)
.firstOrNull()?.let { _ ->
showSettingsModal { SockProxySettings(it) }()
if (networkUseSocksProxy.value || !migration) {
showModal { SockProxySettings(chatModel, networkProxyHostPort, migration) }
}
}
},
shouldConsumeEvent = { offset ->
@@ -254,7 +271,11 @@ fun UseSocksProxySwitch(
}
@Composable
fun SockProxySettings(m: ChatModel) {
fun SockProxySettings(
m: ChatModel,
networkProxyHostPort: SharedPreference<String?> = m.controller.appPrefs.networkProxyHostPort,
migration: Boolean,
) {
Column(
Modifier
.fillMaxWidth()
@@ -262,17 +283,17 @@ fun SockProxySettings(m: ChatModel) {
) {
val defaultHostPort = remember { "localhost:9050" }
AppBarTitle(generalGetString(MR.strings.network_socks_proxy_settings))
val hostPort by remember { m.controller.appPrefs.networkProxyHostPort.state }
val hostPortSaved by remember { networkProxyHostPort.state }
val hostUnsaved = rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue(hostPort?.split(":")?.firstOrNull() ?: "localhost"))
mutableStateOf(TextFieldValue(hostPortSaved?.split(":")?.firstOrNull() ?: "localhost"))
}
val portUnsaved = rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue(hostPort?.split(":")?.lastOrNull() ?: "9050"))
mutableStateOf(TextFieldValue(hostPortSaved?.split(":")?.lastOrNull() ?: "9050"))
}
val save = {
withBGApi {
m.controller.appPrefs.networkProxyHostPort.set(hostUnsaved.value.text + ":" + portUnsaved.value.text)
if (m.controller.appPrefs.networkUseSocksProxy.get()) {
networkProxyHostPort.set(hostUnsaved.value.text + ":" + portUnsaved.value.text)
if (m.controller.appPrefs.networkUseSocksProxy.get() && !migration) {
m.controller.apiSetNetworkConfig(m.controller.getNetCfg())
}
}
@@ -281,21 +302,21 @@ fun SockProxySettings(m: ChatModel) {
SectionItemView {
ResetToDefaultsButton({
val reset = {
m.controller.appPrefs.networkProxyHostPort.set(defaultHostPort)
networkProxyHostPort.set(defaultHostPort)
val newHost = defaultHostPort.split(":").first()
val newPort = defaultHostPort.split(":").last()
hostUnsaved.value = hostUnsaved.value.copy(newHost, TextRange(newHost.length))
portUnsaved.value = portUnsaved.value.copy(newPort, TextRange(newPort.length))
save()
}
if (m.controller.appPrefs.networkUseSocksProxy.get()) {
if (m.controller.appPrefs.networkUseSocksProxy.get() && !migration) {
showUpdateNetworkSettingsDialog {
reset()
}
} else {
reset()
}
}, disabled = hostPort == defaultHostPort)
}, disabled = hostPortSaved == defaultHostPort)
}
SectionItemView {
DefaultConfigurableTextField(
@@ -321,14 +342,14 @@ fun SockProxySettings(m: ChatModel) {
SectionCustomFooter {
NetworkSectionFooter(
revert = {
val prevHost = m.controller.appPrefs.networkProxyHostPort.get()?.split(":")?.firstOrNull() ?: "localhost"
val prevPort = m.controller.appPrefs.networkProxyHostPort.get()?.split(":")?.lastOrNull() ?: "9050"
val prevHost = hostPortSaved?.split(":")?.firstOrNull() ?: "localhost"
val prevPort = hostPortSaved?.split(":")?.lastOrNull() ?: "9050"
hostUnsaved.value = hostUnsaved.value.copy(prevHost, TextRange(prevHost.length))
portUnsaved.value = portUnsaved.value.copy(prevPort, TextRange(prevPort.length))
},
save = { if (m.controller.appPrefs.networkUseSocksProxy.get()) showUpdateNetworkSettingsDialog { save() } else save() },
revertDisabled = hostPort == (hostUnsaved.value.text + ":" + portUnsaved.value.text),
saveDisabled = hostPort == (hostUnsaved.value.text + ":" + portUnsaved.value.text) ||
save = { if (m.controller.appPrefs.networkUseSocksProxy.get() && !migration) showUpdateNetworkSettingsDialog { save() } else save() },
revertDisabled = hostPortSaved == (hostUnsaved.value.text + ":" + portUnsaved.value.text),
saveDisabled = hostPortSaved == (hostUnsaved.value.text + ":" + portUnsaved.value.text) ||
remember { derivedStateOf { !validHost(hostUnsaved.value.text) } }.value ||
remember { derivedStateOf { !validPort(portUnsaved.value.text) } }.value
)
@@ -341,7 +362,7 @@ fun SockProxySettings(m: ChatModel) {
private fun UseOnionHosts(
onionHosts: MutableState<OnionHosts>,
enabled: State<Boolean>,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showModal: (@Composable ModalData.() -> Unit) -> Unit,
useOnion: (OnionHosts) -> Unit,
) {
val values = remember {
@@ -353,29 +374,43 @@ private fun UseOnionHosts(
}
}
}
val onSelected = showModal {
Column(
Modifier.fillMaxWidth(),
) {
AppBarTitle(stringResource(MR.strings.network_use_onion_hosts))
SectionViewSelectable(null, onionHosts, values, useOnion)
val onSelected = {
showModal {
Column(
Modifier.fillMaxWidth(),
) {
AppBarTitle(stringResource(MR.strings.network_use_onion_hosts))
SectionViewSelectable(null, onionHosts, values, useOnion)
}
}
}
SectionItemWithValue(
generalGetString(MR.strings.network_use_onion_hosts),
onionHosts,
values,
icon = painterResource(MR.images.ic_security),
enabled = enabled,
onSelected = onSelected
)
if (enabled.value) {
SectionItemWithValue(
generalGetString(MR.strings.network_use_onion_hosts),
onionHosts,
values,
icon = painterResource(MR.images.ic_security),
enabled = enabled,
onSelected = onSelected
)
} else {
// In reality, when socks proxy is disabled, this option acts like NEVER regardless of what was chosen before
SectionItemWithValue(
generalGetString(MR.strings.network_use_onion_hosts),
remember { mutableStateOf(OnionHosts.NEVER) },
listOf(ValueTitleDesc(OnionHosts.NEVER, generalGetString(MR.strings.network_use_onion_hosts_no), AnnotatedString(generalGetString(MR.strings.network_use_onion_hosts_no_desc)))),
icon = painterResource(MR.images.ic_security),
enabled = enabled,
onSelected = {}
)
}
}
@Composable
private fun SessionModePicker(
sessionMode: MutableState<TransportSessionMode>,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showModal: (@Composable ModalData.() -> Unit) -> Unit,
updateSessionMode: (TransportSessionMode) -> Unit,
) {
val density = LocalDensity.current
@@ -393,12 +428,14 @@ private fun SessionModePicker(
sessionMode,
values,
icon = painterResource(MR.images.ic_safety_divider),
onSelected = showModal {
Column(
Modifier.fillMaxWidth(),
) {
AppBarTitle(stringResource(MR.strings.network_session_mode_transport_isolation))
SectionViewSelectable(null, sessionMode, values, updateSessionMode)
onSelected = {
showModal {
Column(
Modifier.fillMaxWidth(),
) {
AppBarTitle(stringResource(MR.strings.network_session_mode_transport_isolation))
SectionViewSelectable(null, sessionMode, values, updateSessionMode)
}
}
}
)
@@ -455,9 +492,6 @@ fun PreviewNetworkAndServersLayout() {
developerTools = true,
networkUseSocksProxy = remember { mutableStateOf(true) },
proxyPort = remember { mutableStateOf(9050) },
showModal = { {} },
showSettingsModal = { {} },
showCustomModal = { {} },
toggleSocksProxy = {},
onionHosts = remember { mutableStateOf(OnionHosts.PREFER) },
sessionMode = remember { mutableStateOf(TransportSessionMode.User) },
@@ -28,6 +28,8 @@ import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.CreateProfile
import chat.simplex.common.views.database.DatabaseView
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.migration.MigrateFromAnotherDeviceView
import chat.simplex.common.views.migration.MigrateToAnotherDeviceView
import chat.simplex.common.views.onboarding.SimpleXInfo
import chat.simplex.common.views.onboarding.WhatsNewView
import chat.simplex.common.views.remote.ConnectDesktopView
@@ -135,12 +137,13 @@ fun SettingsLayout(
} else {
SettingsActionItem(painterResource(MR.images.ic_desktop), stringResource(MR.strings.settings_section_title_use_from_desktop), showCustomModal{ it, close -> ConnectDesktopView(close) }, disabled = stopped, extraPadding = true)
}
SettingsActionItem(painterResource(MR.images.ic_ios_share), stringResource(MR.strings.migrate_to_device), { withAuth(generalGetString(MR.strings.auth_open_migration_to_another_device), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.fullscreen.showCustomModal { close -> MigrateToAnotherDeviceView(close) } }}, disabled = stopped, extraPadding = true)
}
SectionDividerSpaced()
SectionView(stringResource(MR.strings.settings_section_title_settings)) {
SettingsActionItem(painterResource(if (notificationsMode.value == NotificationsMode.OFF) MR.images.ic_bolt_off else MR.images.ic_bolt), stringResource(MR.strings.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showSettingsModal { NetworkAndServersView(it, showModal, showSettingsModal, showCustomModal) }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showSettingsModal { NetworkAndServersView() }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it, showSettingsModal) }, extraPadding = true)
@@ -366,7 +369,7 @@ fun SettingsActionItem(icon: Painter, text: String, click: (() -> Unit)? = null,
}
@Composable
fun SettingsActionItemWithContent(icon: Painter?, text: String? = null, click: (() -> Unit)? = null, iconColor: Color = MaterialTheme.colors.secondary, disabled: Boolean = false, extraPadding: Boolean = false, content: @Composable RowScope.() -> Unit) {
fun SettingsActionItemWithContent(icon: Painter?, text: String? = null, click: (() -> Unit)? = null, iconColor: Color = MaterialTheme.colors.secondary, textColor: Color = MaterialTheme.colors.onBackground, disabled: Boolean = false, extraPadding: Boolean = false, content: @Composable RowScope.() -> Unit) {
SectionItemView(
click,
extraPadding = extraPadding,
@@ -382,7 +385,7 @@ fun SettingsActionItemWithContent(icon: Painter?, text: String? = null, click: (
}
if (text != null) {
val padding = with(LocalDensity.current) { 6.sp.toDp() }
Text(text, Modifier.weight(1f).padding(vertical = padding), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.onBackground)
Text(text, Modifier.weight(1f).padding(vertical = padding), color = if (disabled) MaterialTheme.colors.secondary else textColor)
Spacer(Modifier.width(DEFAULT_PADDING))
Row(Modifier.widthIn(max = (windowWidth() - DEFAULT_PADDING * 2) / 2)) {
content()
@@ -245,6 +245,7 @@
<string name="auth_stop_chat">Stop chat</string>
<string name="auth_open_chat_console">Open chat console</string>
<string name="auth_open_chat_profiles">Open chat profiles</string>
<string name="auth_open_migration_to_another_device">Open migration screen</string>
<string name="lock_not_enabled">SimpleX Lock not enabled!</string>
<string name="you_can_turn_on_lock">You can turn on SimpleX Lock via Settings.</string>
@@ -823,6 +824,7 @@
<string name="opensource_protocol_and_code_anybody_can_run_servers">Open-source protocol and code anybody can run the servers.</string>
<string name="create_your_profile">Create your profile</string>
<string name="make_private_connection">Make a private connection</string>
<string name="migrate_from_another_device">Migrate from another device</string>
<string name="how_it_works">How it works</string>
<!-- How SimpleX Works -->
@@ -1081,6 +1083,7 @@
<string name="confirm_new_passphrase">Confirm new passphrase…</string>
<string name="update_database_passphrase">Update database passphrase</string>
<string name="set_database_passphrase">Set database passphrase</string>
<string name="set_passphrase">Set passphrase</string>
<string name="enter_correct_current_passphrase">Please enter correct current passphrase.</string>
<string name="database_is_not_encrypted">Your chat database is not encrypted - set passphrase to protect it.</string>
<string name="keychain_is_storing_securely">Android Keystore is used to securely store passphrase - it allows notification service to work.</string>
@@ -1842,4 +1845,64 @@
<string name="agent_internal_error_title">Internal error</string>
<string name="agent_internal_error_desc">Please report it to the developers: \n%s</string>
<string name="restart_chat_button">Restart chat</string>
<!-- MigrateFromAnotherDevice.kt -->
<string name="migrate_here">Migrate here</string>
<string name="or_paste_archive_link">Or paste archive link</string>
<string name="paste_archive_link">Paste archive link</string>
<string name="invalid_file_link">Invalid link</string>
<string name="migration_from_device_migrating">Migrating</string>
<string name="migration_from_device_database_init">Preparing download</string>
<string name="migration_from_device_downloading_details">Downloading link details</string>
<string name="migration_from_device_downloading_archive">Downloading archive</string>
<string name="migration_from_device_bytes_downloaded">%s downloaded</string>
<string name="migration_from_device_download_failed">Download failed</string>
<string name="migration_from_device_repeat_download">Repeat download</string>
<string name="migration_from_device_try_again">You can give another try.</string>
<string name="migration_from_device_importing_archive">Importing archive</string>
<string name="migration_from_device_import_failed">Import failed</string>
<string name="migration_from_device_repeat_import">Repeat import</string>
<string name="migration_from_device_enter_passphrase">Enter passphrase</string>
<string name="migration_from_device_file_delete_or_link_invalid">File was deleted or link is invalid</string>
<string name="migration_from_device_error_downloading_archive">Error downloading the archive</string>
<string name="migration_from_device_chat_migrated">Chat migrated!</string>
<string name="migration_from_device_finalize_migration">Finalize migration on another device.</string>
<string name="migration_from_device_confirm_network_settings">Confirm network settings</string>
<string name="migration_from_device_confirm_network_settings_footer">Please confirm that network settings are correct for this device.</string>
<string name="migration_from_device_apply_onion">Apply</string>
<!-- MigrateToAnotherDevice.kt -->
<string name="migrate_to_device">Migrate to another device</string>
<string name="migrate_to_device_error_saving_settings">Error saving settings</string>
<string name="migrate_to_device_exported_file_doesnt_exist">Exported file doesn\'t exist</string>
<string name="migrate_to_device_error_exporting_archive">Error exporting chat database</string>
<string name="migration_to_device_database_init">Preparing upload</string>
<string name="migration_to_device_error_uploading_archive">Error uploading the archive</string>
<string name="migration_to_device_error_deleting_database">Error deleting database</string>
<string name="migration_to_device_stopping_chat">Stopping chat</string>
<string name="migration_to_device_chat_should_be_stopped">In order to continue, chat should be stopped.</string>
<string name="migration_to_device_archive_and_upload">Archive and upload</string>
<string name="migration_to_device_confirm_upload">Confirm upload</string>
<string name="migration_to_device_all_data_will_be_uploaded">All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays.</string>
<string name="migration_to_device_archiving_database">Archiving database</string>
<string name="migration_to_device_bytes_uploaded">%s uploaded</string>
<string name="migration_to_device_uploading_archive">Uploading archive</string>
<string name="migration_to_device_upload_failed">Upload failed</string>
<string name="migration_to_device_repeat_upload">Repeat upload</string>
<string name="migration_to_device_try_again">You can give another try.</string>
<string name="migration_to_device_creating_archive_link">Creating archive link</string>
<string name="migration_to_device_cancel_migration">Cancel migration</string>
<string name="migration_to_device_finalize_migration">Finalize migration</string>
<string name="migration_to_device_choose_migrate_from_another_device"><![CDATA[Choose <i>Migrate from another device</i> on the new device and scan QR code.]]></string>
<string name="migration_to_device_or_share_this_file_link">Or securely share this file link</string>
<string name="migration_to_device_delete_database_from_device">Delete database from this device</string>
<string name="migration_to_device_starting_chat_on_multiple_devices_unsupported">Warning: starting chat on multiple devices is not supported and will cause message delivery failures</string>
<string name="migration_to_device_start_chat">Start chat</string>
<string name="migration_to_device_migration_complete">Migration complete</string>
<string name="migration_to_device_you_must_not_start_database_on_two_device"><![CDATA[You <b>must not</b> use the same database on two devices.]]></string>
<string name="migration_to_device_using_on_two_device_breaks_encryption"><![CDATA[<b>Please note</b>: using the same database on two devices will break the decryption of messages from your connections, as a security protection.]]></string>
<string name="migration_to_device_verify_database_passphrase">Verify database passphrase</string>
<string name="migration_to_device_verify_passphrase">Verify passphrase</string>
<string name="migration_to_device_confirm_you_remember_passphrase">Confirm that you remember database passphrase to migrate it.</string>
</resources>
@@ -2,6 +2,7 @@ package chat.simplex.common.views.database
import SectionItemView
import SectionTextFooter
import TextIconSpaced
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
@@ -22,8 +23,9 @@ actual fun SavePassphraseSetting(
useKeychain: Boolean,
initialRandomDBPassphrase: Boolean,
storedKey: Boolean,
progressIndicator: Boolean,
minHeight: Dp,
enabled: Boolean,
smallPadding: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
SectionItemView(minHeight = minHeight) {
@@ -33,7 +35,11 @@ actual fun SavePassphraseSetting(
stringResource(MR.strings.save_passphrase_in_settings),
tint = if (storedKey) WarningOrange else MaterialTheme.colors.secondary
)
Spacer(Modifier.padding(horizontal = 4.dp))
if (smallPadding) {
Spacer(Modifier.padding(horizontal = 4.dp))
} else {
TextIconSpaced(false)
}
Text(
stringResource(MR.strings.save_passphrase_in_settings),
Modifier.padding(end = 24.dp),
@@ -43,7 +49,7 @@ actual fun SavePassphraseSetting(
DefaultSwitch(
checked = useKeychain,
onCheckedChange = onCheckedChange,
enabled = !initialRandomDBPassphrase && !progressIndicator
enabled = enabled
)
}
}
@@ -55,13 +61,14 @@ actual fun DatabaseEncryptionFooter(
chatDbEncrypted: Boolean?,
storedKey: MutableState<Boolean>,
initialRandomDBPassphrase: MutableState<Boolean>,
migration: Boolean,
) {
if (chatDbEncrypted == false) {
SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted))
} else if (useKeychain.value) {
if (storedKey.value) {
SectionTextFooter(generalGetString(MR.strings.settings_is_storing_in_clear_text))
if (initialRandomDBPassphrase.value) {
if (initialRandomDBPassphrase.value && !migration) {
SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase))
} else {
SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase))