Add API key validation, secure DB key storage, input field padding, scroll fixes

This commit is contained in:
Алексей Будаев 2026-04-07 22:31:28 +08:00
parent 7eadec669c
commit 5d59c5e351
4 changed files with 71 additions and 30 deletions

View file

@ -4,6 +4,8 @@ import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import net.sqlcipher.database.SupportFactory
@Database(
@ -44,14 +46,25 @@ abstract class ChatDatabase : RoomDatabase() {
}
private fun getOrCreatePassphrase(context: Context): ByteArray {
val prefs = context.getSharedPreferences("db_prefs", Context.MODE_PRIVATE)
val existingKey = prefs.getString("db_key", null)
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val securePrefs = EncryptedSharedPreferences.create(
context,
"db_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
val existingKey = securePrefs.getString("db_key", null)
return if (existingKey != null) {
existingKey.toByteArray(Charsets.UTF_8)
} else {
val newKey = generateSecureKey()
prefs.edit().putString("db_key", newKey).apply()
securePrefs.edit().putString("db_key", newKey).apply()
newKey.toByteArray(Charsets.UTF_8)
}
}

View file

@ -83,6 +83,9 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
private var userMessageCount = 0
private var titleGenerationJob: kotlinx.coroutines.Job? = null
private var isFirstLoad = true
private var userScrolledAfterSend = false
private var lastUserMessagePosition = -1
private var apiKeyDialog: AlertDialog? = null
companion object {
private const val PREFS_NAME = "mistral_chat_prefs"
@ -599,15 +602,24 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
stackFromEnd = true
}
recyclerView.adapter = adapter
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
val lastVisible = layoutManager.findLastVisibleItemPosition()
val totalItems = layoutManager.itemCount
if (lastVisible < totalItems - 2) {
userScrolledAfterSend = true
}
}
})
}
private fun setupInput() {
sendButton.setOnClickListener {
if (currentJob?.isActive == true) {
cancelRequest()
} else {
sendInput()
}
sendInput()
}
inputField.setOnEditorActionListener { _, actionId, event ->
@ -624,6 +636,13 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
val userInput = inputField.text?.toString()?.trim()
if (userInput.isNullOrEmpty()) return
val apiKey = getApiKey()
if (apiKey.isEmpty() || apiKey.length < 32 || !apiKey.matches(Regex("^[a-zA-Z0-9]+$"))) {
Toast.makeText(this, getString(R.string.api_key_required), Toast.LENGTH_SHORT).show()
showApiKeyDialog()
return
}
if (currentSessionId == null) {
createNewSessionAndSend(userInput)
return
@ -632,6 +651,14 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName()))
inputField.text?.clear()
userScrolledAfterSend = false
lastUserMessagePosition = messages.size - 1
recyclerView.postDelayed({
recyclerView.scrollToPosition(lastUserMessagePosition)
}, 100)
sendMessage(userInput)
}
@ -741,10 +768,11 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
messages.add(message)
adapter.notifyItemInserted(newPosition)
// Scroll to show new AI response
if (!message.isUser) {
if (!message.isUser && !userScrolledAfterSend) {
recyclerView.postDelayed({
recyclerView.scrollToPosition(newPosition)
if (!userScrolledAfterSend) {
recyclerView.scrollToPosition(newPosition)
}
}, 150)
}
@ -821,7 +849,14 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
.setPositiveButton(R.string.save) { _, _ ->
val newKey = inputField.text?.toString()?.trim()
if (!newKey.isNullOrEmpty()) {
if (newKey.length < 32 || !newKey.matches(Regex("^[a-zA-Z0-9]+$"))) {
Toast.makeText(this, "Неверный формат API ключа (минимум 32 символа, только a-z, A-Z, 0-9)", Toast.LENGTH_LONG).show()
return@setPositiveButton
}
saveApiKey(newKey)
client = MistralClient(newKey)
apiKeyDialog?.dismiss()
Toast.makeText(this, getString(R.string.api_key_saved), Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this, getString(R.string.enter_api_key), Toast.LENGTH_SHORT).show()
@ -829,13 +864,17 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
}
.apply {
if (hasCustomKey) {
setNegativeButton(R.string.cancel, null)
setNegativeButton(R.string.cancel) { dialog, _ ->
dialog.dismiss()
}
setNeutralButton(R.string.delete) { _, _ ->
deleteApiKey()
}
}
}
.setCancelable(false)
.create()
.also { apiKeyDialog = it }
.show()
}
@ -843,7 +882,6 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
val selectedModel = selectedModelName
sendButton.isEnabled = false
sendButton.setImageResource(R.drawable.ic_stop)
progressIndicator.isVisible = true
currentJob = lifecycleScope.launch {
@ -889,35 +927,22 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
}
sendButton.isEnabled = true
sendButton.setImageResource(R.drawable.ic_mistral_logo)
progressIndicator.isVisible = false
} catch (e: kotlinx.coroutines.CancellationException) {
if (!isActive) return@launch
addMessage(Message(content = "Запрос отменён", isUser = false, senderName = "System"))
sendButton.isEnabled = true
sendButton.setImageResource(R.drawable.ic_mistral_logo)
progressIndicator.isVisible = false
} catch (e: Exception) {
if (!isActive) return@launch
val userFriendlyMessage = getUserFriendlyError(e.message ?: "Unknown error")
addMessage(Message(content = userFriendlyMessage, isUser = false, senderName = "Error"))
sendButton.isEnabled = true
sendButton.setImageResource(R.drawable.ic_mistral_logo)
progressIndicator.isVisible = false
}
}
}
private fun cancelRequest() {
currentJob?.cancel()
titleGenerationJob?.cancel()
client?.cancelRequest()
sendButton.isEnabled = true
sendButton.setImageResource(R.drawable.ic_mistral_logo)
progressIndicator.isVisible = false
}
private fun getSelectedProfileContext(): String {
if (currentProfileId == null) return ""

View file

@ -109,8 +109,8 @@
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="4dp"
android:paddingEnd="0dp"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="4dp"
android:paddingBottom="4dp">
@ -125,8 +125,8 @@
android:inputType="textMultiLine|textCapSentences"
android:maxLines="5"
android:minHeight="56dp"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="12dp"
android:paddingBottom="12dp" />
@ -134,6 +134,7 @@
android:id="@+id/sendButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="4dp"
android:padding="8dp"
android:src="@drawable/ic_mistral_logo"
android:background="@drawable/bg_send_button"

View file

@ -19,6 +19,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:digits="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
android:maxLength="64"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>