From 16720a035a92484267b803b6cbc207099300c3cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B5=D0=B9=20=D0=91=D1=83?= =?UTF-8?q?=D0=B4=D0=B0=D0=B5=D0=B2?= Date: Wed, 15 Apr 2026 19:11:04 +0800 Subject: [PATCH] Add background notification with clickable action --- AGENTS.md | 1 + .../mistral/chat/api/ApiForegroundService.kt | 66 +++++++++++++ .../java/com/mistral/chat/ui/MainActivity.kt | 96 ++++++++++++++++++- 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/mistral/chat/api/ApiForegroundService.kt diff --git a/AGENTS.md b/AGENTS.md index 3975605..175e05c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,7 @@ Android-приложение для чата с Mistral AI. Перспектив - **WakeLock** - приложение остаётся активным при выключенном экране (ожидание ответа API) - **Timeout 120 сек** - увеличен с 60 до 120 секунд для больших ответов - **Foreground Service** - приложение продолжает работу в фоне при выключенном экране (ожидание ответа API) +- **Уведомление о ответе ИИ** - при получении ответа в фоне показывается системное уведомление с текстом ответа и звуком/вибрацией, нажатие открывает сессию с ответом ### ✅ Security - API ключ: EncryptedSharedPreferences (AES-256-GCM) diff --git a/app/src/main/java/com/mistral/chat/api/ApiForegroundService.kt b/app/src/main/java/com/mistral/chat/api/ApiForegroundService.kt new file mode 100644 index 0000000..bd67c52 --- /dev/null +++ b/app/src/main/java/com/mistral/chat/api/ApiForegroundService.kt @@ -0,0 +1,66 @@ +package com.mistral.chat.api + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import com.mistral.chat.R + +class ApiForegroundService : Service() { + + companion object { + const val CHANNEL_ID = "api_service_channel" + const val NOTIFICATION_ID = 1002 // Different from AI response notification + + fun start(context: Context) { + val intent = Intent(context, ApiForegroundService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + fun stop(context: Context) { + val intent = Intent(context, ApiForegroundService::class.java) + context.stopService(intent) + } + } + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Mistral Chat") + .setContentText("Получение ответа от AI...") + .setSmallIcon(android.R.drawable.ic_menu_send) + .setOngoing(true) + .build() + startForeground(NOTIFICATION_ID, notification) + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + super.onDestroy() + stopForeground(STOP_FOREGROUND_REMOVE) + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "API Service", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Уведомление о работе API" + } + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(channel) + } + } +} \ No newline at end of file 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 36cddc8..7759c13 100644 --- a/app/src/main/java/com/mistral/chat/ui/MainActivity.kt +++ b/app/src/main/java/com/mistral/chat/ui/MainActivity.kt @@ -1,6 +1,10 @@ package com.mistral.chat.ui +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent import android.content.Context +import android.content.Intent import android.content.SharedPreferences import android.content.pm.PackageManager import android.os.Build @@ -20,6 +24,7 @@ import android.widget.EditText import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate +import androidx.core.app.NotificationCompat import androidx.core.view.GravityCompat import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope @@ -99,6 +104,8 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte private var userScrolledAfterSend = false private var lastUserMessagePosition = -1 private var apiKeyDialog: AlertDialog? = null + private var leftAppDuringApiCall = false + private var notificationSessionId: Long = -1L companion object { private const val PREFS_NAME = "mistral_chat_prefs" @@ -110,6 +117,8 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte private const val KEY_DEFAULT_TIMEZONE = "default_timezone" private const val KEY_DEFAULT_CITY = "default_city" private const val MAX_PROFILES = 10 + private const val CHANNEL_ID_AI_RESPONSE = "ai_response_channel" + private const val NOTIFICATION_ID_AI_RESPONSE = 1001 } override fun onCreate(savedInstanceState: Bundle?) { @@ -179,6 +188,18 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte loadProfilesAndSessions() restoreCalDavConnection() + // Обработка session_id из уведомления + intent?.getLongExtra("session_id", -1L)?.let { sessionId -> + if (sessionId > 0) { + lifecycleScope.launch { + val session = database.sessionDao().getSessionById(sessionId) + session?.let { + selectSession(it) + } + } + } + } + inputField.postDelayed({ val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager inputField.requestFocus() @@ -1290,6 +1311,10 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte val selectedModel = selectedModelName val sessionIdAtStart = currentSessionId + // Сбрасываем флаг в начале нового запроса + leftAppDuringApiCall = false + notificationSessionId = currentSessionId ?: -1L + sendButton.isEnabled = false progressIndicator.isVisible = true @@ -1504,7 +1529,15 @@ if (!isActive) return@launch Toast.makeText(this@MainActivity, responseToShow, Toast.LENGTH_LONG).show() } } - + + // Если пользователь ушел из приложения пока готовился ответ - показываем пуш + if (leftAppDuringApiCall) { + showBackgroundNotification(responseToShow) + } + + // Сбрасываем флаг после обработки + leftAppDuringApiCall = false + sendButton.isEnabled = true progressIndicator.isVisible = false @@ -1693,4 +1726,65 @@ if (!isActive) return@launch else -> "❌ Произошла ошибка: $error" } } + + private fun showBackgroundNotification(response: String) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val hasPermission = checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + if (!hasPermission) { + return + } + } + + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID_AI_RESPONSE, + "Ответы ИИ", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Уведомления о полученных ответах ИИ" + enableVibration(true) + enableLights(true) + } + notificationManager.createNotificationChannel(channel) + } + + val title = "Ответ ИИ получен" + val body = if (response.length > 100) response.take(100) + "..." else response + + val intent = Intent(applicationContext, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra("session_id", notificationSessionId) + } + val pendingIntent = PendingIntent.getActivity( + applicationContext, + notificationSessionId.toInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID_AI_RESPONSE) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(title) + .setContentText(body) + .setStyle(NotificationCompat.BigTextStyle().bigText(response)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .setDefaults(NotificationCompat.DEFAULT_ALL) + .setContentIntent(pendingIntent) + .build() + + notificationManager.notify(NOTIFICATION_ID_AI_RESPONSE, notification) + } + + override fun onPause() { + super.onPause() + leftAppDuringApiCall = true + } + + override fun onResume() { + super.onResume() + leftAppDuringApiCall = false + } } \ No newline at end of file