feat: push notification modes and readable detail views (0.4.3)

Add three notification modes in device settings: push with sound, push silent, and off.
Improve event/host detail formatting for reports and inventory.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-06-11 09:35:45 +10:00
parent 38965c1a82
commit 35ba0d4a4e
9 changed files with 315 additions and 30 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "ru.kalinamall.seaca" applicationId = "ru.kalinamall.seaca"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 5 versionCode = 7
versionName = "0.4.1" versionName = "0.4.3"
} }
buildTypes { buildTypes {
@@ -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
}
}
@@ -32,6 +32,17 @@ class SessionStore(private val context: Context) {
!prefs[KEY_BASE_URL].isNullOrBlank() && securePrefs.contains(KEY_ACCESS) !prefs[KEY_BASE_URL].isNullOrBlank() && securePrefs.contains(KEY_ACCESS)
} }
val notificationMode: Flow<NotificationMode> = 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 getBaseUrl(): String? = context.dataStore.data.first()[KEY_BASE_URL]
suspend fun trustAllCerts(): Boolean = context.dataStore.data.first()[KEY_TRUST_ALL] ?: false 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_BASE_URL = stringPreferencesKey("base_url")
private val KEY_DEVICE_UUID = stringPreferencesKey("device_uuid") private val KEY_DEVICE_UUID = stringPreferencesKey("device_uuid")
private val KEY_TRUST_ALL = booleanPreferencesKey("trust_all_certs") 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_ACCESS = "access_token"
private const val KEY_REFRESH = "refresh_token" private const val KEY_REFRESH = "refresh_token"
private const val KEY_DEVICE_ID = "device_id" private const val KEY_DEVICE_ID = "device_id"
@@ -14,21 +14,50 @@ object NotificationHelper {
const val CHANNEL_DEFAULT = "sac_default" const val CHANNEL_DEFAULT = "sac_default"
const val CHANNEL_HIGH = "sac_high" const val CHANNEL_HIGH = "sac_high"
const val CHANNEL_CRITICAL = "sac_critical" 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) { fun ensureChannels(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val mgr = context.getSystemService(NotificationManager::class.java) val mgr = context.getSystemService(NotificationManager::class.java)
mgr.createNotificationChannel( 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( 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( 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( fun show(
context: Context, context: Context,
title: String, title: String,
@@ -36,13 +65,10 @@ object NotificationHelper {
severity: String?, severity: String?,
kind: String?, kind: String?,
id: String?, id: String?,
silent: Boolean = false,
) { ) {
ensureChannels(context) ensureChannels(context)
val channel = when (severity?.lowercase()) { val channel = resolveChannel(severity, silent)
"critical" -> CHANNEL_CRITICAL
"high" -> CHANNEL_HIGH
else -> CHANNEL_DEFAULT
}
val intent = Intent(context, MainActivity::class.java).apply { val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
when (kind) { when (kind) {
@@ -56,21 +82,44 @@ object NotificationHelper {
intent, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, 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) .setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(title) .setContentTitle(title)
.setContentText(body) .setContentText(body)
.setAutoCancel(true) .setAutoCancel(true)
.setContentIntent(pending) .setContentIntent(pending)
.setPriority( .setPriority(
when (channel) { when {
CHANNEL_CRITICAL -> NotificationCompat.PRIORITY_MAX silent -> NotificationCompat.PRIORITY_DEFAULT
CHANNEL_HIGH -> NotificationCompat.PRIORITY_HIGH channel == CHANNEL_CRITICAL || channel == CHANNEL_CRITICAL_SILENT ->
NotificationCompat.PRIORITY_MAX
channel == CHANNEL_HIGH || channel == CHANNEL_HIGH_SILENT ->
NotificationCompat.PRIORITY_HIGH
else -> NotificationCompat.PRIORITY_DEFAULT else -> NotificationCompat.PRIORITY_DEFAULT
}, },
) )
.build() if (silent) {
builder.setSilent(true)
builder.setSound(null)
builder.setVibrate(null)
builder.setDefaults(0)
}
context.getSystemService(NotificationManager::class.java) 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
}
} }
} }
@@ -6,6 +6,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.kalinamall.seaca.data.NotificationMode
import ru.kalinamall.seaca.data.SacRepository import ru.kalinamall.seaca.data.SacRepository
class SeacaMessagingService : FirebaseMessagingService() { class SeacaMessagingService : FirebaseMessagingService() {
@@ -13,6 +14,16 @@ class SeacaMessagingService : FirebaseMessagingService() {
private val repo by lazy { SacRepository(applicationContext) } private val repo by lazy { SacRepository(applicationContext) }
override fun onMessageReceived(message: RemoteMessage) { 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 data = message.data
val title = message.notification?.title ?: data["title"] ?: "SAC" val title = message.notification?.title ?: data["title"] ?: "SAC"
val body = message.notification?.body ?: data["body"] ?: "" val body = message.notification?.body ?: data["body"] ?: ""
@@ -23,6 +34,7 @@ class SeacaMessagingService : FirebaseMessagingService() {
severity = data["severity"], severity = data["severity"],
kind = data["kind"], kind = data["kind"],
id = data["id"], id = data["id"],
silent = silent,
) )
} }
@@ -11,22 +11,32 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import ru.kalinamall.seaca.data.EventDetail import ru.kalinamall.seaca.data.EventDetail
import ru.kalinamall.seaca.data.HostDetail import ru.kalinamall.seaca.data.HostDetail
import ru.kalinamall.seaca.data.NotificationMode
import ru.kalinamall.seaca.data.ProblemDetail import ru.kalinamall.seaca.data.ProblemDetail
import ru.kalinamall.seaca.data.SacRepository 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 @Composable
fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { 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) { DetailFrame(title = data?.title ?: "Событие", loading, error, onBack) {
val d = data ?: return@DetailFrame val d = data ?: return@DetailFrame
Field("Severity", d.severity) Field("Severity", d.severity)
Field("Тип", d.type) Field(
"Тип",
if (isDailyReportType(d.type)) dailyReportTypeLabel(d.type) else d.type,
)
Field("Хост", d.hostname) Field("Хост", d.hostname)
Field("Время", d.occurredAt) Field("Время", d.occurredAt)
Field("Кратко", d.summary) 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.osFamily} ${d.osVersion.orEmpty()}".trim())
Field("Агент", d.agentStatus) Field("Агент", d.agentStatus)
Field("Последний контакт", d.lastSeenAt) 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<String?>(null) } var username by remember { mutableStateOf<String?>(null) }
var role by remember { mutableStateOf<String?>(null) } var role by remember { mutableStateOf<String?>(null) }
var deviceId by remember { mutableStateOf(0) } var deviceId by remember { mutableStateOf(0) }
val notificationMode by repo.session.notificationMode.collectAsState(initial = NotificationMode.PUSH_SOUND)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -187,6 +214,7 @@ fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) {
Column( Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
@@ -196,6 +224,33 @@ fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) {
Field("Роль", role ?: "") Field("Роль", role ?: "")
Field("Device ID", deviceId.toString()) Field("Device ID", deviceId.toString())
Field("Версия", ru.kalinamall.seaca.BuildConfig.VERSION_NAME) 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( Button(
onClick = { onClick = {
scope.launch { scope.launch {
@@ -235,13 +290,35 @@ private fun DetailFrame(
} }
@Composable @Composable
private fun Field(label: String, value: String) { private fun MultilineField(label: String, value: String) {
Column(Modifier.fillMaxWidth()) { Column(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
) {
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
Text( Text(
value, value,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface, 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(),
) )
} }
} }
@@ -41,6 +41,11 @@ import ru.kalinamall.seaca.data.ProblemListResponse
import ru.kalinamall.seaca.data.ProblemSummary import ru.kalinamall.seaca.data.ProblemSummary
import ru.kalinamall.seaca.data.SacRepository 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun DashboardScreen(repo: SacRepository) { fun DashboardScreen(repo: SacRepository) {
@@ -173,25 +178,25 @@ fun ReportsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
LaunchedEffect(page, reportType) { load() } LaunchedEffect(page, reportType) { load() }
val reportTypeLabel = REPORT_TYPE_OPTIONS.firstOrNull { it.first == reportType }?.second ?: reportType
Column(Modifier.fillMaxSize().padding(16.dp)) { Column(Modifier.fillMaxSize().padding(16.dp)) {
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField( OutlinedTextField(
readOnly = true, readOnly = true,
value = reportType, value = reportTypeLabel,
onValueChange = {}, onValueChange = {},
label = { Text("Тип отчёта") }, label = { Text("Тип отчёта") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
modifier = Modifier.menuAnchor().fillMaxWidth(), modifier = Modifier.menuAnchor().fillMaxWidth(),
) )
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem( REPORT_TYPE_OPTIONS.forEach { (value, label) ->
text = { Text("report.daily.ssh") }, DropdownMenuItem(
onClick = { reportType = "report.daily.ssh"; expanded = false; page = 1 }, text = { Text(label) },
) onClick = { reportType = value; expanded = false; page = 1 },
DropdownMenuItem( )
text = { Text("report.daily.rdp") }, }
onClick = { reportType = "report.daily.rdp"; expanded = false; page = 1 },
)
} }
} }
error?.let { Text(it, color = MaterialTheme.colorScheme.error) } error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
@@ -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("<br\\s*/?>", RegexOption.IGNORE_CASE), "\n")
.trim()
private fun stripHtmlTags(html: String): String =
html
.replace(Regex("<br\\s*/?>", RegexOption.IGNORE_CASE), "\n")
.replace(Regex("<[^>]+>"), "")
.replace("&nbsp;", " ")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
.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<Pair<String, String>> {
val obj = inventory as? JsonObject ?: return emptyList()
val out = mutableListOf<Pair<String, String>>()
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
}
+4
View File
@@ -4,4 +4,8 @@
<string name="channel_default">SAC уведомления</string> <string name="channel_default">SAC уведомления</string>
<string name="channel_high">SAC: важные</string> <string name="channel_high">SAC: важные</string>
<string name="channel_critical">SAC: критические</string> <string name="channel_critical">SAC: критические</string>
<string name="channel_default_silent">SAC уведомления (без звука)</string>
<string name="channel_high_silent">SAC: важные (без звука)</string>
<string name="channel_critical_silent">SAC: критические (без звука)</string>
<string name="notification_mode_title">Push-уведомления</string>
</resources> </resources>