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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
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 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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String?>(null) }
|
||||
var role by remember { mutableStateOf<String?>(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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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(" ", " ")
|
||||
.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<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,4 +4,8 @@
|
||||
<string name="channel_default">SAC уведомления</string>
|
||||
<string name="channel_high">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>
|
||||
|
||||
Reference in New Issue
Block a user