Add background notification with clickable action

This commit is contained in:
Алексей Будаев 2026-04-15 19:11:04 +08:00
parent dc461ad5dc
commit 16720a035a
3 changed files with 162 additions and 1 deletions

View file

@ -53,6 +53,7 @@ Android-приложение для чата с Mistral AI. Перспектив
- **WakeLock** - приложение остаётся активным при выключенном экране (ожидание ответа API) - **WakeLock** - приложение остаётся активным при выключенном экране (ожидание ответа API)
- **Timeout 120 сек** - увеличен с 60 до 120 секунд для больших ответов - **Timeout 120 сек** - увеличен с 60 до 120 секунд для больших ответов
- **Foreground Service** - приложение продолжает работу в фоне при выключенном экране (ожидание ответа API) - **Foreground Service** - приложение продолжает работу в фоне при выключенном экране (ожидание ответа API)
- **Уведомление о ответе ИИ** - при получении ответа в фоне показывается системное уведомление с текстом ответа и звуком/вибрацией, нажатие открывает сессию с ответом
### ✅ Security ### ✅ Security
- API ключ: EncryptedSharedPreferences (AES-256-GCM) - API ключ: EncryptedSharedPreferences (AES-256-GCM)

View file

@ -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)
}
}
}

View file

@ -1,6 +1,10 @@
package com.mistral.chat.ui package com.mistral.chat.ui
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
@ -20,6 +24,7 @@ import android.widget.EditText
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.app.NotificationCompat
import androidx.core.view.GravityCompat import androidx.core.view.GravityCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@ -99,6 +104,8 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
private var userScrolledAfterSend = false private var userScrolledAfterSend = false
private var lastUserMessagePosition = -1 private var lastUserMessagePosition = -1
private var apiKeyDialog: AlertDialog? = null private var apiKeyDialog: AlertDialog? = null
private var leftAppDuringApiCall = false
private var notificationSessionId: Long = -1L
companion object { companion object {
private const val PREFS_NAME = "mistral_chat_prefs" 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_TIMEZONE = "default_timezone"
private const val KEY_DEFAULT_CITY = "default_city" private const val KEY_DEFAULT_CITY = "default_city"
private const val MAX_PROFILES = 10 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?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -179,6 +188,18 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
loadProfilesAndSessions() loadProfilesAndSessions()
restoreCalDavConnection() 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({ inputField.postDelayed({
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
inputField.requestFocus() inputField.requestFocus()
@ -1290,6 +1311,10 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
val selectedModel = selectedModelName val selectedModel = selectedModelName
val sessionIdAtStart = currentSessionId val sessionIdAtStart = currentSessionId
// Сбрасываем флаг в начале нового запроса
leftAppDuringApiCall = false
notificationSessionId = currentSessionId ?: -1L
sendButton.isEnabled = false sendButton.isEnabled = false
progressIndicator.isVisible = true progressIndicator.isVisible = true
@ -1504,7 +1529,15 @@ if (!isActive) return@launch
Toast.makeText(this@MainActivity, responseToShow, Toast.LENGTH_LONG).show() Toast.makeText(this@MainActivity, responseToShow, Toast.LENGTH_LONG).show()
} }
} }
// Если пользователь ушел из приложения пока готовился ответ - показываем пуш
if (leftAppDuringApiCall) {
showBackgroundNotification(responseToShow)
}
// Сбрасываем флаг после обработки
leftAppDuringApiCall = false
sendButton.isEnabled = true sendButton.isEnabled = true
progressIndicator.isVisible = false progressIndicator.isVisible = false
@ -1693,4 +1726,65 @@ if (!isActive) return@launch
else -> "❌ Произошла ошибка: $error" 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
}
} }