Add API key validation, secure DB key storage, input field padding, scroll fixes
This commit is contained in:
parent
7eadec669c
commit
5d59c5e351
4 changed files with 71 additions and 30 deletions
|
|
@ -4,6 +4,8 @@ import android.content.Context
|
||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
import net.sqlcipher.database.SupportFactory
|
import net.sqlcipher.database.SupportFactory
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
|
|
@ -44,14 +46,25 @@ abstract class ChatDatabase : RoomDatabase() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getOrCreatePassphrase(context: Context): ByteArray {
|
private fun getOrCreatePassphrase(context: Context): ByteArray {
|
||||||
val prefs = context.getSharedPreferences("db_prefs", Context.MODE_PRIVATE)
|
val masterKey = MasterKey.Builder(context)
|
||||||
val existingKey = prefs.getString("db_key", null)
|
.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) {
|
return if (existingKey != null) {
|
||||||
existingKey.toByteArray(Charsets.UTF_8)
|
existingKey.toByteArray(Charsets.UTF_8)
|
||||||
} else {
|
} else {
|
||||||
val newKey = generateSecureKey()
|
val newKey = generateSecureKey()
|
||||||
prefs.edit().putString("db_key", newKey).apply()
|
securePrefs.edit().putString("db_key", newKey).apply()
|
||||||
newKey.toByteArray(Charsets.UTF_8)
|
newKey.toByteArray(Charsets.UTF_8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,9 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
private var userMessageCount = 0
|
private var userMessageCount = 0
|
||||||
private var titleGenerationJob: kotlinx.coroutines.Job? = null
|
private var titleGenerationJob: kotlinx.coroutines.Job? = null
|
||||||
private var isFirstLoad = true
|
private var isFirstLoad = true
|
||||||
|
private var userScrolledAfterSend = false
|
||||||
|
private var lastUserMessagePosition = -1
|
||||||
|
private var apiKeyDialog: AlertDialog? = null
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val PREFS_NAME = "mistral_chat_prefs"
|
private const val PREFS_NAME = "mistral_chat_prefs"
|
||||||
|
|
@ -599,16 +602,25 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
stackFromEnd = true
|
stackFromEnd = true
|
||||||
}
|
}
|
||||||
recyclerView.adapter = adapter
|
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() {
|
private fun setupInput() {
|
||||||
sendButton.setOnClickListener {
|
sendButton.setOnClickListener {
|
||||||
if (currentJob?.isActive == true) {
|
|
||||||
cancelRequest()
|
|
||||||
} else {
|
|
||||||
sendInput()
|
sendInput()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
inputField.setOnEditorActionListener { _, actionId, event ->
|
inputField.setOnEditorActionListener { _, actionId, event ->
|
||||||
if (actionId == EditorInfo.IME_ACTION_SEND || actionId == EditorInfo.IME_ACTION_GO || actionId == EditorInfo.IME_ACTION_DONE || (event?.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN)) {
|
if (actionId == EditorInfo.IME_ACTION_SEND || actionId == EditorInfo.IME_ACTION_GO || actionId == EditorInfo.IME_ACTION_DONE || (event?.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN)) {
|
||||||
|
|
@ -624,6 +636,13 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
val userInput = inputField.text?.toString()?.trim()
|
val userInput = inputField.text?.toString()?.trim()
|
||||||
if (userInput.isNullOrEmpty()) return
|
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) {
|
if (currentSessionId == null) {
|
||||||
createNewSessionAndSend(userInput)
|
createNewSessionAndSend(userInput)
|
||||||
return
|
return
|
||||||
|
|
@ -632,6 +651,14 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName()))
|
addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName()))
|
||||||
|
|
||||||
inputField.text?.clear()
|
inputField.text?.clear()
|
||||||
|
|
||||||
|
userScrolledAfterSend = false
|
||||||
|
lastUserMessagePosition = messages.size - 1
|
||||||
|
|
||||||
|
recyclerView.postDelayed({
|
||||||
|
recyclerView.scrollToPosition(lastUserMessagePosition)
|
||||||
|
}, 100)
|
||||||
|
|
||||||
sendMessage(userInput)
|
sendMessage(userInput)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -741,10 +768,11 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
messages.add(message)
|
messages.add(message)
|
||||||
adapter.notifyItemInserted(newPosition)
|
adapter.notifyItemInserted(newPosition)
|
||||||
|
|
||||||
// Scroll to show new AI response
|
if (!message.isUser && !userScrolledAfterSend) {
|
||||||
if (!message.isUser) {
|
|
||||||
recyclerView.postDelayed({
|
recyclerView.postDelayed({
|
||||||
|
if (!userScrolledAfterSend) {
|
||||||
recyclerView.scrollToPosition(newPosition)
|
recyclerView.scrollToPosition(newPosition)
|
||||||
|
}
|
||||||
}, 150)
|
}, 150)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -821,7 +849,14 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
.setPositiveButton(R.string.save) { _, _ ->
|
.setPositiveButton(R.string.save) { _, _ ->
|
||||||
val newKey = inputField.text?.toString()?.trim()
|
val newKey = inputField.text?.toString()?.trim()
|
||||||
if (!newKey.isNullOrEmpty()) {
|
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)
|
saveApiKey(newKey)
|
||||||
|
client = MistralClient(newKey)
|
||||||
|
apiKeyDialog?.dismiss()
|
||||||
Toast.makeText(this, getString(R.string.api_key_saved), Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, getString(R.string.api_key_saved), Toast.LENGTH_SHORT).show()
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(this, getString(R.string.enter_api_key), Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, getString(R.string.enter_api_key), Toast.LENGTH_SHORT).show()
|
||||||
|
|
@ -829,13 +864,17 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
}
|
}
|
||||||
.apply {
|
.apply {
|
||||||
if (hasCustomKey) {
|
if (hasCustomKey) {
|
||||||
setNegativeButton(R.string.cancel, null)
|
setNegativeButton(R.string.cancel) { dialog, _ ->
|
||||||
|
dialog.dismiss()
|
||||||
|
}
|
||||||
setNeutralButton(R.string.delete) { _, _ ->
|
setNeutralButton(R.string.delete) { _, _ ->
|
||||||
deleteApiKey()
|
deleteApiKey()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.setCancelable(false)
|
.setCancelable(false)
|
||||||
|
.create()
|
||||||
|
.also { apiKeyDialog = it }
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -843,7 +882,6 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
val selectedModel = selectedModelName
|
val selectedModel = selectedModelName
|
||||||
|
|
||||||
sendButton.isEnabled = false
|
sendButton.isEnabled = false
|
||||||
sendButton.setImageResource(R.drawable.ic_stop)
|
|
||||||
progressIndicator.isVisible = true
|
progressIndicator.isVisible = true
|
||||||
|
|
||||||
currentJob = lifecycleScope.launch {
|
currentJob = lifecycleScope.launch {
|
||||||
|
|
@ -889,35 +927,22 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
|
||||||
}
|
}
|
||||||
|
|
||||||
sendButton.isEnabled = true
|
sendButton.isEnabled = true
|
||||||
sendButton.setImageResource(R.drawable.ic_mistral_logo)
|
|
||||||
progressIndicator.isVisible = false
|
progressIndicator.isVisible = false
|
||||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||||
if (!isActive) return@launch
|
if (!isActive) return@launch
|
||||||
addMessage(Message(content = "Запрос отменён", isUser = false, senderName = "System"))
|
addMessage(Message(content = "Запрос отменён", isUser = false, senderName = "System"))
|
||||||
sendButton.isEnabled = true
|
sendButton.isEnabled = true
|
||||||
sendButton.setImageResource(R.drawable.ic_mistral_logo)
|
|
||||||
progressIndicator.isVisible = false
|
progressIndicator.isVisible = false
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (!isActive) return@launch
|
if (!isActive) return@launch
|
||||||
val userFriendlyMessage = getUserFriendlyError(e.message ?: "Unknown error")
|
val userFriendlyMessage = getUserFriendlyError(e.message ?: "Unknown error")
|
||||||
addMessage(Message(content = userFriendlyMessage, isUser = false, senderName = "Error"))
|
addMessage(Message(content = userFriendlyMessage, isUser = false, senderName = "Error"))
|
||||||
sendButton.isEnabled = true
|
sendButton.isEnabled = true
|
||||||
sendButton.setImageResource(R.drawable.ic_mistral_logo)
|
|
||||||
progressIndicator.isVisible = false
|
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 {
|
private fun getSelectedProfileContext(): String {
|
||||||
if (currentProfileId == null) return ""
|
if (currentProfileId == null) return ""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -109,8 +109,8 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:paddingStart="4dp"
|
android:paddingStart="8dp"
|
||||||
android:paddingEnd="0dp"
|
android:paddingEnd="8dp"
|
||||||
android:paddingTop="4dp"
|
android:paddingTop="4dp"
|
||||||
android:paddingBottom="4dp">
|
android:paddingBottom="4dp">
|
||||||
|
|
||||||
|
|
@ -125,8 +125,8 @@
|
||||||
android:inputType="textMultiLine|textCapSentences"
|
android:inputType="textMultiLine|textCapSentences"
|
||||||
android:maxLines="5"
|
android:maxLines="5"
|
||||||
android:minHeight="56dp"
|
android:minHeight="56dp"
|
||||||
android:paddingStart="8dp"
|
android:paddingStart="12dp"
|
||||||
android:paddingEnd="8dp"
|
android:paddingEnd="12dp"
|
||||||
android:paddingTop="12dp"
|
android:paddingTop="12dp"
|
||||||
android:paddingBottom="12dp" />
|
android:paddingBottom="12dp" />
|
||||||
|
|
||||||
|
|
@ -134,6 +134,7 @@
|
||||||
android:id="@+id/sendButton"
|
android:id="@+id/sendButton"
|
||||||
android:layout_width="40dp"
|
android:layout_width="40dp"
|
||||||
android:layout_height="40dp"
|
android:layout_height="40dp"
|
||||||
|
android:layout_marginStart="4dp"
|
||||||
android:padding="8dp"
|
android:padding="8dp"
|
||||||
android:src="@drawable/ic_mistral_logo"
|
android:src="@drawable/ic_mistral_logo"
|
||||||
android:background="@drawable/bg_send_button"
|
android:background="@drawable/bg_send_button"
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:inputType="textPassword"
|
android:inputType="textPassword"
|
||||||
|
android:digits="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
android:maxLength="64"
|
||||||
android:maxLines="1" />
|
android:maxLines="1" />
|
||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue