Merge pull request #985 from simplex-chat/sqlcipher

core: switch from SQLite to SQLCipher
This commit is contained in:
Evgeny Poberezkin
2022-09-14 22:46:14 +01:00
committed by GitHub
79 changed files with 3492 additions and 273 deletions
+25 -3
View File
@@ -5,6 +5,7 @@ on:
branches:
- master
- stable
- sqlcipher
tags:
- "v*"
pull_request:
@@ -67,9 +68,9 @@ jobs:
- name: Setup Stack
uses: haskell/actions/setup@v1
with:
ghc-version: '8.10.7'
ghc-version: "8.10.7"
enable-stack: true
stack-version: 'latest'
stack-version: "latest"
- name: Cache dependencies
uses: actions/cache@v2
@@ -81,7 +82,7 @@ jobs:
- name: Unix build
id: unix_build
if: matrix.os != 'windows-latest'
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-18.04'
shell: bash
run: |
stack build --test
@@ -98,6 +99,27 @@ jobs:
# Unix /
# / Mac
- name: Mac build
id: mac_build
if: matrix.os == 'macos-latest'
shell: bash
run: |
stack build --test --extra-include-dirs=/usr/local/opt/openssl@1.1/include --extra-lib-dirs=/usr/local/opt/openssl@1.1/lib
echo "::set-output name=local_install_root::$(stack path --local-install-root)"
- name: Unix upload binary to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ steps.unix_build.outputs.local_install_root }}/bin/simplex-chat
asset_name: ${{ matrix.asset_name }}
tag: ${{ github.ref }}
# Mac /
# / Windows
# * In powershell multiline commands do not fail if individual commands fail - https://github.community/t/multiline-commands-on-windows-do-not-fail-if-individual-commands-fail/16753
+1
View File
@@ -16,3 +16,4 @@
.externalNativeBuild
.cxx
local.properties
app/src/main/cpp/libs/
+4 -1
View File
@@ -53,6 +53,9 @@ add_library( support SHARED IMPORTED )
set_target_properties( support PROPERTIES IMPORTED_LOCATION
${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libsupport.so)
add_library( crypto SHARED IMPORTED )
set_target_properties( crypto PROPERTIES IMPORTED_LOCATION
${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libcrypto.so)
# Specifies libraries CMake should link to your target library. You
# can link multiple libraries, such as libraries you define in this
@@ -61,7 +64,7 @@ set_target_properties( support PROPERTIES IMPORTED_LOCATION
target_link_libraries( # Specifies the target library.
app-lib
simplex support
simplex support crypto
# Links the target library to the log library
# included in the NDK.
+29 -7
View File
@@ -24,20 +24,42 @@ Java_chat_simplex_app_SimplexAppKt_initHS(__unused JNIEnv *env, __unused jclass
// from simplex-chat
typedef void* chat_ctrl;
extern chat_ctrl chat_init(const char *path);
extern char *chat_migrate_db(const char *path, const char *key);
extern chat_ctrl chat_init_key(const char *path, const char *key);
extern chat_ctrl chat_init(const char *path); // deprecated
extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
extern char *chat_recv_msg(chat_ctrl ctrl);
extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated
extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
extern char *chat_parse_markdown(const char *str);
JNIEXPORT jlong JNICALL
Java_chat_simplex_app_SimplexAppKt_chatInit(JNIEnv *env, __unused jclass clazz, jstring datadir) {
const char *_data = (*env)->GetStringUTFChars(env, datadir, JNI_FALSE);
jlong res = (jlong)chat_init(_data);
(*env)->ReleaseStringUTFChars(env, datadir, _data);
JNIEXPORT jstring JNICALL
Java_chat_simplex_app_SimplexAppKt_chatMigrateDB(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey) {
const char *_dbPath = (*env)->GetStringUTFChars(env, dbPath, JNI_FALSE);
const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_migrate_db(_dbPath, _dbKey));
(*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
(*env)->ReleaseStringUTFChars(env, dbKey, _dbKey);
return res;
}
JNIEXPORT jlong JNICALL
Java_chat_simplex_app_SimplexAppKt_chatInitKey(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey) {
const char *_dbPath = (*env)->GetStringUTFChars(env, dbPath, JNI_FALSE);
const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE);
jlong ctrl = (jlong)chat_init_key(_dbPath, _dbKey);
(*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
(*env)->ReleaseStringUTFChars(env, dbKey, _dbKey);
return ctrl;
}
JNIEXPORT jlong JNICALL
Java_chat_simplex_app_SimplexAppKt_chatInit(JNIEnv *env, __unused jclass clazz, jstring dbPath) {
const char *_dbPath = (*env)->GetStringUTFChars(env, dbPath, JNI_FALSE);
jlong ctrl = (jlong)chat_init(_dbPath);
(*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
return ctrl;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_app_SimplexAppKt_chatSendCmd(JNIEnv *env, __unused jclass clazz, jlong controller, jstring msg) {
const char *_msg = (*env)->GetStringUTFChars(env, msg, JNI_FALSE);
@@ -32,6 +32,7 @@ import chat.simplex.app.views.call.IncomingCallAlertView
import chat.simplex.app.views.chat.ChatView
import chat.simplex.app.views.chatlist.ChatListView
import chat.simplex.app.views.chatlist.openChat
import chat.simplex.app.views.database.DatabaseErrorView
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.connectViaUri
import chat.simplex.app.views.newchat.withUriAction
@@ -56,7 +57,6 @@ class MainActivity: FragmentActivity() {
}
}
private val vm by viewModels<SimplexViewModel>()
private val chatController by lazy { (application as SimplexApp).chatController }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -251,6 +251,13 @@ fun MainPage(
}
chatsAccessAuthorized = userAuthorized.value == true
}
var showChatDatabaseError by rememberSaveable {
mutableStateOf(chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null)
}
LaunchedEffect(chatModel.chatDbStatus.value) {
showChatDatabaseError = chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null
}
var showAdvertiseLAAlert by remember { mutableStateOf(false) }
LaunchedEffect(showAdvertiseLAAlert) {
if (
@@ -296,6 +303,11 @@ fun MainPage(
val onboarding = chatModel.onboardingStage.value
val userCreated = chatModel.userCreated.value
when {
showChatDatabaseError -> {
chatModel.chatDbStatus.value?.let {
DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs)
}
}
onboarding == null || userCreated == null -> SplashView()
!chatsAccessAuthorized -> {
if (chatModel.controller.appPrefs.performLA.get() && laFailed.value) {
@@ -26,21 +26,46 @@ external fun pipeStdOutToSocket(socketName: String) : Int
// SimpleX API
typealias ChatCtrl = Long
external fun chatInit(path: String): ChatCtrl
external fun chatMigrateDB(dbPath: String, dbKey: String): String
external fun chatInitKey(dbPath: String, dbKey: String): ChatCtrl
external fun chatInit(dbPath: String): ChatCtrl
external fun chatSendCmd(ctrl: ChatCtrl, msg: String): String
external fun chatRecvMsg(ctrl: ChatCtrl): String
external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
external fun chatParseMarkdown(str: String): String
class SimplexApp: Application(), LifecycleEventObserver {
val chatController: ChatController by lazy {
val ctrl = chatInit(getFilesDirectory(applicationContext))
ChatController(ctrl, ntfManager, applicationContext, appPreferences)
lateinit var chatController: ChatController
fun initChatController(useKey: String? = null, startChat: Boolean = true) {
val dbKey = useKey ?: DatabaseUtils.getDatabaseKey() ?: ""
val res = DatabaseUtils.migrateChatDatabase(dbKey)
val ctrl = if (res.second is DBMigrationResult.OK) {
chatInitKey(getFilesDirectory(applicationContext), dbKey)
} else null
if (::chatController.isInitialized) {
chatController.ctrl = ctrl
} else {
chatController = ChatController(ctrl, ntfManager, applicationContext, appPreferences)
}
chatModel.chatDbEncrypted.value = res.first
chatModel.chatDbStatus.value = res.second
if (res.second != DBMigrationResult.OK) {
Log.d(TAG, "Unable to migrate successfully: ${res.second}")
} else if (startChat) {
withApi {
val user = chatController.apiGetActiveUser()
if (user == null) {
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
} else {
chatController.startChat(user)
}
}
}
}
val chatModel: ChatModel by lazy {
chatController.chatModel
}
val chatModel: ChatModel
get() = chatController.chatModel
private val ntfManager: NtfManager by lazy {
NtfManager(applicationContext, appPreferences)
@@ -53,15 +78,8 @@ class SimplexApp: Application(), LifecycleEventObserver {
override fun onCreate() {
super.onCreate()
context = this
initChatController()
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
withApi {
val user = chatController.apiGetActiveUser()
if (user == null) {
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
} else {
chatController.startChat(user)
}
}
}
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
@@ -9,7 +9,7 @@ import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.work.*
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.onboarding.OnboardingStage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -24,7 +24,6 @@ class SimplexService: Service() {
private var isStartingService = false
private var notificationManager: NotificationManager? = null
private var serviceNotification: Notification? = null
private val chatController by lazy { (application as SimplexApp).chatController }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand startId: $startId")
@@ -67,19 +66,21 @@ class SimplexService: Service() {
val self = this
isStartingService = true
withApi {
val chatController = (application as SimplexApp).chatController
try {
val user = chatController.apiGetActiveUser()
if (user == null) {
chatController.chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
} else {
Log.w(TAG, "Starting foreground service")
chatController.startChat(user)
isServiceStarted = true
saveServiceState(self, ServiceState.STARTED)
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
acquire()
}
Log.w(TAG, "Starting foreground service")
val chatDbStatus = chatController.chatModel.chatDbStatus.value
if (chatDbStatus != DBMigrationResult.OK) {
Log.w(chat.simplex.app.TAG, "SimplexService: problem with the database: $chatDbStatus")
showPassphraseNotification(chatDbStatus)
stopService()
return@withApi
}
isServiceStarted = true
saveServiceState(self, ServiceState.STARTED)
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
acquire()
}
}
} finally {
@@ -227,6 +228,8 @@ class SimplexService: Service() {
const val SERVICE_START_WORKER_INTERVAL_MINUTES = 3 * 60L
const val SERVICE_START_WORKER_WORK_NAME_PERIODIC = "SimplexAutoRestartWorkerPeriodic" // Do not change!
private const val PASSPHRASE_NOTIFICATION_ID = 1535
private const val WAKE_LOCK_TAG = "SimplexService::lock"
private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_SERVICE_PREFS"
private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE"
@@ -271,6 +274,41 @@ class SimplexService: Service() {
return ServiceState.valueOf(value!!)
}
fun showPassphraseNotification(chatDbStatus: DBMigrationResult?) {
val pendingIntent: PendingIntent = Intent(SimplexApp.context, MainActivity::class.java).let { notificationIntent ->
PendingIntent.getActivity(SimplexApp.context, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
}
val title = when(chatDbStatus) {
is DBMigrationResult.ErrorNotADatabase -> generalGetString(R.string.enter_passphrase_notification_title)
is DBMigrationResult.OK -> return
else -> generalGetString(R.string.database_initialization_error_title)
}
val description = when(chatDbStatus) {
is DBMigrationResult.ErrorNotADatabase -> generalGetString(R.string.enter_passphrase_notification_desc)
is DBMigrationResult.OK -> return
else -> generalGetString(R.string.database_initialization_error_desc)
}
val builder = NotificationCompat.Builder(SimplexApp.context, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ntf_service_icon)
.setColor(0x88FFFF)
.setContentTitle(title)
.setContentText(description)
.setContentIntent(pendingIntent)
.setSilent(true)
.setShowWhen(false)
val notificationManager = SimplexApp.context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(PASSPHRASE_NOTIFICATION_ID, builder.build())
}
fun cancelPassphraseNotification() {
val notificationManager = SimplexApp.context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(PASSPHRASE_NOTIFICATION_ID)
}
private fun getPreferences(context: Context): SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
}
}
@@ -10,6 +10,7 @@ import androidx.compose.ui.text.style.TextDecoration
import chat.simplex.app.R
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.call.*
import chat.simplex.app.views.helpers.DBMigrationResult
import chat.simplex.app.views.helpers.generalGetString
import chat.simplex.app.views.onboarding.OnboardingStage
import chat.simplex.app.views.usersettings.NotificationPreviewMode
@@ -27,6 +28,8 @@ class ChatModel(val controller: ChatController) {
val userCreated = mutableStateOf<Boolean?>(null)
val chatRunning = mutableStateOf<Boolean?>(null)
val chatDbChanged = mutableStateOf<Boolean>(false)
val chatDbEncrypted = mutableStateOf<Boolean?>(false)
val chatDbStatus = mutableStateOf<DBMigrationResult?>(null)
val chats = mutableStateListOf<Chat>()
// current chat
@@ -34,13 +34,13 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
private val msgNtfTimeoutMs = 30000L
init {
manager.createNotificationChannel(NotificationChannel(MessageChannel, "SimpleX Chat messages", NotificationManager.IMPORTANCE_HIGH))
manager.createNotificationChannel(NotificationChannel(LockScreenCallChannel, "SimpleX Chat calls (lock screen)", NotificationManager.IMPORTANCE_HIGH))
manager.createNotificationChannel(NotificationChannel(MessageChannel, generalGetString(R.string.ntf_channel_messages), NotificationManager.IMPORTANCE_HIGH))
manager.createNotificationChannel(NotificationChannel(LockScreenCallChannel, generalGetString(R.string.ntf_channel_calls_lockscreen), NotificationManager.IMPORTANCE_HIGH))
manager.createNotificationChannel(callNotificationChannel())
}
private fun callNotificationChannel(): NotificationChannel {
val callChannel = NotificationChannel(CallChannel, "SimpleX Chat calls", NotificationManager.IMPORTANCE_HIGH)
val callChannel = NotificationChannel(CallChannel, generalGetString(R.string.ntf_channel_calls), NotificationManager.IMPORTANCE_HIGH)
val attrs = AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
@@ -106,6 +106,11 @@ class AppPreferences(val context: Context) {
val networkTCPKeepCnt = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_CNT, KeepAliveOpts.defaults.keepCnt)
val incognito = mkBoolPreference(SHARED_PREFS_INCOGNITO, false)
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)
val initializationVectorDBPassphrase = mkStrPreference(SHARED_PREFS_INITIALIZATION_VECTOR_DB_PASSPHRASE, null)
val currentTheme = mkStrPreference(SHARED_PREFS_CURRENT_THEME, DefaultTheme.SYSTEM.name)
val primaryColor = mkIntPreference(SHARED_PREFS_PRIMARY_COLOR, LightColorPalette.primary.toArgb())
@@ -180,6 +185,10 @@ class AppPreferences(val context: Context) {
private const val SHARED_PREFS_NETWORK_TCP_KEEP_INTVL = "NetworkTCPKeepIntvl"
private const val SHARED_PREFS_NETWORK_TCP_KEEP_CNT = "NetworkTCPKeepCnt"
private const val SHARED_PREFS_INCOGNITO = "Incognito"
private const val SHARED_PREFS_STORE_DB_PASSPHRASE = "StoreDBPassphrase"
private const val SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE = "InitialRandomDBPassphrase"
private const val SHARED_PREFS_ENCRYPTED_DB_PASSPHRASE = "EncryptedDBPassphrase"
private const val SHARED_PREFS_INITIALIZATION_VECTOR_DB_PASSPHRASE = "InitializationVectorDBPassphrase"
private const val SHARED_PREFS_CURRENT_THEME = "CurrentTheme"
private const val SHARED_PREFS_PRIMARY_COLOR = "PrimaryColor"
}
@@ -187,7 +196,7 @@ class AppPreferences(val context: Context) {
private const val MESSAGE_TIMEOUT: Int = 15_000_000
open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager, val appContext: Context, val appPrefs: AppPreferences) {
open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val appContext: Context, val appPrefs: AppPreferences) {
val chatModel = ChatModel(this)
private var receiverStarted = false
var lastMsgReceivedTimestamp: Long = System.currentTimeMillis()
@@ -242,10 +251,12 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
suspend fun sendCmd(cmd: CC): CR {
val ctrl = ctrl ?: throw Exception("Controller is not initialized")
return withContext(Dispatchers.IO) {
val c = cmd.cmdString
if (cmd !is CC.ApiParseMarkdown) {
chatModel.terminalItems.add(TerminalItem.cmd(cmd))
chatModel.terminalItems.add(TerminalItem.cmd(cmd.obfuscated))
Log.d(TAG, "sendCmd: ${cmd.cmdType}")
}
val json = chatSendCmd(ctrl, c)
@@ -261,7 +272,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
}
private suspend fun recvMsg(): CR? {
private suspend fun recvMsg(ctrl: ChatCtrl): CR? {
return withContext(Dispatchers.IO) {
val json = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT)
if (json == "") {
@@ -276,7 +287,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
private suspend fun recvMspLoop() {
val msg = recvMsg()
val msg = recvMsg(ctrl ?: return)
if (msg != null) processReceivedMsg(msg)
recvMspLoop()
}
@@ -343,6 +354,13 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
throw Error("failed to delete storage: ${r.responseType} ${r.details}")
}
suspend fun apiStorageEncryption(currentKey: String = "", newKey: String = ""): CR.ChatCmdError? {
val r = sendCmd(CC.ApiStorageEncryption(DBEncryptionConfig(currentKey, newKey)))
if (r is CR.CmdOk) return null
else if (r is CR.ChatCmdError) return r
throw Exception("failed to set storage encryption: ${r.responseType} ${r.details}")
}
private suspend fun apiGetChats(): List<Chat> {
val r = sendCmd(CC.ApiGetChats())
if (r is CR.ApiChats ) return r.chats
@@ -1197,6 +1215,7 @@ sealed class CC {
class ApiExportArchive(val config: ArchiveConfig): CC()
class ApiImportArchive(val config: ArchiveConfig): CC()
class ApiDeleteStorage: CC()
class ApiStorageEncryption(val config: DBEncryptionConfig): CC()
class ApiGetChats: CC()
class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC()
class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent): CC()
@@ -1251,6 +1270,7 @@ sealed class CC {
is ApiExportArchive -> "/_db export ${json.encodeToString(config)}"
is ApiImportArchive -> "/_db import ${json.encodeToString(config)}"
is ApiDeleteStorage -> "/_db delete"
is ApiStorageEncryption -> "/_db encryption ${json.encodeToString(config)}"
is ApiGetChats -> "/_get chats pcc=on"
is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search")
is ApiSendMessage -> "/_send ${chatRef(type, id)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}"
@@ -1305,6 +1325,7 @@ sealed class CC {
is ApiExportArchive -> "apiExportArchive"
is ApiImportArchive -> "apiImportArchive"
is ApiDeleteStorage -> "apiDeleteStorage"
is ApiStorageEncryption -> "apiStorageEncryption"
is ApiGetChats -> "apiGetChats"
is ApiGetChat -> "apiGetChat"
is ApiSendMessage -> "apiSendMessage"
@@ -1350,6 +1371,14 @@ sealed class CC {
class ItemRange(val from: Long, val to: Long)
val obfuscated: CC
get() = when (this) {
is ApiStorageEncryption -> ApiStorageEncryption(DBEncryptionConfig(obfuscate(config.currentKey), obfuscate(config.newKey)))
else -> this
}
private fun obfuscate(s: String): String = if (s.isEmpty()) "" else "***"
companion object {
fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}"
@@ -1381,6 +1410,9 @@ class ComposedMessage(val filePath: String?, val quotedItemId: Long?, val msgCon
@Serializable
class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = null, val parentTempDirectory: String? = null)
@Serializable
class DBEncryptionConfig(val currentKey: String, val newKey: String)
@Serializable
data class NetCfg(
val socksProxy: String? = null,
@@ -1785,10 +1817,12 @@ sealed class ChatError {
is ChatErrorChat -> "chat ${errorType.string}"
is ChatErrorAgent -> "agent ${agentError.string}"
is ChatErrorStore -> "store ${storeError.string}"
is ChatErrorDatabase -> "database ${databaseError.string}"
}
@Serializable @SerialName("error") class ChatErrorChat(val errorType: ChatErrorType): ChatError()
@Serializable @SerialName("errorAgent") class ChatErrorAgent(val agentError: AgentErrorType): ChatError()
@Serializable @SerialName("errorStore") class ChatErrorStore(val storeError: StoreError): ChatError()
@Serializable @SerialName("errorDatabase") class ChatErrorDatabase(val databaseError: DatabaseError): ChatError()
}
@Serializable
@@ -1813,6 +1847,28 @@ sealed class StoreError {
@Serializable @SerialName("groupNotFound") class GroupNotFound: StoreError()
}
@Serializable
sealed class DatabaseError {
val string: String get() = when (this) {
is ErrorEncrypted -> "errorEncrypted"
is ErrorPlaintext -> "errorPlaintext"
is ErrorNoFile -> "errorPlaintext"
is ErrorExport -> "errorNoFile"
is ErrorOpen -> "errorExport"
}
@Serializable @SerialName("errorEncrypted") object ErrorEncrypted: DatabaseError()
@Serializable @SerialName("errorPlaintext") object ErrorPlaintext: DatabaseError()
@Serializable @SerialName("errorNoFile") class ErrorNoFile(val dbFile: String): DatabaseError()
@Serializable @SerialName("errorExport") class ErrorExport(val sqliteError: SQLiteError): DatabaseError()
@Serializable @SerialName("errorOpen") class ErrorOpen(val sqliteError: SQLiteError): DatabaseError()
}
@Serializable
sealed class SQLiteError {
@Serializable @SerialName("errorNotADatabase") object ErrorNotADatabase: SQLiteError()
@Serializable @SerialName("error") class Error(val error: String): SQLiteError()
}
@Serializable
sealed class AgentErrorType {
val string: String get() = when (this) {
@@ -24,5 +24,6 @@ val GroupDark = Color(80, 80, 80, 60)
val IncomingCallLight = Color(239, 237, 236, 255)
val IncomingCallDark = Color(34, 30, 29, 255)
val WarningOrange = Color(255, 127, 0, 255)
val WarningYellow = Color(255, 192, 0, 255)
val FileLight = Color(183, 190, 199, 255)
val FileDark = Color(101, 101, 106, 255)
@@ -1,5 +1,6 @@
package chat.simplex.app.views
import android.content.Context
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
@@ -7,39 +8,86 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.fragment.app.FragmentActivity
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.*
import chat.simplex.app.views.helpers.*
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.launch
@Composable
fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }
BackHandler(onBack = close)
TerminalLayout(
chatModel.terminalItems,
composeState,
sendCommand = {
withApi {
// show "in progress"
chatModel.controller.sendCmd(CC.Console(composeState.value.message))
composeState.value = ComposeState(useLinkPreviews = false)
// hide "in progress"
val authorized = remember { mutableStateOf(!chatModel.controller.appPrefs.performLA.get()) }
val context = LocalContext.current
LaunchedEffect(authorized.value) {
if (!authorized.value) {
runAuth(authorized = authorized, context)
}
}
if (authorized.value) {
TerminalLayout(
chatModel.terminalItems,
composeState,
sendCommand = {
withApi {
// show "in progress"
chatModel.controller.sendCmd(CC.Console(composeState.value.message))
composeState.value = ComposeState(useLinkPreviews = false)
// hide "in progress"
}
},
close
)
} else {
Surface(Modifier.fillMaxSize()) {
Column(Modifier.background(MaterialTheme.colors.background)) {
CloseSheetBar(close)
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
SimpleButton(
stringResource(R.string.auth_unlock),
icon = Icons.Outlined.Lock,
click = {
runAuth(authorized = authorized, context)
}
)
}
}
},
close
}
}
}
private fun runAuth(authorized: MutableState<Boolean>, context: Context) {
authenticate(
generalGetString(R.string.auth_open_chat_console),
generalGetString(R.string.auth_log_in_using_credential),
context as FragmentActivity,
completed = { laResult ->
when (laResult) {
LAResult.Success, LAResult.Unavailable -> authorized.value = true
is LAResult.Error, LAResult.Failed -> authorized.value = false
}
}
)
}
@@ -0,0 +1,509 @@
package chat.simplex.app.views.database
import SectionItemView
import SectionItemViewSpaceBetween
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.ZeroCornerSize
import androidx.compose.foundation.text.*
import androidx.compose.material.*
import androidx.compose.material.TextFieldDefaults.indicatorLine
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.*
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.SimplexApp
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import kotlin.math.log2
@Composable
fun DatabaseEncryptionView(m: ChatModel) {
val progressIndicator = remember { mutableStateOf(false) }
val prefs = m.controller.appPrefs
val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
val initialRandomDBPassphrase = remember { mutableStateOf(prefs.initialRandomDBPassphrase.get()) }
val storedKey = remember { val key = DatabaseUtils.getDatabaseKey(); mutableStateOf(key != null && key != "") }
// Do not do rememberSaveable on current key to prevent saving it on disk in clear text
val currentKey = remember { mutableStateOf(if (initialRandomDBPassphrase.value) DatabaseUtils.getDatabaseKey() ?: "" else "") }
val newKey = rememberSaveable { mutableStateOf("") }
val confirmNewKey = rememberSaveable { mutableStateOf("") }
Box(
Modifier.fillMaxSize(),
) {
DatabaseEncryptionLayout(
useKeychain,
prefs,
m.chatDbEncrypted.value,
currentKey,
newKey,
confirmNewKey,
storedKey,
initialRandomDBPassphrase,
progressIndicator,
onConfirmEncrypt = {
progressIndicator.value = true
withApi {
try {
val error = m.controller.apiStorageEncryption(currentKey.value, newKey.value)
val sqliteError = ((error?.chatError as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError
when {
sqliteError is SQLiteError.ErrorNotADatabase -> {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(
generalGetString(R.string.wrong_passphrase_title),
generalGetString(R.string.enter_correct_current_passphrase)
)
}
}
error != null -> {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_encrypting_database),
"failed to set storage encryption: ${error.responseType} ${error.details}"
)
}
}
else -> {
prefs.initialRandomDBPassphrase.set(false)
initialRandomDBPassphrase.value = false
if (useKeychain.value) {
DatabaseUtils.setDatabaseKey(newKey.value)
}
resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value)
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.database_encrypted))
}
}
}
} catch (e: Exception) {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_encrypting_database), e.stackTraceToString())
}
}
}
}
)
if (progressIndicator.value) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = HighOrLowlight,
strokeWidth = 2.5.dp
)
}
}
}
}
@Composable
fun DatabaseEncryptionLayout(
useKeychain: MutableState<Boolean>,
prefs: AppPreferences,
chatDbEncrypted: Boolean?,
currentKey: MutableState<String>,
newKey: MutableState<String>,
confirmNewKey: MutableState<String>,
storedKey: MutableState<Boolean>,
initialRandomDBPassphrase: MutableState<Boolean>,
progressIndicator: MutableState<Boolean>,
onConfirmEncrypt: () -> Unit,
) {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start,
) {
Text(
stringResource(R.string.database_passphrase),
Modifier.padding(start = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
SectionView(null) {
SavePassphraseSetting(useKeychain.value, initialRandomDBPassphrase.value, storedKey.value, progressIndicator.value) { checked ->
if (checked) {
setUseKeychain(true, useKeychain, prefs)
} else if (storedKey.value) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.remove_passphrase_from_keychain),
text = generalGetString(R.string.notifications_will_be_hidden) + "\n" + storeSecurelyDanger(),
confirmText = generalGetString(R.string.remove_passphrase),
onConfirm = {
DatabaseUtils.removeDatabaseKey()
setUseKeychain(false, useKeychain, prefs)
storedKey.value = false
},
destructive = true,
)
} else {
setUseKeychain(false, useKeychain, prefs)
}
}
if (!initialRandomDBPassphrase.value && chatDbEncrypted == true) {
DatabaseKeyField(
currentKey,
generalGetString(R.string.current_passphrase),
modifier = Modifier.padding(start = 8.dp),
isValid = ::validKey,
keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }),
)
}
DatabaseKeyField(
newKey,
generalGetString(R.string.new_passphrase),
modifier = Modifier.padding(start = 8.dp),
showStrength = true,
isValid = ::validKey,
keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }),
)
val onClickUpdate = {
// Don't do things concurrently. Shouldn't be here concurrently, just in case
if (!progressIndicator.value) {
if (currentKey.value == "") {
if (useKeychain.value)
encryptDatabaseSavedAlert(onConfirmEncrypt)
else
encryptDatabaseAlert(onConfirmEncrypt)
} else {
if (useKeychain.value)
changeDatabaseKeySavedAlert(onConfirmEncrypt)
else
changeDatabaseKeyAlert(onConfirmEncrypt)
}
}
}
val disabled = currentKey.value == newKey.value ||
newKey.value != confirmNewKey.value ||
newKey.value.isEmpty() ||
!validKey(currentKey.value) ||
!validKey(newKey.value) ||
progressIndicator.value
DatabaseKeyField(
confirmNewKey,
generalGetString(R.string.confirm_new_passphrase),
modifier = Modifier.padding(start = 8.dp),
isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value },
keyboardActions = KeyboardActions(onDone = {
if (!disabled) onClickUpdate()
defaultKeyboardAction(ImeAction.Done)
}),
)
SectionItemViewSpaceBetween(onClickUpdate, padding = PaddingValues(start = 8.dp, end = 12.dp), disabled = disabled) {
Text(generalGetString(R.string.update_database_passphrase), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
}
}
Column {
if (chatDbEncrypted == false) {
SectionTextFooter(generalGetString(R.string.database_is_not_encrypted))
} else if (useKeychain.value) {
if (storedKey.value) {
SectionTextFooter(generalGetString(R.string.keychain_is_storing_securely))
if (initialRandomDBPassphrase.value) {
SectionTextFooter(generalGetString(R.string.encrypted_with_random_passphrase))
} else {
SectionTextFooter(generalGetString(R.string.impossible_to_recover_passphrase))
}
} else {
SectionTextFooter(generalGetString(R.string.keychain_allows_to_receive_ntfs))
}
} else {
SectionTextFooter(generalGetString(R.string.you_have_to_enter_passphrase_every_time))
SectionTextFooter(generalGetString(R.string.impossible_to_recover_passphrase))
}
}
}
}
fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.encrypt_database_question),
text = generalGetString(R.string.database_will_be_encrypted_and_passphrase_stored) + "\n" + storeSecurelySaved(),
confirmText = generalGetString(R.string.encrypt_database),
onConfirm = onConfirm,
destructive = false,
)
}
fun encryptDatabaseAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.encrypt_database_question),
text = generalGetString(R.string.database_will_be_encrypted) +"\n" + storeSecurelyDanger(),
confirmText = generalGetString(R.string.encrypt_database),
onConfirm = onConfirm,
destructive = true,
)
}
fun changeDatabaseKeySavedAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.change_database_passphrase_question),
text = generalGetString(R.string.database_encryption_will_be_updated) + "\n" + storeSecurelySaved(),
confirmText = generalGetString(R.string.update_database),
onConfirm = onConfirm,
destructive = false,
)
}
fun changeDatabaseKeyAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.change_database_passphrase_question),
text = generalGetString(R.string.database_passphrase_will_be_updated) + "\n" + storeSecurelyDanger(),
confirmText = generalGetString(R.string.update_database),
onConfirm = onConfirm,
destructive = true,
)
}
@Composable
fun SavePassphraseSetting(
useKeychain: Boolean,
initialRandomDBPassphrase: Boolean,
storedKey: Boolean,
progressIndicator: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
SectionItemView() {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (storedKey) Icons.Filled.VpnKey else Icons.Filled.VpnKeyOff,
stringResource(R.string.save_passphrase_in_keychain),
tint = if (storedKey) SimplexGreen else HighOrLowlight
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
stringResource(R.string.save_passphrase_in_keychain),
Modifier.padding(end = 24.dp),
color = Color.Unspecified
)
Spacer(Modifier.fillMaxWidth().weight(1f))
Switch(
checked = useKeychain,
onCheckedChange = onCheckedChange,
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
enabled = !initialRandomDBPassphrase && !progressIndicator
)
}
}
}
fun resetFormAfterEncryption(
m: ChatModel,
initialRandomDBPassphrase: MutableState<Boolean>,
currentKey: MutableState<String>,
newKey: MutableState<String>,
confirmNewKey: MutableState<String>,
storedKey: MutableState<Boolean>,
stored: Boolean = false,
) {
m.chatDbEncrypted.value = true
initialRandomDBPassphrase.value = false
m.controller.appPrefs.initialRandomDBPassphrase.set(false)
currentKey.value = ""
newKey.value = ""
confirmNewKey.value = ""
storedKey.value = stored
}
fun setUseKeychain(value: Boolean, useKeychain: MutableState<Boolean>, prefs: AppPreferences) {
useKeychain.value = value
prefs.storeDBPassphrase.set(value)
}
fun storeSecurelySaved() = generalGetString(R.string.store_passphrase_securely)
fun storeSecurelyDanger() = generalGetString(R.string.store_passphrase_securely_without_recover)
private fun operationEnded(m: ChatModel, progressIndicator: MutableState<Boolean>, alert: () -> Unit) {
m.chatDbChanged.value = true
progressIndicator.value = false
alert.invoke()
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun DatabaseKeyField(
key: MutableState<String>,
placeholder: String,
modifier: Modifier = Modifier,
showStrength: Boolean = false,
isValid: (String) -> Boolean,
keyboardActions: KeyboardActions = KeyboardActions(),
) {
var valid by remember { mutableStateOf(validKey(key.value)) }
var showKey by remember { mutableStateOf(false) }
val icon = if (valid) {
if (showKey) Icons.Filled.VisibilityOff else Icons.Filled.Visibility
} else Icons.Outlined.Error
val iconColor = if (valid) {
if (showStrength && key.value.isNotEmpty()) PassphraseStrength.check(key.value).color else HighOrLowlight
} else Color.Red
val keyboard = LocalSoftwareKeyboardController.current
val keyboardOptions = KeyboardOptions(
imeAction = if (keyboardActions.onNext != null) ImeAction.Next else ImeAction.Done,
autoCorrect = false,
keyboardType = KeyboardType.Password
)
val state = remember {
mutableStateOf(TextFieldValue(key.value))
}
val enabled = true
val colors = TextFieldDefaults.textFieldColors(
backgroundColor = Color.Unspecified,
textColor = MaterialTheme.colors.onBackground,
focusedIndicatorColor = Color.Unspecified,
unfocusedIndicatorColor = Color.Unspecified,
)
val color = MaterialTheme.colors.onBackground
val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize)
val interactionSource = remember { MutableInteractionSource() }
BasicTextField(
value = state.value,
modifier = modifier
.fillMaxWidth()
.background(colors.backgroundColor(enabled).value, shape)
.indicatorLine(enabled, false, interactionSource, colors)
.defaultMinSize(
minWidth = TextFieldDefaults.MinWidth,
minHeight = TextFieldDefaults.MinHeight
),
onValueChange = {
state.value = it
key.value = it.text
valid = isValid(it.text)
},
cursorBrush = SolidColor(colors.cursorColor(false).value),
visualTransformation = if (showKey)
VisualTransformation.None
else
VisualTransformation { TransformedText(AnnotatedString(it.text.map { "*" }.joinToString(separator = "")), OffsetMapping.Identity) },
keyboardOptions = keyboardOptions,
keyboardActions = KeyboardActions(onDone = {
keyboard?.hide()
keyboardActions.onDone?.invoke(this)
}),
singleLine = true,
textStyle = TextStyle.Default.copy(
color = color,
fontWeight = FontWeight.Normal,
fontSize = 16.sp
),
interactionSource = interactionSource,
decorationBox = @Composable { innerTextField ->
TextFieldDefaults.TextFieldDecorationBox(
value = state.value.text,
innerTextField = innerTextField,
placeholder = { Text(placeholder, color = HighOrLowlight) },
singleLine = true,
enabled = enabled,
isError = !valid,
trailingIcon = {
IconButton({ showKey = !showKey }) {
Icon(icon, null, tint = iconColor)
}
},
interactionSource = interactionSource,
contentPadding = TextFieldDefaults.textFieldWithLabelPadding(start = 0.dp, end = 0.dp),
visualTransformation = VisualTransformation.None,
colors = colors
)
}
)
}
// based on https://generatepasswords.org/how-to-calculate-entropy/
private fun passphraseEntropy(s: String): Double {
var hasDigits = false
var hasUppercase = false
var hasLowercase = false
var hasSymbols = false
for (c in s) {
if (c.isDigit()) {
hasDigits = true
} else if (c.isLetter()) {
if (c.isUpperCase()) {
hasUppercase = true
} else {
hasLowercase = true
}
} else if (c.isASCII()) {
hasSymbols = true
}
}
val poolSize = (if (hasDigits) 10 else 0) + (if (hasUppercase) 26 else 0) + (if (hasLowercase) 26 else 0) + (if (hasSymbols) 32 else 0)
return s.length * log2(poolSize.toDouble())
}
private enum class PassphraseStrength(val color: Color) {
VERY_WEAK(Color.Red), WEAK(WarningOrange), REASONABLE(WarningYellow), STRONG(SimplexGreen);
companion object {
fun check(s: String) = with(passphraseEntropy(s)) {
when {
this > 100 -> STRONG
this > 70 -> REASONABLE
this > 40 -> WEAK
else -> VERY_WEAK
}
}
}
}
fun validKey(s: String): Boolean {
for (c in s) {
if (c.isWhitespace() || !c.isASCII()) {
return false
}
}
return true
}
private fun Char.isASCII() = code in 32..126
@Preview
@Composable
fun PreviewDatabaseEncryptionLayout() {
SimpleXTheme {
DatabaseEncryptionLayout(
useKeychain = remember { mutableStateOf(true) },
prefs = AppPreferences(SimplexApp.context),
chatDbEncrypted = true,
currentKey = remember { mutableStateOf("") },
newKey = remember { mutableStateOf("") },
confirmNewKey = remember { mutableStateOf("") },
storedKey = remember { mutableStateOf(true) },
initialRandomDBPassphrase = remember { mutableStateOf(true) },
progressIndicator = remember { mutableStateOf(false) },
onConfirmEncrypt = {},
)
}
}
@@ -0,0 +1,199 @@
package chat.simplex.app.views.database
import SectionSpacer
import SectionView
import android.util.Log
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.AppPreferences
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.NotificationsMode
import kotlinx.coroutines.*
@Composable
fun DatabaseErrorView(
chatDbStatus: State<DBMigrationResult?>,
appPreferences: AppPreferences,
) {
val progressIndicator = remember { mutableStateOf(false) }
val dbKey = remember { mutableStateOf("") }
var storedDBKey by remember { mutableStateOf(DatabaseUtils.getDatabaseKey()) }
var useKeychain by remember { mutableStateOf(appPreferences.storeDBPassphrase.get()) }
val saveAndRunChatOnClick: () -> Unit = {
DatabaseUtils.setDatabaseKey(dbKey.value)
storedDBKey = dbKey.value
appPreferences.storeDBPassphrase.set(true)
useKeychain = true
appPreferences.initialRandomDBPassphrase.set(false)
runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences)
}
val title = when (chatDbStatus.value) {
is DBMigrationResult.OK -> ""
is DBMigrationResult.ErrorNotADatabase -> if (useKeychain && !storedDBKey.isNullOrEmpty())
generalGetString(R.string.wrong_passphrase)
else
generalGetString(R.string.encrypted_database)
is DBMigrationResult.Error -> generalGetString(R.string.database_error)
is DBMigrationResult.ErrorKeychain -> generalGetString(R.string.keychain_error)
is DBMigrationResult.Unknown -> generalGetString(R.string.database_error)
null -> "" // should never be here
}
Column(
Modifier.fillMaxWidth().fillMaxHeight().verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Center,
) {
Text(
title,
Modifier.padding(start = 16.dp, top = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
SectionView(null) {
Column(
Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
) {
val buttonEnabled = validKey(dbKey.value) && !progressIndicator.value
when (val status = chatDbStatus.value) {
is DBMigrationResult.ErrorNotADatabase -> {
if (useKeychain && !storedDBKey.isNullOrEmpty()) {
Text(generalGetString(R.string.passphrase_is_different))
DatabaseKeyField(dbKey, buttonEnabled) {
saveAndRunChatOnClick()
}
SaveAndOpenButton(buttonEnabled, saveAndRunChatOnClick)
SectionSpacer()
Text(String.format(generalGetString(R.string.file_with_path), status.dbFile))
} else {
Text(generalGetString(R.string.database_passphrase_is_required))
DatabaseKeyField(dbKey, buttonEnabled) {
if (useKeychain) saveAndRunChatOnClick() else runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences)
}
if (useKeychain) {
SaveAndOpenButton(buttonEnabled, saveAndRunChatOnClick)
} else {
OpenChatButton(buttonEnabled) { runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences) }
}
}
}
is DBMigrationResult.Error -> {
Text(String.format(generalGetString(R.string.file_with_path), status.dbFile))
Text(String.format(generalGetString(R.string.error_with_info), status.migrationError))
}
is DBMigrationResult.ErrorKeychain -> {
Text(generalGetString(R.string.cannot_access_keychain))
}
is DBMigrationResult.Unknown -> {
Text(String.format(generalGetString(R.string.unknown_database_error_with_info), status.json))
}
is DBMigrationResult.OK -> {
}
null -> {
}
}
}
}
}
if (progressIndicator.value) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(30.dp),
color = HighOrLowlight,
strokeWidth = 2.5.dp
)
}
}
}
private fun runChat(
dbKey: String,
chatDbStatus: State<DBMigrationResult?>,
progressIndicator: MutableState<Boolean>,
prefs: AppPreferences
) = CoroutineScope(Dispatchers.Default).launch {
// Don't do things concurrently. Shouldn't be here concurrently, just in case
if (progressIndicator.value) return@launch
progressIndicator.value = true
try {
SimplexApp.context.initChatController(dbKey)
} catch (e: Exception) {
Log.d(TAG, "initializeChat ${e.stackTraceToString()}")
}
progressIndicator.value = false
when (val status = chatDbStatus.value) {
is DBMigrationResult.OK -> {
SimplexService.cancelPassphraseNotification()
when (prefs.notificationsMode.get()) {
NotificationsMode.SERVICE.name -> CoroutineScope(Dispatchers.Default).launch { SimplexService.start(SimplexApp.context) }
NotificationsMode.PERIODIC.name -> SimplexApp.context.schedulePeriodicWakeUp()
}
}
is DBMigrationResult.ErrorNotADatabase -> {
AlertManager.shared.showAlertMsg( generalGetString(R.string.wrong_passphrase_title), generalGetString(R.string.enter_correct_passphrase))
}
is DBMigrationResult.Error -> {
AlertManager.shared.showAlertMsg( generalGetString(R.string.database_error), status.migrationError)
}
is DBMigrationResult.ErrorKeychain -> {
AlertManager.shared.showAlertMsg( generalGetString(R.string.keychain_error))
}
is DBMigrationResult.Unknown -> {
AlertManager.shared.showAlertMsg( generalGetString(R.string.unknown_error), status.json)
}
null -> {}
}
}
@Composable
private fun DatabaseKeyField(text: MutableState<String>, enabled: Boolean, onClick: (() -> Unit)? = null) {
DatabaseKeyField(
text,
generalGetString(R.string.enter_passphrase),
isValid = ::validKey,
keyboardActions = KeyboardActions(onDone = if (enabled) {
{ onClick?.invoke() }
} else null
)
)
}
@Composable
private fun ColumnScope.SaveAndOpenButton(enabled: Boolean, onClick: () -> Unit) {
TextButton(onClick, Modifier.align(Alignment.CenterHorizontally), enabled = enabled) {
Text(generalGetString(R.string.save_passphrase_and_open_chat))
}
}
@Composable
private fun ColumnScope.OpenChatButton(enabled: Boolean, onClick: () -> Unit) {
TextButton(onClick, Modifier.align(Alignment.CenterHorizontally), enabled = enabled) {
Text(generalGetString(R.string.open_chat))
}
}
@Preview
@Composable
fun PreviewChatInfoLayout() {
SimpleXTheme {
DatabaseErrorView(
remember { mutableStateOf(DBMigrationResult.ErrorNotADatabase("simplex_v1_chat.db")) },
AppPreferences(SimplexApp.context)
)
}
}
@@ -15,10 +15,11 @@ import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -28,13 +29,14 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.usersettings.*
import kotlinx.coroutines.*
import kotlinx.datetime.*
import java.io.*
import java.text.SimpleDateFormat
@@ -49,6 +51,7 @@ fun DatabaseView(
val progressIndicator = remember { mutableStateOf(false) }
val runChat = remember { mutableStateOf(m.chatRunning.value ?: true) }
val prefs = m.controller.appPrefs
val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
val chatArchiveName = remember { mutableStateOf(prefs.chatArchiveName.get()) }
val chatArchiveTime = remember { mutableStateOf(prefs.chatArchiveTime.get()) }
val chatLastStart = remember { mutableStateOf(prefs.chatLastStart.get()) }
@@ -68,12 +71,14 @@ fun DatabaseView(
DatabaseLayout(
progressIndicator.value,
runChat.value,
m.chatDbChanged.value,
useKeychain.value,
m.chatDbEncrypted.value,
m.controller.appPrefs.initialRandomDBPassphrase,
importArchiveLauncher,
chatArchiveName,
chatArchiveTime,
chatLastStart,
startChat = { startChat(m, runChat, chatLastStart, context) },
startChat = { startChat(m, runChat, chatLastStart, m.chatDbChanged) },
stopChatAlert = { stopChatAlert(m, runChat, context) },
exportArchive = { exportArchive(context, m, progressIndicator, chatArchiveName, chatArchiveTime, chatArchiveFile, saveArchiveLauncher) },
deleteChatAlert = { deleteChatAlert(m, progressIndicator) },
@@ -100,7 +105,9 @@ fun DatabaseView(
fun DatabaseLayout(
progressIndicator: Boolean,
runChat: Boolean,
chatDbChanged: Boolean,
useKeyChain: Boolean,
chatDbEncrypted: Boolean?,
initialRandomDBPassphrase: Preference<Boolean>,
importArchiveLauncher: ManagedActivityResultLauncher<String, Uri?>,
chatArchiveName: MutableState<String?>,
chatArchiveTime: MutableState<Instant?>,
@@ -112,10 +119,10 @@ fun DatabaseLayout(
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
) {
val stopped = !runChat
val operationsDisabled = !stopped || progressIndicator || chatDbChanged
val operationsDisabled = !stopped || progressIndicator
Column(
Modifier.fillMaxWidth(),
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.Start,
) {
Text(
@@ -125,15 +132,30 @@ fun DatabaseLayout(
)
SectionView(stringResource(R.string.run_chat_section)) {
RunChatSetting(runChat, stopped, chatDbChanged, startChat, stopChatAlert)
RunChatSetting(runChat, stopped, startChat, stopChatAlert)
}
SectionSpacer()
SectionView(stringResource(R.string.chat_database_section)) {
val unencrypted = chatDbEncrypted == false
SettingsActionItem(
if (unencrypted) Icons.Outlined.LockOpen else if (useKeyChain) Icons.Filled.VpnKey else Icons.Outlined.Lock,
stringResource(R.string.database_passphrase),
click = showSettingsModal { DatabaseEncryptionView(it) },
iconColor = if (unencrypted) WarningOrange else HighOrLowlight,
disabled = operationsDisabled
)
SectionDivider()
SettingsActionItem(
Icons.Outlined.IosShare,
stringResource(R.string.export_database),
exportArchive,
click = {
if (initialRandomDBPassphrase.get()) {
exportProhibitedAlert()
} else {
exportArchive()
}
},
textColor = MaterialTheme.colors.primary,
disabled = operationsDisabled
)
@@ -168,14 +190,10 @@ fun DatabaseLayout(
)
}
SectionTextFooter(
if (chatDbChanged) {
stringResource(R.string.restart_the_app_to_use_new_chat_database)
if (stopped) {
stringResource(R.string.you_must_use_the_most_recent_version_of_database)
} else {
if (stopped) {
stringResource(R.string.you_must_use_the_most_recent_version_of_database)
} else {
stringResource(R.string.stop_chat_to_enable_database_actions)
}
stringResource(R.string.stop_chat_to_enable_database_actions)
}
)
}
@@ -185,7 +203,6 @@ fun DatabaseLayout(
fun RunChatSetting(
runChat: Boolean,
stopped: Boolean,
chatDbChanged: Boolean,
startChat: () -> Unit,
stopChatAlert: () -> Unit
) {
@@ -195,13 +212,12 @@ fun RunChatSetting(
Icon(
if (stopped) Icons.Filled.Report else Icons.Filled.PlayArrow,
chatRunningText,
tint = if (chatDbChanged) HighOrLowlight else if (stopped) Color.Red else MaterialTheme.colors.primary
tint = if (stopped) Color.Red else MaterialTheme.colors.primary
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
chatRunningText,
Modifier.padding(end = 24.dp),
color = if (chatDbChanged) HighOrLowlight else Color.Unspecified
Modifier.padding(end = 24.dp)
)
Spacer(Modifier.fillMaxWidth().weight(1f))
Switch(
@@ -217,7 +233,6 @@ fun RunChatSetting(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
enabled = !chatDbChanged
)
}
}
@@ -228,16 +243,28 @@ fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String {
return stringResource(if (chatArchiveTime < chatLastStart) R.string.old_database_archive else R.string.new_database_archive)
}
private fun startChat(m: ChatModel, runChat: MutableState<Boolean>, chatLastStart: MutableState<Instant?>, context: Context) {
private fun startChat(m: ChatModel, runChat: MutableState<Boolean>, chatLastStart: MutableState<Instant?>, chatDbChanged: MutableState<Boolean>) {
withApi {
try {
if (chatDbChanged.value) {
SimplexApp.context.initChatController()
chatDbChanged.value = false
}
if (m.chatDbStatus.value !is DBMigrationResult.OK) {
/** Hide current view and show [DatabaseErrorView] */
ModalManager.shared.closeModals()
return@withApi
}
m.controller.apiStartChat()
runChat.value = true
m.chatRunning.value = true
val ts = Clock.System.now()
m.controller.appPrefs.chatLastStart.set(ts)
chatLastStart.value = ts
SimplexService.start(context)
when (m.controller.appPrefs.notificationsMode.get()) {
NotificationsMode.SERVICE.name -> CoroutineScope(Dispatchers.Default).launch { SimplexService.start(SimplexApp.context) }
NotificationsMode.PERIODIC.name -> SimplexApp.context.schedulePeriodicWakeUp()
}
} catch (e: Error) {
runChat.value = false
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString())
@@ -250,11 +277,42 @@ private fun stopChatAlert(m: ChatModel, runChat: MutableState<Boolean>, context:
title = generalGetString(R.string.stop_chat_question),
text = generalGetString(R.string.stop_chat_to_export_import_or_delete_chat_database),
confirmText = generalGetString(R.string.stop_chat_confirmation),
onConfirm = { stopChat(m, runChat, context) },
onConfirm = { authStopChat(m, runChat, context) },
onDismiss = { runChat.value = true }
)
}
private fun exportProhibitedAlert() {
AlertManager.shared.showAlertMsg(
title = generalGetString(R.string.set_password_to_export),
text = generalGetString(R.string.set_password_to_export_desc),
)
}
private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean>, context: Context) {
if (m.controller.appPrefs.performLA.get()) {
authenticate(
generalGetString(R.string.auth_stop_chat),
generalGetString(R.string.auth_log_in_using_credential),
context as FragmentActivity,
completed = { laResult ->
when (laResult) {
LAResult.Success, LAResult.Unavailable -> {
stopChat(m, runChat, context)
}
is LAResult.Error -> {
}
LAResult.Failed -> {
runChat.value = true
}
}
}
)
} else {
stopChat(m, runChat, context)
}
}
private fun stopChat(m: ChatModel, runChat: MutableState<Boolean>, context: Context) {
withApi {
try {
@@ -262,6 +320,7 @@ private fun stopChat(m: ChatModel, runChat: MutableState<Boolean>, context: Cont
runChat.value = false
m.chatRunning.value = false
SimplexService.stop(context)
MessagesFetcherWorker.cancelAll()
} catch (e: Error) {
runChat.value = true
AlertManager.shared.showAlertMsg(generalGetString(R.string.error_starting_chat), e.toString())
@@ -377,6 +436,7 @@ private fun importArchive(m: ChatModel, context: Context, importedArchiveUri: Ur
try {
val config = ArchiveConfig(archivePath, parentTempDirectory = context.cacheDir.toString())
m.controller.apiImportArchive(config)
DatabaseUtils.removeDatabaseKey()
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_imported), generalGetString(R.string.restart_the_app_to_use_imported_chat_database))
}
@@ -429,6 +489,8 @@ private fun deleteChat(m: ChatModel, progressIndicator: MutableState<Boolean>) {
withApi {
try {
m.controller.apiDeleteStorage()
DatabaseUtils.removeDatabaseKey()
m.controller.appPrefs.storeDBPassphrase.set(true)
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_deleted), generalGetString(R.string.restart_the_app_to_create_a_new_chat_profile))
}
@@ -458,7 +520,9 @@ fun PreviewDatabaseLayout() {
DatabaseLayout(
progressIndicator = false,
runChat = true,
chatDbChanged = false,
useKeyChain = false,
chatDbEncrypted = false,
initialRandomDBPassphrase = Preference({ true }, {}),
importArchiveLauncher = rememberGetContentLauncher {},
chatArchiveName = remember { mutableStateOf("dummy_archive") },
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
@@ -0,0 +1,77 @@
package chat.simplex.app.views.helpers
import android.util.Log
import chat.simplex.app.*
import chat.simplex.app.model.AppPreferences
import chat.simplex.app.model.json
import chat.simplex.app.views.usersettings.Cryptor
import kotlinx.serialization.*
import java.io.File
import java.security.SecureRandom
object DatabaseUtils {
private val cryptor = Cryptor()
private val appPreferences: AppPreferences by lazy {
AppPreferences(SimplexApp.context)
}
private const val DATABASE_PASSWORD_ALIAS: String = "databasePassword"
fun hasDatabase(filesDirectory: String): Boolean = File(filesDirectory + File.separator + "files_chat.db").exists()
fun getDatabaseKey(): String? {
return cryptor.decryptData(
appPreferences.encryptedDBPassphrase.get()?.toByteArrayFromBase64() ?: return null,
appPreferences.initializationVectorDBPassphrase.get()?.toByteArrayFromBase64() ?: return null,
DATABASE_PASSWORD_ALIAS,
)
}
fun setDatabaseKey(key: String) {
val data = cryptor.encryptText(key, DATABASE_PASSWORD_ALIAS)
appPreferences.encryptedDBPassphrase.set(data.first.toBase64String())
appPreferences.initializationVectorDBPassphrase.set(data.second.toBase64String())
}
fun removeDatabaseKey() {
cryptor.deleteKey(DATABASE_PASSWORD_ALIAS)
appPreferences.encryptedDBPassphrase.set(null)
appPreferences.initializationVectorDBPassphrase.set(null)
}
fun migrateChatDatabase(useKey: String? = null): Pair<Boolean, DBMigrationResult> {
Log.d(TAG, "migrateChatDatabase ${appPreferences.storeDBPassphrase.get()}")
val dbPath = getFilesDirectory(SimplexApp.context)
var dbKey = ""
val useKeychain = appPreferences.storeDBPassphrase.get()
if (useKey != null) {
dbKey = useKey
} else if (useKeychain) {
if (!hasDatabase(dbPath)) {
dbKey = randomDatabasePassword()
appPreferences.initialRandomDBPassphrase.set(true)
} else {
dbKey = getDatabaseKey() ?: ""
}
}
Log.d(TAG, "migrateChatDatabase DB path: $dbPath")
val migrated = chatMigrateDB(dbPath, dbKey)
val res: DBMigrationResult = kotlin.runCatching {
json.decodeFromString<DBMigrationResult>(migrated)
}.getOrElse { DBMigrationResult.Unknown(migrated) }
val encrypted = dbKey != ""
return encrypted to res
}
private fun randomDatabasePassword(): String = ByteArray(32).apply { SecureRandom().nextBytes(this) }.toBase64String()
}
@Serializable
sealed class DBMigrationResult {
@Serializable @SerialName("ok") object OK: DBMigrationResult()
@Serializable @SerialName("errorNotADatabase") class ErrorNotADatabase(val dbFile: String): DBMigrationResult()
@Serializable @SerialName("error") class Error(val dbFile: String, val migrationError: String): DBMigrationResult()
@Serializable @SerialName("errorKeychain") object ErrorKeychain: DBMigrationResult()
@Serializable @SerialName("unknown") class Unknown(val json: String): DBMigrationResult()
}
@@ -4,6 +4,7 @@ import android.content.Context
import android.util.Log
import androidx.work.*
import chat.simplex.app.*
import chat.simplex.app.SimplexService.Companion.showPassphraseNotification
import kotlinx.coroutines.*
import java.util.Date
import java.util.concurrent.TimeUnit
@@ -51,12 +52,18 @@ class MessagesFetcherWork(
return Result.success()
}
val durationSeconds = inputData.getInt(INPUT_DATA_DURATION, 60)
var shouldReschedule = true
try {
withTimeout(durationSeconds * 1000L) {
val chatController = (applicationContext as SimplexApp).chatController
val user = chatController.apiGetActiveUser() ?: return@withTimeout
val chatDbStatus = chatController.chatModel.chatDbStatus.value
if (chatDbStatus != DBMigrationResult.OK) {
Log.w(TAG, "Worker: problem with the database: $chatDbStatus")
showPassphraseNotification(chatDbStatus)
shouldReschedule = false
return@withTimeout
}
Log.w(TAG, "Worker: starting work")
chatController.startChat(user)
// Give some time to start receiving messages
delay(10_000)
while (!isStopped) {
@@ -75,7 +82,7 @@ class MessagesFetcherWork(
Log.d(TAG, "Worker: unexpected exception: ${e.stackTraceToString()}")
}
reschedule()
if (shouldReschedule) reschedule()
return Result.success()
}
@@ -135,9 +135,10 @@ fun <T> SectionItemWithValue(
fun SectionTextFooter(text: String) {
Text(
text,
Modifier.padding(horizontal = 16.dp).padding(top = 5.dp).fillMaxWidth(0.9F),
Modifier.padding(horizontal = 16.dp).padding(top = 8.dp).fillMaxWidth(0.9F),
color = HighOrLowlight,
fontSize = 12.sp
lineHeight = 18.sp,
fontSize = 14.sp
)
}
@@ -10,6 +10,7 @@ import android.provider.OpenableColumns
import android.text.Spanned
import android.text.SpannedString
import android.text.style.*
import android.util.Base64
import android.util.Log
import android.view.ViewTreeObserver
import androidx.annotation.StringRes
@@ -403,3 +404,7 @@ fun removeFile(context: Context, fileName: String): Boolean {
}
return fileDeleted
}
fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT)
fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT)
@@ -0,0 +1,53 @@
package chat.simplex.app.views.usersettings
import android.annotation.SuppressLint
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.*
import javax.crypto.spec.GCMParameterSpec
@SuppressLint("ObsoleteSdkInt")
internal class Cryptor {
private var keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String {
val cipher: Cipher = Cipher.getInstance(TRANSFORMATION)
val spec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(alias), spec)
return String(cipher.doFinal(data))
}
fun encryptText(text: String, alias: String): Pair<ByteArray, ByteArray> {
val cipher: Cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, createSecretKey(alias))
return Pair(cipher.doFinal(text.toByteArray(charset("UTF-8"))), cipher.iv)
}
fun deleteKey(alias: String) {
if (!keyStore.containsAlias(alias)) return
keyStore.deleteEntry(alias)
}
private fun createSecretKey(alias: String): SecretKey {
if (keyStore.containsAlias(alias)) return getSecretKey(alias)
val keyGenerator: KeyGenerator = KeyGenerator.getInstance(KEY_ALGORITHM, "AndroidKeyStore")
keyGenerator.init(
KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(BLOCK_MODE)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build()
)
return keyGenerator.generateKey()
}
private fun getSecretKey(alias: String): SecretKey {
return (keyStore.getEntry(alias, null) as KeyStore.SecretKeyEntry).secretKey
}
companion object {
private val KEY_ALGORITHM = KeyProperties.KEY_ALGORITHM_AES
private val BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM
private val TRANSFORMATION = "AES/GCM/NoPadding"
}
}
@@ -44,6 +44,7 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
SettingsLayout(
profile = user.profile,
stopped,
chatModel.chatDbEncrypted.value == true,
chatModel.incognito,
chatModel.controller.appPrefs.incognito,
developerTools = chatModel.controller.appPrefs.developerTools,
@@ -79,6 +80,7 @@ val simplexTeamUri =
fun SettingsLayout(
profile: LocalProfile,
stopped: Boolean,
encrypted: Boolean,
incognito: MutableState<Boolean>,
incognitoPref: Preference<Boolean>,
developerTools: Preference<Boolean>,
@@ -113,7 +115,7 @@ fun SettingsLayout(
SectionDivider()
SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { UserAddressView(it) }, disabled = stopped)
SectionDivider()
DatabaseItem(showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
DatabaseItem(encrypted, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
}
SectionSpacer()
@@ -199,7 +201,7 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
}
}
@Composable private fun DatabaseItem(openDatabaseView: () -> Unit, stopped: Boolean) {
@Composable private fun DatabaseItem(encrypted: Boolean, openDatabaseView: () -> Unit, stopped: Boolean) {
SectionItemView(openDatabaseView) {
Row(
Modifier.fillMaxWidth(),
@@ -207,12 +209,12 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
) {
Row {
Icon(
Icons.Outlined.Archive,
contentDescription = stringResource(R.string.database_export_and_import),
tint = HighOrLowlight,
Icons.Outlined.FolderOpen,
contentDescription = stringResource(R.string.database_passphrase_and_export),
tint = if (encrypted) HighOrLowlight else WarningOrange,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(stringResource(R.string.database_export_and_import))
Text(stringResource(R.string.database_passphrase_and_export))
}
if (stopped) {
Icon(
@@ -305,9 +307,9 @@ fun MaintainIncognitoState(chatModel: ChatModel) {
}
@Composable
fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified, disabled: Boolean = false) {
fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified, iconColor: Color = HighOrLowlight, disabled: Boolean = false) {
SectionItemView(click, disabled = disabled) {
Icon(icon, text, tint = HighOrLowlight)
Icon(icon, text, tint = iconColor)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(text, color = if (disabled) HighOrLowlight else textColor)
}
@@ -355,6 +357,7 @@ fun PreviewSettingsLayout() {
SettingsLayout(
profile = LocalProfile.sampleData,
stopped = false,
encrypted = false,
incognito = remember { mutableStateOf(false) },
incognitoPref = Preference({ false}, {}),
developerTools = Preference({ false }, {}),
@@ -67,12 +67,21 @@
<string name="periodic_notifications">Периодические уведомления</string>
<string name="periodic_notifications_disabled">Периодические уведомления выключены!</string>
<string name="periodic_notifications_desc">Приложение периодически получает новые сообщения — это потребляет несколько процентов батареи в день. Приложение не использует push уведомления — данные не отправляются с вашего устройства на сервер.</string>
<string name="enter_passphrase_notification_title">Введите пароль</string>
<string name="enter_passphrase_notification_desc">Для получения уведомлений, пожалуйста, введите пароль от базы данных</string>
<string name="database_initialization_error_title">Ошибка базы данных</string>
<string name="database_initialization_error_desc">Ошибка при инициализации базы данных. Нажмите чтобы узнать больше</string>
<!-- SimpleX Chat foreground Service -->
<string name="simplex_service_notification_title"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> сервис</string>
<string name="simplex_service_notification_text">Приём сообщений…</string>
<string name="hide_notification">Скрыть</string>
<!-- Notification channels -->
<string name="ntf_channel_messages">SimpleX Chat сообщения</string>
<string name="ntf_channel_calls">SimpleX Chat звонки</string>
<string name="ntf_channel_calls_lockscreen">SimpleX Chat звонки (экран блокировки)</string>
<!-- Notifications -->
<string name="settings_notifications_mode_title">Сервис уведомлений</string>
<string name="settings_notification_preview_mode_title">Показывать уведомления</string>
@@ -112,6 +121,8 @@
<string name="auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled">Аутентификация устройства не включена. Вы можете включить блокировку SimpleX в Настройках после включения аутентификации.</string>
<string name="auth_device_authentication_is_disabled_turning_off">Аутентификация устройства выключена. Отключение блокировки SimpleX Chat.</string>
<string name="auth_retry">Повторить</string>
<string name="auth_stop_chat">Остановить чат</string>
<string name="auth_open_chat_console">Открыть консоль</string>
<!-- Chat Actions - ChatItemView.kt (and general) -->
<string name="reply_verb">Ответить</string>
@@ -291,7 +302,7 @@
<!-- settings - SettingsView.kt -->
<string name="your_settings">Настройки</string>
<string name="your_simplex_contact_address">Ваш <xliff:g id="appName">SimpleX</xliff:g> адрес</string>
<string name="database_export_and_import">Экспорт и импорт архива чата</string>
<string name="database_passphrase_and_export">Пароль и экспорт базы</string>
<string name="about_simplex_chat">Информация о <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
<string name="how_to_use_simplex_chat">Как использовать</string>
<string name="markdown_help">Форматирование сообщений</string>
@@ -512,11 +523,12 @@
<string name="settings_section_title_incognito">Режим Инкогнито</string>
<!-- DatabaseView.kt -->
<string name="your_chat_database">Данные чата</string>
<string name="your_chat_database">База данных</string>
<string name="run_chat_section">ЗАПУСТИТЬ ЧАТ</string>
<string name="chat_is_running">Чат запущен</string>
<string name="chat_is_stopped">Чат остановлен</string>
<string name="chat_database_section">АРХИВ ЧАТА</string>
<string name="chat_database_section">БАЗА ДАННЫХ</string>
<string name="database_passphrase">Пароль базы данных</string>
<string name="export_database">Экспорт архива чата</string>
<string name="import_database">Импорт архива чата</string>
<string name="new_database_archive">Новый архив чата</string>
@@ -524,8 +536,10 @@
<string name="delete_database">Удалить данные чата</string>
<string name="error_starting_chat">Ошибка при запуске чата</string>
<string name="stop_chat_question">Остановить чат?</string>
<string name="stop_chat_to_export_import_or_delete_chat_database">Остановите чат, чтобы экспортировать или импортировать архив чата или удалить данные чата. Вы не сможете получать и отправлять сообщения, пока чат остановлен.</string>
<string name="stop_chat_to_export_import_or_delete_chat_database">Остановите чат, чтобы экспортировать или импортировать архив чата или удалить базу данных. Вы не сможете получать и отправлять сообщения, пока чат остановлен.</string>
<string name="stop_chat_confirmation">Остановить</string>
<string name="set_password_to_export">Установите пароль</string>
<string name="set_password_to_export_desc">База данных зашифрована случайным паролем. Пожалуйста, поменяйте его перед экспортом.</string>
<string name="error_stopping_chat">Ошибка при остановке чата</string>
<string name="error_exporting_chat_database">Ошибка при экспорте архива чата</string>
<string name="import_database_question">Импортировать архив чата?</string>
@@ -541,7 +555,53 @@
<string name="restart_the_app_to_create_a_new_chat_profile">Перезапустите приложение, чтобы создать новый профиль.</string>
<string name="you_must_use_the_most_recent_version_of_database">Используйте самую последнюю версию архива чата и ТОЛЬКО на одном устройстве, иначе вы можете перестать получать сообщения от некоторых контактов.</string>
<string name="stop_chat_to_enable_database_actions">Остановите чат, чтобы разблокировать операции с архивом чата.</string>
<string name="restart_the_app_to_use_new_chat_database">Перезапустите приложение, чтобы использовать новый архив чата.</string>
<!-- DatabaseEncryptionView.kt -->
<string name="save_passphrase_in_keychain">Сохранить пароль в Keystore</string>
<string name="database_encrypted">База данных зашифрована!</string>
<string name="error_encrypting_database">Ошибка при шифровании</string>
<string name="remove_passphrase_from_keychain">Удалить пароль из Keystore?</string>
<string name="notifications_will_be_hidden">Уведомления будут работать только до остановки приложения!</string>
<string name="remove_passphrase">Удалить</string>
<string name="encrypt_database">Зашифровать</string>
<string name="update_database">Поменять</string>
<string name="current_passphrase">Текущий пароль…</string>
<string name="new_passphrase">Новый пароль…</string>
<string name="confirm_new_passphrase">Подтвердите новый пароль…</string>
<string name="update_database_passphrase">Поменять пароль</string>
<string name="enter_correct_current_passphrase">Пожалуйста, введите правильный пароль.</string>
<string name="database_is_not_encrypted">База данных НЕ зашифрована. Установите пароль, чтобы защитить ваши данные.</string>
<string name="keychain_is_storing_securely">Android Keystore используется для безопасного хранения пароля - это позволяет стабильно получать уведомления в фоновом режиме.</string>
<string name="encrypted_with_random_passphrase">База данных зашифрована случайным паролем, вы можете его поменять.</string>
<string name="impossible_to_recover_passphrase"><b>Внимание</b>: вы не сможете восстановить или поменять пароль, если потеряете его.</string>
<string name="keychain_allows_to_receive_ntfs">Пароль базы данных будет безопасно сохранен в Android Keystore после запуска чата или изменения пароля - это позволит стабильно получать уведомления.</string>
<string name="you_have_to_enter_passphrase_every_time">Пароль не сохранен на устройстве — вы будете должны ввести его при каждом запуске чата.</string>
<string name="encrypt_database_question">Зашифровать базу данных?</string>
<string name="change_database_passphrase_question">Поменять пароль базы данных?</string>
<string name="database_will_be_encrypted">База данных будет зашифрована.</string>
<string name="database_will_be_encrypted_and_passphrase_stored">База данных будет зашифрована и пароль сохранен в Keystore.</string>
<string name="database_encryption_will_be_updated">Пароль базы данных будет изменен и сохранен в Keystore.</string>
<string name="database_passphrase_will_be_updated">Пароль базы данных будет изменен.</string>
<string name="store_passphrase_securely">Пожалуйста, надежно сохраните пароль, вы НЕ сможете его поменять, если потеряете.</string>
<string name="store_passphrase_securely_without_recover">Пожалуйста, надежно сохраните пароль, вы НЕ сможете открыть чат, если потеряете его.</string>
<!-- DatabaseErrorView.kt -->
<string name="wrong_passphrase">Неправильный пароль базы данных</string>
<string name="encrypted_database">База данных зашифрована</string>
<string name="database_error">Ошибка базы данных</string>
<string name="keychain_error">Ошибка Keystore</string>
<string name="passphrase_is_different">Пароль базы данных отличается от сохраненного в Keystore.</string>
<string name="file_with_path">Файл: %s</string>
<string name="database_passphrase_is_required">Введите пароль базы данных, чтобы открыть чат.</string>
<string name="error_with_info">Ошибка: %s</string>
<string name="cannot_access_keychain">Невозможно сохранить пароль в Keystore</string>
<string name="unknown_database_error_with_info">Неизвестная ошибка базы данных: %s</string>
<string name="wrong_passphrase_title">Неправильный пароль!</string>
<string name="enter_correct_passphrase">Введите правильный пароль.</string>
<string name="unknown_error">Неизвестная ошибка</string>
<string name="enter_passphrase">Введите пароль…</string>
<string name="save_passphrase_and_open_chat">Сохранить пароль и открыть чат</string>
<string name="open_chat">Открыть чат</string>
<!-- ChatModel.chatRunning interactions -->
<string name="chat_is_stopped_indication">Чат остановлен</string>
@@ -67,12 +67,21 @@
<string name="periodic_notifications">Periodic notifications</string>
<string name="periodic_notifications_disabled">Periodic notifications are disabled!</string>
<string name="periodic_notifications_desc">The app fetches new messages periodically — it uses a few percent of the battery per day. The app doesn\'t use push notifications — data from your device is not sent to the servers.</string>
<string name="enter_passphrase_notification_title">Passphrase is needed</string>
<string name="enter_passphrase_notification_desc">To receive notifications, please, enter the database passphrase</string>
<string name="database_initialization_error_title">Can\'t initialize the database</string>
<string name="database_initialization_error_desc">The database is not working correctly. Tap to learn more</string>
<!-- SimpleX Chat foreground Service -->
<string name="simplex_service_notification_title"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> service</string>
<string name="simplex_service_notification_text">Receiving messages…</string>
<string name="hide_notification">Hide</string>
<!-- Notification channels -->
<string name="ntf_channel_messages">SimpleX Chat messages</string>
<string name="ntf_channel_calls">SimpleX Chat calls</string>
<string name="ntf_channel_calls_lockscreen">SimpleX Chat calls (lock screen)</string>
<!-- Notifications -->
<string name="settings_notifications_mode_title">Notification service</string>
<string name="settings_notification_preview_mode_title">Show preview</string>
@@ -112,6 +121,8 @@
<string name="auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled">Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication.</string>
<string name="auth_device_authentication_is_disabled_turning_off">Device authentication is disabled. Turning off SimpleX Lock.</string>
<string name="auth_retry">Retry</string>
<string name="auth_stop_chat">Stop chat</string>
<string name="auth_open_chat_console">Open chat console</string>
<!-- Chat Actions - ChatItemView.kt (and general) -->
<string name="reply_verb">Reply</string>
@@ -295,7 +306,7 @@
<!-- settings - SettingsView.kt -->
<string name="your_settings">Your settings</string>
<string name="your_simplex_contact_address">Your <xliff:g id="appName">SimpleX</xliff:g> contact address</string>
<string name="database_export_and_import">Database export &amp; import</string>
<string name="database_passphrase_and_export">Database passphrase &amp; export</string>
<string name="about_simplex_chat">About <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
<string name="how_to_use_simplex_chat">How to use it</string>
<string name="markdown_help">Markdown help</string>
@@ -518,6 +529,7 @@
<string name="chat_is_running">Chat is running</string>
<string name="chat_is_stopped">Chat is stopped</string>
<string name="chat_database_section">CHAT DATABASE</string>
<string name="database_passphrase">Database passphrase</string>
<string name="export_database">Export database</string>
<string name="import_database">Import database</string>
<string name="new_database_archive">New database archive</string>
@@ -527,6 +539,8 @@
<string name="stop_chat_question">Stop chat?</string>
<string name="stop_chat_to_export_import_or_delete_chat_database">Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped.</string>
<string name="stop_chat_confirmation">Stop</string>
<string name="set_password_to_export">Set passphrase to export</string>
<string name="set_password_to_export_desc">Database is encrypted using a random passphrase. Please change it before exporting.</string>
<string name="error_stopping_chat">Error stopping chat</string>
<string name="error_exporting_chat_database">Error exporting chat database</string>
<string name="import_database_question">Import chat database?</string>
@@ -542,7 +556,53 @@
<string name="restart_the_app_to_create_a_new_chat_profile">Restart the app to create a new chat profile.</string>
<string name="you_must_use_the_most_recent_version_of_database">You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts.</string>
<string name="stop_chat_to_enable_database_actions">Stop chat to enable database actions.</string>
<string name="restart_the_app_to_use_new_chat_database">Restart the app to use new chat database.</string>
<!-- DatabaseEncryptionView.kt -->
<string name="save_passphrase_in_keychain">Save passphrase in Keystore</string>
<string name="database_encrypted">Database encrypted!</string>
<string name="error_encrypting_database">Error encrypting database</string>
<string name="remove_passphrase_from_keychain">Remove passphrase from Keystore?</string>
<string name="notifications_will_be_hidden">Notifications will be delivered only until the app stops!</string>
<string name="remove_passphrase">Remove</string>
<string name="encrypt_database">Encrypt</string>
<string name="update_database">Update</string>
<string name="current_passphrase">Current passphrase…</string>
<string name="new_passphrase">New passphrase…</string>
<string name="confirm_new_passphrase">Confirm new passphrase…</string>
<string name="update_database_passphrase">Update database 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>
<string name="encrypted_with_random_passphrase">Database is encrypted using a random passphrase, you can change it.</string>
<string name="impossible_to_recover_passphrase"><b>Please note</b>: you will NOT be able to recover or change passphrase if you lose it.</string>
<string name="keychain_allows_to_receive_ntfs">Android Keystore will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving notifications.</string>
<string name="you_have_to_enter_passphrase_every_time">You have to enter passphrase every time the app starts - it is not stored on the device.</string>
<string name="encrypt_database_question">Encrypt database?</string>
<string name="change_database_passphrase_question">Change database passphrase?</string>
<string name="database_will_be_encrypted">Database will be encrypted.</string>
<string name="database_will_be_encrypted_and_passphrase_stored">Database will be encrypted and the passphrase stored in the Keystore.</string>
<string name="database_encryption_will_be_updated">Database encryption passphrase will be updated and stored in the Keystore.</string>
<string name="database_passphrase_will_be_updated">Database encryption passphrase will be updated.</string>
<string name="store_passphrase_securely">Please store passphrase securely, you will NOT be able to change it if you lose it.</string>
<string name="store_passphrase_securely_without_recover">Please store passphrase securely, you will NOT be able to access chat if you lose it.</string>
<!-- DatabaseErrorView.kt -->
<string name="wrong_passphrase">Wrong database passphrase</string>
<string name="encrypted_database">Encrypted database</string>
<string name="database_error">Database error</string>
<string name="keychain_error">Keychain error</string>
<string name="passphrase_is_different">Database passphrase is different from saved in the Keystore.</string>
<string name="file_with_path">File: %s</string>
<string name="database_passphrase_is_required">Database passphrase is required to open chat.</string>
<string name="error_with_info">Error: %s</string>
<string name="cannot_access_keychain">Cannot access Keystore to save database password</string>
<string name="unknown_database_error_with_info">Unknown database error: %s</string>
<string name="wrong_passphrase_title">Wrong passphrase!</string>
<string name="enter_correct_passphrase">Enter correct passphrase.</string>
<string name="unknown_error">Unknown error</string>
<string name="enter_passphrase">Enter passphrase…</string>
<string name="save_passphrase_and_open_chat">Save passphrase and open chat</string>
<string name="open_chat">Open chat</string>
<!-- ChatModel.chatRunning interactions -->
<string name="chat_is_stopped_indication">Chat is stopped</string>
+3
View File
@@ -6,6 +6,7 @@
//
import SwiftUI
import SimpleXChat
struct ContentView: View {
@EnvironmentObject var chatModel: ChatModel
@@ -21,6 +22,8 @@ struct ContentView: View {
ZStack {
if prefPerformLA && userAuthorized != true {
Button(action: runAuthenticate) { Label("Unlock", systemImage: "lock") }
} else if let status = chatModel.chatDbStatus, status != .ok {
DatabaseErrorView(status: status)
} else if !chatModel.v3DBMigration.startChat {
MigrateToAppGroupView()
} else if let step = chatModel.onboardingStage {
+4
View File
@@ -18,6 +18,8 @@ final class ChatModel: ObservableObject {
@Published var currentUser: User?
@Published var chatRunning: Bool?
@Published var chatDbChanged = false
@Published var chatDbEncrypted: Bool?
@Published var chatDbStatus: DBMigrationResult?
// list of chat "previews"
@Published var chats: [Chat] = []
// current chat
@@ -51,6 +53,8 @@ final class ChatModel: ObservableObject {
static let shared = ChatModel()
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
func hasChat(_ id: String) -> Bool {
chats.first(where: { $0.id == id }) != nil
}
+19 -16
View File
@@ -94,7 +94,7 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? =
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
}
DispatchQueue.main.async {
ChatModel.shared.terminalItems.append(.cmd(.now, cmd))
ChatModel.shared.terminalItems.append(.cmd(.now, cmd.obfuscated))
ChatModel.shared.terminalItems.append(.resp(.now, resp))
}
return resp
@@ -185,6 +185,10 @@ func apiDeleteStorage() async throws {
try await sendCommandOkResp(.apiDeleteStorage)
}
func apiStorageEncryption(currentKey: String = "", newKey: String = "") async throws {
try await sendCommandOkResp(.apiStorageEncryption(config: DBEncryptionConfig(currentKey: currentKey, newKey: newKey)))
}
func apiGetChats() throws -> [ChatData] {
let r = chatSendCmdSync(.apiGetChats)
if case let .apiChats(chats) = r { return chats }
@@ -654,22 +658,21 @@ func apiUpdateGroup(_ groupId: Int64, _ groupProfile: GroupProfile) async throws
throw r
}
func initializeChat(start: Bool) throws {
func initializeChat(start: Bool, dbKey: String? = nil) throws {
logger.debug("initializeChat")
do {
let m = ChatModel.shared
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
try apiSetIncognito(incognito: incognitoGroupDefault.get())
m.currentUser = try apiGetActiveUser()
if m.currentUser == nil {
m.onboardingStage = .step1_SimpleXInfo
} else if start {
try startChat()
} else {
m.chatRunning = false
}
} catch {
fatalError("Failed to initialize chat controller or database: \(responseError(error))")
let m = ChatModel.shared
(m.chatDbEncrypted, m.chatDbStatus) = migrateChatDatabase(dbKey)
if m.chatDbStatus != .ok { return }
let _ = getChatCtrl(dbKey)
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
try apiSetIncognito(incognito: incognitoGroupDefault.get())
m.currentUser = try apiGetActiveUser()
if m.currentUser == nil {
m.onboardingStage = .step1_SimpleXInfo
} else if start {
try startChat()
} else {
m.chatRunning = false
}
}
+10 -6
View File
@@ -19,10 +19,14 @@ let bgSuspendTimeout: Int = 5 // seconds
let terminationTimeout: Int = 3 // seconds
private func _suspendChat(timeout: Int) {
appStateGroupDefault.set(.suspending)
apiSuspendChat(timeoutMicroseconds: timeout * 1000000)
let endTask = beginBGTask(chatSuspended)
DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: endTask)
if ChatModel.ok {
appStateGroupDefault.set(.suspending)
apiSuspendChat(timeoutMicroseconds: timeout * 1000000)
let endTask = beginBGTask(chatSuspended)
DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: endTask)
} else {
appStateGroupDefault.set(.suspended)
}
}
func suspendChat() {
@@ -47,7 +51,7 @@ func terminateChat() {
case .suspending:
// suspend instantly if already suspending
_chatSuspended()
apiSuspendChat(timeoutMicroseconds: 0)
if ChatModel.ok { apiSuspendChat(timeoutMicroseconds: 0) }
case .stopped: ()
default:
_suspendChat(timeout: terminationTimeout)
@@ -74,6 +78,6 @@ private func _chatSuspended() {
func activateChat(appState: AppState = .active) {
suspendLockQueue.sync {
appStateGroupDefault.set(appState)
apiActivateChat()
if ChatModel.ok { apiActivateChat() }
}
}
@@ -0,0 +1,345 @@
//
// DatabaseEncryptionView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 04/09/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
enum DatabaseEncryptionAlert: Identifiable {
case keychainRemoveKey
case encryptDatabaseSaved
case encryptDatabase
case changeDatabaseKeySaved
case changeDatabaseKey
case databaseEncrypted
case currentPassphraseError
case error(title: LocalizedStringKey, error: String = "")
var id: String {
switch self {
case .keychainRemoveKey: return "keychainRemoveKey"
case .encryptDatabaseSaved: return "encryptDatabaseSaved"
case .encryptDatabase: return "encryptDatabase"
case .changeDatabaseKeySaved: return "changeDatabaseKeySaved"
case .changeDatabaseKey: return "changeDatabaseKey"
case .databaseEncrypted: return "databaseEncrypted"
case .currentPassphraseError: return "currentPassphraseError"
case let .error(title, _): return "error \(title)"
}
}
}
struct DatabaseEncryptionView: View {
@EnvironmentObject private var m: ChatModel
@Binding var useKeychain: Bool
@State private var alert: DatabaseEncryptionAlert? = nil
@State private var progressIndicator = false
@State private var useKeychainToggle = storeDBPassphraseGroupDefault.get()
@State private var initialRandomDBPassphrase = initialRandomDBPassphraseGroupDefault.get()
@State private var storedKey = getDatabaseKey() != nil
@State private var currentKey = ""
@State private var newKey = ""
@State private var confirmNewKey = ""
@State private var currentKeyShown = false
var body: some View {
ZStack {
databaseEncryptionView()
if progressIndicator {
ProgressView().scaleEffect(2)
}
}
}
private func databaseEncryptionView() -> some View {
List {
Section {
settingsRow(storedKey ? "key.fill" : "key", color: storedKey ? .green : .secondary) {
Toggle("Save passphrase in Keychain", isOn: $useKeychainToggle)
.onChange(of: useKeychainToggle) { _ in
if useKeychainToggle {
setUseKeychain(true)
} else if storedKey {
alert = .keychainRemoveKey
} else {
setUseKeychain(false)
}
}
.disabled(initialRandomDBPassphrase)
}
if !initialRandomDBPassphrase && m.chatDbEncrypted == true {
DatabaseKeyField(key: $currentKey, placeholder: "Current passphrase…", valid: validKey(currentKey))
}
DatabaseKeyField(key: $newKey, placeholder: "New passphrase…", valid: validKey(newKey), showStrength: true)
DatabaseKeyField(key: $confirmNewKey, placeholder: "Confirm new passphrase…", valid: confirmNewKey == "" || newKey == confirmNewKey)
settingsRow("lock.rotation") {
Button("Update database passphrase") {
alert = currentKey == ""
? (useKeychain ? .encryptDatabaseSaved : .encryptDatabase)
: (useKeychain ? .changeDatabaseKeySaved : .changeDatabaseKey)
}
}
.disabled(
currentKey == newKey ||
newKey != confirmNewKey ||
newKey == "" ||
!validKey(currentKey) ||
!validKey(newKey)
)
} header: {
Text("")
} footer: {
VStack(alignment: .leading, spacing: 16) {
if m.chatDbEncrypted == false {
Text("Your chat database is not encrypted - set passphrase to encrypt it.")
} else if useKeychain {
if storedKey {
Text("iOS Keychain is used to securely store passphrase - it allows receiving push notifications.")
if initialRandomDBPassphrase {
Text("Database is encrypted using a random passphrase, you can change it.")
} else {
Text("**Please note**: you will NOT be able to recover or change passphrase if you lose it.")
}
} else {
Text("iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications.")
}
} else {
Text("You have to enter passphrase every time the app starts - it is not stored on the device.")
Text("**Please note**: you will NOT be able to recover or change passphrase if you lose it.")
if m.notificationMode == .instant && m.notificationPreview != .hidden {
Text("**Warning**: Instant push notifications require passphrase saved in Keychain.")
}
}
}
.padding(.top, 1)
.font(.callout)
}
}
.onAppear {
if initialRandomDBPassphrase { currentKey = getDatabaseKey() ?? "" }
}
.disabled(m.chatRunning != false)
.alert(item: $alert) { item in databaseEncryptionAlert(item) }
}
private func encryptDatabase() {
progressIndicator = true
Task {
do {
try await apiStorageEncryption(currentKey: currentKey, newKey: newKey)
initialRandomDBPassphraseGroupDefault.set(false)
if useKeychain {
if setDatabaseKey(newKey) {
await resetFormAfterEncryption(true)
await operationEnded(.databaseEncrypted)
} else {
await resetFormAfterEncryption()
await operationEnded(.error(title: "Keychain error", error: "Error saving passphrase to keychain"))
}
} else {
await resetFormAfterEncryption()
await operationEnded(.databaseEncrypted)
}
} catch let error {
if case .chatCmdError(.errorDatabase(.errorExport(.errorNotADatabase))) = error as? ChatResponse {
await operationEnded(.currentPassphraseError)
} else {
await operationEnded(.error(title: "Error encrypting database", error: responseError(error)))
}
}
}
}
private func resetFormAfterEncryption(_ stored: Bool = false) async {
await MainActor.run {
m.chatDbEncrypted = true
initialRandomDBPassphrase = false
currentKey = ""
newKey = ""
confirmNewKey = ""
storedKey = stored
}
}
private func setUseKeychain(_ value: Bool) {
useKeychain = value
storeDBPassphraseGroupDefault.set(value)
}
private func databaseEncryptionAlert(_ alertItem: DatabaseEncryptionAlert) -> Alert {
switch alertItem {
case .keychainRemoveKey:
return Alert(
title: Text("Remove passphrase from keychain?"),
message: Text("Instant push notifications will be hidden!\n") + storeSecurelyDanger(),
primaryButton: .destructive(Text("Remove")) {
if removeDatabaseKey() {
setUseKeychain(false)
storedKey = false
} else {
alert = .error(title: "Keychain error", error: "Failed to remove passphrase")
}
},
secondaryButton: .cancel() {
withAnimation { useKeychainToggle = true }
}
)
case .encryptDatabaseSaved:
return Alert(
title: Text("Encrypt database?"),
message: Text("Database will be encrypted and the passphrase stored in the keychain.\n") + storeSecurelySaved(),
primaryButton: .default(Text("Encrypt")) { encryptDatabase() },
secondaryButton: .cancel()
)
case .encryptDatabase:
return Alert(
title: Text("Encrypt database?"),
message: Text("Database will be encrypted.\n") + storeSecurelyDanger(),
primaryButton: .destructive(Text("Encrypt")) { encryptDatabase() },
secondaryButton: .cancel()
)
case .changeDatabaseKeySaved:
return Alert(
title: Text("Change database passphrase?"),
message: Text("Database encryption passphrase will be updated and stored in the keychain.\n") + storeSecurelySaved(),
primaryButton: .default(Text("Update")) { encryptDatabase() },
secondaryButton: .cancel()
)
case .changeDatabaseKey:
return Alert(
title: Text("Change database passphrase?"),
message: Text("Database encryption passphrase will be updated.\n") + storeSecurelyDanger(),
primaryButton: .destructive(Text("Update")) { encryptDatabase() },
secondaryButton: .cancel()
)
case .databaseEncrypted:
return Alert(title: Text("Database encrypted!"))
case .currentPassphraseError:
return Alert(
title: Text("Wrong passphrase!"),
message: Text("Please enter correct current passphrase.")
)
case let .error(title, error):
return Alert(title: Text(title), message: Text("\(error)"))
}
}
private func storeSecurelySaved() -> Text {
Text("Please store passphrase securely, you will NOT be able to change it if you lose it.")
}
private func storeSecurelyDanger() -> Text {
Text("Please store passphrase securely, you will NOT be able to access chat if you lose it.")
}
private func operationEnded(_ dbAlert: DatabaseEncryptionAlert) async {
await MainActor.run {
m.chatDbChanged = true
progressIndicator = false
alert = dbAlert
}
}
}
struct DatabaseKeyField: View {
@Binding var key: String
var placeholder: LocalizedStringKey
var valid: Bool
var showStrength = false
var onSubmit: () -> Void = {}
@State private var showKey = false
var body: some View {
ZStack(alignment: .leading) {
let iconColor = valid
? (showStrength && key != "" ? PassphraseStrength(passphrase: key).color : .secondary)
: .red
Image(systemName: valid ? (showKey ? "eye.slash" : "eye") : "exclamationmark.circle")
.resizable()
.scaledToFit()
.frame(width: 20, height: 22, alignment: .center)
.foregroundColor(iconColor)
.onTapGesture { showKey = !showKey }
textField()
.disableAutocorrection(true)
.autocapitalization(.none)
.submitLabel(.done)
.padding(.leading, 36)
.onSubmit(onSubmit)
}
}
@ViewBuilder func textField() -> some View {
if showKey {
TextField(placeholder, text: $key)
} else {
SecureField(placeholder, text: $key)
}
}
}
// based on https://generatepasswords.org/how-to-calculate-entropy/
private func passphraseEnthropy(_ s: String) -> Double {
var hasDigits = false
var hasUppercase = false
var hasLowercase = false
var hasSymbols = false
for c in s {
if c.isNumber {
hasDigits = true
} else if c.isLetter {
if c.isUppercase { hasUppercase = true }
else { hasLowercase = true }
} else if c.isASCII {
hasSymbols = true
}
}
let poolSize: Double = (hasDigits ? 10 : 0) + (hasUppercase ? 26 : 0) + (hasLowercase ? 26 : 0) + (hasSymbols ? 32 : 0)
return Double(s.count) * log2(poolSize)
}
enum PassphraseStrength {
case veryWeak
case weak
case reasonable
case strong
init(passphrase s: String) {
let enthropy = passphraseEnthropy(s)
self = enthropy > 60
? .strong
: enthropy > 45
? .reasonable
: enthropy > 30
? .weak
: .veryWeak
}
var color: Color {
switch self {
case .veryWeak: return .red
case .weak: return .orange
case .reasonable: return .yellow
case .strong: return .green
}
}
}
func validKey(_ s: String) -> Bool {
for c in s { if c.isWhitespace || !c.isASCII { return false } }
return true
}
struct DatabaseEncryptionView_Previews: PreviewProvider {
static var previews: some View {
DatabaseEncryptionView(useKeychain: Binding.constant(true))
}
}
@@ -0,0 +1,127 @@
//
// DatabaseErrorView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 04/09/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct DatabaseErrorView: View {
@EnvironmentObject var m: ChatModel
@State var status: DBMigrationResult
@State private var dbKey = ""
@State private var storedDBKey = getDatabaseKey()
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
var body: some View {
VStack(alignment: .leading, spacing: 16) {
switch status {
case let .errorNotADatabase(dbFile):
if useKeychain && storedDBKey != nil && storedDBKey != "" {
Text("Wrong database passphrase").font(.title)
Text("Database passphrase is different from saved in the keychain.")
databaseKeyField(onSubmit: saveAndRunChat)
saveAndOpenButton()
Spacer()
Text("File: \(dbFile)")
} else {
Text("Encrypted database").font(.title)
Text("Database passphrase is required to open chat.")
if useKeychain {
databaseKeyField(onSubmit: saveAndRunChat)
saveAndOpenButton()
} else {
databaseKeyField(onSubmit: runChat)
openChatButton()
}
Spacer()
}
case let .error(dbFile, migrationError):
Text("Database error")
.font(.title)
Text("File: \(dbFile)")
Text("Error: \(migrationError)")
Spacer()
case .errorKeychain:
Text("Keychain error")
.font(.title)
Text("Cannot access keychain to save database password")
Spacer()
case let .unknown(json):
Text("Database error")
.font(.title)
Text("Unknown database error: \(json)")
Spacer()
case .ok:
EmptyView()
}
}
.padding()
.frame(maxHeight: .infinity)
}
private func databaseKeyField(onSubmit: @escaping () -> Void) -> some View {
DatabaseKeyField(key: $dbKey, placeholder: "Enter passphrase…", valid: validKey(dbKey), onSubmit: onSubmit)
}
private func saveAndOpenButton() -> some View {
Button("Save passphrase and open chat") {
saveAndRunChat()
}
}
private func openChatButton() -> some View {
Button("Open chat") {
runChat()
}
}
private func saveAndRunChat() {
if setDatabaseKey(dbKey) {
storeDBPassphraseGroupDefault.set(true)
initialRandomDBPassphraseGroupDefault.set(false)
}
runChat()
}
private func runChat() {
do {
try initializeChat(start: m.v3DBMigration.startChat, dbKey: dbKey)
if let s = m.chatDbStatus {
status = s
let am = AlertManager.shared
switch s {
case .errorNotADatabase:
am.showAlertMsg(
title: "Wrong passphrase!",
message: "Enter correct passphrase."
)
case .errorKeychain:
am.showAlertMsg(title: "Keychain error")
case let .error(_, error):
am.showAlert(Alert(
title: Text("Database error"),
message: Text(error)
))
case let .unknown(error):
am.showAlert(Alert(
title: Text("Unknown error"),
message: Text(error)
))
case .ok: ()
}
}
} catch let error {
logger.error("initializeChat \(responseError(error))")
}
}
}
struct DatabaseErrorView_Previews: PreviewProvider {
static var previews: some View {
DatabaseErrorView(status: .errorNotADatabase(dbFile: "simplex_v1_chat.db"))
}
}
@@ -11,6 +11,7 @@ import SimpleXChat
enum DatabaseAlert: Identifiable {
case stopChat
case exportProhibited
case importArchive
case archiveImported
case deleteChat
@@ -21,6 +22,7 @@ enum DatabaseAlert: Identifiable {
var id: String {
switch self {
case .stopChat: return "stopChat"
case .exportProhibited: return "exportProhibited"
case .importArchive: return "importArchive"
case .archiveImported: return "archiveImported"
case .deleteChat: return "deleteChat"
@@ -43,6 +45,7 @@ struct DatabaseView: View {
@AppStorage(DEFAULT_CHAT_ARCHIVE_TIME) private var chatArchiveTime: Double = 0
@State private var dbContainer = dbContainerGroupDefault.get()
@State private var legacyDatabase = hasLegacyDatabase()
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
var body: some View {
ZStack {
@@ -82,18 +85,28 @@ struct DatabaseView: View {
}
Section {
settingsRow("square.and.arrow.up") {
Button {
exportArchive()
let unencrypted = m.chatDbEncrypted == false
let color: Color = unencrypted ? .orange : .secondary
settingsRow(unencrypted ? "lock.open" : useKeychain ? "key" : "lock", color: color) {
NavigationLink {
DatabaseEncryptionView(useKeychain: $useKeychain)
.navigationTitle("Database passphrase")
} label: {
Text("Export database")
Text("Database passphrase")
}
}
settingsRow("square.and.arrow.up") {
Button("Export database") {
if initialRandomDBPassphraseGroupDefault.get() {
alert = .exportProhibited
} else {
exportArchive()
}
}
}
settingsRow("square.and.arrow.down") {
Button(role: .destructive) {
Button("Import database", role: .destructive) {
showFileImporter = true
} label: {
Text("Import database")
}
}
if let archiveName = chatArchiveName {
@@ -110,10 +123,8 @@ struct DatabaseView: View {
}
}
settingsRow("trash.slash") {
Button(role: .destructive) {
Button("Delete database", role: .destructive) {
alert = .deleteChat
} label: {
Text("Delete database")
}
}
} header: {
@@ -130,10 +141,8 @@ struct DatabaseView: View {
if case .group = dbContainer, legacyDatabase {
Section("Old database") {
settingsRow("trash") {
Button {
Button("Delete old database") {
alert = .deleteLegacyDatabase
} label: {
Text("Delete old database")
}
}
}
@@ -160,12 +169,17 @@ struct DatabaseView: View {
title: Text("Stop chat?"),
message: Text("Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped."),
primaryButton: .destructive(Text("Stop")) {
stopChat()
authStopChat()
},
secondaryButton: .cancel {
withAnimation { runChat = true }
}
)
case .exportProhibited:
return Alert(
title: Text("Set passphrase to export"),
message: Text("Database is encrypted using a random passphrase. Please change it before exporting.")
)
case .importArchive:
if let fileURL = importedArchivePath {
return Alert(
@@ -213,6 +227,20 @@ struct DatabaseView: View {
}
}
private func authStopChat() {
if UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) {
authenticate(reason: NSLocalizedString("Stop SimpleX", comment: "authentication reason")) { laResult in
switch laResult {
case .success: stopChat()
case .unavailable: stopChat()
case .failed: withAnimation { runChat = true }
}
}
} else {
stopChat()
}
}
private func stopChat() {
Task {
do {
@@ -254,6 +282,7 @@ struct DatabaseView: View {
do {
let config = ArchiveConfig(archivePath: archivePath.path)
try await apiImportArchive(config: config)
_ = removeDatabaseKey()
await operationEnded(.archiveImported)
} catch let error {
await operationEnded(.error(title: "Error importing chat database", error: responseError(error)))
@@ -273,6 +302,8 @@ struct DatabaseView: View {
Task {
do {
try await apiDeleteStorage()
_ = removeDatabaseKey()
storeDBPassphraseGroupDefault.set(true)
await operationEnded(.chatDeleted)
} catch let error {
await operationEnded(.error(title: "Error deleting database", error: responseError(error)))
+20
View File
@@ -17,8 +17,28 @@ struct TerminalView: View {
@EnvironmentObject var chatModel: ChatModel
@State var composeState: ComposeState = ComposeState()
@FocusState private var keyboardVisible: Bool
@State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA)
var body: some View {
if authorized {
terminalView()
} else {
Button(action: runAuth) { Label("Unlock", systemImage: "lock") }
.onAppear(perform: runAuth)
}
}
private func runAuth() {
authenticate(reason: NSLocalizedString("Open chat console", comment: "authentication reason")) { laResult in
switch laResult {
case .success: authorized = true
case .unavailable: authorized = true
case .failed: authorized = false
}
}
}
private func terminalView() -> some View {
VStack {
ScrollViewReader { proxy in
ScrollView {
@@ -44,7 +44,7 @@ let appDefaults: [String: Any] = [
DEFAULT_ACCENT_COLOR_RED: 0.000,
DEFAULT_ACCENT_COLOR_GREEN: 0.533,
DEFAULT_ACCENT_COLOR_BLUE: 1.000,
DEFAULT_USER_INTERFACE_STYLE: 0
DEFAULT_USER_INTERFACE_STYLE: 0,
]
private var indent: CGFloat = 36
@@ -92,9 +92,10 @@ struct SettingsView: View {
DatabaseView(showSettings: $showSettings)
.navigationTitle("Your chat database")
} label: {
settingsRow("internaldrive") {
let color: Color = chatModel.chatDbEncrypted == false ? .orange : .secondary
settingsRow("internaldrive", color: color) {
HStack {
Text("Database export & import")
Text("Database passphrase & export")
Spacer()
if chatModel.chatRunning == false {
Image(systemName: "exclamationmark.octagon.fill").foregroundColor(.red)
+4
View File
@@ -14,5 +14,9 @@
<array>
<string>group.chat.simplex.app</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)chat.simplex.app</string>
</array>
</dict>
</plist>
@@ -127,6 +127,11 @@
<target>**Paste received link** or open it in the browser and tap **Open in mobile app**.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="**Please note**: you will NOT be able to recover or change passphrase if you lose it." xml:space="preserve">
<source>**Please note**: you will NOT be able to recover or change passphrase if you lose it.</source>
<target>**Please note**: you will NOT be able to recover or change passphrase if you lose it.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from." xml:space="preserve">
<source>**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from.</source>
<target>**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from.</target>
@@ -137,6 +142,11 @@
<target>**Scan QR code**: to connect to your contact in person or via video call.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="**Warning**: Instant push notifications require passphrase saved in Keychain." xml:space="preserve">
<source>**Warning**: Instant push notifications require passphrase saved in Keychain.</source>
<target>**Warning**: Instant push notifications require passphrase saved in Keychain.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="**e2e encrypted** audio call" xml:space="preserve">
<source>**e2e encrypted** audio call</source>
<target>**e2e encrypted** audio call</target>
@@ -303,6 +313,16 @@
<target>Cancel</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot access keychain to save database password" xml:space="preserve">
<source>Cannot access keychain to save database password</source>
<target>Cannot access keychain to save database password</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Change database passphrase?" xml:space="preserve">
<source>Change database passphrase?</source>
<target>Change database passphrase?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Chat archive" xml:space="preserve">
<source>Chat archive</source>
<target>Chat archive</target>
@@ -346,7 +366,7 @@
<trans-unit id="Chats" xml:space="preserve">
<source>Chats</source>
<target>Chats</target>
<note>back button to return to chats list</note>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Choose file" xml:space="preserve">
<source>Choose file</source>
@@ -388,6 +408,11 @@
<target>Confirm</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Confirm new passphrase…" xml:space="preserve">
<source>Confirm new passphrase…</source>
<target>Confirm new passphrase…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Connect" xml:space="preserve">
<source>Connect</source>
<target>Connect</target>
@@ -528,6 +553,11 @@
<target>Created on %@</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Current passphrase…" xml:space="preserve">
<source>Current passphrase…</source>
<target>Current passphrase…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Currently maximum supported file size is %@." xml:space="preserve">
<source>Currently maximum supported file size is %@.</source>
<target>Currently maximum supported file size is %@.</target>
@@ -543,9 +573,72 @@
<target>Database ID</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database export &amp; import" xml:space="preserve">
<source>Database export &amp; import</source>
<target>Database export &amp; import</target>
<trans-unit id="Database encrypted!" xml:space="preserve">
<source>Database encrypted!</source>
<target>Database encrypted!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database encryption passphrase will be updated and stored in the keychain.&#10;" xml:space="preserve">
<source>Database encryption passphrase will be updated and stored in the keychain.
</source>
<target>Database encryption passphrase will be updated and stored in the keychain.
</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database encryption passphrase will be updated.&#10;" xml:space="preserve">
<source>Database encryption passphrase will be updated.
</source>
<target>Database encryption passphrase will be updated.
</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database error" xml:space="preserve">
<source>Database error</source>
<target>Database error</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database is encrypted using a random passphrase, you can change it." xml:space="preserve">
<source>Database is encrypted using a random passphrase, you can change it.</source>
<target>Database is encrypted using a random passphrase, you can change it.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database is encrypted using a random passphrase. Please change it before exporting." xml:space="preserve">
<source>Database is encrypted using a random passphrase. Please change it before exporting.</source>
<target>Database is encrypted using a random passphrase. Please change it before exporting.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase" xml:space="preserve">
<source>Database passphrase</source>
<target>Database passphrase</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase &amp; export" xml:space="preserve">
<source>Database passphrase &amp; export</source>
<target>Database passphrase &amp; export</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase is different from saved in the keychain." xml:space="preserve">
<source>Database passphrase is different from saved in the keychain.</source>
<target>Database passphrase is different from saved in the keychain.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase is required to open chat." xml:space="preserve">
<source>Database passphrase is required to open chat.</source>
<target>Database passphrase is required to open chat.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database will be encrypted and the passphrase stored in the keychain.&#10;" xml:space="preserve">
<source>Database will be encrypted and the passphrase stored in the keychain.
</source>
<target>Database will be encrypted and the passphrase stored in the keychain.
</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database will be encrypted.&#10;" xml:space="preserve">
<source>Database will be encrypted.
</source>
<target>Database will be encrypted.
</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database will be migrated when the app restarts" xml:space="preserve">
@@ -743,6 +836,56 @@
<target>Enable periodic notifications?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypt" xml:space="preserve">
<source>Encrypt</source>
<target>Encrypt</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypt database?" xml:space="preserve">
<source>Encrypt database?</source>
<target>Encrypt database?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypted database" xml:space="preserve">
<source>Encrypted database</source>
<target>Encrypted database</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypted message or another event" xml:space="preserve">
<source>Encrypted message or another event</source>
<target>Encrypted message or another event</target>
<note>notification</note>
</trans-unit>
<trans-unit id="Encrypted message: database error" xml:space="preserve">
<source>Encrypted message: database error</source>
<target>Encrypted message: database error</target>
<note>notification</note>
</trans-unit>
<trans-unit id="Encrypted message: keychain error" xml:space="preserve">
<source>Encrypted message: keychain error</source>
<target>Encrypted message: keychain error</target>
<note>notification</note>
</trans-unit>
<trans-unit id="Encrypted message: no passphrase" xml:space="preserve">
<source>Encrypted message: no passphrase</source>
<target>Encrypted message: no passphrase</target>
<note>notification</note>
</trans-unit>
<trans-unit id="Encrypted message: unexpeсted error" xml:space="preserve">
<source>Encrypted message: unexpeсted error</source>
<target>Encrypted message: unexpeсted error</target>
<note>notification</note>
</trans-unit>
<trans-unit id="Enter correct passphrase." xml:space="preserve">
<source>Enter correct passphrase.</source>
<target>Enter correct passphrase.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Enter passphrase…" xml:space="preserve">
<source>Enter passphrase…</source>
<target>Enter passphrase…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error accessing database file" xml:space="preserve">
<source>Error accessing database file</source>
<target>Error accessing database file</target>
@@ -783,6 +926,11 @@
<target>Error enabling notifications</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error encrypting database" xml:space="preserve">
<source>Error encrypting database</source>
<target>Error encrypting database</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error exporting chat database" xml:space="preserve">
<source>Error exporting chat database</source>
<target>Error exporting chat database</target>
@@ -863,6 +1011,11 @@
<target>File will be received when your contact is online, please wait or check later!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="File: %@" xml:space="preserve">
<source>File: %@</source>
<target>File: %@</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="For console" xml:space="preserve">
<source>For console</source>
<target>For console</target>
@@ -1053,6 +1206,13 @@
<target>Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Instant push notifications will be hidden!&#10;" xml:space="preserve">
<source>Instant push notifications will be hidden!
</source>
<target>Instant push notifications will be hidden!
</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Instantly" xml:space="preserve">
<source>Instantly</source>
<target>Instantly</target>
@@ -1128,6 +1288,11 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Joining group</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Keychain error" xml:space="preserve">
<source>Keychain error</source>
<target>Keychain error</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Large file!" xml:space="preserve">
<source>Large file!</source>
<target>Large file!</target>
@@ -1278,6 +1443,11 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>New message</target>
<note>notification</note>
</trans-unit>
<trans-unit id="New passphrase…" xml:space="preserve">
<source>New passphrase…</source>
<target>New passphrase…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="No" xml:space="preserve">
<source>No</source>
<target>No</target>
@@ -1363,6 +1533,16 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Open Settings</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open chat" xml:space="preserve">
<source>Open chat</source>
<target>Open chat</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open chat console" xml:space="preserve">
<source>Open chat console</source>
<target>Open chat console</target>
<note>authentication reason</note>
</trans-unit>
<trans-unit id="Open-source protocol and code anybody can run the servers." xml:space="preserve">
<source>Open-source protocol and code anybody can run the servers.</source>
<target>Open-source protocol and code anybody can run the servers.</target>
@@ -1423,11 +1603,26 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Please check your network connection and try again.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please enter correct current passphrase." xml:space="preserve">
<source>Please enter correct current passphrase.</source>
<target>Please enter correct current passphrase.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please restart the app and migrate the database to enable push notifications." xml:space="preserve">
<source>Please restart the app and migrate the database to enable push notifications.</source>
<target>Please restart the app and migrate the database to enable push notifications.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please store passphrase securely, you will NOT be able to access chat if you lose it." xml:space="preserve">
<source>Please store passphrase securely, you will NOT be able to access chat if you lose it.</source>
<target>Please store passphrase securely, you will NOT be able to access chat if you lose it.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please store passphrase securely, you will NOT be able to change it if you lose it." xml:space="preserve">
<source>Please store passphrase securely, you will NOT be able to change it if you lose it.</source>
<target>Please store passphrase securely, you will NOT be able to change it if you lose it.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Privacy &amp; security" xml:space="preserve">
<source>Privacy &amp; security</source>
<target>Privacy &amp; security</target>
@@ -1518,6 +1713,11 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Remove member?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove passphrase from keychain?" xml:space="preserve">
<source>Remove passphrase from keychain?</source>
<target>Remove passphrase from keychain?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Reply" xml:space="preserve">
<source>Reply</source>
<target>Reply</target>
@@ -1588,6 +1788,16 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Save group profile</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save passphrase and open chat" xml:space="preserve">
<source>Save passphrase and open chat</source>
<target>Save passphrase and open chat</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save passphrase in Keychain" xml:space="preserve">
<source>Save passphrase in Keychain</source>
<target>Save passphrase in Keychain</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Saved SMP servers will be removed" xml:space="preserve">
<source>Saved SMP servers will be removed</source>
<target>Saved SMP servers will be removed</target>
@@ -1648,6 +1858,11 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Set contact name…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Set passphrase to export" xml:space="preserve">
<source>Set passphrase to export</source>
<target>Set passphrase to export</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Set timeouts for proxy/VPN" xml:space="preserve">
<source>Set timeouts for proxy/VPN</source>
<target>Set timeouts for proxy/VPN</target>
@@ -1723,6 +1938,11 @@ We will be adding server redundancy to prevent lost messages.</target>
<target>Stop</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Stop SimpleX" xml:space="preserve">
<source>Stop SimpleX</source>
<target>Stop SimpleX</target>
<note>authentication reason</note>
</trans-unit>
<trans-unit id="Stop chat to enable database actions" xml:space="preserve">
<source>Stop chat to enable database actions</source>
<target>Stop chat to enable database actions</target>
@@ -1940,6 +2160,16 @@ You will be prompted to complete authentication before this feature is enabled.<
<target>Unexpected migration state</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unknown database error: %@" xml:space="preserve">
<source>Unknown database error: %@</source>
<target>Unknown database error: %@</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unknown error" xml:space="preserve">
<source>Unknown error</source>
<target>Unknown error</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.&#10;To connect, please ask your contact to create another connection link and check that you have a stable network connection." xml:space="preserve">
<source>Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.
To connect, please ask your contact to create another connection link and check that you have a stable network connection.</source>
@@ -1957,11 +2187,21 @@ To connect, please ask your contact to create another connection link and check
<target>Unmute</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Update" xml:space="preserve">
<source>Update</source>
<target>Update</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Update .onion hosts setting?" xml:space="preserve">
<source>Update .onion hosts setting?</source>
<target>Update .onion hosts setting?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Update database passphrase" xml:space="preserve">
<source>Update database passphrase</source>
<target>Update database passphrase</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Update network settings?" xml:space="preserve">
<source>Update network settings?</source>
<target>Update network settings?</target>
@@ -2032,6 +2272,16 @@ To connect, please ask your contact to create another connection link and check
<target>When you share an incognito profile with somebody, this profile will be used for the groups they invite you to.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Wrong database passphrase" xml:space="preserve">
<source>Wrong database passphrase</source>
<target>Wrong database passphrase</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Wrong passphrase!" xml:space="preserve">
<source>Wrong passphrase!</source>
<target>Wrong passphrase!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You" xml:space="preserve">
<source>You</source>
<target>You</target>
@@ -2097,6 +2347,11 @@ To connect, please ask your contact to create another connection link and check
<target>You could not be verified; please try again.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You have to enter passphrase every time the app starts - it is not stored on the device." xml:space="preserve">
<source>You have to enter passphrase every time the app starts - it is not stored on the device.</source>
<target>You have to enter passphrase every time the app starts - it is not stored on the device.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You invited your contact" xml:space="preserve">
<source>You invited your contact</source>
<target>You invited your contact</target>
@@ -2182,6 +2437,11 @@ To connect, please ask your contact to create another connection link and check
<target>Your chat database</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your chat database is not encrypted - set passphrase to encrypt it." xml:space="preserve">
<source>Your chat database is not encrypted - set passphrase to encrypt it.</source>
<target>Your chat database is not encrypted - set passphrase to encrypt it.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your chat profile" xml:space="preserve">
<source>Your chat profile</source>
<target>Your chat profile</target>
@@ -2366,7 +2626,7 @@ SimpleX servers cannot see your profile.</target>
<target>connecting (introduction invitation)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="connecting call" xml:space="preserve">
<trans-unit id="connecting call" xml:space="preserve">
<source>connecting call…</source>
<target>connecting call…</target>
<note>call status</note>
@@ -2451,6 +2711,16 @@ SimpleX servers cannot see your profile.</target>
<target>group profile updated</target>
<note>snd group event chat item</note>
</trans-unit>
<trans-unit id="iOS Keychain is used to securely store passphrase - it allows receiving push notifications." xml:space="preserve">
<source>iOS Keychain is used to securely store passphrase - it allows receiving push notifications.</source>
<target>iOS Keychain is used to securely store passphrase - it allows receiving push notifications.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." xml:space="preserve">
<source>iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications.</source>
<target>iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="incognito via contact address link" xml:space="preserve">
<source>incognito via contact address link</source>
<target>incognito via contact address link</target>
@@ -13,6 +13,9 @@
/* No comment provided by engineer. */
"~strike~" = "\\~strike~";
/* call status */
"connecting call" = "connecting call…";
/* No comment provided by engineer. */
"Connecting server…" = "Connecting to server…";
@@ -127,6 +127,11 @@
<target>**Вставить полученную ссылку**, или откройте её в браузере и нажмите **Open in mobile app**.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="**Please note**: you will NOT be able to recover or change passphrase if you lose it." xml:space="preserve">
<source>**Please note**: you will NOT be able to recover or change passphrase if you lose it.</source>
<target>**Внимание**: вы не сможете восстановить или поменять пароль, если вы его потеряете.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from." xml:space="preserve">
<source>**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from.</source>
<target>**Рекомендовано**: токен устройства и уведомления отправляются на сервер SimpleX Chat, но сервер не получает сами сообщения, их размер или от кого они.</target>
@@ -137,6 +142,11 @@
<target>**Сканировать QR код**: соединиться с вашим контактом при встрече или во время видеозвонка.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="**Warning**: Instant push notifications require passphrase saved in Keychain." xml:space="preserve">
<source>**Warning**: Instant push notifications require passphrase saved in Keychain.</source>
<target>**Внимание**: для работы мгновенных уведомлений пароль должен быть сохранен в Keychain.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="**e2e encrypted** audio call" xml:space="preserve">
<source>**e2e encrypted** audio call</source>
<target>**e2e зашифрованный** аудиозвонок</target>
@@ -303,6 +313,16 @@
<target>Отменить</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot access keychain to save database password" xml:space="preserve">
<source>Cannot access keychain to save database password</source>
<target>Ошибка доступа к Keychain при сохранении пароля</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Change database passphrase?" xml:space="preserve">
<source>Change database passphrase?</source>
<target>Поменять пароль базы данных?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Chat archive" xml:space="preserve">
<source>Chat archive</source>
<target>Архив чата</target>
@@ -346,7 +366,7 @@
<trans-unit id="Chats" xml:space="preserve">
<source>Chats</source>
<target>Чаты</target>
<note>back button to return to chats list</note>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Choose file" xml:space="preserve">
<source>Choose file</source>
@@ -388,6 +408,11 @@
<target>Подтвердить</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Confirm new passphrase…" xml:space="preserve">
<source>Confirm new passphrase…</source>
<target>Подтвердите новый пароль…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Connect" xml:space="preserve">
<source>Connect</source>
<target>Соединиться</target>
@@ -528,6 +553,11 @@
<target>Дата создания %@</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Current passphrase…" xml:space="preserve">
<source>Current passphrase…</source>
<target>Текущий пароль…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Currently maximum supported file size is %@." xml:space="preserve">
<source>Currently maximum supported file size is %@.</source>
<target>Максимальный размер файла - %@.</target>
@@ -543,9 +573,72 @@
<target>ID базы данных</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database export &amp; import" xml:space="preserve">
<source>Database export &amp; import</source>
<target>Экспорт и импорт архива чата</target>
<trans-unit id="Database encrypted!" xml:space="preserve">
<source>Database encrypted!</source>
<target>База данных зашифрована!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database encryption passphrase will be updated and stored in the keychain.&#10;" xml:space="preserve">
<source>Database encryption passphrase will be updated and stored in the keychain.
</source>
<target>Пароль базы данных будет изменен и сохранен в Keychain.
</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database encryption passphrase will be updated.&#10;" xml:space="preserve">
<source>Database encryption passphrase will be updated.
</source>
<target>Пароль базы данных будет изменен.
</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database error" xml:space="preserve">
<source>Database error</source>
<target>Ошибка базы данных</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database is encrypted using a random passphrase, you can change it." xml:space="preserve">
<source>Database is encrypted using a random passphrase, you can change it.</source>
<target>База данных зашифрована случайным паролем, вы можете его поменять.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database is encrypted using a random passphrase. Please change it before exporting." xml:space="preserve">
<source>Database is encrypted using a random passphrase. Please change it before exporting.</source>
<target>База данных зашифрована случайным паролем. Пожалуйста, поменяйте его перед экспортом.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase" xml:space="preserve">
<source>Database passphrase</source>
<target>Пароль базы данных</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase &amp; export" xml:space="preserve">
<source>Database passphrase &amp; export</source>
<target>Пароль и экспорт базы</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase is different from saved in the keychain." xml:space="preserve">
<source>Database passphrase is different from saved in the keychain.</source>
<target>Пароль базы данных отличается от сохраненного в Keychain.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase is required to open chat." xml:space="preserve">
<source>Database passphrase is required to open chat.</source>
<target>Введите пароль базы данных чтобы открыть чат.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database will be encrypted and the passphrase stored in the keychain.&#10;" xml:space="preserve">
<source>Database will be encrypted and the passphrase stored in the keychain.
</source>
<target>База данных будет зашифрована и пароль сохранен в Keychain.
</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database will be encrypted.&#10;" xml:space="preserve">
<source>Database will be encrypted.
</source>
<target>База данных будет зашифрована.
</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database will be migrated when the app restarts" xml:space="preserve">
@@ -743,6 +836,56 @@
<target>Включить периодические уведомления?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypt" xml:space="preserve">
<source>Encrypt</source>
<target>Зашифровать</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypt database?" xml:space="preserve">
<source>Encrypt database?</source>
<target>Зашифровать базу данных?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypted database" xml:space="preserve">
<source>Encrypted database</source>
<target>База данных зашифрована</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypted message or another event" xml:space="preserve">
<source>Encrypted message or another event</source>
<target>Зашифрованное сообщение или событие чата</target>
<note>notification</note>
</trans-unit>
<trans-unit id="Encrypted message: database error" xml:space="preserve">
<source>Encrypted message: database error</source>
<target>Зашифрованное сообщение: ошибка базы данных</target>
<note>notification</note>
</trans-unit>
<trans-unit id="Encrypted message: keychain error" xml:space="preserve">
<source>Encrypted message: keychain error</source>
<target>Зашифрованное сообщение: ошибка Keychain</target>
<note>notification</note>
</trans-unit>
<trans-unit id="Encrypted message: no passphrase" xml:space="preserve">
<source>Encrypted message: no passphrase</source>
<target>Зашифрованное сообщение: пароль не сохранен</target>
<note>notification</note>
</trans-unit>
<trans-unit id="Encrypted message: unexpeсted error" xml:space="preserve">
<source>Encrypted message: unexpeсted error</source>
<target>Зашифрованное сообщение: неожиданная ошибка</target>
<note>notification</note>
</trans-unit>
<trans-unit id="Enter correct passphrase." xml:space="preserve">
<source>Enter correct passphrase.</source>
<target>Введите правильный пароль.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Enter passphrase…" xml:space="preserve">
<source>Enter passphrase…</source>
<target>Введите пароль…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error accessing database file" xml:space="preserve">
<source>Error accessing database file</source>
<target>Ошибка при доступе к данным чата</target>
@@ -783,6 +926,11 @@
<target>Ошибка при включении уведомлений</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error encrypting database" xml:space="preserve">
<source>Error encrypting database</source>
<target>Ошибка при шифровании</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error exporting chat database" xml:space="preserve">
<source>Error exporting chat database</source>
<target>Ошибка при экспорте архива чата</target>
@@ -863,6 +1011,11 @@
<target>Файл будет принят, когда ваш контакт будет в сети, подождите или проверьте позже!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="File: %@" xml:space="preserve">
<source>File: %@</source>
<target>Файл: %@</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="For console" xml:space="preserve">
<source>For console</source>
<target>Для консоли</target>
@@ -1053,6 +1206,13 @@
<target>[SimpleX Chat для терминала](https://github.com/simplex-chat/simplex-chat)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Instant push notifications will be hidden!&#10;" xml:space="preserve">
<source>Instant push notifications will be hidden!
</source>
<target>Мгновенные уведомления будут скрыты!
</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Instantly" xml:space="preserve">
<source>Instantly</source>
<target>Мгновенно</target>
@@ -1128,6 +1288,11 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Вступление в группу</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Keychain error" xml:space="preserve">
<source>Keychain error</source>
<target>Ошибка Keychain</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Large file!" xml:space="preserve">
<source>Large file!</source>
<target>Большой файл!</target>
@@ -1278,6 +1443,11 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Новое сообщение</target>
<note>notification</note>
</trans-unit>
<trans-unit id="New passphrase…" xml:space="preserve">
<source>New passphrase…</source>
<target>Новый пароль…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="No" xml:space="preserve">
<source>No</source>
<target>Нет</target>
@@ -1363,6 +1533,16 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Открыть Настройки</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open chat" xml:space="preserve">
<source>Open chat</source>
<target>Открыть чат</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open chat console" xml:space="preserve">
<source>Open chat console</source>
<target>Открыть консоль</target>
<note>authentication reason</note>
</trans-unit>
<trans-unit id="Open-source protocol and code anybody can run the servers." xml:space="preserve">
<source>Open-source protocol and code anybody can run the servers.</source>
<target>Открытый протокол и код - кто угодно может запустить сервер.</target>
@@ -1423,11 +1603,26 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Пожалуйста, проверьте ваше соединение с сетью и попробуйте еще раз.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please enter correct current passphrase." xml:space="preserve">
<source>Please enter correct current passphrase.</source>
<target>Пожалуйста, введите правильный пароль.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please restart the app and migrate the database to enable push notifications." xml:space="preserve">
<source>Please restart the app and migrate the database to enable push notifications.</source>
<target>Пожалуйста, перезапустите приложение и переместите данные чата, чтобы включить доставку уведомлений.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please store passphrase securely, you will NOT be able to access chat if you lose it." xml:space="preserve">
<source>Please store passphrase securely, you will NOT be able to access chat if you lose it.</source>
<target>Пожалуйста, надежно сохраните пароль, вы НЕ сможете открыть чат, если потеряете его.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please store passphrase securely, you will NOT be able to change it if you lose it." xml:space="preserve">
<source>Please store passphrase securely, you will NOT be able to change it if you lose it.</source>
<target>Пожалуйста, надежно сохраните пароль, вы НЕ сможете его поменять, если потеряете.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Privacy &amp; security" xml:space="preserve">
<source>Privacy &amp; security</source>
<target>Конфиденциальность</target>
@@ -1518,6 +1713,11 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Удалить члена группы?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove passphrase from keychain?" xml:space="preserve">
<source>Remove passphrase from keychain?</source>
<target>Удалить пароль из Keychain?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Reply" xml:space="preserve">
<source>Reply</source>
<target>Ответить</target>
@@ -1588,6 +1788,16 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Сохранить профиль группы</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save passphrase and open chat" xml:space="preserve">
<source>Save passphrase and open chat</source>
<target>Сохранить пароль и открыть чат</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Save passphrase in Keychain" xml:space="preserve">
<source>Save passphrase in Keychain</source>
<target>Сохранить пароль в Keychain</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Saved SMP servers will be removed" xml:space="preserve">
<source>Saved SMP servers will be removed</source>
<target>Сохраненные SMP серверы будут удалены</target>
@@ -1648,6 +1858,11 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Имя контакта…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Set passphrase to export" xml:space="preserve">
<source>Set passphrase to export</source>
<target>Установите пароль</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Set timeouts for proxy/VPN" xml:space="preserve">
<source>Set timeouts for proxy/VPN</source>
<target>Установить таймауты для прокси/VPN</target>
@@ -1723,6 +1938,11 @@ We will be adding server redundancy to prevent lost messages.</source>
<target>Остановить</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Stop SimpleX" xml:space="preserve">
<source>Stop SimpleX</source>
<target>Остановить SimpleX</target>
<note>authentication reason</note>
</trans-unit>
<trans-unit id="Stop chat to enable database actions" xml:space="preserve">
<source>Stop chat to enable database actions</source>
<target>Остановите чат, чтобы разблокировать операции с архивом чата</target>
@@ -1940,6 +2160,16 @@ You will be prompted to complete authentication before this feature is enabled.<
<target>Неожиданная ошибка при перемещении данных чата</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unknown database error: %@" xml:space="preserve">
<source>Unknown database error: %@</source>
<target>Неизвестная ошибка базы данных: %@</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unknown error" xml:space="preserve">
<source>Unknown error</source>
<target>Неизвестная ошибка</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.&#10;To connect, please ask your contact to create another connection link and check that you have a stable network connection." xml:space="preserve">
<source>Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.
To connect, please ask your contact to create another connection link and check that you have a stable network connection.</source>
@@ -1957,11 +2187,21 @@ To connect, please ask your contact to create another connection link and check
<target>Уведомлять</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Update" xml:space="preserve">
<source>Update</source>
<target>Обновить</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Update .onion hosts setting?" xml:space="preserve">
<source>Update .onion hosts setting?</source>
<target>Обновить настройки .onion хостов?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Update database passphrase" xml:space="preserve">
<source>Update database passphrase</source>
<target>Поменять пароль</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Update network settings?" xml:space="preserve">
<source>Update network settings?</source>
<target>Обновить настройки сети?</target>
@@ -2032,6 +2272,16 @@ To connect, please ask your contact to create another connection link and check
<target>Когда вы соединены с контактом инкогнито, тот же самый инкогнито профиль будет использоваться для групп с этим контактом.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Wrong database passphrase" xml:space="preserve">
<source>Wrong database passphrase</source>
<target>Неправильный пароль базы данных</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Wrong passphrase!" xml:space="preserve">
<source>Wrong passphrase!</source>
<target>Неправильный пароль!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You" xml:space="preserve">
<source>You</source>
<target>Вы</target>
@@ -2097,6 +2347,11 @@ To connect, please ask your contact to create another connection link and check
<target>Верификация не удалась; пожалуйста, попробуйте ещё раз.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You have to enter passphrase every time the app starts - it is not stored on the device." xml:space="preserve">
<source>You have to enter passphrase every time the app starts - it is not stored on the device.</source>
<target>Пароль не сохранен на устройстве — вы будете должны ввести его при каждом запуске чата.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You invited your contact" xml:space="preserve">
<source>You invited your contact</source>
<target>Вы пригласили ваш контакт</target>
@@ -2179,7 +2434,12 @@ To connect, please ask your contact to create another connection link and check
</trans-unit>
<trans-unit id="Your chat database" xml:space="preserve">
<source>Your chat database</source>
<target>Данные чата</target>
<target>База данных</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your chat database is not encrypted - set passphrase to encrypt it." xml:space="preserve">
<source>Your chat database is not encrypted - set passphrase to encrypt it.</source>
<target>База данных НЕ зашифрована. Установите пароль, чтобы защитить ваши данные.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your chat profile" xml:space="preserve">
@@ -2366,7 +2626,7 @@ SimpleX серверы не могут получить доступ к ваше
<target>соединяется (приглашение по представлению)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="connecting call" xml:space="preserve">
<trans-unit id="connecting call" xml:space="preserve">
<source>connecting call…</source>
<target>звонок соединяется…</target>
<note>call status</note>
@@ -2451,6 +2711,16 @@ SimpleX серверы не могут получить доступ к ваше
<target>профиль группы обновлен</target>
<note>snd group event chat item</note>
</trans-unit>
<trans-unit id="iOS Keychain is used to securely store passphrase - it allows receiving push notifications." xml:space="preserve">
<source>iOS Keychain is used to securely store passphrase - it allows receiving push notifications.</source>
<target>iOS Keychain используется для безопасного хранения пароля - это позволяет получать мгновенные уведомления.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." xml:space="preserve">
<source>iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications.</source>
<target>Пароль базы данных будет безопасно сохранен в iOS Keychain после запуска чата или изменения пароля - это позволит получать мгновенные уведомления.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="incognito via contact address link" xml:space="preserve">
<source>incognito via contact address link</source>
<target>инкогнито через ссылку-контакт</target>
@@ -13,6 +13,9 @@
/* No comment provided by engineer. */
"~strike~" = "\\~strike~";
/* call status */
"connecting call" = "connecting call…";
/* No comment provided by engineer. */
"Connecting server…" = "Connecting to server…";
+14 -6
View File
@@ -105,9 +105,9 @@ class NotificationService: UNNotificationServiceExtension {
if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any],
let nonce = ntfData["nonce"] as? String,
let encNtfInfo = ntfData["message"] as? String,
let _ = startChat() {
logger.debug("NotificationService: receiveNtfMessages: chat is started")
if let ntfMsgInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) {
let dbStatus = startChat() {
if case .ok = dbStatus,
let ntfMsgInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) {
logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfMsgInfo), privacy: .public)")
if let connEntity = ntfMsgInfo.connEntity {
setBestAttemptNtf(createConnectionEventNtf(connEntity))
@@ -118,9 +118,11 @@ class NotificationService: UNNotificationServiceExtension {
await PendingNtfs.shared.readStream(id, for: self, msgCount: ntfMsgInfo.ntfMessages.count)
deliverBestAttemptNtf()
}
return
}
}
return
} else {
setBestAttemptNtf(createErrorNtf(dbStatus))
}
}
deliverBestAttemptNtf()
@@ -151,20 +153,26 @@ class NotificationService: UNNotificationServiceExtension {
}
}
func startChat() -> User? {
var chatStarted = false
func startChat() -> DBMigrationResult? {
hs_init(0, nil)
if chatStarted { return .ok }
let (_, dbStatus) = migrateChatDatabase()
if dbStatus != .ok { return dbStatus }
if let user = apiGetActiveUser() {
logger.debug("active user \(String(describing: user))")
do {
try setNetworkConfig(getNetCfg())
let justStarted = try apiStartChat()
chatStarted = true
if justStarted {
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
try apiSetIncognito(incognito: incognitoGroupDefault.get())
chatLastStartGroupDefault.set(Date.now)
Task { await receiveMessages() }
}
return user
return .ok
} catch {
logger.error("NotificationService startChat error: \(responseError(error), privacy: .public)")
}
@@ -6,5 +6,9 @@
<array>
<string>group.chat.simplex.app</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)chat.simplex.app</string>
</array>
</dict>
</plist>
@@ -0,0 +1 @@
@@ -38,7 +38,7 @@
"calling…" = "входящий звонок…";
/* call status */
"connecting call" = "звонок соединяется…";
"connecting call" = "звонок соединяется…";
/* chat list item title */
"connecting…" = "соединяется…";
+34 -20
View File
@@ -13,11 +13,12 @@
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; };
3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; };
5C00164428A26FBC0094D739 /* ContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C00164328A26FBC0094D739 /* ContextMenu.swift */; };
5C00166A28C119300094D739 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00166528C119300094D739 /* libgmp.a */; };
5C00166B28C119300094D739 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00166628C119300094D739 /* libffi.a */; };
5C00166C28C119300094D739 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00166728C119300094D739 /* libgmpxx.a */; };
5C00166D28C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00166828C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme-ghc8.10.7.a */; };
5C00166E28C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00166928C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme.a */; };
5C00167528C28A6B0094D739 /* libHSsimplex-chat-3.2.1-Iu3WidfJ6Vb3MmatyiWdoG.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00166F28C28A6B0094D739 /* libHSsimplex-chat-3.2.1-Iu3WidfJ6Vb3MmatyiWdoG.a */; };
5C00167728C28A6B0094D739 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00167028C28A6B0094D739 /* libgmpxx.a */; };
5C00167928C28A6B0094D739 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00167128C28A6B0094D739 /* libgmp.a */; };
5C00167B28C28A6B0094D739 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00167228C28A6B0094D739 /* libffi.a */; };
5C00167D28C28A6B0094D739 /* libHSsimplex-chat-3.2.1-Iu3WidfJ6Vb3MmatyiWdoG-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00167328C28A6B0094D739 /* libHSsimplex-chat-3.2.1-Iu3WidfJ6Vb3MmatyiWdoG-ghc8.10.7.a */; };
5C00168128C4FE760094D739 /* KeyChain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C00168028C4FE760094D739 /* KeyChain.swift */; };
5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA72837DBB3004A9677 /* CICallItemView.swift */; };
5C029EAA283942EA004A9677 /* CallController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA9283942EA004A9677 /* CallController.swift */; };
5C05DF532840AA1D00C683F9 /* CallSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C05DF522840AA1D00C683F9 /* CallSettings.swift */; };
@@ -61,6 +62,8 @@
5C9C2DA52894777E00CC63B1 /* GroupProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9C2DA42894777E00CC63B1 /* GroupProfileView.swift */; };
5C9C2DA7289957AE00CC63B1 /* AdvancedNetworkSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */; };
5C9C2DA92899DA6F00CC63B1 /* NetworkAndServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */; };
5C9CC7A928C532AB00BEF955 /* DatabaseErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9CC7A828C532AB00BEF955 /* DatabaseErrorView.swift */; };
5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9CC7AC28C55D7800BEF955 /* DatabaseEncryptionView.swift */; };
5C9D13A3282187BB00AB8B43 /* WebRTC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9D13A2282187BB00AB8B43 /* WebRTC.swift */; };
5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */; };
5CA059DC279559F40002BEB4 /* Tests_iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059DB279559F40002BEB4 /* Tests_iOS.swift */; };
@@ -197,11 +200,12 @@
3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = "<group>"; };
3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = "<group>"; };
5C00164328A26FBC0094D739 /* ContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenu.swift; sourceTree = "<group>"; };
5C00166528C119300094D739 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C00166628C119300094D739 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5C00166728C119300094D739 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C00166828C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme-ghc8.10.7.a"; sourceTree = "<group>"; };
5C00166928C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme.a"; sourceTree = "<group>"; };
5C00166F28C28A6B0094D739 /* libHSsimplex-chat-3.2.1-Iu3WidfJ6Vb3MmatyiWdoG.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-3.2.1-Iu3WidfJ6Vb3MmatyiWdoG.a"; sourceTree = "<group>"; };
5C00167028C28A6B0094D739 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C00167128C28A6B0094D739 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C00167228C28A6B0094D739 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5C00167328C28A6B0094D739 /* libHSsimplex-chat-3.2.1-Iu3WidfJ6Vb3MmatyiWdoG-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-3.2.1-Iu3WidfJ6Vb3MmatyiWdoG-ghc8.10.7.a"; sourceTree = "<group>"; };
5C00168028C4FE760094D739 /* KeyChain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyChain.swift; sourceTree = "<group>"; };
5C029EA72837DBB3004A9677 /* CICallItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CICallItemView.swift; sourceTree = "<group>"; };
5C029EA9283942EA004A9677 /* CallController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallController.swift; sourceTree = "<group>"; };
5C05DF522840AA1D00C683F9 /* CallSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSettings.swift; sourceTree = "<group>"; };
@@ -247,6 +251,9 @@
5C9C2DA42894777E00CC63B1 /* GroupProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupProfileView.swift; sourceTree = "<group>"; };
5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedNetworkSettings.swift; sourceTree = "<group>"; };
5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkAndServers.swift; sourceTree = "<group>"; };
5C9CC7A828C532AB00BEF955 /* DatabaseErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseErrorView.swift; sourceTree = "<group>"; };
5C9CC7AC28C55D7800BEF955 /* DatabaseEncryptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseEncryptionView.swift; sourceTree = "<group>"; };
5C9CC7B128D1F8F400BEF955 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
5C9D13A2282187BB00AB8B43 /* WebRTC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTC.swift; sourceTree = "<group>"; };
5C9FD96A27A56D4D0075386C /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = "<group>"; };
5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageView.swift; sourceTree = "<group>"; };
@@ -351,13 +358,13 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
5C00166E28C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme.a in Frameworks */,
5C00167728C28A6B0094D739 /* libgmpxx.a in Frameworks */,
5C00167B28C28A6B0094D739 /* libffi.a in Frameworks */,
5C00167D28C28A6B0094D739 /* libHSsimplex-chat-3.2.1-Iu3WidfJ6Vb3MmatyiWdoG-ghc8.10.7.a in Frameworks */,
5C00167928C28A6B0094D739 /* libgmp.a in Frameworks */,
5C00167528C28A6B0094D739 /* libHSsimplex-chat-3.2.1-Iu3WidfJ6Vb3MmatyiWdoG.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
5C00166C28C119300094D739 /* libgmpxx.a in Frameworks */,
5C00166A28C119300094D739 /* libgmp.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
5C00166B28C119300094D739 /* libffi.a in Frameworks */,
5C00166D28C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme-ghc8.10.7.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -412,11 +419,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
5C00166628C119300094D739 /* libffi.a */,
5C00166528C119300094D739 /* libgmp.a */,
5C00166728C119300094D739 /* libgmpxx.a */,
5C00166828C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme-ghc8.10.7.a */,
5C00166928C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme.a */,
5C00167228C28A6B0094D739 /* libffi.a */,
5C00167128C28A6B0094D739 /* libgmp.a */,
5C00167028C28A6B0094D739 /* libgmpxx.a */,
5C00167328C28A6B0094D739 /* libHSsimplex-chat-3.2.1-Iu3WidfJ6Vb3MmatyiWdoG-ghc8.10.7.a */,
5C00166F28C28A6B0094D739 /* libHSsimplex-chat-3.2.1-Iu3WidfJ6Vb3MmatyiWdoG.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -596,6 +603,7 @@
5CDCAD7D2818941F00503DA2 /* API.swift */,
5CDCAD80281A7E2700503DA2 /* Notifications.swift */,
64DAE1502809D9F5000DA960 /* FileUtils.swift */,
5C00168028C4FE760094D739 /* KeyChain.swift */,
5CE2BA76284530BF00EC33A6 /* SimpleXChat.h */,
5CE2BA8A2845332200EC33A6 /* SimpleX.h */,
5CE2BA78284530CC00EC33A6 /* SimpleXChat.docc */,
@@ -642,6 +650,8 @@
5C4B3B09285FB130003915F2 /* DatabaseView.swift */,
5CFA59CF286477B400863A68 /* ChatArchiveView.swift */,
5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */,
5C9CC7A828C532AB00BEF955 /* DatabaseErrorView.swift */,
5C9CC7AC28C55D7800BEF955 /* DatabaseEncryptionView.swift */,
);
path = Database;
sourceTree = "<group>";
@@ -858,6 +868,7 @@
5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */,
5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */,
5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */,
5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */,
5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */,
5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */,
5C029EAA283942EA004A9677 /* CallController.swift in Sources */,
@@ -934,6 +945,7 @@
5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */,
5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */,
5C3F1D5A2844B4DE00EC8A82 /* ExperimentalFeaturesView.swift in Sources */,
5C9CC7A928C532AB00BEF955 /* DatabaseErrorView.swift in Sources */,
5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */,
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */,
5C9C2DA7289957AE00CC63B1 /* AdvancedNetworkSettings.swift in Sources */,
@@ -962,6 +974,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
5C00168128C4FE760094D739 /* KeyChain.swift in Sources */,
5CE2BA97284537A800EC33A6 /* dummy.m in Sources */,
5CE2BA922845340900EC33A6 /* FileUtils.swift in Sources */,
5CE2BA91284533A300EC33A6 /* Notifications.swift in Sources */,
@@ -1014,6 +1027,7 @@
isa = PBXVariantGroup;
children = (
5CB0BA8A2826CB3A00B3292C /* ru */,
5C9CC7B128D1F8F400BEF955 /* en */,
);
name = Localizable.strings;
sourceTree = "<group>";
+55 -4
View File
@@ -10,16 +10,46 @@ import Foundation
private var chatController: chat_ctrl?
public func getChatCtrl() -> chat_ctrl {
public func getChatCtrl(_ useKey: String? = nil) -> chat_ctrl {
if let controller = chatController { return controller }
let dbPath = getAppDatabasePath().path
let dbKey = useKey ?? getDatabaseKey() ?? ""
logger.debug("getChatCtrl DB path: \(dbPath)")
var cstr = dbPath.cString(using: .utf8)!
chatController = chat_init(&cstr)
logger.debug("getChatCtrl: chat_init")
var cPath = dbPath.cString(using: .utf8)!
var cKey = dbKey.cString(using: .utf8)!
chatController = chat_init_key(&cPath, &cKey)
logger.debug("getChatCtrl: chat_init_key")
return chatController!
}
public func migrateChatDatabase(_ useKey: String? = nil) -> (Bool, DBMigrationResult) {
logger.debug("migrateChatDatabase \(storeDBPassphraseGroupDefault.get())")
let dbPath = getAppDatabasePath().path
var dbKey = ""
let useKeychain = storeDBPassphraseGroupDefault.get()
if let key = useKey {
dbKey = key
} else if useKeychain {
if !hasDatabase() {
dbKey = randomDatabasePassword()
initialRandomDBPassphraseGroupDefault.set(true)
} else if let key = getDatabaseKey() {
dbKey = key
}
}
logger.debug("migrateChatDatabase DB path: \(dbPath)")
// logger.debug("migrateChatDatabase DB key: \(dbKey)")
var cPath = dbPath.cString(using: .utf8)!
var cKey = dbKey.cString(using: .utf8)!
let cjson = chat_migrate_db(&cPath, &cKey)!
let res = dbMigrationResult(fromCString(cjson))
let encrypted = dbKey != ""
if case .ok = res, useKeychain && encrypted && !setDatabaseKey(dbKey) {
return (encrypted, .errorKeychain)
}
return (encrypted, res)
}
public func resetChatCtrl() {
chatController = nil
}
@@ -103,3 +133,24 @@ public func responseError(_ err: Error) -> String {
return err.localizedDescription
}
}
public enum DBMigrationResult: Decodable, Equatable {
case ok
case errorNotADatabase(dbFile: String)
case error(dbFile: String, migrationError: String)
case errorKeychain
case unknown(json: String)
}
func dbMigrationResult(_ s: String) -> DBMigrationResult {
let d = s.data(using: .utf8)!
// TODO is there a way to do it without copying the data? e.g:
// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson))
// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free)
do {
return try jsonDecoder.decode(DBMigrationResult.self, from: d)
} catch let error {
logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)")
return .unknown(json: s)
}
}
+39
View File
@@ -24,6 +24,7 @@ public enum ChatCommand {
case apiExportArchive(config: ArchiveConfig)
case apiImportArchive(config: ArchiveConfig)
case apiDeleteStorage
case apiStorageEncryption(config: DBEncryptionConfig)
case apiGetChats
case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String)
case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent)
@@ -88,6 +89,7 @@ public enum ChatCommand {
case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))"
case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))"
case .apiDeleteStorage: return "/_db delete"
case let .apiStorageEncryption(cfg): return "/_db encryption \(encodeJSON(cfg))"
case .apiGetChats: return "/_get chats pcc=on"
case let .apiGetChat(type, id, pagination, search): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" +
(search == "" ? "" : " search=\(search)")
@@ -156,6 +158,7 @@ public enum ChatCommand {
case .apiExportArchive: return "apiExportArchive"
case .apiImportArchive: return "apiImportArchive"
case .apiDeleteStorage: return "apiDeleteStorage"
case .apiStorageEncryption: return "apiStorageEncryption"
case .apiGetChats: return "apiGetChats"
case .apiGetChat: return "apiGetChat"
case .apiSendMessage: return "apiSendMessage"
@@ -214,6 +217,18 @@ public enum ChatCommand {
func smpServersStr(smpServers: [String]) -> String {
smpServers.isEmpty ? "default" : smpServers.joined(separator: ",")
}
public var obfuscated: ChatCommand {
switch self {
case let .apiStorageEncryption(cfg):
return .apiStorageEncryption(config: DBEncryptionConfig(currentKey: obfuscate(cfg.currentKey), newKey: obfuscate(cfg.newKey)))
default: return self
}
}
private func obfuscate(_ s: String) -> String {
s == "" ? "" : "***"
}
}
struct APIResponse: Decodable {
@@ -527,6 +542,16 @@ public struct ArchiveConfig: Encodable {
}
}
public struct DBEncryptionConfig: Encodable {
public init(currentKey: String, newKey: String) {
self.currentKey = currentKey
self.newKey = newKey
}
public var currentKey: String
public var newKey: String
}
public struct NetCfg: Codable, Equatable {
public var socksProxy: String? = nil
public var hostMode: HostMode = .publicHost
@@ -710,6 +735,7 @@ public enum ChatError: Decodable {
case error(errorType: ChatErrorType)
case errorAgent(agentError: AgentErrorType)
case errorStore(storeError: StoreError)
case errorDatabase(databaseError: DatabaseError)
}
public enum ChatErrorType: Decodable {
@@ -779,6 +805,19 @@ public enum StoreError: Decodable {
case chatItemNotFoundByFileId(fileId: Int64)
}
public enum DatabaseError: Decodable {
case errorEncrypted
case errorPlaintext
case errorNoFile(dbFile: String)
case errorExport(sqliteError: SQLiteError)
case errorOpen(sqliteError: SQLiteError)
}
public enum SQLiteError: Decodable {
case errorNotADatabase
case error(String)
}
public enum AgentErrorType: Decodable {
case CMD(cmdErr: CommandErrorType)
case CONN(connErr: ConnectionErrorType)
+9 -1
View File
@@ -24,6 +24,8 @@ let GROUP_DEFAULT_NETWORK_TCP_KEEP_IDLE = "networkTCPKeepIdle"
let GROUP_DEFAULT_NETWORK_TCP_KEEP_INTVL = "networkTCPKeepIntvl"
let GROUP_DEFAULT_NETWORK_TCP_KEEP_CNT = "networkTCPKeepCnt"
let GROUP_DEFAULT_INCOGNITO = "incognito"
let GROUP_DEFAULT_STORE_DB_PASSPHRASE = "storeDBPassphrase"
let GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE = "initialRandomDBPassphrase"
let APP_GROUP_NAME = "group.chat.simplex.app"
@@ -39,7 +41,9 @@ public func registerGroupDefaults() {
GROUP_DEFAULT_NETWORK_TCP_KEEP_IDLE: KeepAliveOpts.defaults.keepIdle,
GROUP_DEFAULT_NETWORK_TCP_KEEP_INTVL: KeepAliveOpts.defaults.keepIntvl,
GROUP_DEFAULT_NETWORK_TCP_KEEP_CNT: KeepAliveOpts.defaults.keepCnt,
GROUP_DEFAULT_INCOGNITO: false
GROUP_DEFAULT_INCOGNITO: false,
GROUP_DEFAULT_STORE_DB_PASSPHRASE: true,
GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE: false
])
}
@@ -96,6 +100,10 @@ public let networkUseOnionHostsGroupDefault = EnumDefault<OnionHosts>(
withDefault: .no
)
public let storeDBPassphraseGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_STORE_DB_PASSPHRASE)
public let initialRandomDBPassphraseGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE)
public class DateDefault {
var defaults: UserDefaults
var key: String
+1 -1
View File
@@ -1355,7 +1355,7 @@ public enum CICallStatus: String, Decodable {
case .missed: return NSLocalizedString("missed call", comment: "call status")
case .rejected: return NSLocalizedString("rejected call", comment: "call status")
case .accepted: return NSLocalizedString("accepted call", comment: "call status")
case .negotiated: return NSLocalizedString("connecting call", comment: "call status")
case .negotiated: return NSLocalizedString("connecting call", comment: "call status")
case .progress: return NSLocalizedString("call in progress", comment: "call status")
case .ended: return String.localizedStringWithFormat(NSLocalizedString("ended call %@", comment: "call status"), CICallStatus.durationText(sec))
case .error: return NSLocalizedString("call error", comment: "call status")
+8 -2
View File
@@ -42,11 +42,17 @@ public func getAppDatabasePath() -> URL {
dbContainerGroupDefault.get() == .group
? getGroupContainerDirectory().appendingPathComponent(DB_FILE_PREFIX, isDirectory: false)
: getLegacyDatabasePath()
// getLegacyDatabasePath()
}
public func hasLegacyDatabase() -> Bool {
let dbPath = getLegacyDatabasePath()
hasDatabaseAtPath(getLegacyDatabasePath())
}
public func hasDatabase() -> Bool {
hasDatabaseAtPath(getAppDatabasePath())
}
func hasDatabaseAtPath(_ dbPath: URL) -> Bool {
let fm = FileManager.default
return fm.isReadableFile(atPath: dbPath.path + "_agent.db") &&
fm.isReadableFile(atPath: dbPath.path + "_chat.db")
+107
View File
@@ -0,0 +1,107 @@
//
// KeyChain.swift
// SimpleXChat
//
// Created by Evgeny on 04/09/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import Foundation
import Security
private let ACCESS_POLICY: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
private let ACCESS_GROUP: String = "5NN7GUYB6T.chat.simplex.app"
private let DATABASE_PASSWORD_ITEM: String = "databasePassword"
public func getDatabaseKey() -> String? {
getItemString(forKey: DATABASE_PASSWORD_ITEM)
}
public func setDatabaseKey(_ key: String) -> Bool {
setItemString(key, forKey: DATABASE_PASSWORD_ITEM)
}
public func removeDatabaseKey() -> Bool {
deleteItem(forKey: DATABASE_PASSWORD_ITEM)
}
func randomDatabasePassword() -> String {
var keyData = Data(count: 32)
let status = keyData.withUnsafeMutableBytes {
SecRandomCopyBytes(kSecRandomDefault, 32, $0.baseAddress!)
}
if status == errSecSuccess {
return keyData.base64EncodedString()
} else {
logger.error("randomDatabasePassword: error \(status)")
return ""
}
}
private func getItemData(forKey key: String) -> Data? {
var query = baseItemQuery(forKey: key)
query[kSecMatchLimit] = kSecMatchLimitOne
query[kSecReturnData] = true as AnyObject?
var dataRef: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &dataRef)
if status != errSecSuccess && status != errSecItemNotFound {
logger.error("getItemData: error getting data for key '\(key)', error: \(status)")
}
return dataRef as? Data
}
private func getItemString(forKey key: String) -> String? {
if let data = getItemData(forKey: key) {
return NSString(data: data, encoding: String.Encoding.utf8.rawValue) as? String
}
return nil
}
private func setItemData(_ data: Data, forKey key: String) -> Bool {
var query = baseItemQuery(forKey: key)
var update = [NSString : AnyObject]()
update[kSecValueData] = data as AnyObject?
update[kSecAttrAccessible] = ACCESS_POLICY
var status: OSStatus
if getItemData(forKey: key) == nil {
for (key, value) in update { query[key] = value }
status = SecItemAdd(query as CFDictionary, nil)
} else {
status = SecItemUpdate(query as CFDictionary, update as CFDictionary)
}
if status != errSecSuccess {
logger.error("setItemData: error setting data for key '\(key)', error: \(status)")
return false
}
return true
}
private func setItemString(_ s: String, forKey key: String) -> Bool {
if let data = s.data(using: .utf8) {
return setItemData(data, forKey: key)
}
return false
}
private func deleteItem(forKey key: String) -> Bool {
let query = baseItemQuery(forKey: key)
if getItemData(forKey: key) != nil {
let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess {
logger.error("deleteItem: error deleting data for key '\(key)', error: \(status)")
return false
}
}
return true
}
private func baseItemQuery(forKey key: String) -> [NSString : AnyObject] {
var query = [NSString : AnyObject]()
query[kSecClass] = kSecClassGenericPassword
query[kSecAttrAccount] = key as AnyObject?
#if TARGET_OS_IOS && !TARGET_OS_SIMULATOR
query[kSecAttrAccessGroup] = ACCESS_GROUP
#endif
return query
}
+20
View File
@@ -119,6 +119,26 @@ public func createConnectionEventNtf(_ connEntity: ConnectionEntity) -> UNMutabl
)
}
public func createErrorNtf(_ dbStatus: DBMigrationResult) -> UNMutableNotificationContent {
var title: String
switch dbStatus {
case .errorNotADatabase:
title = NSLocalizedString("Encrypted message: no passphrase", comment: "notification")
case .error:
title = NSLocalizedString("Encrypted message: database error", comment: "notification")
case .errorKeychain:
title = NSLocalizedString("Encrypted message: keychain error", comment: "notification")
case .unknown:
title = NSLocalizedString("Encrypted message: unexpeсted error", comment: "notification")
case .ok:
title = NSLocalizedString("Encrypted message or another event", comment: "notification")
}
return createNotification(
categoryIdentifier: ntfCategoryConnectionEvent,
title: title
)
}
private func groupMsgNtfTitle(_ groupInfo: GroupInfo, _ groupMember: GroupMember, hideContent: Bool) -> String {
hideContent
? NSLocalizedString("Group message:", comment: "notification")
+2 -1
View File
@@ -15,7 +15,8 @@ extern void hs_init(int argc, char **argv[]);
typedef void* chat_ctrl;
extern chat_ctrl chat_init(char *path);
extern char *chat_migrate_db(char *path, char *key);
extern chat_ctrl chat_init_key(char *path, char *key);
extern char *chat_send_cmd(chat_ctrl ctl, char *cmd);
extern char *chat_recv_msg(chat_ctrl ctl);
extern char *chat_recv_msg_wait(chat_ctrl ctl, int wait);
+3
View File
@@ -13,6 +13,9 @@
/* No comment provided by engineer. */
"~strike~" = "\\~strike~";
/* call status */
"connecting call" = "connecting call…";
/* No comment provided by engineer. */
"Connecting server…" = "Connecting to server…";
+159 -3
View File
@@ -52,12 +52,18 @@
/* No comment provided by engineer. */
"**Paste received link** or open it in the browser and tap **Open in mobile app**." = "**Вставить полученную ссылку**, или откройте её в браузере и нажмите **Open in mobile app**.";
/* No comment provided by engineer. */
"**Please note**: you will NOT be able to recover or change passphrase if you lose it." = "**Внимание**: вы не сможете восстановить или поменять пароль, если вы его потеряете.";
/* No comment provided by engineer. */
"**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from." = "**Рекомендовано**: токен устройства и уведомления отправляются на сервер SimpleX Chat, но сервер не получает сами сообщения, их размер или от кого они.";
/* No comment provided by engineer. */
"**Scan QR code**: to connect to your contact in person or via video call." = "**Сканировать QR код**: соединиться с вашим контактом при встрече или во время видеозвонка.";
/* No comment provided by engineer. */
"**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Внимание**: для работы мгновенных уведомлений пароль должен быть сохранен в Keychain.";
/* No comment provided by engineer. */
"*bold*" = "\\*жирный*";
@@ -218,6 +224,12 @@
/* No comment provided by engineer. */
"Cancel" = "Отменить";
/* No comment provided by engineer. */
"Cannot access keychain to save database password" = "Ошибка доступа к Keychain при сохранении пароля";
/* No comment provided by engineer. */
"Change database passphrase?" = "Поменять пароль базы данных?";
/* No comment provided by engineer. */
"Chat archive" = "Архив чата";
@@ -275,6 +287,9 @@
/* No comment provided by engineer. */
"Confirm" = "Подтвердить";
/* No comment provided by engineer. */
"Confirm new passphrase…" = "Подтвердите новый пароль…";
/* No comment provided by engineer. */
"Connect" = "Соединиться";
@@ -315,7 +330,7 @@
"connecting (introduction invitation)" = "соединяется (приглашение по представлению)";
/* call status */
"connecting call" = "звонок соединяется…";
"connecting call" = "звонок соединяется…";
/* No comment provided by engineer. */
"Connecting server…" = "Устанавливается соединение с сервером…";
@@ -401,6 +416,9 @@
/* No comment provided by engineer. */
"creator" = "создатель";
/* No comment provided by engineer. */
"Current passphrase…" = "Текущий пароль…";
/* No comment provided by engineer. */
"Currently maximum supported file size is %@." = "Максимальный размер файла - %@.";
@@ -408,11 +426,44 @@
"Dark" = "Тёмная";
/* No comment provided by engineer. */
"Database export & import" = "Экспорт и импорт архива чата";
"Database encrypted!" = "База данных зашифрована!";
/* No comment provided by engineer. */
"Database encryption passphrase will be updated and stored in the keychain.\n" = "Пароль базы данных будет изменен и сохранен в Keychain.\n";
/* No comment provided by engineer. */
"Database encryption passphrase will be updated.\n" = "Пароль базы данных будет изменен.\n";
/* No comment provided by engineer. */
"Database error" = "Ошибка базы данных";
/* No comment provided by engineer. */
"Database ID" = "ID базы данных";
/* No comment provided by engineer. */
"Database is encrypted using a random passphrase, you can change it." = "База данных зашифрована случайным паролем, вы можете его поменять.";
/* No comment provided by engineer. */
"Database is encrypted using a random passphrase. Please change it before exporting." = "База данных зашифрована случайным паролем. Пожалуйста, поменяйте его перед экспортом.";
/* No comment provided by engineer. */
"Database passphrase" = "Пароль базы данных";
/* No comment provided by engineer. */
"Database passphrase & export" = "Пароль и экспорт базы";
/* No comment provided by engineer. */
"Database passphrase is different from saved in the keychain." = "Пароль базы данных отличается от сохраненного в Keychain.";
/* No comment provided by engineer. */
"Database passphrase is required to open chat." = "Введите пароль базы данных чтобы открыть чат.";
/* No comment provided by engineer. */
"Database will be encrypted and the passphrase stored in the keychain.\n" = "База данных будет зашифрована и пароль сохранен в Keychain.\n";
/* No comment provided by engineer. */
"Database will be encrypted.\n" = "База данных будет зашифрована.\n";
/* No comment provided by engineer. */
"Database will be migrated when the app restarts" = "Данные чата будут мигрированы при перезапуске";
@@ -545,12 +596,42 @@
/* No comment provided by engineer. */
"Enable TCP keep-alive" = "Включить TCP keep-alive";
/* No comment provided by engineer. */
"Encrypt" = "Зашифровать";
/* No comment provided by engineer. */
"Encrypt database?" = "Зашифровать базу данных?";
/* No comment provided by engineer. */
"Encrypted database" = "База данных зашифрована";
/* notification */
"Encrypted message or another event" = "Зашифрованное сообщение или событие чата";
/* notification */
"Encrypted message: database error" = "Зашифрованное сообщение: ошибка базы данных";
/* notification */
"Encrypted message: keychain error" = "Зашифрованное сообщение: ошибка Keychain";
/* notification */
"Encrypted message: no passphrase" = "Зашифрованное сообщение: пароль не сохранен";
/* notification */
"Encrypted message: unexpeсted error" = "Зашифрованное сообщение: неожиданная ошибка";
/* No comment provided by engineer. */
"ended" = "завершён";
/* call status */
"ended call %@" = "завершённый звонок %@";
/* No comment provided by engineer. */
"Enter correct passphrase." = "Введите правильный пароль.";
/* No comment provided by engineer. */
"Enter passphrase…" = "Введите пароль…";
/* No comment provided by engineer. */
"error" = "ошибка";
@@ -578,6 +659,9 @@
/* No comment provided by engineer. */
"Error enabling notifications" = "Ошибка при включении уведомлений";
/* No comment provided by engineer. */
"Error encrypting database" = "Ошибка при шифровании";
/* No comment provided by engineer. */
"Error exporting chat database" = "Ошибка при экспорте архива чата";
@@ -626,6 +710,9 @@
/* No comment provided by engineer. */
"File will be received when your contact is online, please wait or check later!" = "Файл будет принят, когда ваш контакт будет в сети, подождите или проверьте позже!";
/* No comment provided by engineer. */
"File: %@" = "Файл: %@";
/* No comment provided by engineer. */
"For console" = "Для консоли";
@@ -755,6 +842,9 @@
/* No comment provided by engineer. */
"Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "[SimpleX Chat для терминала](https://github.com/simplex-chat/simplex-chat)";
/* No comment provided by engineer. */
"Instant push notifications will be hidden!\n" = "Мгновенные уведомления будут скрыты!\n";
/* No comment provided by engineer. */
"Instantly" = "Мгновенно";
@@ -782,6 +872,12 @@
/* chat list item title */
"invited to connect" = "приглашение";
/* No comment provided by engineer. */
"iOS Keychain is used to securely store passphrase - it allows receiving push notifications." = "iOS Keychain используется для безопасного хранения пароля - это позволяет получать мгновенные уведомления.";
/* No comment provided by engineer. */
"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Пароль базы данных будет безопасно сохранен в iOS Keychain после запуска чата или изменения пароля - это позволит получать мгновенные уведомления.";
/* No comment provided by engineer. */
"It allows having many anonymous connections without any shared data between them in a single chat profile." = "Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя.";
@@ -812,6 +908,9 @@
/* No comment provided by engineer. */
"Joining group" = "Вступление в группу";
/* No comment provided by engineer. */
"Keychain error" = "Ошибка Keychain";
/* No comment provided by engineer. */
"Large file!" = "Большой файл!";
@@ -920,6 +1019,9 @@
/* notification */
"New message" = "Новое сообщение";
/* No comment provided by engineer. */
"New passphrase…" = "Новый пароль…";
/* No comment provided by engineer. */
"No" = "Нет";
@@ -971,6 +1073,12 @@
/* No comment provided by engineer. */
"Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." = "Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются **с двухуровневым end-to-end шифрованием**";
/* No comment provided by engineer. */
"Open chat" = "Открыть чат";
/* authentication reason */
"Open chat console" = "Открыть консоль";
/* No comment provided by engineer. */
"Open Settings" = "Открыть Настройки";
@@ -1019,9 +1127,18 @@
/* No comment provided by engineer. */
"Please check your network connection and try again." = "Пожалуйста, проверьте ваше соединение с сетью и попробуйте еще раз.";
/* No comment provided by engineer. */
"Please enter correct current passphrase." = "Пожалуйста, введите правильный пароль.";
/* No comment provided by engineer. */
"Please restart the app and migrate the database to enable push notifications." = "Пожалуйста, перезапустите приложение и переместите данные чата, чтобы включить доставку уведомлений.";
/* No comment provided by engineer. */
"Please store passphrase securely, you will NOT be able to access chat if you lose it." = "Пожалуйста, надежно сохраните пароль, вы НЕ сможете открыть чат, если потеряете его.";
/* No comment provided by engineer. */
"Please store passphrase securely, you will NOT be able to change it if you lose it." = "Пожалуйста, надежно сохраните пароль, вы НЕ сможете его поменять, если потеряете.";
/* No comment provided by engineer. */
"Privacy & security" = "Конфиденциальность";
@@ -1085,6 +1202,9 @@
/* No comment provided by engineer. */
"Remove member?" = "Удалить члена группы?";
/* No comment provided by engineer. */
"Remove passphrase from keychain?" = "Удалить пароль из Keychain?";
/* No comment provided by engineer. */
"removed" = "удален(а)";
@@ -1130,6 +1250,12 @@
/* No comment provided by engineer. */
"Save group profile" = "Сохранить профиль группы";
/* No comment provided by engineer. */
"Save passphrase and open chat" = "Сохранить пароль и открыть чат";
/* No comment provided by engineer. */
"Save passphrase in Keychain" = "Сохранить пароль в Keychain";
/* No comment provided by engineer. */
"Saved SMP servers will be removed" = "Сохраненные SMP серверы будут удалены";
@@ -1172,6 +1298,9 @@
/* No comment provided by engineer. */
"Set contact name…" = "Имя контакта…";
/* No comment provided by engineer. */
"Set passphrase to export" = "Установите пароль";
/* No comment provided by engineer. */
"Set timeouts for proxy/VPN" = "Установить таймауты для прокси/VPN";
@@ -1235,6 +1364,9 @@
/* No comment provided by engineer. */
"Stop chat?" = "Остановить чат?";
/* authentication reason */
"Stop SimpleX" = "Остановить SimpleX";
/* No comment provided by engineer. */
"strike" = "зачеркнуть";
@@ -1364,6 +1496,12 @@
/* connection info */
"unknown" = "неизвестно";
/* No comment provided by engineer. */
"Unknown database error: %@" = "Неизвестная ошибка базы данных: %@";
/* No comment provided by engineer. */
"Unknown error" = "Неизвестная ошибка";
/* No comment provided by engineer. */
"Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." = "Возможно, ваш контакт удалил ссылку, или она уже была использована. Если это не так, то это может быть ошибкой - пожалуйста, сообщите нам об этом.\nЧтобы установить соединение, попросите ваш контакт создать еще одну ссылку и проверьте ваше соединение с сетью.";
@@ -1373,9 +1511,15 @@
/* No comment provided by engineer. */
"Unmute" = "Уведомлять";
/* No comment provided by engineer. */
"Update" = "Обновить";
/* No comment provided by engineer. */
"Update .onion hosts setting?" = "Обновить настройки .onion хостов?";
/* No comment provided by engineer. */
"Update database passphrase" = "Поменять пароль";
/* No comment provided by engineer. */
"Update network settings?" = "Обновить настройки сети?";
@@ -1445,6 +1589,12 @@
/* No comment provided by engineer. */
"When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." = "Когда вы соединены с контактом инкогнито, тот же самый инкогнито профиль будет использоваться для групп с этим контактом.";
/* No comment provided by engineer. */
"Wrong database passphrase" = "Неправильный пароль базы данных";
/* No comment provided by engineer. */
"Wrong passphrase!" = "Неправильный пароль!";
/* No comment provided by engineer. */
"You" = "Вы";
@@ -1487,6 +1637,9 @@
/* No comment provided by engineer. */
"You could not be verified; please try again." = "Верификация не удалась; пожалуйста, попробуйте ещё раз.";
/* No comment provided by engineer. */
"You have to enter passphrase every time the app starts - it is not stored on the device." = "Пароль не сохранен на устройстве — вы будете должны ввести его при каждом запуске чата.";
/* No comment provided by engineer. */
"You invited your contact" = "Вы пригласили ваш контакт";
@@ -1545,7 +1698,10 @@
"Your chat address" = "Ваш SimpleX адрес";
/* No comment provided by engineer. */
"Your chat database" = "Данные чата";
"Your chat database" = "База данных";
/* No comment provided by engineer. */
"Your chat database is not encrypted - set passphrase to encrypt it." = "База данных НЕ зашифрована. Установите пароль, чтобы защитить ваши данные.";
/* No comment provided by engineer. */
"Your chat profile" = "Ваш профиль";
+13 -1
View File
@@ -1,11 +1,23 @@
packages: .
-- packages: . ../simplexmq
-- packages: . ../simplexmq ../direct-sqlcipher ../sqlcipher-simple
constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: e328ae5d060645a8ef090b1b3d88bc20a5902e45
tag: f08d81252deb68eb4a1ce4502712b99264b6749d
source-repository-package
type: git
location: https://github.com/simplex-chat/direct-sqlcipher.git
tag: 34309410eb2069b029b8fc1872deb1e0db123294
source-repository-package
type: git
location: https://github.com/simplex-chat/sqlcipher-simple.git
tag: 5e154a2aeccc33ead6c243ec07195ab673137221
source-repository-package
type: git
+16
View File
@@ -0,0 +1,16 @@
# Contributing guide
## Compiling with SQLCipher encryption enabled
Add `cabal.project.local` to project root with the location of OpenSSL headers and libraries and flag setting encryption mode:
```
ignore-project: False
package direct-sqlcipher
extra-include-dirs: /opt/homebrew/opt/openssl@3/include
extra-lib-dirs: /opt/homebrew/opt/openssl@3/lib
flags: +openssl
```
OpenSSL can be installed with `brew install openssl`
@@ -0,0 +1,25 @@
# Database encryption
## Approach
Using SQLCipher - it is a drop in replacement for SQLite that works for non-encrypted databases without any changes (TODO test on iOS/Android).
`direct-sqlite` and `sqlite-simple` libraries are forked and renamed to `direct-sqlcipher` and `sqlcipher-simple`, with replaced cbits in `direct-sqlcipher` (TODO include SQLCipher as git submodule with a script to upgrade cbits).
While SQLCipher provides additional C functions to set and change database key, they do not necessarily need to be exported as they are available as PRAGMAs.
Moving from plaintext to encrypted database (and back) requires migration process using [sqlcipher_export() function](https://discuss.zetetic.net/t/how-to-encrypt-a-plaintext-sqlite-database-to-use-sqlcipher-and-avoid-file-is-encrypted-or-is-not-a-database-errors/868).
The approach would be similar to database migration for the notifications:
1. the current users will be offered to migrate to encrypted database once, with a notice that it can be done later via settings.
2. the new users will be asked to enter a pass-phrase to create a new database (it can be empty, in which case the database won't be encrypted).
3. during the migration the database backup will be created and the old database files will be preserved - in case of the app failing to open the new database right after the migration it should revert to using the previous database.
When opening the database the key must be passed via chat command / agent configuration, some test query must be performed to check that the key is correct: https://www.zetetic.net/sqlcipher/sqlcipher-api/#PRAGMA_key
Options to support in chat settings:
- encrypt database (with automatic rollback in case of failure)
- decrypt database (-"-)
- change key (using [PRAGMA rekey](https://www.zetetic.net/sqlcipher/sqlcipher-api/#rekey))
+67 -11
View File
@@ -25,7 +25,7 @@
};
sha256map = import ./scripts/nix/sha256map.nix;
modules = [{
packages.direct-sqlite.patches = [ ./scripts/nix/direct-sqlite-2.3.26.patch ];
packages.direct-sqlcipher.patches = [ ./scripts/nix/direct-sqlcipher-2.3.27.patch ];
packages.entropy.patches = [ ./scripts/nix/entropy.patch ];
}
({ pkgs,lib, ... }: lib.mkIf (pkgs.stdenv.hostPlatform.isAndroid) {
@@ -118,13 +118,25 @@
> $out/nix-support/hydra-build-products
'';
};
"aarch64-android:lib:simplex-chat" = (drv androidPkgs).simplex-chat.components.library.override {
"aarch64-android:lib:simplex-chat" = (drv' {
pkgs' = androidPkgs;
extra-modules = [{
packages.direct-sqlcipher.flags.openssl = true;
packages.direct-sqlcipher.components.library.libs = pkgs.lib.mkForce [
(androidPkgs.openssl.override { static = true; })
];
packages.direct-sqlcipher.patches = [
./scripts/nix/direct-sqlcipher-android-log.patch
];
}];
}).simplex-chat.components.library.override {
smallAddressSpace = true; enableShared = false;
# for android we build a shared library, passing these arguments is a bit tricky, as
# we want only the threaded rts (HSrts_thr) and ffi to be linked, but not fed into iserv for
# template haskell cross compilation. Thus we just pass them as linker options (-optl).
setupBuildFlags = map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsimplex.so" "-optl-lHSrts_thr" "-optl-lffi"];
postInstall = ''
set -x
${pkgs.tree}/bin/tree $out
mkdir -p $out/_pkg
# copy over includes, we might want those, but maybe not.
@@ -138,8 +150,26 @@
# find ${androidPkgs.gmp6.override { withStatic = true; }}/lib -name "*.a" -exec cp {} $out/_pkg \;
# find ${androidIconv}/lib -name "*.a" -exec cp {} $out/_pkg \;
# find ${androidPkgs.stdenv.cc.libc}/lib -name "*.a" -exec cp {} $out/_pkg \;
echo ${androidPkgs.openssl}
find ${androidPkgs.openssl.out}/lib -name "*.so" -exec cp {} $out/_pkg \;
${pkgs.patchelf}/bin/patchelf --remove-needed libunwind.so.1 $out/_pkg/libsimplex.so
# remove the .1 and other version suffixes from .so's. Androids linker
# doesn't play nice with them.
for lib in $out/_pkg/*.so; do
for dep in $(${pkgs.patchelf}/bin/patchelf --print-needed "$lib"); do
if [[ "''${dep##*.so}" ]]; then
echo "$lib : $dep -> ''${dep%%.so*}.so"
chmod +w "$lib"
${pkgs.patchelf}/bin/patchelf --replace-needed "$dep" "''${dep%%.so*}.so" "$lib"
fi
done
done
for lib in $out/_pkg/*.so; do
chmod +w "$lib"
${pkgs.patchelf}/bin/patchelf --remove-needed libunwind.so "$lib"
[[ "$lib" != *libsimplex.so ]] && ${pkgs.patchelf}/bin/patchelf --set-soname "$(basename -a $lib)" "$lib"
done
${pkgs.tree}/bin/tree $out/_pkg
(cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-aarch64-android-libsimplex.zip *)
@@ -165,7 +195,12 @@
> $out/nix-support/hydra-build-products
'';
};
"x86_64-android:lib:simplex-chat" = (drv androidPkgs).simplex-chat.components.library.override {
"x86_64-android:lib:simplex-chat" = (drv' {
pkgs' = androidPkgs;
extra-modules = [{
packages.direct-sqlcipher.flags.openssl = true;
}];
}).simplex-chat.components.library.override {
smallAddressSpace = true; enableShared = false;
# for android we build a shared library, passing these arguments is a bit tricky, as
# we want only the threaded rts (HSrts_thr) and ffi to be linked, but not fed into iserv for
@@ -212,7 +247,12 @@
> $out/nix-support/hydra-build-products
'';
};
"x86_64-linux:lib:simplex-chat" = (drv androidPkgs).simplex-chat.components.library.override {
"x86_64-linux:lib:simplex-chat" = (drv' {
pkgs' = androidPkgs;
extra-modules = [{
packages.direct-sqlcipher.flags.openssl = true;
}];
}).simplex-chat.components.library.override {
smallAddressSpace = true; enableShared = false;
# for android we build a shared library, passing these arguments is a bit tricky, as
# we want only the threaded rts (HSrts_thr) and ffi to be linked, but not fed into iserv for
@@ -250,25 +290,41 @@
# aarch64-darwin iOS build (to be patched with mac2ios)
"aarch64-darwin-ios:lib:simplex-chat" = (drv' {
pkgs' = pkgs;
extra-modules = [{ packages.simplexmq.flags.swift = true; }];
extra-modules = [{
packages.simplexmq.flags.swift = true;
packages.direct-sqlcipher.flags.commoncrypto = true;
}];
}).simplex-chat.components.library.override (
iosOverrides "pkg-ios-aarch64-swift-json"
);
# aarch64-darwin build with tagged JSON format (for Mac & Flutter)
"aarch64-darwin:lib:simplex-chat" = (drv pkgs).simplex-chat.components.library.override (
"aarch64-darwin:lib:simplex-chat" = (drv' {
pkgs' = pkgs;
extra-modules = [{
packages.direct-sqlcipher.flags.commoncrypto = true;
}];
}).simplex-chat.components.library.override (
iosOverrides "pkg-ios-aarch64-tagged-json"
);
};
"x86_64-darwin" = {
# this is the x86_64-darwin iOS simulator build (to be patched with mac2ios)
# x86_64-darwin iOS simulator build (to be patched with mac2ios)
"x86_64-darwin-ios:lib:simplex-chat" = (drv' {
pkgs' = pkgs;
extra-modules = [{ packages.simplexmq.flags.swift = true; }];
extra-modules = [{
packages.simplexmq.flags.swift = true;
packages.direct-sqlcipher.flags.commoncrypto = true;
}];
}).simplex-chat.components.library.override (
iosOverrides "pkg-ios-x86_64-swift-json"
);
# This is the x86_64-darwin build with tagged JSON format (for Mac & Flutter iOS simulator)
"x86_64-darwin:lib:simplex-chat" = (drv pkgs).simplex-chat.components.library.override (
# x86_64-darwin build with tagged JSON format (for Mac & Flutter iOS simulator)
"x86_64-darwin:lib:simplex-chat" = (drv' {
pkgs' = pkgs;
extra-modules = [{
packages.direct-sqlcipher.flags.commoncrypto = true;
}];
}).simplex-chat.components.library.override (
iosOverrides "pkg-ios-x86_64-tagged-json"
);
};
+2 -1
View File
@@ -23,6 +23,7 @@ dependencies:
- containers == 0.6.*
- cryptonite >= 0.27 && < 0.30
- directory == 1.3.*
- direct-sqlcipher == 2.3.*
- email-validate == 2.3.*
- exceptions == 0.10.*
- filepath == 1.4.*
@@ -35,7 +36,7 @@ dependencies:
- simple-logger == 0.1.*
- simplexmq >= 3.0
- socks == 0.6.*
- sqlite-simple == 0.4.*
- sqlcipher-simple == 0.4.*
- stm == 2.5.*
- terminal == 0.2.*
- text == 1.2.*
+2 -1
View File
@@ -2,5 +2,6 @@
# libsimplex.so and libsupport.so binaries should be in ~/Downloads folder
rm ./apps/android/app/src/main/cpp/libs/arm64-v8a/*
cp ~/Downloads/libsimplex.so ./apps/android/app/src/main/cpp/libs/arm64-v8a/
cp ~/Downloads/libsupport.so ./apps/android/app/src/main/cpp/libs/arm64-v8a/
cp ~/Downloads/pkg-aarch64-android-libsimplex/libsimplex.so ./apps/android/app/src/main/cpp/libs/arm64-v8a/
cp ~/Downloads/pkg-aarch64-android-libsimplex/libcrypto.so ./apps/android/app/src/main/cpp/libs/arm64-v8a/
+12
View File
@@ -0,0 +1,12 @@
diff --git a/direct-sqlcipher.cabal b/direct-sqlcipher.cabal
index 728ba3e..c63745e 100644
--- a/direct-sqlcipher.cabal
+++ b/direct-sqlcipher.cabal
@@ -84,6 +84,8 @@ library
cc-options: -DSQLITE_TEMP_STORE=2
-DSQLITE_HAS_CODEC
+ extra-libraries: dl
+
if !os(windows) && !os(android)
extra-libraries: pthread
@@ -0,0 +1,68 @@
diff --git a/cbits/sqlite3.c b/cbits/sqlite3.c
index 66bb609..00c33c1 100644
--- a/cbits/sqlite3.c
+++ b/cbits/sqlite3.c
@@ -101739,9 +101739,9 @@ sqlite3_mutex* sqlcipher_mutex(int);
/* #include "pager.h" */
/* #include "vdbeInt.h" */
-#ifdef __ANDROID__
-#include <android/log.h>
-#endif
+// #ifdef __ANDROID__
+// #include <android/log.h>
+// #endif
/* #include <time.h> */
@@ -104934,11 +104934,11 @@ static int sqlcipher_profile_callback(unsigned int trace, void *file, void *stmt
FILE *f = (FILE*) file;
char *fmt = "Elapsed time:%.3f ms - %s\n";
double elapsed = (*((sqlite3_uint64*)run_time))/1000000.0;
-#ifdef __ANDROID__
- if(f == NULL) {
- __android_log_print(ANDROID_LOG_DEBUG, "sqlcipher", fmt, elapsed, sqlite3_sql((sqlite3_stmt*)stmt));
- }
-#endif
+// #ifdef __ANDROID__
+// if(f == NULL) {
+// __android_log_print(ANDROID_LOG_DEBUG, "sqlcipher", fmt, elapsed, sqlite3_sql((sqlite3_stmt*)stmt));
+// }
+// #endif
if(f) fprintf(f, fmt, elapsed, sqlite3_sql((sqlite3_stmt*)stmt));
return SQLITE_OK;
}
@@ -104988,12 +104988,12 @@ void sqlcipher_log(unsigned int level, const char *message, ...) {
va_start(params, message);
#ifdef CODEC_DEBUG
-#ifdef __ANDROID__
- __android_log_vprint(ANDROID_LOG_DEBUG, "sqlcipher", message, params);
-#else
+// #ifdef __ANDROID__
+// __android_log_vprint(ANDROID_LOG_DEBUG, "sqlcipher", message, params);
+// #else
vfprintf(stderr, message, params);
fprintf(stderr, "\n");
-#endif
+// #endif
#endif
if(level > sqlcipher_log_level || (sqlcipher_log_logcat == 0 && sqlcipher_log_file == NULL)) {
@@ -105026,11 +105026,11 @@ void sqlcipher_log(unsigned int level, const char *message, ...) {
fprintf((FILE*)sqlcipher_log_file, "\n");
}
}
-#ifdef __ANDROID__
- if(sqlcipher_log_logcat) {
- __android_log_vprint(ANDROID_LOG_DEBUG, "sqlcipher", message, params);
- }
-#endif
+// #ifdef __ANDROID__
+// if(sqlcipher_log_logcat) {
+// __android_log_vprint(ANDROID_LOG_DEBUG, "sqlcipher", message, params);
+// }
+// #endif
end:
va_end(params);
}
-15
View File
@@ -1,15 +0,0 @@
diff --git a/direct-sqlite.cabal b/direct-sqlite.cabal
index 96f26b7..996198e 100644
--- a/direct-sqlite.cabal
+++ b/direct-sqlite.cabal
@@ -69,7 +69,9 @@ library
install-includes: sqlite3.h, sqlite3ext.h
include-dirs: cbits
- if !os(windows) && !os(android)
+ extra-libraries: dl
+
+ if !os(windows) && !os(android)
extra-libraries: pthread
if flag(fulltextsearch)
+3 -1
View File
@@ -1,5 +1,7 @@
{
"https://github.com/simplex-chat/simplexmq.git"."e328ae5d060645a8ef090b1b3d88bc20a5902e45" = "0y9k1v2ss7f68md01azhh55b7xbk654hcpmyjkxkazy261nn9rxg";
"https://github.com/simplex-chat/simplexmq.git"."f08d81252deb68eb4a1ce4502712b99264b6749d" = "0v3z9sfy7vjbgm3cznb7vaycn2r2yav83x74khf6a001cs1zv171";
"https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd";
"https://github.com/simplex-chat/sqlcipher-simple.git"."5e154a2aeccc33ead6c243ec07195ab673137221" = "1d1gc5wax4vqg0801ajsmx1sbwvd9y7p7b8mmskvqsmpbwgbh0m0";
"https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp";
"https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj";
"https://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97";
+10 -5
View File
@@ -78,6 +78,7 @@ library
, composition ==1.0.*
, containers ==0.6.*
, cryptonite >=0.27 && <0.30
, direct-sqlcipher ==2.3.*
, directory ==1.3.*
, email-validate ==2.3.*
, exceptions ==0.10.*
@@ -91,7 +92,7 @@ library
, simple-logger ==0.1.*
, simplexmq >=3.0
, socks ==0.6.*
, sqlite-simple ==0.4.*
, sqlcipher-simple ==0.4.*
, stm ==2.5.*
, terminal ==0.2.*
, text ==1.2.*
@@ -119,6 +120,7 @@ executable simplex-bot
, composition ==1.0.*
, containers ==0.6.*
, cryptonite >=0.27 && <0.30
, direct-sqlcipher ==2.3.*
, directory ==1.3.*
, email-validate ==2.3.*
, exceptions ==0.10.*
@@ -133,7 +135,7 @@ executable simplex-bot
, simplex-chat
, simplexmq >=3.0
, socks ==0.6.*
, sqlite-simple ==0.4.*
, sqlcipher-simple ==0.4.*
, stm ==2.5.*
, terminal ==0.2.*
, text ==1.2.*
@@ -161,6 +163,7 @@ executable simplex-bot-advanced
, composition ==1.0.*
, containers ==0.6.*
, cryptonite >=0.27 && <0.30
, direct-sqlcipher ==2.3.*
, directory ==1.3.*
, email-validate ==2.3.*
, exceptions ==0.10.*
@@ -175,7 +178,7 @@ executable simplex-bot-advanced
, simplex-chat
, simplexmq >=3.0
, socks ==0.6.*
, sqlite-simple ==0.4.*
, sqlcipher-simple ==0.4.*
, stm ==2.5.*
, terminal ==0.2.*
, text ==1.2.*
@@ -204,6 +207,7 @@ executable simplex-chat
, composition ==1.0.*
, containers ==0.6.*
, cryptonite >=0.27 && <0.30
, direct-sqlcipher ==2.3.*
, directory ==1.3.*
, email-validate ==2.3.*
, exceptions ==0.10.*
@@ -218,7 +222,7 @@ executable simplex-chat
, simplex-chat
, simplexmq >=3.0
, socks ==0.6.*
, sqlite-simple ==0.4.*
, sqlcipher-simple ==0.4.*
, stm ==2.5.*
, terminal ==0.2.*
, text ==1.2.*
@@ -255,6 +259,7 @@ test-suite simplex-chat-test
, containers ==0.6.*
, cryptonite >=0.27 && <0.30
, deepseq ==1.4.*
, direct-sqlcipher ==2.3.*
, directory ==1.3.*
, email-validate ==2.3.*
, exceptions ==0.10.*
@@ -270,7 +275,7 @@ test-suite simplex-chat-test
, simplex-chat
, simplexmq >=3.0
, socks ==0.6.*
, sqlite-simple ==0.4.*
, sqlcipher-simple ==0.4.*
, stm ==2.5.*
, terminal ==0.2.*
, text ==1.2.*
+17 -9
View File
@@ -86,6 +86,7 @@ defaultChatConfig =
defaultAgentConfig
{ tcpPort = undefined, -- agent does not listen to TCP
dbFile = "simplex_v1",
dbKey = "",
yesToMigrations = False
},
yesToMigrations = False,
@@ -124,7 +125,7 @@ logCfg :: LogConfig
logCfg = LogConfig {lc_file = Nothing, lc_stderr = True}
newChatController :: SQLiteStore -> Maybe User -> ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> IO ChatController
newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize, defaultServers} ChatOpts {dbFilePrefix, smpServers, networkConfig, logConnections, logServerHosts} sendToast = do
newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize, defaultServers} ChatOpts {dbFilePrefix, dbKey, smpServers, networkConfig, logConnections, logServerHosts} sendToast = do
let f = chatStoreFile dbFilePrefix
config = cfg {subscriptionEvents = logConnections, hostEvents = logServerHosts}
sendNotification = fromMaybe (const $ pure ()) sendToast
@@ -132,7 +133,7 @@ newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize, de
firstTime <- not <$> doesFileExist f
currentUser <- newTVarIO user
servers <- resolveServers defaultServers
smpAgent <- getSMPAgentClient aCfg {dbFile = dbFilePrefix <> "_agent.db"} servers {netCfg = networkConfig}
smpAgent <- getSMPAgentClient aCfg {dbFile = agentStoreFile dbFilePrefix, dbKey} servers {netCfg = networkConfig}
agentAsync <- newTVarIO Nothing
idsDrg <- newTVarIO =<< drgNew
inputQ <- newTBQueueIO tbqSize
@@ -216,11 +217,7 @@ processChatCommand = \case
StartChat subConns -> withUser' $ \user ->
asks agentAsync >>= readTVarIO >>= \case
Just _ -> pure CRChatRunning
_ ->
ifM
(asks chatStoreChanged >>= readTVarIO)
(throwChatError CEChatStoreChanged)
(startChatController user subConns $> CRChatStarted)
_ -> checkStoreNotChanged $ startChatController user subConns $> CRChatStarted
APIStopChat -> do
ask >>= stopChatController
pure CRChatStopped
@@ -239,8 +236,9 @@ processChatCommand = \case
atomically . writeTVar incognito $ onOff
pure CRCmdOk
APIExportArchive cfg -> checkChatStopped $ exportArchive cfg $> CRCmdOk
APIImportArchive cfg -> checkChatStopped $ importArchive cfg >> setStoreChanged $> CRCmdOk
APIDeleteStorage -> checkChatStopped $ deleteStorage >> setStoreChanged $> CRCmdOk
APIImportArchive cfg -> withStoreChanged $ importArchive cfg
APIDeleteStorage -> withStoreChanged $ deleteStorage
APIStorageEncryption cfg -> withStoreChanged $ sqlCipherExport cfg
APIGetChats withPCC -> CRApiChats <$> withUser (\user -> withStore' $ \db -> getChatPreviews db user withPCC)
APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of
CTDirect -> CRApiChat . AChat SCTDirect <$> withStore (\db -> getDirectChat db user cId pagination search)
@@ -947,6 +945,10 @@ processChatCommand = \case
checkChatStopped a = asks agentAsync >>= readTVarIO >>= maybe a (const $ throwChatError CEChatNotStopped)
setStoreChanged :: m ()
setStoreChanged = asks chatStoreChanged >>= atomically . (`writeTVar` True)
withStoreChanged :: m () -> m ChatResponse
withStoreChanged a = checkChatStopped $ a >> setStoreChanged $> CRCmdOk
checkStoreNotChanged :: m ChatResponse -> m ChatResponse
checkStoreNotChanged = ifM (asks chatStoreChanged >>= readTVarIO) (throwChatError CEChatStoreChanged)
getSentChatItemIdByText :: User -> ChatRef -> ByteString -> m Int64
getSentChatItemIdByText user@User {userId, localDisplayName} (ChatRef cType cId) msg = case cType of
CTDirect -> withStore $ \db -> getDirectChatItemIdByText db userId cId SMDSnd (safeDecodeUtf8 msg)
@@ -2635,6 +2637,10 @@ chatCommandP =
"/_db export " *> (APIExportArchive <$> jsonP),
"/_db import " *> (APIImportArchive <$> jsonP),
"/_db delete" $> APIDeleteStorage,
"/_db encryption " *> (APIStorageEncryption <$> jsonP),
"/db encrypt " *> (APIStorageEncryption . DBEncryptionConfig "" <$> dbKeyP),
"/db key " *> (APIStorageEncryption <$> (DBEncryptionConfig <$> dbKeyP <* A.space <*> dbKeyP)),
"/db decrypt " *> (APIStorageEncryption . (`DBEncryptionConfig` "") <$> dbKeyP),
"/_get chats" *> (APIGetChats <$> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)),
"/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP <*> optional searchP),
"/_get items count=" *> (APIGetChatItems <$> A.decimal),
@@ -2784,6 +2790,8 @@ chatCommandP =
t_ <- optional $ " timeout=" *> A.decimal
let tcpTimeout = 1000000 * fromMaybe (maybe 5 (const 10) socksProxy) t_
pure $ fullNetworkConfig socksProxy tcpTimeout
dbKeyP = nonEmptyKey <$?> strP
nonEmptyKey k@(DBEncryptionKey s) = if null s then Left "empty key" else Right k
adminContactReq :: ConnReqContact
adminContactReq =
+73 -7
View File
@@ -1,16 +1,30 @@
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Simplex.Chat.Archive where
module Simplex.Chat.Archive
( exportArchive,
importArchive,
deleteStorage,
sqlCipherExport,
)
where
import qualified Codec.Archive.Zip as Z
import Control.Monad.Except
import Control.Monad.Reader
import Data.Functor (($>))
import qualified Data.Text as T
import qualified Database.SQLite3 as SQL
import Simplex.Chat.Controller
import Simplex.Messaging.Agent.Client (agentDbPath)
import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..))
import Simplex.Messaging.Util (whenM)
import Simplex.Messaging.Agent.Client (agentStore)
import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), sqlString)
import Simplex.Messaging.Util (unlessM, whenM)
import System.FilePath
import UnliftIO.Directory
import UnliftIO.Exception (SomeException, bracket, catch)
import UnliftIO.STM
import UnliftIO.Temporary
@@ -73,14 +87,66 @@ deleteStorage = do
data StorageFiles = StorageFiles
{ chatDb :: FilePath,
chatEncrypted :: TVar Bool,
agentDb :: FilePath,
agentEncrypted :: TVar Bool,
filesPath :: Maybe FilePath
}
storageFiles :: ChatMonad m => m StorageFiles
storageFiles = do
ChatController {chatStore, filesFolder, smpAgent} <- ask
let SQLiteStore {dbFilePath = chatDb} = chatStore
agentDb = agentDbPath smpAgent
let SQLiteStore {dbFilePath = chatDb, dbEncrypted = chatEncrypted} = chatStore
SQLiteStore {dbFilePath = agentDb, dbEncrypted = agentEncrypted} = agentStore smpAgent
filesPath <- readTVarIO filesFolder
pure StorageFiles {chatDb, agentDb, filesPath}
pure StorageFiles {chatDb, chatEncrypted, agentDb, agentEncrypted, filesPath}
sqlCipherExport :: forall m. ChatMonad m => DBEncryptionConfig -> m ()
sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = DBEncryptionKey key'} =
when (key /= key') $ do
fs@StorageFiles {chatDb, chatEncrypted, agentDb, agentEncrypted} <- storageFiles
checkFile `with` fs
backup `with` fs
(export chatDb chatEncrypted >> export agentDb agentEncrypted)
`catchError` \e -> (restore `with` fs) >> throwError e
where
action `with` StorageFiles {chatDb, agentDb} = action chatDb >> action agentDb
backup f = copyFile f (f <> ".bak")
restore f = copyFile (f <> ".bak") f
checkFile f = unlessM (doesFileExist f) $ throwDBError $ DBErrorNoFile f
export f dbEnc = do
enc <- readTVarIO dbEnc
when (enc && null key) $ throwDBError DBErrorEncrypted
when (not enc && not (null key)) $ throwDBError DBErrorPlaintext
withDB (`SQL.exec` exportSQL) DBErrorExport
renameFile (f <> ".exported") f
withDB (`SQL.exec` testSQL) DBErrorOpen
atomically $ writeTVar dbEnc $ not (null key')
where
withDB a err =
liftIO (bracket (SQL.open $ T.pack f) SQL.close a $> Nothing)
`catch` checkSQLError
`catch` (\(e :: SomeException) -> sqliteError' e)
>>= mapM_ (throwDBError . err)
where
checkSQLError e = case SQL.sqlError e of
SQL.ErrorNotADatabase -> pure $ Just SQLiteErrorNotADatabase
_ -> sqliteError' e
sqliteError' :: Show e => e -> m (Maybe SQLiteError)
sqliteError' = pure . Just . SQLiteError . show
exportSQL =
T.unlines $
keySQL key
<> [ "ATTACH DATABASE " <> sqlString (f <> ".exported") <> " AS exported KEY " <> sqlString key' <> ";",
"SELECT sqlcipher_export('exported');",
"DETACH DATABASE exported;"
]
testSQL =
T.unlines $
keySQL key'
<> [ "PRAGMA foreign_keys = ON;",
"PRAGMA secure_delete = ON;",
"PRAGMA auto_vacuum = FULL;",
"SELECT count(*) FROM sqlite_master;"
]
keySQL k = ["PRAGMA key = " <> sqlString k <> ";" | not (null k)]
+45 -1
View File
@@ -17,9 +17,13 @@ import Control.Monad.Reader
import Crypto.Random (ChaChaDRG)
import Data.Aeson (FromJSON, ToJSON)
import qualified Data.Aeson as J
import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.Char (ord)
import Data.Int (Int64)
import Data.Map.Strict (Map)
import Data.String
import Data.Text (Text)
import Data.Time (ZonedTime)
import Data.Time.Clock (UTCTime)
@@ -38,8 +42,9 @@ import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, InitialAgentServers, Net
import Simplex.Messaging.Agent.Protocol
import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus)
import Simplex.Messaging.Parsers (dropPrefix, enumJSON, sumTypeJSON)
import Simplex.Messaging.Parsers (dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON)
import Simplex.Messaging.Protocol (AProtocolType, CorrId, MsgFlags)
import Simplex.Messaging.TMap (TMap)
import Simplex.Messaging.Transport.Client (TransportHost)
@@ -112,6 +117,7 @@ data ChatCommand
| APIExportArchive ArchiveConfig
| APIImportArchive ArchiveConfig
| APIDeleteStorage
| APIStorageEncryption DBEncryptionConfig
| APIGetChats {pendingConnections :: Bool}
| APIGetChat ChatRef ChatPagination (Maybe String)
| APIGetChatItems Int
@@ -323,6 +329,21 @@ instance ToJSON ChatResponse where
data ArchiveConfig = ArchiveConfig {archivePath :: FilePath, disableCompression :: Maybe Bool, parentTempDirectory :: Maybe FilePath}
deriving (Show, Generic, FromJSON)
data DBEncryptionConfig = DBEncryptionConfig {currentKey :: DBEncryptionKey, newKey :: DBEncryptionKey}
deriving (Show, Generic, FromJSON)
newtype DBEncryptionKey = DBEncryptionKey String
deriving (Show)
instance IsString DBEncryptionKey where fromString = parseString $ parseAll strP
instance StrEncoding DBEncryptionKey where
strEncode (DBEncryptionKey s) = B.pack s
strP = DBEncryptionKey . B.unpack <$> A.takeWhile (\c -> c /= ' ' && ord c >= 0x21 && ord c <= 0x7E)
instance FromJSON DBEncryptionKey where
parseJSON = strParseJSON "DBEncryptionKey"
data ContactSubStatus = ContactSubStatus
{ contact :: Contact,
contactError :: Maybe ChatError
@@ -372,6 +393,7 @@ data ChatError
= ChatError {errorType :: ChatErrorType}
| ChatErrorAgent {agentError :: AgentErrorType}
| ChatErrorStore {storeError :: StoreError}
| ChatErrorDatabase {databaseError :: DatabaseError}
deriving (Show, Exception, Generic)
instance ToJSON ChatError where
@@ -430,6 +452,28 @@ instance ToJSON ChatErrorType where
toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CE"
toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CE"
data DatabaseError
= DBErrorEncrypted
| DBErrorPlaintext
| DBErrorNoFile {dbFile :: String}
| DBErrorExport {sqliteError :: SQLiteError}
| DBErrorOpen {sqliteError :: SQLiteError}
deriving (Show, Exception, Generic)
instance ToJSON DatabaseError where
toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "DB"
toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "DB"
data SQLiteError = SQLiteErrorNotADatabase | SQLiteError String
deriving (Show, Exception, Generic)
instance ToJSON SQLiteError where
toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "SQLite"
toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "SQLite"
throwDBError :: ChatMonad m => DatabaseError -> m ()
throwDBError = throwError . ChatErrorDatabase
type ChatMonad m = (MonadUnliftIO m, MonadReader ChatController m, MonadError ChatError m)
chatCmdError :: String -> ChatResponse
+1 -1
View File
@@ -23,7 +23,7 @@ simplexChatCore cfg@ChatConfig {yesToMigrations} opts sendToast chat
where
initRun = do
let f = chatStoreFile $ dbFilePrefix opts
st <- createStore f yesToMigrations
st <- createChatStore f (dbKey opts) yesToMigrations
u <- getCreateActiveUser st
cc <- newChatController st (Just u) cfg opts sendToast
runSimplexChat opts u cc chat
+60 -5
View File
@@ -1,18 +1,23 @@
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module Simplex.Chat.Mobile where
import Control.Concurrent.STM
import Control.Exception (catch)
import Control.Monad.Reader
import Data.Aeson (ToJSON (..))
import qualified Data.Aeson as J
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Functor (($>))
import Data.List (find)
import Data.Maybe (fromMaybe)
import Database.SQLite.Simple (SQLError (..))
import qualified Database.SQLite.Simple as DB
import Foreign.C.String
import Foreign.C.Types (CInt (..))
import Foreign.StablePtr
@@ -24,13 +29,21 @@ import Simplex.Chat.Options
import Simplex.Chat.Store
import Simplex.Chat.Types
import Simplex.Chat.Util (safeDecodeUtf8)
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (yesToMigrations))
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (yesToMigrations), createAgentStore)
import Simplex.Messaging.Agent.Store.SQLite (closeSQLiteStore)
import Simplex.Messaging.Client (defaultNetworkConfig)
import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON)
import Simplex.Messaging.Protocol (CorrId (..))
import Simplex.Messaging.Util (catchAll)
import System.Timeout (timeout)
foreign export ccall "chat_migrate_db" cChatMigrateDB :: CString -> CString -> IO CJSONString
-- chat_init is deprecated
foreign export ccall "chat_init" cChatInit :: CString -> IO (StablePtr ChatController)
foreign export ccall "chat_init_key" cChatInitKey :: CString -> CString -> IO (StablePtr ChatController)
foreign export ccall "chat_send_cmd" cChatSendCmd :: StablePtr ChatController -> CString -> IO CJSONString
foreign export ccall "chat_recv_msg" cChatRecvMsg :: StablePtr ChatController -> IO CJSONString
@@ -39,11 +52,23 @@ foreign export ccall "chat_recv_msg_wait" cChatRecvMsgWait :: StablePtr ChatCont
foreign export ccall "chat_parse_markdown" cChatParseMarkdown :: CString -> IO CJSONString
-- | initialize chat controller
-- | check and migrate the database
-- This function validates that the encryption is correct and runs migrations - it should be called before cChatInitKey
cChatMigrateDB :: CString -> CString -> IO CJSONString
cChatMigrateDB fp key =
((,) <$> peekCAString fp <*> peekCAString key) >>= uncurry chatMigrateDB >>= newCAString . LB.unpack . J.encode
-- | initialize chat controller (deprecated)
-- The active user has to be created and the chat has to be started before most commands can be used.
cChatInit :: CString -> IO (StablePtr ChatController)
cChatInit fp = peekCAString fp >>= chatInit >>= newStablePtr
-- | initialize chat controller with encrypted database
-- The active user has to be created and the chat has to be started before most commands can be used.
cChatInitKey :: CString -> CString -> IO (StablePtr ChatController)
cChatInitKey fp key =
((,) <$> peekCAString fp <*> peekCAString key) >>= uncurry chatInitKey >>= newStablePtr
-- | send command to chat (same syntax as in terminal for now)
cChatSendCmd :: StablePtr ChatController -> CString -> IO CJSONString
cChatSendCmd cPtr cCmd = do
@@ -67,6 +92,7 @@ mobileChatOpts :: ChatOpts
mobileChatOpts =
ChatOpts
{ dbFilePrefix = undefined,
dbKey = "",
smpServers = [],
networkConfig = defaultNetworkConfig,
logConnections = False,
@@ -90,12 +116,41 @@ type CJSONString = CString
getActiveUser_ :: SQLiteStore -> IO (Maybe User)
getActiveUser_ st = find activeUser <$> withTransaction st getUsers
data DBMigrationResult
= DBMOk
| DBMErrorNotADatabase {dbFile :: String}
| DBMError {dbFile :: String, migrationError :: String}
deriving (Show, Generic)
instance ToJSON DBMigrationResult where
toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "DBM"
toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "DBM"
chatMigrateDB :: String -> String -> IO DBMigrationResult
chatMigrateDB dbFilePrefix dbKey =
migrate createChatStore (chatStoreFile dbFilePrefix) >>= \case
DBMOk -> migrate createAgentStore (agentStoreFile dbFilePrefix)
e -> pure e
where
migrate createStore dbFile =
((createStore dbFile dbKey True >>= closeSQLiteStore) $> DBMOk)
`catch` (pure . checkDBError)
`catchAll` (pure . dbError)
where
checkDBError e = case sqlError e of
DB.ErrorNotADatabase -> DBMErrorNotADatabase dbFile
_ -> dbError e
dbError e = DBMError dbFile $ show e
chatInit :: String -> IO ChatController
chatInit dbFilePrefix = do
chatInit = (`chatInitKey` "")
chatInitKey :: String -> String -> IO ChatController
chatInitKey dbFilePrefix dbKey = do
let f = chatStoreFile dbFilePrefix
chatStore <- createStore f (yesToMigrations (defaultMobileConfig :: ChatConfig))
chatStore <- createChatStore f dbKey (yesToMigrations (defaultMobileConfig :: ChatConfig))
user_ <- getActiveUser_ chatStore
newChatController chatStore user_ defaultMobileConfig mobileChatOpts {dbFilePrefix} Nothing
newChatController chatStore user_ defaultMobileConfig mobileChatOpts {dbFilePrefix, dbKey} Nothing
chatSendCmd :: ChatController -> String -> IO JSONString
chatSendCmd cc s = LB.unpack . J.encode . APIResponse Nothing <$> runReaderT (execChatCommand $ B.pack s) cc
+10
View File
@@ -25,6 +25,7 @@ import System.FilePath (combine)
data ChatOpts = ChatOpts
{ dbFilePrefix :: String,
dbKey :: String,
smpServers :: [SMPServer],
networkConfig :: NetworkConfig,
logConnections :: Bool,
@@ -47,6 +48,14 @@ chatOpts appDir defaultDbFileName = do
<> value defaultDbFilePath
<> showDefault
)
dbKey <-
strOption
( long "key"
<> short 'k'
<> metavar "KEY"
<> help "Database encryption key/pass-phrase"
<> value ""
)
smpServers <-
option
parseSMPServers
@@ -126,6 +135,7 @@ chatOpts appDir defaultDbFileName = do
pure
ChatOpts
{ dbFilePrefix,
dbKey,
smpServers,
networkConfig = fullNetworkConfig socksProxy $ useTcpTimeout socksProxy t,
logConnections,
+7 -3
View File
@@ -20,8 +20,9 @@
module Simplex.Chat.Store
( SQLiteStore,
StoreError (..),
createStore,
createChatStore,
chatStoreFile,
agentStoreFile,
createUser,
getUsers,
setActiveUser,
@@ -287,12 +288,15 @@ migrations = sortBy (compare `on` name) $ map migration schemaMigrations
where
migration (name, query) = Migration {name = name, up = fromQuery query}
createStore :: FilePath -> Bool -> IO SQLiteStore
createStore dbFilePath = createSQLiteStore dbFilePath migrations
createChatStore :: FilePath -> String -> Bool -> IO SQLiteStore
createChatStore dbFilePath dbKey = createSQLiteStore dbFilePath dbKey migrations
chatStoreFile :: FilePath -> FilePath
chatStoreFile = (<> "_chat.db")
agentStoreFile :: FilePath -> FilePath
agentStoreFile = (<> "_agent.db")
checkConstraint :: StoreError -> ExceptT StoreError IO a -> ExceptT StoreError IO a
checkConstraint err action = ExceptT $ runExceptT action `E.catch` (pure . Left . handleSQLError err)
+12 -1
View File
@@ -5,8 +5,11 @@
module Simplex.Chat.Terminal where
import Control.Exception (handle, throwIO)
import Control.Monad.Except
import qualified Data.List.NonEmpty as L
import Database.SQLite.Simple (SQLError (..))
import qualified Database.SQLite.Simple as DB
import Simplex.Chat (defaultChatConfig)
import Simplex.Chat.Controller
import Simplex.Chat.Core
@@ -18,6 +21,7 @@ import Simplex.Chat.Terminal.Output
import Simplex.Messaging.Agent.Env.SQLite (InitialAgentServers (..))
import Simplex.Messaging.Client (defaultNetworkConfig)
import Simplex.Messaging.Util (raceAny_)
import System.Exit (exitFailure)
terminalChatConfig :: ChatConfig
terminalChatConfig =
@@ -38,10 +42,17 @@ terminalChatConfig =
simplexChatTerminal :: WithTerminal t => ChatConfig -> ChatOpts -> t -> IO ()
simplexChatTerminal cfg opts t = do
sendToast <- initializeNotifications
simplexChatCore cfg opts (Just sendToast) $ \u cc -> do
handle checkDBKeyError . simplexChatCore cfg opts (Just sendToast) $ \u cc -> do
ct <- newChatTerminal t
when (firstTime cc) . printToTerminal ct $ chatWelcome u
runChatTerminal ct cc
checkDBKeyError :: SQLError -> IO ()
checkDBKeyError e = case sqlError e of
DB.ErrorNotADatabase -> do
putStrLn "Database file is invalid or you passed an incorrect encryption key"
exitFailure
_ -> throwIO e
runChatTerminal :: ChatTerminal -> ChatController -> IO ()
runChatTerminal ct cc = raceAny_ [runTerminalInput ct cc, runTerminalOutput ct cc, runInputLoop ct cc]
+10 -1
View File
@@ -886,7 +886,7 @@ viewChatError = \case
CEActiveUserExists -> ["error: active user already exists"]
CEChatNotStarted -> ["error: chat not started"]
CEChatNotStopped -> ["error: chat not stopped"]
CEChatStoreChanged -> ["error: chat store changed"]
CEChatStoreChanged -> ["error: chat store changed, please restart chat"]
CEInvalidConnReq -> viewInvalidConnReq
CEInvalidChatMessage e -> ["chat message error: " <> sShow e]
CEContactNotReady c -> [ttyContact' c <> ": not ready"]
@@ -944,6 +944,12 @@ viewChatError = \case
SEConnectionNotFound _ -> [] -- TODO mutes delete group error, but also mutes any error from getConnectionEntity
SEQuotedChatItemNotFound -> ["message not found - reply is not sent"]
e -> ["chat db error: " <> sShow e]
ChatErrorDatabase err -> case err of
DBErrorEncrypted -> ["error: chat database is already encrypted"]
DBErrorPlaintext -> ["error: chat database is not encrypted"]
DBErrorExport e -> ["error encrypting database: " <> sqliteError' e]
DBErrorOpen e -> ["error opening database after encryption: " <> sqliteError' e]
e -> ["chat database error: " <> sShow e]
ChatErrorAgent err -> case err of
SMP SMP.AUTH ->
[ "error: connection authorization failed - this could happen if connection was deleted,\
@@ -955,6 +961,9 @@ viewChatError = \case
e -> ["smp agent error: " <> sShow e]
where
fileNotFound fileId = ["file " <> sShow fileId <> " not found"]
sqliteError' = \case
SQLiteErrorNotADatabase -> "wrong passphrase or invalid database file"
SQLiteError e -> sShow e
ttyContact :: ContactName -> StyledString
ttyContact = styled $ colored Green
+9 -2
View File
@@ -49,7 +49,13 @@ extra-deps:
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
# - ../simplexmq
- github: simplex-chat/simplexmq
commit: e328ae5d060645a8ef090b1b3d88bc20a5902e45
commit: f08d81252deb68eb4a1ce4502712b99264b6749d
# - ../direct-sqlcipher
- github: simplex-chat/direct-sqlcipher
commit: 34309410eb2069b029b8fc1872deb1e0db123294
# - ../sqlcipher-simple
- github: simplex-chat/sqlcipher-simple
commit: 5e154a2aeccc33ead6c243ec07195ab673137221
# - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977
- github: simplex-chat/aeson
commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7
@@ -63,6 +69,8 @@ flags:
zip:
disable-bzip2: true
disable-zstd: true
direct-sqlcipher:
openssl: true
# Extra package databases containing global packages
# extra-package-dbs: []
@@ -78,7 +86,6 @@ flags:
# arch: x86_64
#
# Extra directories used by stack for building
# extra-include-dirs: [/path/to/dir]
# extra-lib-dirs: [/path/to/dir]
#
# Allow a newer minor version of GHC than the snapshot specifies
+6 -4
View File
@@ -48,6 +48,8 @@ testOpts :: ChatOpts
testOpts =
ChatOpts
{ dbFilePrefix = undefined,
dbKey = "",
-- dbKey = "this is a pass-phrase to encrypt the database",
smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001"],
networkConfig = defaultNetworkConfig,
logConnections = False,
@@ -101,16 +103,16 @@ testCfgV1 :: ChatConfig
testCfgV1 = testCfg {agentConfig = testAgentCfgV1}
createTestChat :: ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC
createTestChat cfg opts dbPrefix profile = do
createTestChat cfg opts@ChatOpts {dbKey} dbPrefix profile = do
let dbFilePrefix = testDBPrefix <> dbPrefix
st <- createStore (dbFilePrefix <> "_chat.db") False
st <- createChatStore (dbFilePrefix <> "_chat.db") dbKey False
Right user <- withTransaction st $ \db -> runExceptT $ createUser db profile True
startTestChat_ st cfg opts dbFilePrefix user
startTestChat :: ChatConfig -> ChatOpts -> String -> IO TestCC
startTestChat cfg opts dbPrefix = do
startTestChat cfg opts@ChatOpts {dbKey} dbPrefix = do
let dbFilePrefix = testDBPrefix <> dbPrefix
st <- createStore (dbFilePrefix <> "_chat.db") False
st <- createChatStore (dbFilePrefix <> "_chat.db") dbKey False
Just user <- find activeUser <$> withTransaction st getUsers
startTestChat_ st cfg opts dbFilePrefix user
+46 -2
View File
@@ -115,6 +115,7 @@ chatTests = do
describe "maintenance mode" $ do
it "start/stop/export/import chat" testMaintenanceMode
it "export/import chat with files" testMaintenanceModeWithFiles
it "encrypt/decrypt database" testDatabaseEncryption
describe "mute/unmute messages" $ do
it "mute/unmute contact" testMuteContact
it "mute/unmute group" testMuteGroup
@@ -2717,7 +2718,7 @@ testMaintenanceMode = withTmpFiles $ do
alice <## "ok"
-- cannot start chat after import
alice ##> "/_start"
alice <## "error: chat store changed"
alice <## "error: chat store changed, please restart chat"
-- works after full restart
withTestChat "alice" $ \alice -> testChatWorking alice bob
@@ -2752,7 +2753,7 @@ testMaintenanceModeWithFiles = withTmpFiles $ do
alice <## "ok"
-- cannot start chat after delete
alice ##> "/_start"
alice <## "error: chat store changed"
alice <## "error: chat store changed, please restart chat"
doesDirectoryExist "./tests/tmp/alice_files" `shouldReturn` False
alice ##> "/_db import {\"archivePath\": \"./tests/tmp/alice-chat.zip\"}"
alice <## "ok"
@@ -2760,6 +2761,49 @@ testMaintenanceModeWithFiles = withTmpFiles $ do
-- works after full restart
withTestChat "alice" $ \alice -> testChatWorking alice bob
testDatabaseEncryption :: IO ()
testDatabaseEncryption = withTmpFiles $ do
withNewTestChat "bob" bobProfile $ \bob -> do
withNewTestChatOpts testOpts {maintenance = True} "alice" aliceProfile $ \alice -> do
alice ##> "/_start"
alice <## "chat started"
connectUsers alice bob
alice #> "@bob hi"
bob <# "alice> hi"
alice ##> "/db encrypt mykey"
alice <## "error: chat not stopped"
alice ##> "/db decrypt mykey"
alice <## "error: chat not stopped"
alice ##> "/_stop"
alice <## "chat stopped"
alice ##> "/db decrypt mykey"
alice <## "error: chat database is not encrypted"
alice ##> "/db encrypt mykey"
alice <## "ok"
alice ##> "/_start"
alice <## "error: chat store changed, please restart chat"
withTestChatOpts testOpts {maintenance = True, dbKey = "mykey"} "alice" $ \alice -> do
alice ##> "/_start"
alice <## "chat started"
testChatWorking alice bob
alice ##> "/_stop"
alice <## "chat stopped"
alice ##> "/db key wrongkey nextkey"
alice <## "error encrypting database: wrong passphrase or invalid database file"
alice ##> "/db key mykey nextkey"
alice <## "ok"
alice ##> "/_db encryption {\"currentKey\":\"nextkey\",\"newKey\":\"anotherkey\"}"
alice <## "ok"
withTestChatOpts testOpts {maintenance = True, dbKey = "anotherkey"} "alice" $ \alice -> do
alice ##> "/_start"
alice <## "chat started"
testChatWorking alice bob
alice ##> "/_stop"
alice <## "chat stopped"
alice ##> "/db decrypt anotherkey"
alice <## "ok"
withTestChat "alice" $ \alice -> testChatWorking alice bob
testMuteContact :: IO ()
testMuteContact =
testChat2 aliceProfile bobProfile $
+9 -3
View File
@@ -73,7 +73,9 @@ parsedMarkdown = "{\"formattedText\":[{\"format\":{\"type\":\"bold\"},\"text\":\
testChatApiNoUser :: IO ()
testChatApiNoUser = withTmpFiles $ do
DBMOk <- chatMigrateDB testDBPrefix ""
cc <- chatInit testDBPrefix
DBMErrorNotADatabase _ <- chatMigrateDB testDBPrefix "myKey"
chatSendCmd cc "/u" `shouldReturn` noActiveUser
chatSendCmd cc "/_start" `shouldReturn` noActiveUser
chatSendCmd cc "/u alice Alice" `shouldReturn` activeUser
@@ -81,10 +83,14 @@ testChatApiNoUser = withTmpFiles $ do
testChatApi :: IO ()
testChatApi = withTmpFiles $ do
let f = chatStoreFile $ testDBPrefix <> "1"
st <- createStore f True
let dbPrefix = testDBPrefix <> "1"
f = chatStoreFile dbPrefix
st <- createChatStore f "myKey" True
Right _ <- withTransaction st $ \db -> runExceptT $ createUser db aliceProfile True
cc <- chatInit $ testDBPrefix <> "1"
DBMOk <- chatMigrateDB testDBPrefix "myKey"
cc <- chatInitKey dbPrefix "myKey"
DBMErrorNotADatabase _ <- chatMigrateDB testDBPrefix ""
DBMErrorNotADatabase _ <- chatMigrateDB testDBPrefix "anotherKey"
chatSendCmd cc "/u" `shouldReturn` activeUser
chatSendCmd cc "/u alice Alice" `shouldReturn` activeUserExists
chatSendCmd cc "/_start" `shouldReturn` chatStarted
+2 -2
View File
@@ -5,7 +5,7 @@ module SchemaDump where
import ChatClient (withTmpFiles)
import Control.DeepSeq
import Control.Monad (void)
import Simplex.Chat.Store (createStore)
import Simplex.Chat.Store (createChatStore)
import System.Process (readCreateProcess, shell)
import Test.Hspec
@@ -22,7 +22,7 @@ schemaDumpTest =
testVerifySchemaDump :: IO ()
testVerifySchemaDump =
withTmpFiles $ do
void $ createStore testDB False
void $ createChatStore testDB "" False
void $ readCreateProcess (shell $ "touch " <> schema) ""
savedSchema <- readFile schema
savedSchema `deepseq` pure ()