diff --git a/app/src/main/java/com/mistral/chat/data/ChatDatabase.kt b/app/src/main/java/com/mistral/chat/data/ChatDatabase.kt index d735978..1a23bc7 100644 --- a/app/src/main/java/com/mistral/chat/data/ChatDatabase.kt +++ b/app/src/main/java/com/mistral/chat/data/ChatDatabase.kt @@ -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) } } diff --git a/app/src/main/java/com/mistral/chat/ui/MainActivity.kt b/app/src/main/java/com/mistral/chat/ui/MainActivity.kt index 14a1c0d..c8038e7 100644 --- a/app/src/main/java/com/mistral/chat/ui/MainActivity.kt +++ b/app/src/main/java/com/mistral/chat/ui/MainActivity.kt @@ -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,34 +927,21 @@ 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 "" diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index fcbe9a1..b2e085d 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -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" diff --git a/app/src/main/res/layout/dialog_api_key.xml b/app/src/main/res/layout/dialog_api_key.xml index f9e89ab..0d50567 100644 --- a/app/src/main/res/layout/dialog_api_key.xml +++ b/app/src/main/res/layout/dialog_api_key.xml @@ -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" />