diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d4433c3..7bd19c0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "ru.kalinamall.seaca" minSdk = 26 targetSdk = 35 - versionCode = 5 - versionName = "0.4.1" + versionCode = 7 + versionName = "0.4.3" } buildTypes { diff --git a/app/src/main/java/ru/kalinamall/seaca/data/NotificationMode.kt b/app/src/main/java/ru/kalinamall/seaca/data/NotificationMode.kt new file mode 100644 index 0000000..52eec3d --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/data/NotificationMode.kt @@ -0,0 +1,16 @@ +package ru.kalinamall.seaca.data + +enum class NotificationMode( + val storageKey: String, + val label: String, +) { + PUSH_SOUND("push_sound", "Push и звук"), + PUSH_SILENT("push_silent", "Push без звука"), + OFF("off", "Без push и звука"), + ; + + companion object { + fun fromStorage(value: String?): NotificationMode = + entries.firstOrNull { it.storageKey == value } ?: PUSH_SOUND + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/data/SessionStore.kt b/app/src/main/java/ru/kalinamall/seaca/data/SessionStore.kt index 5dbde79..1741155 100644 --- a/app/src/main/java/ru/kalinamall/seaca/data/SessionStore.kt +++ b/app/src/main/java/ru/kalinamall/seaca/data/SessionStore.kt @@ -32,6 +32,17 @@ class SessionStore(private val context: Context) { !prefs[KEY_BASE_URL].isNullOrBlank() && securePrefs.contains(KEY_ACCESS) } + val notificationMode: Flow = context.dataStore.data.map { prefs -> + NotificationMode.fromStorage(prefs[KEY_NOTIFICATION_MODE]) + } + + suspend fun getNotificationMode(): NotificationMode = + NotificationMode.fromStorage(context.dataStore.data.first()[KEY_NOTIFICATION_MODE]) + + suspend fun setNotificationMode(mode: NotificationMode) { + context.dataStore.edit { it[KEY_NOTIFICATION_MODE] = mode.storageKey } + } + suspend fun getBaseUrl(): String? = context.dataStore.data.first()[KEY_BASE_URL] suspend fun trustAllCerts(): Boolean = context.dataStore.data.first()[KEY_TRUST_ALL] ?: false @@ -90,6 +101,7 @@ class SessionStore(private val context: Context) { private val KEY_BASE_URL = stringPreferencesKey("base_url") private val KEY_DEVICE_UUID = stringPreferencesKey("device_uuid") private val KEY_TRUST_ALL = booleanPreferencesKey("trust_all_certs") + private val KEY_NOTIFICATION_MODE = stringPreferencesKey("notification_mode") private const val KEY_ACCESS = "access_token" private const val KEY_REFRESH = "refresh_token" private const val KEY_DEVICE_ID = "device_id" diff --git a/app/src/main/java/ru/kalinamall/seaca/push/NotificationHelper.kt b/app/src/main/java/ru/kalinamall/seaca/push/NotificationHelper.kt index dca3b80..d062c74 100644 --- a/app/src/main/java/ru/kalinamall/seaca/push/NotificationHelper.kt +++ b/app/src/main/java/ru/kalinamall/seaca/push/NotificationHelper.kt @@ -14,21 +14,50 @@ object NotificationHelper { const val CHANNEL_DEFAULT = "sac_default" const val CHANNEL_HIGH = "sac_high" const val CHANNEL_CRITICAL = "sac_critical" + const val CHANNEL_DEFAULT_SILENT = "sac_default_silent" + const val CHANNEL_HIGH_SILENT = "sac_high_silent" + const val CHANNEL_CRITICAL_SILENT = "sac_critical_silent" fun ensureChannels(context: Context) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val mgr = context.getSystemService(NotificationManager::class.java) mgr.createNotificationChannel( - NotificationChannel(CHANNEL_DEFAULT, context.getString(R.string.channel_default), NotificationManager.IMPORTANCE_DEFAULT), + NotificationChannel( + CHANNEL_DEFAULT, + context.getString(R.string.channel_default), + NotificationManager.IMPORTANCE_DEFAULT, + ), ) mgr.createNotificationChannel( - NotificationChannel(CHANNEL_HIGH, context.getString(R.string.channel_high), NotificationManager.IMPORTANCE_HIGH), + NotificationChannel( + CHANNEL_HIGH, + context.getString(R.string.channel_high), + NotificationManager.IMPORTANCE_HIGH, + ), ) mgr.createNotificationChannel( - NotificationChannel(CHANNEL_CRITICAL, context.getString(R.string.channel_critical), NotificationManager.IMPORTANCE_HIGH), + NotificationChannel( + CHANNEL_CRITICAL, + context.getString(R.string.channel_critical), + NotificationManager.IMPORTANCE_HIGH, + ), ) + mgr.createNotificationChannel(silentChannel(context, CHANNEL_DEFAULT_SILENT, R.string.channel_default_silent)) + mgr.createNotificationChannel(silentChannel(context, CHANNEL_HIGH_SILENT, R.string.channel_high_silent)) + mgr.createNotificationChannel(silentChannel(context, CHANNEL_CRITICAL_SILENT, R.string.channel_critical_silent)) } + private fun silentChannel(context: Context, id: String, nameRes: Int): NotificationChannel = + NotificationChannel(id, context.getString(nameRes), NotificationManager.IMPORTANCE_DEFAULT).apply { + setSound(null, null) + enableVibration(false) + setShowBadge(true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + setAllowBubbles(false) + } + lockscreenVisibility = NotificationCompat.VISIBILITY_PRIVATE + } + fun show( context: Context, title: String, @@ -36,13 +65,10 @@ object NotificationHelper { severity: String?, kind: String?, id: String?, + silent: Boolean = false, ) { ensureChannels(context) - val channel = when (severity?.lowercase()) { - "critical" -> CHANNEL_CRITICAL - "high" -> CHANNEL_HIGH - else -> CHANNEL_DEFAULT - } + val channel = resolveChannel(severity, silent) val intent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP when (kind) { @@ -56,21 +82,44 @@ object NotificationHelper { intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) - val notification = NotificationCompat.Builder(context, channel) + val builder = NotificationCompat.Builder(context, channel) .setSmallIcon(R.drawable.ic_launcher_foreground) .setContentTitle(title) .setContentText(body) .setAutoCancel(true) .setContentIntent(pending) .setPriority( - when (channel) { - CHANNEL_CRITICAL -> NotificationCompat.PRIORITY_MAX - CHANNEL_HIGH -> NotificationCompat.PRIORITY_HIGH + when { + silent -> NotificationCompat.PRIORITY_DEFAULT + channel == CHANNEL_CRITICAL || channel == CHANNEL_CRITICAL_SILENT -> + NotificationCompat.PRIORITY_MAX + channel == CHANNEL_HIGH || channel == CHANNEL_HIGH_SILENT -> + NotificationCompat.PRIORITY_HIGH else -> NotificationCompat.PRIORITY_DEFAULT }, ) - .build() + if (silent) { + builder.setSilent(true) + builder.setSound(null) + builder.setVibrate(null) + builder.setDefaults(0) + } context.getSystemService(NotificationManager::class.java) - .notify((id ?: title).hashCode(), notification) + .notify((id ?: title).hashCode(), builder.build()) + } + + private fun resolveChannel(severity: String?, silent: Boolean): String { + if (silent) { + return when (severity?.lowercase()) { + "critical" -> CHANNEL_CRITICAL_SILENT + "high" -> CHANNEL_HIGH_SILENT + else -> CHANNEL_DEFAULT_SILENT + } + } + return when (severity?.lowercase()) { + "critical" -> CHANNEL_CRITICAL + "high" -> CHANNEL_HIGH + else -> CHANNEL_DEFAULT + } } } diff --git a/app/src/main/java/ru/kalinamall/seaca/push/SeacaMessagingService.kt b/app/src/main/java/ru/kalinamall/seaca/push/SeacaMessagingService.kt index c7bc012..0611268 100644 --- a/app/src/main/java/ru/kalinamall/seaca/push/SeacaMessagingService.kt +++ b/app/src/main/java/ru/kalinamall/seaca/push/SeacaMessagingService.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import ru.kalinamall.seaca.data.NotificationMode import ru.kalinamall.seaca.data.SacRepository class SeacaMessagingService : FirebaseMessagingService() { @@ -13,6 +14,16 @@ class SeacaMessagingService : FirebaseMessagingService() { private val repo by lazy { SacRepository(applicationContext) } override fun onMessageReceived(message: RemoteMessage) { + scope.launch { + when (repo.session.getNotificationMode()) { + NotificationMode.OFF -> return@launch + NotificationMode.PUSH_SILENT -> show(message, silent = true) + NotificationMode.PUSH_SOUND -> show(message, silent = false) + } + } + } + + private fun show(message: RemoteMessage, silent: Boolean) { val data = message.data val title = message.notification?.title ?: data["title"] ?: "SAC" val body = message.notification?.body ?: data["body"] ?: "" @@ -23,6 +34,7 @@ class SeacaMessagingService : FirebaseMessagingService() { severity = data["severity"], kind = data["kind"], id = data["id"], + silent = silent, ) } diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt b/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt index 455081a..c59ce1a 100644 --- a/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt +++ b/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt @@ -11,22 +11,32 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonElement import ru.kalinamall.seaca.data.EventDetail import ru.kalinamall.seaca.data.HostDetail +import ru.kalinamall.seaca.data.NotificationMode import ru.kalinamall.seaca.data.ProblemDetail import ru.kalinamall.seaca.data.SacRepository +import ru.kalinamall.seaca.R +import ru.kalinamall.seaca.ui.util.dailyReportTypeLabel +import ru.kalinamall.seaca.ui.util.inventoryFieldLines +import ru.kalinamall.seaca.ui.util.isDailyReportType +import ru.kalinamall.seaca.ui.util.reportTextFromDetails @Composable fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { @@ -48,11 +58,19 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { DetailFrame(title = data?.title ?: "Событие", loading, error, onBack) { val d = data ?: return@DetailFrame Field("Severity", d.severity) - Field("Тип", d.type) + Field( + "Тип", + if (isDailyReportType(d.type)) dailyReportTypeLabel(d.type) else d.type, + ) Field("Хост", d.hostname) Field("Время", d.occurredAt) Field("Кратко", d.summary) - d.details?.let { Field("Детали", it.toPrettyString()) } + if (isDailyReportType(d.type)) { + reportTextFromDetails(d.details)?.let { MultilineField("Отчёт", it) } + ?: Field("Отчёт", "Полный текст отчёта недоступен") + } else { + d.details?.let { Field("Детали", it.toPrettyString()) } + } } } @@ -165,7 +183,15 @@ fun HostDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { Field("ОС", "${d.osFamily} ${d.osVersion.orEmpty()}".trim()) Field("Агент", d.agentStatus) Field("Последний контакт", d.lastSeenAt) - d.inventory?.let { Field("Inventory", it.toPrettyString()) } + d.inventory?.let { inv -> + val lines = inventoryFieldLines(inv) + if (lines.isEmpty()) { + Field("Inventory", "—") + } else { + Text("Железо и ПО", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + lines.forEach { (label, value) -> Field(label, value) } + } + } } } @@ -175,6 +201,7 @@ fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) { var username by remember { mutableStateOf(null) } var role by remember { mutableStateOf(null) } var deviceId by remember { mutableStateOf(0) } + val notificationMode by repo.session.notificationMode.collectAsState(initial = NotificationMode.PUSH_SOUND) val scope = rememberCoroutineScope() LaunchedEffect(Unit) { @@ -187,6 +214,7 @@ fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) { Column( Modifier .fillMaxSize() + .verticalScroll(rememberScrollState()) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -196,6 +224,33 @@ fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) { Field("Роль", role ?: "—") Field("Device ID", deviceId.toString()) Field("Версия", ru.kalinamall.seaca.BuildConfig.VERSION_NAME) + + Text( + stringResource(R.string.notification_mode_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp), + ) + NotificationMode.entries.forEach { mode -> + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = notificationMode == mode, + onClick = { + scope.launch { repo.session.setNotificationMode(mode) } + }, + ) + Text( + mode.label, + modifier = Modifier.padding(start = 8.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + Button( onClick = { scope.launch { @@ -235,13 +290,35 @@ private fun DetailFrame( } @Composable -private fun Field(label: String, value: String) { - Column(Modifier.fillMaxWidth()) { +private fun MultilineField(label: String, value: String) { + Column( + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) { Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) Text( value, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun Field(label: String, value: String) { + Column( + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) { + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + Text( + value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth(), ) } } diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt b/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt index da1f978..4b6acb7 100644 --- a/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt +++ b/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt @@ -41,6 +41,11 @@ import ru.kalinamall.seaca.data.ProblemListResponse import ru.kalinamall.seaca.data.ProblemSummary import ru.kalinamall.seaca.data.SacRepository +private val REPORT_TYPE_OPTIONS = listOf( + "report.daily.ssh" to "Отчёты ssh клиентов", + "report.daily.rdp" to "Отчёты RDP клиентов", +) + @OptIn(ExperimentalMaterial3Api::class) @Composable fun DashboardScreen(repo: SacRepository) { @@ -173,25 +178,25 @@ fun ReportsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { LaunchedEffect(page, reportType) { load() } + val reportTypeLabel = REPORT_TYPE_OPTIONS.firstOrNull { it.first == reportType }?.second ?: reportType + Column(Modifier.fillMaxSize().padding(16.dp)) { ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { OutlinedTextField( readOnly = true, - value = reportType, + value = reportTypeLabel, onValueChange = {}, label = { Text("Тип отчёта") }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, modifier = Modifier.menuAnchor().fillMaxWidth(), ) ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - DropdownMenuItem( - text = { Text("report.daily.ssh") }, - onClick = { reportType = "report.daily.ssh"; expanded = false; page = 1 }, - ) - DropdownMenuItem( - text = { Text("report.daily.rdp") }, - onClick = { reportType = "report.daily.rdp"; expanded = false; page = 1 }, - ) + REPORT_TYPE_OPTIONS.forEach { (value, label) -> + DropdownMenuItem( + text = { Text(label) }, + onClick = { reportType = value; expanded = false; page = 1 }, + ) + } } } error?.let { Text(it, color = MaterialTheme.colorScheme.error) } diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/util/DetailFormat.kt b/app/src/main/java/ru/kalinamall/seaca/ui/util/DetailFormat.kt new file mode 100644 index 0000000..1d33438 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/util/DetailFormat.kt @@ -0,0 +1,110 @@ +package ru.kalinamall.seaca.ui.util + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +fun isDailyReportType(type: String): Boolean = + type == "report.daily.ssh" || type == "report.daily.rdp" + +fun dailyReportTypeLabel(type: String): String = when (type) { + "report.daily.ssh" -> "Отчёты ssh клиентов" + "report.daily.rdp" -> "Отчёты RDP клиентов" + else -> type +} + +/** Текст ежедневного отчёта из details (не сырой JSON). */ +fun reportTextFromDetails(details: JsonElement?): String? { + if (details == null) return null + val obj = details as? JsonObject ?: return null + val body = obj["report_body"]?.jsonPrimitive?.contentOrNull?.trim() + if (!body.isNullOrEmpty()) { + return normalizeReportPlainText(body) + } + val html = obj["report_html"]?.jsonPrimitive?.contentOrNull?.trim() + if (!html.isNullOrEmpty()) { + return normalizeReportPlainText(stripHtmlTags(html)) + } + return null +} + +fun normalizeReportPlainText(body: String): String = + body + .replace("\r\n", "\n") + .replace("\\n", "\n") + .replace(Regex("", RegexOption.IGNORE_CASE), "\n") + .trim() + +private fun stripHtmlTags(html: String): String = + html + .replace(Regex("", RegexOption.IGNORE_CASE), "\n") + .replace(Regex("<[^>]+>"), "") + .replace(" ", " ") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") + .trim() + +private fun jsonString(obj: JsonObject, key: String): String? = + obj[key]?.jsonPrimitive?.contentOrNull?.trim()?.takeIf { it.isNotEmpty() } + +private fun jsonInt(obj: JsonObject, key: String): Int? = + obj[key]?.jsonPrimitive?.contentOrNull?.toIntOrNull() + +/** Плоский список полей инвентаризации для карточки хоста. */ +fun inventoryFieldLines(inventory: JsonElement): List> { + val obj = inventory as? JsonObject ?: return emptyList() + val out = mutableListOf>() + + jsonString(obj, "computer_name")?.let { out += "Компьютер" to it } + jsonInt(obj, "memory_gb")?.let { out += "Память" to "$it GB" } + + obj["windows"]?.jsonObject?.let { w -> + listOf("product_name" to "Продукт", "version" to "Версия", "os_hardware_abstraction_layer" to "HAL") + .forEach { (key, label) -> + jsonString(w, key)?.let { out += label to it } + } + } + + obj["motherboard"]?.jsonObject?.let { m -> + val line = listOfNotNull(jsonString(m, "manufacturer"), jsonString(m, "product")) + .joinToString(" ") + .trim() + if (line.isNotEmpty()) out += "Материнская плата" to line + } + + obj["processor"]?.jsonArray?.forEachIndexed { i, el -> + val p = el.jsonObject + val name = jsonString(p, "name") ?: return@forEachIndexed + val cores = jsonInt(p, "cores") + val logical = jsonInt(p, "logical_processors") + val suffix = when { + cores != null && logical != null -> " — $cores ядер / $logical потоков" + cores != null -> " — $cores ядер" + else -> "" + } + out += "Процессор ${i + 1}" to "$name$suffix" + } + + obj["disks"]?.jsonArray?.forEachIndexed { i, el -> + val d = el.jsonObject + val name = jsonString(d, "friendly_name") ?: "—" + val media = jsonString(d, "media_type") ?: "—" + val size = jsonInt(d, "size_gb")?.toString() ?: "—" + out += "Диск ${i + 1}" to "$name · $media · ${size} GB" + } + + obj["video"]?.jsonArray?.forEachIndexed { i, el -> + jsonString(el.jsonObject, "name")?.let { out += "Видео ${i + 1}" to it } + } + + obj["ipv4"]?.jsonArray?.let { arr -> + val ips = arr.mapNotNull { it.jsonPrimitive.contentOrNull?.trim() }.filter { it.isNotEmpty() } + if (ips.isNotEmpty()) out += "IPv4" to ips.joinToString(", ") + } + + return out +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2c4a075..3736660 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,4 +4,8 @@ SAC уведомления SAC: важные SAC: критические + SAC уведомления (без звука) + SAC: важные (без звука) + SAC: критические (без звука) + Push-уведомления