android: database encryption support with a passphrase (#1021)

* Ability to encrypt credentials and to store them securelly

* Don't regenerate key if it exists

* Made code shorter

* Refactoring

* Initial support of encryped database

* Changes in UI and notifications about database problems

* Small changes to how we use chatController instance

* Show unlock view in console automatically

* Fixed wrong place of saving a key

* Fixed a crash

* update icons

* Changing controller correctly

* Enable migration

* fix JNI

* Fixed startup

* Show database error view when password is wrong while enabling a chat

* Chat controller re-init in one more place
- also added one more alert

* Scrollable columns and restarted service and worker

* translations

* database passphrase

* update translations

* translations

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* update translations

* update translations

* update icon colors, show empty passphrase as not stored

* update translation

* update translations

* shared section footer, bigger font, layout, change entropy bounds

* correction

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* update translations

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
This commit is contained in:
Stanislav Dmitrenko
2022-09-14 14:06:12 +03:00
committed by GitHub
parent 17f806e7a2
commit 78f854e2c5
21 changed files with 1278 additions and 97 deletions

View File

@@ -16,3 +16,4 @@
.externalNativeBuild
.cxx
local.properties
app/src/main/cpp/libs/

View File

@@ -36,7 +36,7 @@ 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 = (jlong)chat_migrate_db(_dbPath, _dbKey);
jstring res = (*env)->NewStringUTF(env, chat_migrate_db(_dbPath, _dbKey));
(*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
(*env)->ReleaseStringUTFChars(env, dbKey, _dbKey);
return res;

View File

@@ -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) {

View File

@@ -35,14 +35,37 @@ 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)
@@ -55,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) {

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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
}
}
)
}

View File

@@ -0,0 +1,501 @@
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,
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>,
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) { 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 = {
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)
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,
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
)
}
}
}
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) },
onConfirmEncrypt = {},
)
}
}

View File

@@ -0,0 +1,175 @@
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 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, 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)
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, appPreferences)
}
if (useKeychain) {
SaveAndOpenButton(buttonEnabled, saveAndRunChatOnClick)
} else {
OpenChatButton(buttonEnabled) { runChat(dbKey.value, chatDbStatus, 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 -> {
}
}
}
}
}
}
private fun runChat(dbKey: String, chatDbStatus: State<DBMigrationResult?>, prefs: AppPreferences) {
try {
SimplexApp.context.initChatController(dbKey)
} catch (e: Exception) {
Log.d(TAG, "initializeChat ${e.stackTraceToString()}")
}
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)
)
}
}

View File

@@ -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()) },

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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
)
}

View File

@@ -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)

View File

@@ -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"
}
}

View File

@@ -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 }, {}),

View File

@@ -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>

View File

@@ -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>