diff --git a/apps/android/.gitignore b/apps/android/.gitignore index e4dd4a5169..644d967fb1 100644 --- a/apps/android/.gitignore +++ b/apps/android/.gitignore @@ -16,3 +16,4 @@ .externalNativeBuild .cxx local.properties +app/src/main/cpp/libs/ diff --git a/apps/android/app/src/main/cpp/simplex-api.c b/apps/android/app/src/main/cpp/simplex-api.c index 29676bd57e..10c9efd6fe 100644 --- a/apps/android/app/src/main/cpp/simplex-api.c +++ b/apps/android/app/src/main/cpp/simplex-api.c @@ -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; diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt index de7b76f120..7d0e7ed911 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt @@ -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() - 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) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt index 3f0a5bc312..2cd3094951 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt @@ -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) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt index c1dc9a7af3..b567e0d8c7 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt @@ -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) } } \ No newline at end of file diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 60b5961c2c..c75149437c 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -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(null) val chatRunning = mutableStateOf(null) val chatDbChanged = mutableStateOf(false) + val chatDbEncrypted = mutableStateOf(false) + val chatDbStatus = mutableStateOf(null) val chats = mutableStateListOf() // current chat diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt index ca0aeb4732..070523353a 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt @@ -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) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 10321ac912..d6996d8a26 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -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 { 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) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt index 5b3e0b0ea6..8ac28570f5 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt @@ -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) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt index c0ca2ae479..f55b234571 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt @@ -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, 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 + } + } ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseEncryptionView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseEncryptionView.kt new file mode 100644 index 0000000000..40726a4e55 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseEncryptionView.kt @@ -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, + prefs: AppPreferences, + chatDbEncrypted: Boolean?, + currentKey: MutableState, + newKey: MutableState, + confirmNewKey: MutableState, + storedKey: MutableState, + initialRandomDBPassphrase: MutableState, + 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, + currentKey: MutableState, + newKey: MutableState, + confirmNewKey: MutableState, + storedKey: MutableState, + 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, 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, alert: () -> Unit) { + m.chatDbChanged.value = true + progressIndicator.value = false + alert.invoke() +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun DatabaseKeyField( + key: MutableState, + 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 = {}, + ) + } +} \ No newline at end of file diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseErrorView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseErrorView.kt new file mode 100644 index 0000000000..13becac525 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseErrorView.kt @@ -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, + 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, 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, 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) + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt index 11c9438313..b9c0c43995 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt @@ -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, importArchiveLauncher: ManagedActivityResultLauncher, chatArchiveName: MutableState, chatArchiveTime: MutableState, @@ -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, chatLastStart: MutableState, context: Context) { +private fun startChat(m: ChatModel, runChat: MutableState, chatLastStart: MutableState, chatDbChanged: MutableState) { 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, 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, 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, context: Context) { withApi { try { @@ -262,6 +320,7 @@ private fun stopChat(m: ChatModel, runChat: MutableState, 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) { 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()) }, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DatabaseUtils.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DatabaseUtils.kt new file mode 100644 index 0000000000..431c5e177a --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DatabaseUtils.kt @@ -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 { + 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(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() +} \ No newline at end of file diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/MessagesFetcherWorker.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/MessagesFetcherWorker.kt index aff69665ad..17b856017d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/MessagesFetcherWorker.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/MessagesFetcherWorker.kt @@ -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() } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt index bc653fa2b3..bb03fa565d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt @@ -135,9 +135,10 @@ fun 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 ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt index d09fcc4ce1..12af59fc89 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt @@ -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) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Cryptor.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Cryptor.kt new file mode 100644 index 0000000000..560587ff66 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Cryptor.kt @@ -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 { + 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" + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index d74d2e9477..fd1e150a91 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -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, incognitoPref: Preference, developerTools: Preference, @@ -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 }, {}), diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index daa5ebc3ce..bd8111e434 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -67,12 +67,21 @@ Периодические уведомления Периодические уведомления выключены! Приложение периодически получает новые сообщения — это потребляет несколько процентов батареи в день. Приложение не использует push уведомления — данные не отправляются с вашего устройства на сервер. + Введите пароль + Для получения уведомлений, пожалуйста, введите пароль от базы данных + Ошибка данных + Ошибка при инициализации данных. Нажмите чтобы узнать больше SimpleX Chat сервис Приём сообщений… Скрыть + + SimpleX Chat сообщения + SimpleX Chat звонки + SimpleX Chat звонки (экран блокировки) + Сервис уведомлений Показывать уведомления @@ -112,6 +121,8 @@ Аутентификация устройства не включена. Вы можете включить блокировку SimpleX в Настройках после включения аутентификации. Аутентификация устройства выключена. Отключение блокировки SimpleX Chat. Повторить + Остановить чат + Открыть консоль Ответить @@ -291,7 +302,7 @@ Настройки Ваш SimpleX адрес - Экспорт и импорт архива чата + Пароль и экспорт базы Информация о SimpleX Chat Как использовать Форматирование сообщений @@ -512,11 +523,12 @@ Режим Инкогнито - Данные чата + База данных ЗАПУСТИТЬ ЧАТ Чат запущен Чат остановлен - АРХИВ ЧАТА + БАЗА ДАННЫХ + Пароль базы данных Экспорт архива чата Импорт архива чата Новый архив чата @@ -524,8 +536,10 @@ Удалить данные чата Ошибка при запуске чата Остановить чат? - Остановите чат, чтобы экспортировать или импортировать архив чата или удалить данные чата. Вы не сможете получать и отправлять сообщения, пока чат остановлен. + Остановите чат, чтобы экспортировать или импортировать архив чата или удалить базу данных. Вы не сможете получать и отправлять сообщения, пока чат остановлен. Остановить + Установите пароль + База данных зашифрована случайным паролем. Пожалуйста, поменяйте его перед экспортом. Ошибка при остановке чата Ошибка при экспорте архива чата Импортировать архив чата? @@ -541,7 +555,53 @@ Перезапустите приложение, чтобы создать новый профиль. Используйте самую последнюю версию архива чата и ТОЛЬКО на одном устройстве, иначе вы можете перестать получать сообщения от некоторых контактов. Остановите чат, чтобы разблокировать операции с архивом чата. - Перезапустите приложение, чтобы использовать новый архив чата. + + + Сохранить пароль в Keystore + База данных зашифрована! + Ошибка при шифровании + Удалить пароль из Keystore? + Уведомления будут работать только до остановки приложения! + Удалить + Зашифровать + Поменять + Текущий пароль… + Новый пароль… + Подтвердите новый пароль… + Поменять пароль + Пожалуйста, введите правильный пароль. + База данных НЕ зашифрована. Установите пароль, чтобы защитить ваши данные. + Android Keystore используется для безопасного хранения пароля - это позволяет стабильно получать уведомления в фоновом режиме. + База данных зашифрована случайным паролем, вы можете его поменять. + Внимание: вы не сможете восстановить или поменять пароль, если вы его потеряете. + Пароль базы данных будет безопасно сохранен в Android Keystore после запуска чата или изменения пароля - это позволит стабильно получать уведомления. + Пароль не сохранен на устройстве — вы будете должны ввести его при каждом запуске чата. + Зашифровать базу данных? + Поменять пароль базы данных? + База данных будет зашифрована. + База данных будут зашифрована и пароль сохранен в Keystore. + Пароль базы данных будет изменен и сохранен в Keystore. + Пароль базы данных будет изменен. + Пожалуйста, надежно сохраните пароль, вы НЕ сможете его поменять, если потеряете. + Пожалуйста, надежно сохраните пароль, вы НЕ сможете открыть чат, если вы потеряете пароль. + + + Неправильный пароль базы данных + База данных зашифрована + Ошибка базы данных + Ошибка Keystore + Пароль базы данных отличается от пароля сохрененного в Keystore. + Файл: %s + Введите пароль базы данных чтобы открыть чат. + Ошибка: %s + Невозможно сохранить пароль в Keystore + Неизвестная ошибка базы данных: %s + Неправильный пароль! + Введите правильный пароль. + Неизвестная ошибка + Введите пароль… + Сохранить пароль и открыть чат + Открыть чат Чат остановлен diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 032eb9fa6f..590288ca1e 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -67,12 +67,21 @@ Periodic notifications Periodic notifications are disabled! 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. + Passphrase is needed + To receive notifications, please, enter the database passphrase + Can\'t initialize the database + The database is not working correctly. Tap to learn more SimpleX Chat service Receiving messages… Hide + + SimpleX Chat messages + SimpleX Chat calls + SimpleX Chat calls (lock screen) + Notification service Show preview @@ -112,6 +121,8 @@ Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication. Device authentication is disabled. Turning off SimpleX Lock. Retry + Stop chat + Open chat console Reply @@ -295,7 +306,7 @@ Your settings Your SimpleX contact address - Database export & import + Database passphrase & export About SimpleX Chat How to use it Markdown help @@ -518,6 +529,7 @@ Chat is running Chat is stopped CHAT DATABASE + Database passphrase Export database Import database New database archive @@ -527,6 +539,8 @@ Stop chat? Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped. Stop + Set passphrase to export + Database is encrypted using a random passphrase. Please change it before exporting. Error stopping chat Error exporting chat database Import chat database? @@ -542,7 +556,53 @@ Restart the app to create a new chat profile. You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts. Stop chat to enable database actions. - Restart the app to use new chat database. + + + Save passphrase in Keystore + Database encrypted! + Error encrypting database + Remove passphrase from Keystore? + Notifications will be delivered only until the app stops! + Remove + Encrypt + Update + Current passphrase… + New passphrase… + Confirm new passphrase… + Update database passphrase + Please enter correct current passphrase. + Your chat database is not encrypted - set passphrase to protect it. + Android Keystore is used to securely store passphrase - it allows notification service to work. + Database is encrypted using a random passphrase, you can change it. + Please note: you will NOT be able to recover or change passphrase if you lose it. + Android Keystore will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving notifications. + You have to enter passphrase every time the app starts - it is not stored on the device. + Encrypt database? + Change database passphrase? + Database will be encrypted. + Database will be encrypted and the passphrase stored in the Keystore. + Database encryption passphrase will be updated and stored in the Keystore. + Database encryption passphrase will be updated. + Please store passphrase securely, you will NOT be able to change it if you lose it. + Please store passphrase securely, you will NOT be able to access chat if you lose it. + + + Wrong database passphrase + Encrypted database + Database error + Keychain error + Database passphrase is different from saved in the Keystore. + File: %s + Database passphrase is required to open chat. + Error: %s + Cannot access Keystore to save database password + Unknown database error: %s + Wrong passphrase! + Enter correct passphrase. + Unknown error + Enter passphrase… + Save passphrase and open chat + Open chat Chat is stopped