From 9116e0472d73c91091bd6b1b0a0069ad268e9b9e Mon Sep 17 00:00:00 2001 From: PTah Date: Tue, 14 Jul 2026 20:43:58 +1000 Subject: [PATCH] chore(home): mirror from kalinamall (6398d48) with papatramp URLs --- .gitignore | 33 + README.md | 54 ++ app/build.gradle.kts | 103 +++ app/google-services.json.example | 29 + app/proguard-rules.pro | 1 + app/src/main/AndroidManifest.xml | 52 ++ .../java/ru/kalinamall/seaca/MainActivity.kt | 335 +++++++ .../ru/kalinamall/seaca/SeacaApplication.kt | 23 + .../seaca/data/DateTimeDisplayMode.kt | 18 + .../kalinamall/seaca/data/NotificationMode.kt | 16 + .../seaca/data/NotificationSoundSource.kt | 20 + .../java/ru/kalinamall/seaca/data/SacApi.kt | 92 ++ .../ru/kalinamall/seaca/data/SacModels.kt | 265 ++++++ .../ru/kalinamall/seaca/data/SacRepository.kt | 152 ++++ .../ru/kalinamall/seaca/data/SessionStore.kt | 151 ++++ .../seaca/push/AppForegroundState.kt | 32 + .../ru/kalinamall/seaca/push/FcmRegistrar.kt | 26 + .../seaca/push/NotificationHelper.kt | 155 ++++ .../seaca/push/NotificationSoundResolver.kt | 109 +++ .../seaca/push/SeacaMessagingService.kt | 60 ++ .../seaca/security/BiometricGate.kt | 48 + .../ru/kalinamall/seaca/security/TlsTrust.kt | 38 + .../ru/kalinamall/seaca/ui/RingtonePicker.kt | 61 ++ .../ui/components/HostSessionsSection.kt | 239 +++++ .../seaca/ui/components/NavButtons.kt | 48 + .../seaca/ui/components/RdgAccessBadge.kt | 39 + .../seaca/ui/components/RdgFlapBadge.kt | 43 + .../seaca/ui/components/RdgQwinstaDialog.kt | 334 +++++++ .../seaca/ui/screens/ConnectScreen.kt | 125 +++ .../seaca/ui/screens/DetailScreens.kt | 821 ++++++++++++++++++ .../seaca/ui/screens/ListScreens.kt | 577 ++++++++++++ .../ru/kalinamall/seaca/ui/theme/Theme.kt | 44 + .../kalinamall/seaca/ui/util/DetailFormat.kt | 359 ++++++++ .../ru/kalinamall/seaca/ui/util/RdgQwinsta.kt | 88 ++ .../kalinamall/seaca/ui/util/SacDateTimes.kt | 46 + .../seaca/ui/util/SessionActions.kt | 26 + .../res/drawable/ic_launcher_foreground.xml | 10 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/values/colors.xml | 4 + app/src/main/res/values/strings.xml | 16 + app/src/main/res/values/themes.xml | 4 + app/src/main/res/xml/file_paths.xml | 4 + .../main/res/xml/network_security_config.xml | 4 + build.gradle.kts | 7 + docs/ROADMAP.md | 112 +++ docs/operator-guide.md | 39 + gradle.properties | 4 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43504 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew.bat | 92 ++ scripts/check-github-clean.sh | 52 ++ scripts/push-remotes.sh | 78 ++ scripts/readme/_body.md | 68 ++ scripts/readme/github.md | 46 + scripts/readme/home.md | 46 + scripts/readme/kalinamall.md | 46 + secrets.properties.example | 8 + settings.gradle.kts | 16 + 59 files changed, 5335 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/google-services.json.example create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/ru/kalinamall/seaca/MainActivity.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/SeacaApplication.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/data/DateTimeDisplayMode.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/data/NotificationMode.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/data/NotificationSoundSource.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/data/SacApi.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/data/SacModels.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/data/SacRepository.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/data/SessionStore.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/push/AppForegroundState.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/push/FcmRegistrar.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/push/NotificationHelper.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/push/NotificationSoundResolver.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/push/SeacaMessagingService.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/security/BiometricGate.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/security/TlsTrust.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/ui/RingtonePicker.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/ui/components/HostSessionsSection.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/ui/components/NavButtons.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/ui/components/RdgAccessBadge.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/ui/components/RdgFlapBadge.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/ui/components/RdgQwinstaDialog.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/ui/screens/ConnectScreen.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/ui/theme/Theme.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/ui/util/DetailFormat.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/ui/util/RdgQwinsta.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/ui/util/SacDateTimes.kt create mode 100644 app/src/main/java/ru/kalinamall/seaca/ui/util/SessionActions.kt create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 build.gradle.kts create mode 100644 docs/ROADMAP.md create mode 100644 docs/operator-guide.md create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew.bat create mode 100644 scripts/check-github-clean.sh create mode 100644 scripts/push-remotes.sh create mode 100644 scripts/readme/_body.md create mode 100644 scripts/readme/github.md create mode 100644 scripts/readme/home.md create mode 100644 scripts/readme/kalinamall.md create mode 100644 secrets.properties.example create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a13d5e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Android / Gradle +*.iml +.gradle/ +/local.properties +/.idea/ +.DS_Store +/build/ +/captures/ +.externalNativeBuild/ +.cxx/ +*.apk +*.aab +*.ap_ +*.dex + +# Kotlin +.kotlin/ + +# Secrets +google-services.json +*.keystore +*.jks +local.properties +secrets.properties +app/src/main/java/ru/kalinamall/seaca/ui/screens/DevConnectDefaults.kt + +# Env +.env +.env.* + +# Cursor (не в git) +.cursor/ +.cursorignore diff --git a/README.md b/README.md new file mode 100644 index 0000000..839813b --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# Seaca + +Android-клиент [Security Alert Center (SAC)](https://git.papatramp.ru/PapaTramp/security-alert-center). + +**Версия:** `0.5.16` (versionCode 30) + +## Возможности + +- Обзор, события, проблемы, хосты, отчёты (API веб-SAC) +- Push (FCM), режимы звука, выбор ringtone; уведомления снимаются при открытии приложения +- Ack / Resolve проблем (monitor+) +- **RDG** — метка flap, путь RDS; **«Оборвать сессию»** → SAC API → WinRM на клиентский ПК +- Завершение сессий SSH/RDP с карточки события и списка активных сессий на хосте +- Биометрия при запуске и возврате в приложение +- Формат даты, экран «Моё устройство» + +## Нет в приложении (только веб-SAC) + +Пользователи, каналы оповещений, WinRM domain admin, коды регистрации. + +## Привязка + +1. Админ: мобильные устройства + код `sacmob_…` +2. URL API SAC + код + логин/пароль в Seaca +3. FCM-токен регистрируется автоматически + +[docs/operator-guide.md](docs/operator-guide.md) · SAC [docs/seaca-mobile.md](https://git.papatramp.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-mobile.md) + +## Сборка + +JDK 17+, `local.properties`. Опционально `app/google-services.json` для FCM. + +**Debug** (ключ свой на каждом ПК): + +```bash +./gradlew assembleDebug +``` + +APK: `app/build/outputs/apk/debug/app-debug.apk` + +**Release** (один ключ на всех ПК — для обновлений поверх старой версии): + +1. Положить `seaca-release.jks` в корень репозитория (не в git). +2. Скопировать `secrets.properties.example` → `secrets.properties`, подставить пароль (ASCII). +3. `.\gradlew assembleRelease` — APK: `app/build/outputs/apk/release/app-release.apk` + +## Репозитории + +| Репозиторий | URL | +|-------------|-----| +| seaca | https://git.papatramp.ru/PapaTramp/seaca | +| security-alert-center | https://git.papatramp.ru/PapaTramp/security-alert-center | +| ssh-monitor | https://git.papatramp.ru/PapaTramp/ssh-monitor | +| RDP-login-monitor | https://git.papatramp.ru/PapaTramp/rdp-login-monitor | diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..5a2aaf2 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,103 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.kotlin.plugin.serialization") +} + +val secretsFile = rootProject.file("secrets.properties") +val releaseSecrets = Properties().apply { + if (secretsFile.exists()) { + secretsFile.inputStream().use { load(it) } + } +} +val releaseKeystoreConfigured = secretsFile.exists() && + releaseSecrets.getProperty("SEACA_STORE_FILE") != null + +android { + namespace = "ru.kalinamall.seaca" + compileSdk = 35 + + defaultConfig { + applicationId = "ru.kalinamall.seaca" + minSdk = 26 + targetSdk = 35 + versionCode = 30 + versionName = "0.5.16" + } + + signingConfigs { + if (releaseKeystoreConfigured) { + create("release") { + storeFile = rootProject.file(releaseSecrets.getProperty("SEACA_STORE_FILE")) + storePassword = releaseSecrets.getProperty("SEACA_STORE_PASSWORD") + keyAlias = releaseSecrets.getProperty("SEACA_KEY_ALIAS") + keyPassword = releaseSecrets.getProperty("SEACA_KEY_PASSWORD") + } + } + } + + buildTypes { + release { + if (releaseKeystoreConfigured) { + signingConfig = signingConfigs.getByName("release") + } + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + buildConfig = true + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.10.01") + implementation(composeBom) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.navigation:navigation-compose:2.8.3") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation("androidx.lifecycle:lifecycle-process:2.8.7") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + implementation("androidx.datastore:datastore-preferences:1.1.1") + implementation("androidx.security:security-crypto:1.1.0-alpha06") + implementation("androidx.biometric:biometric:1.1.0") + + implementation("com.squareup.retrofit2:retrofit:2.11.0") + implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0") + + implementation(platform("com.google.firebase:firebase-bom:33.5.1")) + implementation("com.google.firebase:firebase-messaging-ktx") + + debugImplementation("androidx.compose.ui:ui-tooling") +} + +if (file("google-services.json").exists()) { + apply(plugin = "com.google.gms.google-services") +} diff --git a/app/google-services.json.example b/app/google-services.json.example new file mode 100644 index 0000000..9cce29e --- /dev/null +++ b/app/google-services.json.example @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "000000000000", + "project_id": "REPLACE_ME", + "storage_bucket": "REPLACE_ME.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000000", + "android_client_info": { + "package_name": "ru.kalinamall.seaca" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "REPLACE_ME" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..5e662e9 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1 @@ +# Seaca — defaults sufficient for debug/release without minify diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7a175bd --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/ru/kalinamall/seaca/MainActivity.kt b/app/src/main/java/ru/kalinamall/seaca/MainActivity.kt new file mode 100644 index 0000000..770cfe5 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/MainActivity.kt @@ -0,0 +1,335 @@ +package ru.kalinamall.seaca + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Computer +import androidx.compose.material.icons.filled.Dashboard +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableStateOf as composeMutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import kotlinx.coroutines.launch +import ru.kalinamall.seaca.data.SacRepository +import ru.kalinamall.seaca.push.FcmRegistrar +import ru.kalinamall.seaca.security.BiometricGate +import ru.kalinamall.seaca.ui.screens.ConnectScreen +import ru.kalinamall.seaca.ui.screens.DashboardScreen +import ru.kalinamall.seaca.ui.screens.DeviceScreen +import ru.kalinamall.seaca.ui.screens.EventDetailScreen +import ru.kalinamall.seaca.ui.screens.EventsScreen +import ru.kalinamall.seaca.ui.screens.HostDetailScreen +import ru.kalinamall.seaca.ui.screens.HostsScreen +import ru.kalinamall.seaca.ui.screens.ProblemDetailScreen +import ru.kalinamall.seaca.ui.screens.ProblemsScreen +import ru.kalinamall.seaca.ui.ExternalPickerHost +import ru.kalinamall.seaca.ui.RingtonePicker +import ru.kalinamall.seaca.ui.screens.ReportsScreen +import ru.kalinamall.seaca.ui.theme.SeacaTheme + +class MainActivity : FragmentActivity(), ExternalPickerHost { + private lateinit var repo: SacRepository + private val biometricUnlocked = mutableStateOf(true) + private var pendingBiometric = false + private var externalPickerFlowActive = false + private val deepLinkState = composeMutableStateOf(null) + + override fun beginExternalPickerFlow() { + externalPickerFlowActive = true + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + repo = SacRepository(applicationContext) + requestNotificationPermission() + deepLinkState.value = extractDeepLink(intent) + if (shouldRequireBiometric()) { + biometricUnlocked.value = false + pendingBiometric = true + } + setContent { + val unlocked by biometricUnlocked + SeacaApp( + repo = repo, + deepLink = deepLinkState.value, + onDeepLinkConsumed = { deepLinkState.value = null }, + biometricUnlocked = unlocked, + ) + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + deepLinkState.value = extractDeepLink(intent) + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + @Suppress("DEPRECATION") + super.onActivityResult(requestCode, resultCode, data) + RingtonePicker.handleActivityResult(this, requestCode, resultCode, data) + } + + override fun onStop() { + super.onStop() + if (externalPickerFlowActive) return + if (shouldRequireBiometric()) { + biometricUnlocked.value = false + pendingBiometric = true + } + } + + override fun onResume() { + super.onResume() + if (externalPickerFlowActive) { + externalPickerFlowActive = false + return + } + if (pendingBiometric && !biometricUnlocked.value && repo.session.getAccessToken() != null) { + pendingBiometric = false + BiometricGate.authenticate( + activity = this, + onSuccess = { biometricUnlocked.value = true }, + onFailure = { finish() }, + ) + } + } + + private fun shouldRequireBiometric(): Boolean = + repo.session.getAccessToken() != null && BiometricGate.canAuthenticate(this) + + private fun extractDeepLink(intent: Intent?): String? { + intent?.getStringExtra("deep_link")?.let { return it } + val uri: Uri = intent?.data ?: return null + val id = uri.lastPathSegment ?: return null + return when (uri.host) { + "event" -> "event/$id" + "problem" -> "problem/$id" + else -> null + } + } + + private fun requestNotificationPermission() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED + ) { + return + } + ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1001) + } +} + +@Composable +private fun SeacaApp( + repo: SacRepository, + deepLink: String?, + onDeepLinkConsumed: () -> Unit, + biometricUnlocked: Boolean, +) { + val context = LocalContext.current + val loggedIn by repo.session.isLoggedIn.collectAsStateWithLifecycle(initialValue = false) + val nav = rememberNavController() + val scope = rememberCoroutineScope() + LaunchedEffect(loggedIn) { + if (loggedIn) { + FcmRegistrar.registerIfPossible(context, repo) + } + } + + LaunchedEffect(deepLink, loggedIn, biometricUnlocked) { + if (!loggedIn || !biometricUnlocked || deepLink.isNullOrBlank()) return@LaunchedEffect + when { + deepLink.startsWith("event/") -> { + nav.navigate("event/${deepLink.removePrefix("event/")}") + onDeepLinkConsumed() + } + deepLink.startsWith("problem/") -> { + nav.navigate("problem/${deepLink.removePrefix("problem/")}") + onDeepLinkConsumed() + } + } + } + + val canOperate = remember(loggedIn) { + val role = repo.session.getRole() + role == "operator" || role == "admin" + } + var mainTabIndex by rememberSaveable { mutableIntStateOf(MainTab.Dashboard.ordinal) } + var dashboardRefreshTick by rememberSaveable { mutableIntStateOf(0) } + val mainTab = MainTab.entries[mainTabIndex.coerceIn(MainTab.entries.indices)] + + SeacaTheme { + if (!loggedIn) { + ConnectScreen( + repo = repo, + onConnected = { + scope.launch { FcmRegistrar.registerIfPossible(context, repo) } + }, + ) + return@SeacaTheme + } + + if (!biometricUnlocked) { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } + return@SeacaTheme + } + + NavHost(navController = nav, startDestination = "main") { + composable("main") { + MainShell( + repo = repo, + selectedTab = mainTab, + dashboardRefreshTick = dashboardRefreshTick, + onTabSelected = { tab -> + mainTabIndex = tab.ordinal + if (tab == MainTab.Dashboard) { + dashboardRefreshTick++ + } + }, + onOpenEvent = { nav.navigate("event/$it") }, + onOpenProblem = { nav.navigate("problem/$it") }, + onOpenHost = { nav.navigate("host/$it") }, + onOpenDevice = { nav.navigate("device") }, + ) + } + composable( + "event/{id}", + arguments = listOf(navArgument("id") { type = NavType.LongType }), + ) { entry -> + EventDetailScreen(repo, entry.arguments?.getLong("id") ?: 0L) { nav.popBackStack() } + } + composable( + "problem/{id}", + arguments = listOf(navArgument("id") { type = NavType.LongType }), + ) { entry -> + ProblemDetailScreen( + repo = repo, + id = entry.arguments?.getLong("id") ?: 0L, + canOperate = canOperate, + onBack = { nav.popBackStack() }, + onChanged = { }, + ) + } + composable( + "host/{id}", + arguments = listOf(navArgument("id") { type = NavType.LongType }), + ) { entry -> + HostDetailScreen( + repo = repo, + id = entry.arguments?.getLong("id") ?: 0L, + onOpenEvent = { nav.navigate("event/$it") }, + onBack = { nav.popBackStack() }, + ) + } + composable("device") { + DeviceScreen(repo) { nav.popBackStack("main", false) } + } + } + } +} + +private enum class MainTab(val label: String) { + Dashboard("Обзор"), + Events("События"), + Problems("Проблемы"), + Hosts("Хосты"), + Reports("Отчёты"), +} + +@Composable +private fun MainShell( + repo: SacRepository, + selectedTab: MainTab, + dashboardRefreshTick: Int, + onTabSelected: (MainTab) -> Unit, + onOpenEvent: (Long) -> Unit, + onOpenProblem: (Long) -> Unit, + onOpenHost: (Long) -> Unit, + onOpenDevice: () -> Unit, +) { + Scaffold( + bottomBar = { + NavigationBar { + MainTab.entries.forEach { item -> + NavigationBarItem( + selected = selectedTab == item, + onClick = { onTabSelected(item) }, + icon = { + Icon( + when (item) { + MainTab.Dashboard -> Icons.Default.Dashboard + MainTab.Events -> Icons.Default.Warning + MainTab.Problems -> Icons.Default.Error + MainTab.Hosts -> Icons.Default.Computer + MainTab.Reports -> Icons.Default.Description + }, + contentDescription = item.label, + ) + }, + label = { Text(item.label) }, + ) + } + NavigationBarItem( + selected = false, + onClick = onOpenDevice, + icon = { Icon(Icons.Default.PhoneAndroid, contentDescription = "Устройство") }, + label = { Text("Устройство") }, + ) + } + }, + ) { padding -> + Box(Modifier.padding(padding)) { + when (selectedTab) { + MainTab.Dashboard -> DashboardScreen(repo, refreshKey = dashboardRefreshTick) + MainTab.Events -> EventsScreen(repo, onOpenEvent) + MainTab.Problems -> ProblemsScreen(repo, onOpenProblem) + MainTab.Hosts -> HostsScreen(repo, onOpenHost) + MainTab.Reports -> ReportsScreen(repo, onOpenEvent) + } + } + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/SeacaApplication.kt b/app/src/main/java/ru/kalinamall/seaca/SeacaApplication.kt new file mode 100644 index 0000000..3571534 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/SeacaApplication.kt @@ -0,0 +1,23 @@ +package ru.kalinamall.seaca + +import android.app.Application +import kotlinx.coroutines.runBlocking +import ru.kalinamall.seaca.data.SessionStore +import ru.kalinamall.seaca.push.AppForegroundState +import ru.kalinamall.seaca.push.NotificationHelper +import ru.kalinamall.seaca.push.NotificationSoundResolver + +class SeacaApplication : Application() { + override fun onCreate() { + super.onCreate() + AppForegroundState.bind(this) + val store = SessionStore(this) + val settings = runBlocking { + NotificationSoundResolver.normalizeStoredSettings(this@SeacaApplication, store) + } + NotificationHelper.ensureChannels( + this, + NotificationSoundResolver.resolve(this, settings), + ) + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/data/DateTimeDisplayMode.kt b/app/src/main/java/ru/kalinamall/seaca/data/DateTimeDisplayMode.kt new file mode 100644 index 0000000..4411338 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/data/DateTimeDisplayMode.kt @@ -0,0 +1,18 @@ +package ru.kalinamall.seaca.data + +enum class DateTimeDisplayMode( + val storageKey: String, + val label: String, + val pattern: String?, +) { + SLASH("slash", "ДД/ММ/ГГГГ ЧЧ:мм:сс", "dd/MM/yyyy HH:mm:ss"), + DASH("dash", "ДД-ММ-ГГГГ ЧЧ:мм:сс", "dd-MM-yyyy HH:mm:ss"), + DOT("dot", "ДД.ММ.ГГГГ ЧЧ:мм:сс", "dd.MM.yyyy HH:mm:ss"), + LOCALE("locale", "Как в системе телефона", null), + ; + + companion object { + fun fromStorage(value: String?): DateTimeDisplayMode = + entries.firstOrNull { it.storageKey == value } ?: SLASH + } +} 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/NotificationSoundSource.kt b/app/src/main/java/ru/kalinamall/seaca/data/NotificationSoundSource.kt new file mode 100644 index 0000000..f83bb83 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/data/NotificationSoundSource.kt @@ -0,0 +1,20 @@ +package ru.kalinamall.seaca.data + +enum class NotificationSoundSource( + val storageKey: String, + val label: String, +) { + SYSTEM("system", "Системный звук"), + CUSTOM("custom", "Свой звук"), + ; + + companion object { + fun fromStorage(value: String?): NotificationSoundSource = + entries.firstOrNull { it.storageKey == value } ?: SYSTEM + } +} + +data class NotificationSoundSettings( + val source: NotificationSoundSource, + val customUri: String?, +) diff --git a/app/src/main/java/ru/kalinamall/seaca/data/SacApi.kt b/app/src/main/java/ru/kalinamall/seaca/data/SacApi.kt new file mode 100644 index 0000000..d216f0e --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/data/SacApi.kt @@ -0,0 +1,92 @@ +package ru.kalinamall.seaca.data + +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path +import retrofit2.http.Query + +interface SacApi { + @GET("health") + suspend fun health(): HealthResponse + + @POST("api/v1/mobile/enroll") + suspend fun enroll(@Body body: EnrollRequest): AuthResponse + + @POST("api/v1/mobile/auth/refresh") + suspend fun refresh(@Body body: RefreshRequest): AuthResponse + + @PUT("api/v1/mobile/devices/me/fcm") + suspend fun updateFcmToken(@Body body: FcmTokenRequest) + + @GET("api/v1/dashboards/summary") + suspend fun dashboardSummary(): DashboardSummary + + @GET("api/v1/events") + suspend fun listEvents( + @Query("page") page: Int = 1, + @Query("page_size") pageSize: Int = 30, + @Query("severity") severity: String? = null, + @Query("type") type: String? = null, + @Query("host_id") hostId: Long? = null, + @Query("include_hidden") includeHidden: Boolean? = null, + @Query("hostname") hostname: String? = null, + @Query("q") q: String? = null, + ): EventListResponse + + @GET("api/v1/events/{id}") + suspend fun getEvent(@Path("id") id: Long): EventDetail + + @POST("api/v1/events/{id}/actions/qwinsta") + suspend fun postEventQwinsta(@Path("id") id: Long): AgentCommandResponse + + @POST("api/v1/events/{id}/actions/logoff") + suspend fun postEventLogoff( + @Path("id") id: Long, + @Body body: LogoffActionRequest, + ): AgentCommandResponse + + @POST("api/v1/events/{id}/actions/terminate-session") + suspend fun postEventTerminateSession( + @Path("id") id: Long, + @Body body: EventSessionTerminateRequest = EventSessionTerminateRequest(), + ): SessionActionResponse + + @POST("api/v1/hosts/{id}/actions/sessions/list") + suspend fun postHostSessionsList(@Path("id") id: Long): HostSessionsResponse + + @POST("api/v1/hosts/{id}/actions/sessions/terminate") + suspend fun postHostSessionTerminate( + @Path("id") id: Long, + @Body body: HostSessionTerminateRequest, + ): SessionActionResponse + + @GET("api/v1/problems") + suspend fun listProblems( + @Query("page") page: Int = 1, + @Query("page_size") pageSize: Int = 30, + @Query("status") status: String? = null, + @Query("severity") severity: String? = null, + @Query("hostname") hostname: String? = null, + ): ProblemListResponse + + @GET("api/v1/problems/{id}") + suspend fun getProblem(@Path("id") id: Long): ProblemDetail + + @POST("api/v1/problems/{id}/ack") + suspend fun ackProblem(@Path("id") id: Long): ProblemActionResponse + + @POST("api/v1/problems/{id}/resolve") + suspend fun resolveProblem(@Path("id") id: Long): ProblemActionResponse + + @GET("api/v1/hosts") + suspend fun listHosts( + @Query("page") page: Int = 1, + @Query("page_size") pageSize: Int = 30, + @Query("hostname") hostname: String? = null, + ): HostListResponse + + @GET("api/v1/hosts/{id}") + suspend fun getHost(@Path("id") id: Long): HostDetail +} diff --git a/app/src/main/java/ru/kalinamall/seaca/data/SacModels.kt b/app/src/main/java/ru/kalinamall/seaca/data/SacModels.kt new file mode 100644 index 0000000..6474fcc --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/data/SacModels.kt @@ -0,0 +1,265 @@ +package ru.kalinamall.seaca.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class HealthResponse( + val status: String = "", + val database: String? = null, + val version: String? = null, +) + +@Serializable +data class EnrollRequest( + @SerialName("enrollment_code") val enrollmentCode: String, + val username: String? = null, + val password: String? = null, + @SerialName("device_uuid") val deviceUuid: String, + @SerialName("display_name") val displayName: String = "Android", + val platform: String = "android", + @SerialName("app_version") val appVersion: String, + @SerialName("fcm_token") val fcmToken: String? = null, +) + +@Serializable +data class RefreshRequest( + @SerialName("refresh_token") val refreshToken: String, + @SerialName("device_uuid") val deviceUuid: String, +) + +@Serializable +data class AuthResponse( + @SerialName("access_token") val accessToken: String, + @SerialName("refresh_token") val refreshToken: String, + @SerialName("token_type") val tokenType: String = "bearer", + @SerialName("device_id") val deviceId: Int, + val username: String, + val role: String, +) + +@Serializable +data class FcmTokenRequest( + @SerialName("fcm_token") val fcmToken: String, +) + +@Serializable +data class EventSummary( + val id: Long, + @SerialName("event_id") val eventId: String, + @SerialName("host_id") val hostId: Long, + val hostname: String, + @SerialName("display_name") val displayName: String? = null, + @SerialName("occurred_at") val occurredAt: String, + @SerialName("received_at") val receivedAt: String? = null, + val type: String, + val severity: String, + val title: String, + val summary: String, + @SerialName("actor_user") val actorUser: String? = null, + @SerialName("rdg_flap") val rdgFlap: Boolean = false, + @SerialName("rdg_flap_pair_event_id") val rdgFlapPairEventId: Long? = null, + @SerialName("rdg_flap_qwinsta_event_id") val rdgFlapQwinstaEventId: Long? = null, + @SerialName("rdg_access_path") val rdgAccessPath: String? = null, + @SerialName("rdg_qwinsta_enabled") val rdgQwinstaEnabled: Boolean = false, + @SerialName("session_terminated") val sessionTerminated: Boolean = false, +) + +@Serializable +data class EventListResponse( + val items: List, + val total: Int, + val page: Int, + @SerialName("page_size") val pageSize: Int, +) + +@Serializable +data class EventDetail( + val id: Long, + @SerialName("event_id") val eventId: String, + @SerialName("host_id") val hostId: Long, + val hostname: String, + @SerialName("display_name") val displayName: String? = null, + @SerialName("occurred_at") val occurredAt: String, + val type: String, + val severity: String, + val title: String, + val summary: String, + @SerialName("actor_user") val actorUser: String? = null, + @SerialName("rdg_flap") val rdgFlap: Boolean = false, + @SerialName("rdg_flap_pair_event_id") val rdgFlapPairEventId: Long? = null, + @SerialName("rdg_flap_qwinsta_event_id") val rdgFlapQwinstaEventId: Long? = null, + @SerialName("rdg_access_path") val rdgAccessPath: String? = null, + @SerialName("rdg_qwinsta_enabled") val rdgQwinstaEnabled: Boolean = false, + @SerialName("session_terminated") val sessionTerminated: Boolean = false, + val details: JsonElement? = null, + val raw: JsonElement? = null, + val payload: JsonElement? = null, +) + +@Serializable +data class ProblemSummary( + val id: Long, + @SerialName("host_id") val hostId: Long? = null, + val hostname: String? = null, + val title: String, + val summary: String, + val severity: String, + val status: String, + @SerialName("created_at") val createdAt: String, + @SerialName("updated_at") val updatedAt: String? = null, +) + +@Serializable +data class ProblemListResponse( + val items: List, + val total: Int, + val page: Int, + @SerialName("page_size") val pageSize: Int, +) + +@Serializable +data class ProblemEventItem( + val id: Long, + @SerialName("event_id") val eventId: String, + @SerialName("occurred_at") val occurredAt: String, + val type: String, + val severity: String, + val title: String, + val summary: String, +) + +@Serializable +data class ProblemDetail( + val id: Long, + val title: String, + val summary: String, + val severity: String, + val status: String, + val hostname: String? = null, + val events: List = emptyList(), +) + +@Serializable +data class HostSummary( + val id: Long, + val hostname: String, + @SerialName("display_name") val displayName: String? = null, + @SerialName("os_family") val osFamily: String, + val product: String, + @SerialName("agent_status") val agentStatus: String, + @SerialName("last_seen_at") val lastSeenAt: String, +) + +@Serializable +data class HostListResponse( + val items: List, + val total: Int, + val page: Int, + @SerialName("page_size") val pageSize: Int, +) + +@Serializable +data class HostDetail( + val id: Long, + val hostname: String, + @SerialName("display_name") val displayName: String? = null, + @SerialName("os_family") val osFamily: String, + @SerialName("os_version") val osVersion: String? = null, + val product: String, + @SerialName("product_version") val productVersion: String? = null, + @SerialName("agent_status") val agentStatus: String, + @SerialName("last_seen_at") val lastSeenAt: String, + val inventory: JsonElement? = null, +) + +@Serializable +data class TopHostItem( + @SerialName("host_id") val hostId: Long, + val hostname: String, + val count: Int, +) + +@Serializable +data class TopTypeItem( + val type: String, + val count: Int, +) + +@Serializable +data class DashboardSummary( + @SerialName("events_last_24h") val eventsLast24h: Int, + @SerialName("hosts_total") val hostsTotal: Int, + @SerialName("hosts_stale") val hostsStale: Int, + @SerialName("problems_open") val problemsOpen: Int, + @SerialName("severity_24h") val severity24h: Map = emptyMap(), + @SerialName("top_hosts") val topHosts: List = emptyList(), + @SerialName("top_event_types") val topEventTypes: List = emptyList(), + @SerialName("recent_events") val recentEvents: List = emptyList(), +) + +@Serializable +data class ProblemActionResponse( + val id: Long, + val status: String, +) + +@Serializable +data class LogoffActionRequest( + @SerialName("session_id") val sessionId: Int, +) + +@Serializable +data class HostSessionItem( + @SerialName("session_id") val sessionId: String, + val user: String, + val tty: String? = null, + val state: String? = null, + @SerialName("source_ip") val sourceIp: String? = null, + @SerialName("session_name") val sessionName: String? = null, +) + +@Serializable +data class HostSessionsResponse( + val ok: Boolean, + val message: String, + val target: String? = null, + val sessions: List = emptyList(), +) + +@Serializable +data class HostSessionTerminateRequest( + @SerialName("session_id") val sessionId: String, +) + +@Serializable +data class EventSessionTerminateRequest( + @SerialName("session_id") val sessionId: String? = null, +) + +@Serializable +data class SessionActionResponse( + val ok: Boolean, + val message: String, + val target: String? = null, + val stdout: String? = null, + val stderr: String? = null, +) + +@Serializable +data class AgentCommandResponse( + @SerialName("command_uuid") val commandUuid: String, + @SerialName("command_type") val commandType: String, + val status: String, + @SerialName("result_stdout") val resultStdout: String? = null, + @SerialName("result_stderr") val resultStderr: String? = null, + val target: String? = null, + @SerialName("client_hostname") val clientHostname: String? = null, + @SerialName("internal_ip") val internalIp: String? = null, +) + +@Serializable +data class ApiErrorBody( + val detail: String? = null, +) diff --git a/app/src/main/java/ru/kalinamall/seaca/data/SacRepository.kt b/app/src/main/java/ru/kalinamall/seaca/data/SacRepository.kt new file mode 100644 index 0000000..a36244b --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/data/SacRepository.kt @@ -0,0 +1,152 @@ +package ru.kalinamall.seaca.data + +import android.content.Context +import kotlinx.serialization.json.Json +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import ru.kalinamall.seaca.BuildConfig +import ru.kalinamall.seaca.security.TlsTrust +import java.util.concurrent.TimeUnit + +class SacRepository(context: Context) { + val session = SessionStore(context.applicationContext) + private val json = Json { ignoreUnknownKeys = true; isLenient = true } + + @Volatile + private var api: SacApi? = null + + @Volatile + private var baseUrl: String? = null + + suspend fun ensureClient(): SacApi { + val url = session.getBaseUrl() ?: error("Не задан URL сервера") + if (api == null || baseUrl != url) { + baseUrl = url + api = buildApi(url, session.trustAllCerts()) + } + return api!! + } + + suspend fun checkHealth(serverUrl: String, trustAll: Boolean): HealthResponse { + val normalized = normalizeUrl(serverUrl) + return buildApi(normalized, trustAll).health() + } + + suspend fun enroll( + serverUrl: String, + enrollmentCode: String, + username: String?, + password: String?, + displayName: String, + fcmToken: String?, + trustAll: Boolean, + ): AuthResponse { + val normalized = normalizeUrl(serverUrl) + val deviceUuid = session.getDeviceUuid() + val response = buildApi(normalized, trustAll).enroll( + EnrollRequest( + enrollmentCode = enrollmentCode.trim(), + username = username?.trim()?.ifBlank { null }, + password = password?.ifBlank { null }, + deviceUuid = deviceUuid, + displayName = displayName, + appVersion = BuildConfig.VERSION_NAME, + fcmToken = fcmToken, + ), + ) + session.saveSession(normalized, response) + if (trustAll) session.setTrustAllCerts(true) + api = buildApi(normalized, trustAll) + baseUrl = normalized + return response + } + + suspend fun withAuth(block: suspend (SacApi) -> T): T { + val client = ensureClient() + return try { + block(client) + } catch (e: retrofit2.HttpException) { + if (e.code() == 401) { + refreshSession() + block(ensureClient()) + } else { + throw e + } + } + } + + private suspend fun refreshSession() { + val refresh = session.getRefreshToken() ?: throw IllegalStateException("Сессия истекла") + val uuid = session.getDeviceUuid() + val url = session.getBaseUrl() ?: error("Нет URL") + val response = buildApi(url, session.trustAllCerts()).refresh( + RefreshRequest(refreshToken = refresh, deviceUuid = uuid), + ) + session.updateTokens(response) + api = buildApi(url, session.trustAllCerts()) + } + + suspend fun registerFcmToken(token: String) { + withAuth { it.updateFcmToken(FcmTokenRequest(token)) } + } + + suspend fun logout() { + api = null + baseUrl = null + session.clear() + } + + private fun buildApi(url: String, trustAll: Boolean): SacApi { + val authInterceptor = Interceptor { chain -> + val token = session.getAccessToken() + val request = if (token != null) { + chain.request().newBuilder() + .addHeader("Authorization", "Bearer $token") + .build() + } else { + chain.request() + } + chain.proceed(request) + } + val logging = HttpLoggingInterceptor().apply { + level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC else HttpLoggingInterceptor.Level.NONE + } + val okhttp = OkHttpClient.Builder() + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .sslSocketFactory(TlsTrust.socketFactory(trustAll), TlsTrust.trustManager(trustAll)) + .hostnameVerifier(TlsTrust.hostnameVerifier(trustAll)) + .addInterceptor(authInterceptor) + .addInterceptor(logging) + .build() + return Retrofit.Builder() + .baseUrl(url.trimEnd('/') + "/") + .client(okhttp) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(SacApi::class.java) + } + + private fun normalizeUrl(raw: String): String { + var u = raw.trim() + if (!u.startsWith("http://") && !u.startsWith("https://")) { + u = "https://$u" + } + return u.removeSuffix("/") + } + + companion object { + fun parseError(e: retrofit2.HttpException): String { + val body = e.response()?.errorBody()?.string().orEmpty() + return try { + Json.decodeFromString(body).detail ?: body + } catch (_: Exception) { + body.ifBlank { "HTTP ${e.code()}" } + } + } + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/data/SessionStore.kt b/app/src/main/java/ru/kalinamall/seaca/data/SessionStore.kt new file mode 100644 index 0000000..6d65b6b --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/data/SessionStore.kt @@ -0,0 +1,151 @@ +package ru.kalinamall.seaca.data + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import java.util.UUID + +private val Context.dataStore by preferencesDataStore("seaca_prefs") + +class SessionStore(private val context: Context) { + private val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + private val securePrefs = EncryptedSharedPreferences.create( + context, + "seaca_secure", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + val isLoggedIn: Flow = context.dataStore.data.map { prefs -> + !prefs[KEY_BASE_URL].isNullOrBlank() && securePrefs.contains(KEY_ACCESS) + } + + val notificationMode: Flow = context.dataStore.data.map { prefs -> + NotificationMode.fromStorage(prefs[KEY_NOTIFICATION_MODE]) + } + + val dateTimeDisplayMode: Flow = context.dataStore.data.map { prefs -> + DateTimeDisplayMode.fromStorage(prefs[KEY_DATE_TIME_FORMAT]) + } + + val notificationSoundSettings: Flow = context.dataStore.data.map { prefs -> + NotificationSoundSettings( + source = NotificationSoundSource.fromStorage(prefs[KEY_NOTIFICATION_SOUND_SOURCE]), + customUri = prefs[KEY_NOTIFICATION_SOUND_URI], + ) + } + + suspend fun getNotificationMode(): NotificationMode = + NotificationMode.fromStorage(context.dataStore.data.first()[KEY_NOTIFICATION_MODE]) + + suspend fun getDateTimeDisplayMode(): DateTimeDisplayMode = + DateTimeDisplayMode.fromStorage(context.dataStore.data.first()[KEY_DATE_TIME_FORMAT]) + + suspend fun getNotificationSoundSettings(): NotificationSoundSettings { + val prefs = context.dataStore.data.first() + return NotificationSoundSettings( + source = NotificationSoundSource.fromStorage(prefs[KEY_NOTIFICATION_SOUND_SOURCE]), + customUri = prefs[KEY_NOTIFICATION_SOUND_URI], + ) + } + + suspend fun setNotificationMode(mode: NotificationMode) { + context.dataStore.edit { it[KEY_NOTIFICATION_MODE] = mode.storageKey } + } + + suspend fun setDateTimeDisplayMode(mode: DateTimeDisplayMode) { + context.dataStore.edit { it[KEY_DATE_TIME_FORMAT] = mode.storageKey } + } + + suspend fun setNotificationSound(source: NotificationSoundSource, customUri: String?) { + context.dataStore.edit { + it[KEY_NOTIFICATION_SOUND_SOURCE] = source.storageKey + if (customUri.isNullOrBlank()) { + it.remove(KEY_NOTIFICATION_SOUND_URI) + } else { + it[KEY_NOTIFICATION_SOUND_URI] = customUri + } + } + } + + 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 setTrustAllCerts(value: Boolean) { + context.dataStore.edit { it[KEY_TRUST_ALL] = value } + } + + suspend fun getDeviceUuid(): String { + val existing = context.dataStore.data.first()[KEY_DEVICE_UUID] + if (!existing.isNullOrBlank()) return existing + val fresh = UUID.randomUUID().toString() + context.dataStore.edit { it[KEY_DEVICE_UUID] = fresh } + return fresh + } + + fun getAccessToken(): String? = securePrefs.getString(KEY_ACCESS, null) + + fun getRefreshToken(): String? = securePrefs.getString(KEY_REFRESH, null) + + fun getDeviceId(): Int = securePrefs.getInt(KEY_DEVICE_ID, 0) + + fun getUsername(): String? = securePrefs.getString(KEY_USERNAME, null) + + fun getRole(): String? = securePrefs.getString(KEY_ROLE, null) + + suspend fun saveSession(baseUrl: String, auth: AuthResponse) { + val normalized = baseUrl.trim().removeSuffix("/") + context.dataStore.edit { it[KEY_BASE_URL] = normalized } + securePrefs.edit() + .putString(KEY_ACCESS, auth.accessToken) + .putString(KEY_REFRESH, auth.refreshToken) + .putInt(KEY_DEVICE_ID, auth.deviceId) + .putString(KEY_USERNAME, auth.username) + .putString(KEY_ROLE, auth.role) + .apply() + } + + suspend fun updateTokens(auth: AuthResponse) { + securePrefs.edit() + .putString(KEY_ACCESS, auth.accessToken) + .putString(KEY_REFRESH, auth.refreshToken) + .putInt(KEY_DEVICE_ID, auth.deviceId) + .apply() + } + + suspend fun clear() { + context.dataStore.edit { + it.remove(KEY_BASE_URL) + it.remove(KEY_TRUST_ALL) + } + securePrefs.edit().clear().apply() + } + + companion object { + 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 val KEY_DATE_TIME_FORMAT = stringPreferencesKey("date_time_format") + private val KEY_NOTIFICATION_SOUND_SOURCE = stringPreferencesKey("notification_sound_source") + private val KEY_NOTIFICATION_SOUND_URI = stringPreferencesKey("notification_sound_uri") + private const val KEY_ACCESS = "access_token" + private const val KEY_REFRESH = "refresh_token" + private const val KEY_DEVICE_ID = "device_id" + private const val KEY_USERNAME = "username" + private const val KEY_ROLE = "role" + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/push/AppForegroundState.kt b/app/src/main/java/ru/kalinamall/seaca/push/AppForegroundState.kt new file mode 100644 index 0000000..9b0e052 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/push/AppForegroundState.kt @@ -0,0 +1,32 @@ +package ru.kalinamall.seaca.push + +import android.content.Context +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner + +/** True, пока хотя бы одна activity приложения видима (ProcessLifecycleOwner.onStart). */ +object AppForegroundState { + @Volatile + private var inForeground = false + + private lateinit var appContext: Context + + fun bind(context: Context) { + appContext = context.applicationContext + ProcessLifecycleOwner.get().lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + inForeground = true + NotificationHelper.clearAll(appContext) + } + + override fun onStop(owner: LifecycleOwner) { + inForeground = false + } + }, + ) + } + + fun isInForeground(): Boolean = inForeground +} diff --git a/app/src/main/java/ru/kalinamall/seaca/push/FcmRegistrar.kt b/app/src/main/java/ru/kalinamall/seaca/push/FcmRegistrar.kt new file mode 100644 index 0000000..d3537e0 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/push/FcmRegistrar.kt @@ -0,0 +1,26 @@ +package ru.kalinamall.seaca.push + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat +import com.google.firebase.messaging.FirebaseMessaging +import kotlinx.coroutines.tasks.await +import ru.kalinamall.seaca.data.SacRepository + +object FcmRegistrar { + suspend fun registerIfPossible(context: android.content.Context, repo: SacRepository) { + if (repo.session.getAccessToken() == null) return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val granted = ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + if (!granted) return + } + runCatching { + val token = FirebaseMessaging.getInstance().token.await() + repo.registerFcmToken(token) + } + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/push/NotificationHelper.kt b/app/src/main/java/ru/kalinamall/seaca/push/NotificationHelper.kt new file mode 100644 index 0000000..84236b3 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/push/NotificationHelper.kt @@ -0,0 +1,155 @@ +package ru.kalinamall.seaca.push + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.media.AudioAttributes +import android.net.Uri +import android.os.Build +import androidx.core.app.NotificationCompat +import ru.kalinamall.seaca.MainActivity +import ru.kalinamall.seaca.R + +object NotificationHelper { + private const val CHANNEL_DEFAULT_SILENT = "sac_default_silent" + private const val CHANNEL_HIGH_SILENT = "sac_high_silent" + private const val CHANNEL_CRITICAL_SILENT = "sac_critical_silent" + + private val legacySoundChannelIds = listOf("sac_default", "sac_high", "sac_critical") + + private var appliedSoundKey: String? = null + private var soundChannelSuffix: String = "sys" + private var channelDefault = "sac_default_sys" + private var channelHigh = "sac_high_sys" + private var channelCritical = "sac_critical_sys" + + fun ensureChannels(context: Context, soundUri: Uri = NotificationSoundResolver.defaultNotificationUri()) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val soundKey = soundUri.toString() + val mgr = context.getSystemService(NotificationManager::class.java) + if (soundKey != appliedSoundKey) { + legacySoundChannelIds.forEach { mgr.deleteNotificationChannel(it) } + if (appliedSoundKey != null) { + mgr.deleteNotificationChannel(channelDefault) + mgr.deleteNotificationChannel(channelHigh) + mgr.deleteNotificationChannel(channelCritical) + } + appliedSoundKey = soundKey + soundChannelSuffix = soundKey.hashCode().toUInt().toString(16).take(8) + channelDefault = "sac_default_$soundChannelSuffix" + channelHigh = "sac_high_$soundChannelSuffix" + channelCritical = "sac_critical_$soundChannelSuffix" + } + val audioAttributes = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + mgr.createNotificationChannel( + soundChannel(context, channelDefault, R.string.channel_default, NotificationManager.IMPORTANCE_DEFAULT, soundUri, audioAttributes), + ) + mgr.createNotificationChannel( + soundChannel(context, channelHigh, R.string.channel_high, NotificationManager.IMPORTANCE_HIGH, soundUri, audioAttributes), + ) + mgr.createNotificationChannel( + soundChannel(context, channelCritical, R.string.channel_critical, NotificationManager.IMPORTANCE_HIGH, soundUri, audioAttributes), + ) + 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 soundChannel( + context: Context, + id: String, + nameRes: Int, + importance: Int, + soundUri: Uri, + audioAttributes: AudioAttributes, + ): NotificationChannel = NotificationChannel(id, context.getString(nameRes), importance).apply { + setSound(soundUri, audioAttributes) + enableVibration(true) + } + + 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 clearAll(context: Context) { + context.getSystemService(NotificationManager::class.java).cancelAll() + } + + fun show( + context: Context, + title: String, + body: String, + severity: String?, + kind: String?, + id: String?, + silent: Boolean = false, + soundUri: Uri = NotificationSoundResolver.defaultNotificationUri(), + ) { + ensureChannels(context, soundUri) + 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) { + "event" -> putExtra("deep_link", "event/$id") + "problem" -> putExtra("deep_link", "problem/$id") + } + } + val pending = PendingIntent.getActivity( + context, + (id ?: title).hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val builder = NotificationCompat.Builder(context, channel) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(title) + .setContentText(body) + .setAutoCancel(true) + .setContentIntent(pending) + .setPriority( + when { + silent -> NotificationCompat.PRIORITY_DEFAULT + channel == channelCritical || channel == CHANNEL_CRITICAL_SILENT -> + NotificationCompat.PRIORITY_MAX + channel == channelHigh || channel == CHANNEL_HIGH_SILENT -> + NotificationCompat.PRIORITY_HIGH + else -> NotificationCompat.PRIORITY_DEFAULT + }, + ) + if (silent) { + builder.setSilent(true) + builder.setSound(null) + builder.setVibrate(null) + builder.setDefaults(0) + } + context.getSystemService(NotificationManager::class.java) + .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" -> channelCritical + "high" -> channelHigh + else -> channelDefault + } + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/push/NotificationSoundResolver.kt b/app/src/main/java/ru/kalinamall/seaca/push/NotificationSoundResolver.kt new file mode 100644 index 0000000..5cfa8b1 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/push/NotificationSoundResolver.kt @@ -0,0 +1,109 @@ +package ru.kalinamall.seaca.push + +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.media.RingtoneManager +import android.net.Uri +import androidx.core.content.FileProvider +import ru.kalinamall.seaca.data.NotificationSoundSettings +import ru.kalinamall.seaca.data.NotificationSoundSource +import ru.kalinamall.seaca.data.SessionStore +import java.io.File + +object NotificationSoundResolver { + private const val CUSTOM_SOUND_FILE = "custom_notification_sound" + + fun resolve(context: Context, settings: NotificationSoundSettings): Uri { + return when (settings.source) { + NotificationSoundSource.SYSTEM -> defaultNotificationUri() + NotificationSoundSource.CUSTOM -> { + playableCustomUri(context, settings.customUri) ?: defaultNotificationUri() + } + } + } + + suspend fun normalizeStoredSettings(context: Context, store: SessionStore): NotificationSoundSettings { + val settings = store.getNotificationSoundSettings() + if (settings.source != NotificationSoundSource.CUSTOM || settings.customUri.isNullOrBlank()) { + return settings + } + if (isInternalSoundUri(context, settings.customUri)) { + return settings + } + val imported = importCustomSound(context, Uri.parse(settings.customUri)) ?: return settings + store.setNotificationSound(NotificationSoundSource.CUSTOM, imported) + return store.getNotificationSoundSettings() + } + + fun defaultNotificationUri(): Uri = + RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + + fun displayName(context: Context, settings: NotificationSoundSettings): String { + return try { + val uri = resolve(context, settings) + RingtoneManager.getRingtone(context, uri)?.getTitle(context) ?: "—" + } catch (_: Exception) { + "—" + } + } + + /** + * Copies picker/system URI into app storage so FCM in a fresh process can read it. + * Returns stored URI string (FileProvider or android.resource). + */ + fun importCustomSound(context: Context, source: Uri): String? { + if (ContentResolver.SCHEME_ANDROID_RESOURCE == source.scheme) { + return source.toString() + } + if (defaultNotificationUri().toString() == source.toString()) { + return source.toString() + } + persistCustomUri(context, source) + val dest = File(context.filesDir, CUSTOM_SOUND_FILE) + return try { + context.contentResolver.openInputStream(source)?.use { input -> + dest.outputStream().use { output -> input.copyTo(output) } + } ?: return null + internalFileUri(context, dest).toString() + } catch (_: Exception) { + null + } + } + + private fun playableCustomUri(context: Context, stored: String?): Uri? { + if (stored.isNullOrBlank()) return null + val parsed = Uri.parse(stored) + if (isInternalSoundUri(context, stored)) { + val file = File(context.filesDir, CUSTOM_SOUND_FILE) + return if (file.isFile) internalFileUri(context, file) else null + } + if (ContentResolver.SCHEME_ANDROID_RESOURCE == parsed.scheme) { + return parsed + } + return importCustomSound(context, parsed)?.let { Uri.parse(it) } + } + + private fun isInternalSoundUri(context: Context, stored: String): Boolean { + val parsed = Uri.parse(stored) + return parsed.authority == fileProviderAuthority(context) + } + + private fun internalFileUri(context: Context, file: File): Uri = + FileProvider.getUriForFile(context, fileProviderAuthority(context), file) + + private fun fileProviderAuthority(context: Context): String = + "${context.packageName}.fileprovider" + + private fun persistCustomUri(context: Context, uri: Uri) { + if (uri.scheme != ContentResolver.SCHEME_CONTENT) return + try { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + } catch (_: SecurityException) { + // Best-effort; internal copy is the reliable path. + } + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/push/SeacaMessagingService.kt b/app/src/main/java/ru/kalinamall/seaca/push/SeacaMessagingService.kt new file mode 100644 index 0000000..5b046d1 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/push/SeacaMessagingService.kt @@ -0,0 +1,60 @@ +package ru.kalinamall.seaca.push + +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import ru.kalinamall.seaca.data.NotificationMode +import ru.kalinamall.seaca.data.SacRepository + +class SeacaMessagingService : FirebaseMessagingService() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val repo by lazy { SacRepository(applicationContext) } + + override fun onMessageReceived(message: RemoteMessage) { + if (AppForegroundState.isInForeground()) return + val mode = runBlocking { repo.session.getNotificationMode() } + when (mode) { + NotificationMode.OFF -> return + 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 = data["title"] ?: message.notification?.title ?: "SAC" + val body = data["body"] ?: message.notification?.body ?: "" + val soundUri = if (silent) { + NotificationSoundResolver.defaultNotificationUri() + } else { + val settings = runBlocking { + NotificationSoundResolver.normalizeStoredSettings(applicationContext, repo.session) + } + NotificationSoundResolver.resolve(this, settings) + } + NotificationHelper.show( + context = this, + title = title, + body = body, + severity = data["severity"], + kind = data["kind"], + id = data["id"], + silent = silent, + soundUri = soundUri, + ) + } + + override fun onNewToken(token: String) { + scope.launch { + runCatching { + if (repo.session.getAccessToken() != null) { + repo.registerFcmToken(token) + } + } + } + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/security/BiometricGate.kt b/app/src/main/java/ru/kalinamall/seaca/security/BiometricGate.kt new file mode 100644 index 0000000..6cd465d --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/security/BiometricGate.kt @@ -0,0 +1,48 @@ +package ru.kalinamall.seaca.security + +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity + +object BiometricGate { + fun canAuthenticate(activity: FragmentActivity): Boolean { + val mgr = BiometricManager.from(activity) + return mgr.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == + BiometricManager.BIOMETRIC_SUCCESS + } + + fun authenticate( + activity: FragmentActivity, + onSuccess: () -> Unit, + onFailure: () -> Unit, + ) { + if (!canAuthenticate(activity)) { + onSuccess() + return + } + val executor = ContextCompat.getMainExecutor(activity) + val prompt = BiometricPrompt( + activity, + executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + onSuccess() + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + onFailure() + } + + override fun onAuthenticationFailed() = Unit + }, + ) + prompt.authenticate( + BiometricPrompt.PromptInfo.Builder() + .setTitle("Seaca") + .setSubtitle("Подтвердите доступ") + .setNegativeButtonText("Отмена") + .build(), + ) + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/security/TlsTrust.kt b/app/src/main/java/ru/kalinamall/seaca/security/TlsTrust.kt new file mode 100644 index 0000000..53bea49 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/security/TlsTrust.kt @@ -0,0 +1,38 @@ +package ru.kalinamall.seaca.security + +import java.security.SecureRandom +import java.security.cert.X509Certificate +import javax.net.ssl.HostnameVerifier +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + +object TlsTrust { + private val permissiveManager = object : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) = Unit + override fun checkServerTrusted(chain: Array?, authType: String?) = Unit + override fun getAcceptedIssuers(): Array = emptyArray() + } + + private val defaultManager: X509TrustManager by lazy { + val tmf = javax.net.ssl.TrustManagerFactory.getInstance( + javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm(), + ) + tmf.init(null as java.security.KeyStore?) + tmf.trustManagers.filterIsInstance().first() + } + + fun trustManager(trustAll: Boolean): X509TrustManager = if (trustAll) permissiveManager else defaultManager + + fun socketFactory(trustAll: Boolean): SSLSocketFactory { + val ctx = SSLContext.getInstance("TLS") + ctx.init(null, arrayOf(trustManager(trustAll)), SecureRandom()) + return ctx.socketFactory + } + + fun hostnameVerifier(trustAll: Boolean): HostnameVerifier = + if (trustAll) HostnameVerifier { _, _ -> true } else HostnameVerifier { host, session -> + javax.net.ssl.HttpsURLConnection.getDefaultHostnameVerifier().verify(host, session) + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/RingtonePicker.kt b/app/src/main/java/ru/kalinamall/seaca/ui/RingtonePicker.kt new file mode 100644 index 0000000..4bf2a2e --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/RingtonePicker.kt @@ -0,0 +1,61 @@ +package ru.kalinamall.seaca.ui + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.media.RingtoneManager +import android.net.Uri +import android.os.Build +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import ru.kalinamall.seaca.push.NotificationSoundResolver + +object RingtonePicker { + private const val REQUEST_CODE = 9001 + + private var callback: (suspend (Uri?) -> Unit)? = null + + fun launch(activity: FragmentActivity, currentUri: Uri, onResult: suspend (Uri?) -> Unit) { + callback = onResult + (activity as? ExternalPickerHost)?.beginExternalPickerFlow() + val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply { + putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION) + putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true) + putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, false) + putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, NotificationSoundResolver.defaultNotificationUri()) + putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, currentUri) + } + try { + @Suppress("DEPRECATION") + activity.startActivityForResult(intent, REQUEST_CODE) + } catch (_: ActivityNotFoundException) { + callback = null + throw ActivityNotFoundException() + } + } + + fun handleActivityResult(activity: FragmentActivity, requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode != REQUEST_CODE) return + val onResult = callback ?: return + callback = null + val uri = if (resultCode == Activity.RESULT_OK) readResult(data) else null + activity.lifecycleScope.launch { + runCatching { onResult(uri) } + } + } + + private fun readResult(data: Intent?): Uri? { + if (data == null) return null + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, Uri::class.java) + } else { + @Suppress("DEPRECATION") + data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI) + } + } +} + +interface ExternalPickerHost { + fun beginExternalPickerFlow() +} diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/components/HostSessionsSection.kt b/app/src/main/java/ru/kalinamall/seaca/ui/components/HostSessionsSection.kt new file mode 100644 index 0000000..e08d60c --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/components/HostSessionsSection.kt @@ -0,0 +1,239 @@ +package ru.kalinamall.seaca.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.unit.dp +import kotlinx.coroutines.launch +import retrofit2.HttpException +import ru.kalinamall.seaca.data.HostSessionItem +import ru.kalinamall.seaca.data.HostSessionTerminateRequest +import ru.kalinamall.seaca.data.SacRepository + +@Composable +fun HostSessionsSection( + repo: SacRepository, + hostId: Long, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + var loading by remember(hostId) { mutableStateOf(false) } + var loaded by remember(hostId) { mutableStateOf(false) } + var sessions by remember(hostId) { mutableStateOf>(emptyList()) } + var error by remember(hostId) { mutableStateOf(null) } + var message by remember(hostId) { mutableStateOf(null) } + var messageOk by remember(hostId) { mutableStateOf(true) } + var terminatingId by remember(hostId) { mutableStateOf(null) } + var confirmSession by remember(hostId) { mutableStateOf(null) } + + fun load() { + scope.launch { + loading = true + error = null + message = null + try { + val res = repo.withAuth { it.postHostSessionsList(hostId) } + if (!res.ok) { + error = res.message + sessions = emptyList() + } else { + sessions = res.sessions + loaded = true + } + } catch (e: Exception) { + error = if (e is HttpException) SacRepository.parseError(e) else e.message + } finally { + loading = false + } + } + } + + fun terminate(sessionId: String) { + scope.launch { + terminatingId = sessionId + message = null + try { + val res = repo.withAuth { + it.postHostSessionTerminate(hostId, HostSessionTerminateRequest(sessionId)) + } + messageOk = res.ok + message = res.message + if (res.ok) { + sessions = sessions.filter { it.sessionId != sessionId } + } + } catch (e: Exception) { + messageOk = false + message = if (e is HttpException) SacRepository.parseError(e) else e.message + } finally { + terminatingId = null + } + } + } + + confirmSession?.let { session -> + AlertDialog( + onDismissRequest = { confirmSession = null }, + title = { Text("Завершить сессию?") }, + text = { Text("Завершить сессию ${session.sessionId} (${session.user})?") }, + confirmButton = { + Button( + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + onClick = { + val sid = session.sessionId + confirmSession = null + terminate(sid) + }, + ) { Text("Завершить") } + }, + dismissButton = { + TextButton(onClick = { confirmSession = null }) { Text("Отмена") } + }, + ) + } + + Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "Залогиненные пользователи", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + Text( + "Linux: loginctl, Windows: qwinsta. Нужны учётные данные admin в настройках SAC.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + error?.let { + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + if (loaded) { + if (sessions.isEmpty()) { + Text( + "Активных сессий не найдено.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + sessions.forEach { session -> + HostSessionRow( + session = session, + terminating = terminatingId == session.sessionId, + enabled = terminatingId == null && !loading, + onTerminate = { confirmSession = session }, + ) + } + } + message?.let { + Text( + it, + color = if (messageOk) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + OutlinedButton( + enabled = !loading, + onClick = { load() }, + ) { + if (loading) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator() + Text("Загрузка…") + } + } else { + Text(if (loaded) "Обновить" else "Показать пользователей") + } + } + } +} + +@Composable +private fun HostSessionRow( + session: HostSessionItem, + terminating: Boolean, + enabled: Boolean, + onTerminate: () -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(session.user, style = MaterialTheme.typography.bodyMedium) + Text( + buildString { + append("ID ${session.sessionId}") + session.tty?.takeIf { it.isNotBlank() }?.let { append(" · $it") } + session.sessionName?.takeIf { it.isNotBlank() }?.let { append(" · $it") } + session.state?.takeIf { it.isNotBlank() }?.let { append(" · $it") } + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (terminating) { + CircularProgressIndicator(modifier = Modifier.padding(start = 8.dp)) + } else { + Button( + enabled = enabled, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + onClick = onTerminate, + ) { Text("Завершить") } + } + } + } +} + +@Composable +fun EventSessionTerminateButton( + enabled: Boolean, + loading: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedButton( + enabled = enabled && !loading, + onClick = onClick, + modifier = modifier, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(if (loading) "…" else "Завершить") + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/components/NavButtons.kt b/app/src/main/java/ru/kalinamall/seaca/ui/components/NavButtons.kt new file mode 100644 index 0000000..ff0e45e --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/components/NavButtons.kt @@ -0,0 +1,48 @@ +package ru.kalinamall.seaca.ui.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun BackArrowButton( + onClick: () -> Unit, + enabled: Boolean = true, + modifier: Modifier = Modifier, +) { + IconButton(onClick = onClick, enabled = enabled, modifier = modifier) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Назад", + tint = if (enabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } +} + +@Composable +fun ForwardArrowButton( + onClick: () -> Unit, + enabled: Boolean = true, + modifier: Modifier = Modifier, +) { + IconButton(onClick = onClick, enabled = enabled, modifier = modifier) { + Icon( + Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = "Вперёд", + tint = if (enabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/components/RdgAccessBadge.kt b/app/src/main/java/ru/kalinamall/seaca/ui/components/RdgAccessBadge.kt new file mode 100644 index 0000000..b7cc1de --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/components/RdgAccessBadge.kt @@ -0,0 +1,39 @@ +package ru.kalinamall.seaca.ui.components + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +private val RdgAccessText = Color(0xFF7EC8E3) +private val RdgAccessBackground = Color(0x1A7EC8E3) +private val RdgAccessBorder = Color(0x667EC8E3) + +@Composable +fun RdgAccessBadge( + path: String, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .border(1.dp, RdgAccessBorder, RoundedCornerShape(4.dp)), + shape = RoundedCornerShape(4.dp), + color = RdgAccessBackground, + ) { + Text( + text = path, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + color = RdgAccessText, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.3.sp, + ) + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/components/RdgFlapBadge.kt b/app/src/main/java/ru/kalinamall/seaca/ui/components/RdgFlapBadge.kt new file mode 100644 index 0000000..a92e416 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/components/RdgFlapBadge.kt @@ -0,0 +1,43 @@ +package ru.kalinamall.seaca.ui.components + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +private val RdgFlapText = Color(0xFFF0C674) +private val RdgFlapBackground = Color(0x1FF0C674) +private val RdgFlapBorder = Color(0x73F0C674) + +@Composable +fun RdgFlapBadge( + modifier: Modifier = Modifier, + pairEventId: Long? = null, +) { + val label = buildString { + append("RDG FLAP") + if (pairEventId != null) append(" #$pairEventId") + } + Surface( + modifier = modifier + .border(1.dp, RdgFlapBorder, RoundedCornerShape(4.dp)), + shape = RoundedCornerShape(4.dp), + color = RdgFlapBackground, + ) { + Text( + text = label, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + color = RdgFlapText, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.3.sp, + ) + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/components/RdgQwinstaDialog.kt b/app/src/main/java/ru/kalinamall/seaca/ui/components/RdgQwinstaDialog.kt new file mode 100644 index 0000000..48c14cc --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/components/RdgQwinstaDialog.kt @@ -0,0 +1,334 @@ +package ru.kalinamall.seaca.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import kotlinx.coroutines.launch +import retrofit2.HttpException +import ru.kalinamall.seaca.data.EventDetail +import ru.kalinamall.seaca.data.EventSummary +import ru.kalinamall.seaca.data.LogoffActionRequest +import ru.kalinamall.seaca.data.SacRepository +import ru.kalinamall.seaca.ui.util.QwinstaSessionRow +import ru.kalinamall.seaca.ui.util.agentCommandError +import ru.kalinamall.seaca.ui.util.parseQwinstaSessions +import ru.kalinamall.seaca.ui.util.rdgQwinstaEventId + +class RdgQwinstaController(private val repo: SacRepository) { + var open by mutableStateOf(false) + private set + var loading by mutableStateOf(false) + private set + var error by mutableStateOf(null) + private set + var meta by mutableStateOf(null) + private set + var stdout by mutableStateOf(null) + private set + var sessions by mutableStateOf>(emptyList()) + private set + var logoffSessionId by mutableStateOf(null) + private set + var targetEventId by mutableStateOf(null) + private set + private var actorUser: String? = null + + fun openFor(event: EventSummary, scope: kotlinx.coroutines.CoroutineScope) { + val targetId = rdgQwinstaEventId(event) ?: return + targetEventId = targetId + actorUser = event.actorUser + open = true + runQwinsta(scope) + } + + fun openFor(event: EventDetail, scope: kotlinx.coroutines.CoroutineScope) { + val targetId = rdgQwinstaEventId(event) ?: return + targetEventId = targetId + actorUser = event.actorUser + open = true + runQwinsta(scope) + } + + fun dismiss() { + if (!loading && logoffSessionId == null) { + open = false + } + } + + fun runQwinsta(scope: kotlinx.coroutines.CoroutineScope) { + val targetId = targetEventId ?: return + scope.launch { + loading = true + error = null + stdout = null + sessions = emptyList() + try { + val cmd = repo.withAuth { it.postEventQwinsta(targetId) } + meta = listOfNotNull(cmd.clientHostname, cmd.internalIp, cmd.target) + .joinToString(" · ") + .ifBlank { null } + val cmdError = agentCommandError(cmd) + if (cmdError != null) { + error = cmdError + } else { + val out = cmd.resultStdout.orEmpty() + stdout = out.ifBlank { null } + sessions = parseQwinstaSessions(out, actorUser) + } + } catch (e: Exception) { + error = if (e is HttpException) SacRepository.parseError(e) else e.message + } finally { + loading = false + } + } + } + + fun logoff(sessionId: Int, scope: kotlinx.coroutines.CoroutineScope, onDone: () -> Unit = {}) { + val targetId = targetEventId ?: return + scope.launch { + logoffSessionId = sessionId + error = null + try { + val cmd = repo.withAuth { + it.postEventLogoff(targetId, LogoffActionRequest(sessionId)) + } + val cmdError = agentCommandError(cmd) + if (cmdError != null) { + error = cmdError + } else { + runQwinsta(scope) + } + } catch (e: Exception) { + error = if (e is HttpException) SacRepository.parseError(e) else e.message + } finally { + logoffSessionId = null + onDone() + } + } + } +} + +@Composable +fun rememberRdgQwinstaController(repo: SacRepository): RdgQwinstaController = + remember(repo) { RdgQwinstaController(repo) } + +@Composable +fun RdgQwinstaDialog( + controller: RdgQwinstaController, + modifier: Modifier = Modifier, +) { + if (!controller.open) return + val scope = rememberCoroutineScope() + var confirmSessionId by remember { mutableIntStateOf(-1) } + var manualSessionId by remember { mutableStateOf("") } + + if (confirmSessionId >= 0) { + AlertDialog( + onDismissRequest = { confirmSessionId = -1 }, + title = { Text("Завершить сеанс?") }, + text = { Text("Отправить logoff для сеанса ID $confirmSessionId на клиентском ПК?") }, + confirmButton = { + Button( + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + onClick = { + val id = confirmSessionId + confirmSessionId = -1 + controller.logoff(id, scope) + }, + ) { Text("logoff") } + }, + dismissButton = { + TextButton(onClick = { confirmSessionId = -1 }) { Text("Отмена") } + }, + ) + } + + Dialog( + onDismissRequest = { controller.dismiss() }, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + ) { + Column( + Modifier + .padding(16.dp) + .heightIn(max = 520.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text("Оборвать сессию", style = MaterialTheme.typography.titleLarge) + controller.meta?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + when { + controller.loading -> { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + CircularProgressIndicator() + Text("Ожидание ответа…") + } + } + controller.error != null -> { + Text(controller.error!!, color = MaterialTheme.colorScheme.error) + } + } + + if (controller.sessions.isNotEmpty()) { + Text( + "Сеансы — нажмите logoff, чтобы оборвать зависшую сессию:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + controller.sessions.forEach { s -> + SessionLogoffRow( + session = s, + logoffEnabled = !controller.loading && controller.logoffSessionId == null, + logoffInProgress = controller.logoffSessionId == s.id, + onLogoff = { confirmSessionId = s.id }, + ) + } + } else if (!controller.loading && controller.stdout != null) { + Text( + "Сеансы не распознаны автоматически. Введите ID из вывода ниже:", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = manualSessionId, + onValueChange = { manualSessionId = it.filter { ch -> ch.isDigit() } }, + label = { Text("ID сеанса") }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + Button( + enabled = manualSessionId.isNotBlank() && !controller.loading && controller.logoffSessionId == null, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + onClick = { + manualSessionId.toIntOrNull()?.let { confirmSessionId = it } + }, + ) { Text("logoff") } + } + } + + controller.stdout?.let { raw -> + Text("Вывод qwinsta", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + Text(raw, style = MaterialTheme.typography.bodySmall) + } + if (controller.stdout == null && !controller.loading && controller.error == null) { + Text("Нет вывода", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton( + enabled = !controller.loading && controller.logoffSessionId == null, + onClick = { controller.dismiss() }, + ) { Text("Закрыть") } + } + } + } + } +} + +@Composable +private fun SessionLogoffRow( + session: QwinstaSessionRow, + logoffEnabled: Boolean, + logoffInProgress: Boolean, + onLogoff: () -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(session.sessionName, style = MaterialTheme.typography.bodyMedium) + Text( + "${session.userName} · ID ${session.id} · ${session.state}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (logoffInProgress) { + CircularProgressIndicator(modifier = Modifier.padding(start = 8.dp)) + } else { + Button( + enabled = logoffEnabled, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + onClick = onLogoff, + ) { Text("logoff") } + } + } + } +} + +@Composable +fun RdgQwinstaButton( + controller: RdgQwinstaController, + enabled: Boolean = true, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + OutlinedButton( + enabled = enabled && !controller.loading, + onClick = onClick, + modifier = modifier, + ) { + Text(if (controller.loading) "…" else "Оборвать сессию") + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/screens/ConnectScreen.kt b/app/src/main/java/ru/kalinamall/seaca/ui/screens/ConnectScreen.kt new file mode 100644 index 0000000..37c3af2 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/screens/ConnectScreen.kt @@ -0,0 +1,125 @@ +package ru.kalinamall.seaca.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.unit.dp +import kotlinx.coroutines.launch +import ru.kalinamall.seaca.data.SacRepository + +@Composable +fun ConnectScreen( + repo: SacRepository, + onConnected: () -> Unit, +) { + var serverUrl by remember { mutableStateOf("") } + var enrollmentCode by remember { mutableStateOf("") } + var username by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var displayName by remember { mutableStateOf(android.os.Build.MODEL) } + var trustCert by remember { mutableStateOf(false) } + var loading by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Seaca", style = MaterialTheme.typography.headlineMedium) + Text("Подключение к Security Alert Center", style = MaterialTheme.typography.bodyMedium) + OutlinedTextField( + value = serverUrl, + onValueChange = { serverUrl = it }, + label = { Text("URL сервера") }, + placeholder = { Text("https://host.example") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = enrollmentCode, + onValueChange = { enrollmentCode = it }, + label = { Text("Код регистрации") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text("Логин SAC (если нужен)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Пароль SAC") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = displayName, + onValueChange = { displayName = it }, + label = { Text("Имя устройства") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Column(horizontalAlignment = Alignment.Start) { + Checkbox(checked = trustCert, onCheckedChange = { trustCert = it }) + Text("Доверять сертификату (внутренняя сеть / self-signed)") + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + Button( + onClick = { + loading = true + error = null + scope.launch { + try { + repo.checkHealth(serverUrl, trustCert) + repo.enroll( + serverUrl = serverUrl, + enrollmentCode = enrollmentCode, + username = username, + password = password, + displayName = displayName, + fcmToken = null, + trustAll = trustCert, + ) + onConnected() + } catch (e: Exception) { + error = when (e) { + is retrofit2.HttpException -> SacRepository.parseError(e) + else -> e.message ?: "Ошибка подключения" + } + } finally { + loading = false + } + } + }, + enabled = !loading && serverUrl.isNotBlank() && enrollmentCode.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (loading) "Подключение…" else "Подключиться") + } + } +} 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 new file mode 100644 index 0000000..02e3f7a --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt @@ -0,0 +1,821 @@ +package ru.kalinamall.seaca.ui.screens + +import android.content.ActivityNotFoundException +import android.widget.Toast +import androidx.compose.runtime.rememberUpdatedState +import androidx.fragment.app.FragmentActivity +import android.media.RingtoneManager +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.TextButton +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonElement +import retrofit2.HttpException +import ru.kalinamall.seaca.R +import ru.kalinamall.seaca.data.EventSummary +import ru.kalinamall.seaca.data.EventDetail +import ru.kalinamall.seaca.data.HostDetail +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import ru.kalinamall.seaca.data.DateTimeDisplayMode +import ru.kalinamall.seaca.data.NotificationMode +import ru.kalinamall.seaca.data.NotificationSoundSettings +import ru.kalinamall.seaca.data.NotificationSoundSource +import ru.kalinamall.seaca.data.ProblemDetail +import ru.kalinamall.seaca.data.LogoffActionRequest +import ru.kalinamall.seaca.data.SacRepository +import ru.kalinamall.seaca.ui.components.RdgQwinstaButton +import ru.kalinamall.seaca.ui.components.RdgQwinstaDialog +import ru.kalinamall.seaca.ui.components.rememberRdgQwinstaController +import ru.kalinamall.seaca.ui.components.EventSessionTerminateButton +import ru.kalinamall.seaca.ui.components.HostSessionsSection +import ru.kalinamall.seaca.ui.util.hasRdgFlapUi +import ru.kalinamall.seaca.ui.util.rdgQwinstaEventId +import ru.kalinamall.seaca.ui.util.eventSupportsSessionTerminate +import ru.kalinamall.seaca.ui.util.sessionTerminateLabel +import ru.kalinamall.seaca.push.NotificationHelper +import ru.kalinamall.seaca.push.NotificationSoundResolver +import ru.kalinamall.seaca.ui.RingtonePicker +import ru.kalinamall.seaca.ui.components.BackArrowButton +import ru.kalinamall.seaca.ui.components.ForwardArrowButton +import ru.kalinamall.seaca.ui.util.SacDateTimes +import ru.kalinamall.seaca.ui.util.dailyReportTypeLabel +import ru.kalinamall.seaca.ui.util.formatEventDetails +import ru.kalinamall.seaca.ui.util.inventoryFieldLines +import ru.kalinamall.seaca.ui.util.isDailyReportType +import ru.kalinamall.seaca.ui.util.reportTextFromDetails + +private enum class HostDetailTab(val label: String) { + Info("Информация"), + Events("События"), +} + +@Composable +fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { + var data by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(true) } + val scope = rememberCoroutineScope() + val qwinsta = rememberRdgQwinstaController(repo) + var terminateLoading by remember { mutableStateOf(false) } + var terminateConfirm by remember { mutableStateOf(false) } + var terminateMessage by remember { mutableStateOf(null) } + val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( + initialValue = DateTimeDisplayMode.SLASH, + ) + + LaunchedEffect(id) { + loading = true + try { + data = repo.withAuth { it.getEvent(id) } + } catch (e: Exception) { + error = e.message + } finally { + loading = false + } + } + + DetailFrame(title = data?.title ?: "Событие", loading, error, onBack) { + val d = data ?: return@DetailFrame + if (!d.rdgAccessPath.isNullOrBlank()) { + Field("Путь RDS", d.rdgAccessPath) + } + if (hasRdgFlapUi(d)) { + Field("RDG flap", "302→303 — возможна зависшая сессия на ПК пользователя") + d.rdgFlapPairEventId?.let { Field("Пара", "#$it") } + } + rdgQwinstaEventId(d)?.let { + RdgQwinstaButton( + controller = qwinsta, + onClick = { qwinsta.openFor(d, scope) }, + modifier = Modifier.padding(vertical = 4.dp), + ) + } + if (eventSupportsSessionTerminate(d)) { + EventSessionTerminateButton( + enabled = !terminateLoading, + loading = terminateLoading, + onClick = { terminateConfirm = true }, + modifier = Modifier.padding(vertical = 4.dp), + ) + terminateMessage?.let { + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + } + Field("Severity", d.severity) + Field( + "Тип", + if (isDailyReportType(d.type)) dailyReportTypeLabel(d.type) else d.type, + ) + Field("Хост", d.hostname) + Field("Время", SacDateTimes.format(d.occurredAt, dateMode)) + Field("Кратко", d.summary) + if (isDailyReportType(d.type)) { + reportTextFromDetails(d.details)?.let { MultilineField("Отчёт", it) } + ?: Field("Отчёт", "Полный текст отчёта недоступен") + } else { + EventDetailsSection(d.type, d.details, d.payload) + } + } + + if (terminateConfirm) { + val d = data + AlertDialog( + onDismissRequest = { terminateConfirm = false }, + title = { Text("Завершить сессию?") }, + text = { + Text( + if (d != null) { + "Завершить сессию пользователя ${sessionTerminateLabel(d)}?" + } else { + "Завершить сессию пользователя?" + }, + ) + }, + confirmButton = { + Button( + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + onClick = { + terminateConfirm = false + scope.launch { + terminateLoading = true + terminateMessage = null + try { + val res = repo.withAuth { it.postEventTerminateSession(id) } + if (!res.ok) { + terminateMessage = res.message + } else { + data = data?.copy(sessionTerminated = true) + } + } catch (e: Exception) { + terminateMessage = if (e is HttpException) SacRepository.parseError(e) else e.message + } finally { + terminateLoading = false + } + } + }, + ) { Text("Завершить") } + }, + dismissButton = { + TextButton(onClick = { terminateConfirm = false }) { Text("Отмена") } + }, + ) + } + + RdgQwinstaDialog(qwinsta) +} + +@Composable +private fun EventDetailsSection(type: String, details: JsonElement?, payload: JsonElement?) { + val formatted = formatEventDetails(type, details, payload) + if (formatted?.hasContent == true) { + Text( + "Детали", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 4.dp), + ) + formatted.multilineText?.let { text -> + MultilineField(formatted.multilineLabel ?: "Сообщение", text) + } + formatted.fields.forEach { (label, value) -> Field(label, value) } + } else if (details != null) { + Field("Детали", details.toPrettyString()) + } +} + +@Composable +fun ProblemDetailScreen( + repo: SacRepository, + id: Long, + canOperate: Boolean, + onBack: () -> Unit, + onChanged: () -> Unit, +) { + var data by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(true) } + var acting by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( + initialValue = DateTimeDisplayMode.SLASH, + ) + + fun load() { + scope.launch { + loading = true + error = null + try { + data = repo.withAuth { it.getProblem(id) } + } catch (e: Exception) { + error = e.message + } finally { + loading = false + } + } + } + + LaunchedEffect(id) { load() } + + DetailFrame(title = data?.title ?: "Проблема", loading, error, onBack) { + val d = data ?: return@DetailFrame + Field("Статус", d.status) + Field("Severity", d.severity) + Field("Хост", d.hostname ?: "—") + Field("Кратко", d.summary) + if (canOperate && d.status in listOf("open", "acknowledged")) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (d.status == "open") { + Button( + enabled = !acting, + onClick = { + acting = true + scope.launch { + try { + repo.withAuth { it.ackProblem(id) } + load() + onChanged() + } catch (e: Exception) { + error = e.message + } finally { + acting = false + } + } + }, + ) { Text("Подтвердить") } + } + Button( + enabled = !acting, + onClick = { + acting = true + scope.launch { + try { + repo.withAuth { it.resolveProblem(id) } + load() + onChanged() + } catch (e: Exception) { + error = e.message + } finally { + acting = false + } + } + }, + ) { Text("Закрыть") } + } + } + if (d.events.isNotEmpty()) { + Text("Связанные события", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface) + d.events.forEach { e -> + Text( + "${e.severity} · ${e.title}\n${SacDateTimes.format(e.occurredAt, dateMode)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +fun HostDetailScreen( + repo: SacRepository, + id: Long, + onOpenEvent: (Long) -> Unit, + onBack: () -> Unit, +) { + var data by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(true) } + var selectedTab by remember { mutableIntStateOf(HostDetailTab.Info.ordinal) } + val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( + initialValue = DateTimeDisplayMode.SLASH, + ) + + LaunchedEffect(id) { + loading = true + try { + data = repo.withAuth { it.getHost(id) } + } catch (e: Exception) { + error = e.message + } finally { + loading = false + } + } + + Column( + Modifier + .fillMaxSize() + .navigationBarsPadding(), + ) { + Row( + Modifier + .fillMaxWidth() + .padding(start = 4.dp, end = 16.dp, top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + BackArrowButton(onClick = onBack) + Text( + data?.hostname ?: "Хост", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 4.dp), + ) + } + error?.let { + Text( + it, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + if (loading) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(16.dp), + ) + } else { + val host = data ?: return@Column + TabRow(selectedTabIndex = selectedTab) { + HostDetailTab.entries.forEachIndexed { index, tab -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { Text(tab.label) }, + ) + } + } + when (HostDetailTab.entries[selectedTab]) { + HostDetailTab.Info -> HostInfoContent(host, repo, dateMode) + HostDetailTab.Events -> HostEventsContent( + repo = repo, + hostId = host.id, + onOpenEvent = onOpenEvent, + ) + } + } + } +} + +@Composable +private fun HostInfoContent(host: HostDetail, repo: SacRepository, dateMode: DateTimeDisplayMode) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Field("Продукт", host.product) + Field("Версия", host.productVersion ?: "—") + Field("ОС", "${host.osFamily} ${host.osVersion.orEmpty()}".trim()) + Field("Агент", host.agentStatus) + Field("Последний контакт", SacDateTimes.format(host.lastSeenAt, dateMode)) + HostSessionsSection(repo = repo, hostId = host.id) + host.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) } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun HostEventsContent( + repo: SacRepository, + hostId: Long, + onOpenEvent: (Long) -> Unit, +) { + var page by remember { mutableIntStateOf(1) } + var items by remember { mutableStateOf>(emptyList()) } + var total by remember { mutableIntStateOf(0) } + var loading by remember { mutableStateOf(true) } + var refreshing by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( + initialValue = DateTimeDisplayMode.SLASH, + ) + + fun load(showRefreshIndicator: Boolean = false) { + scope.launch { + if (showRefreshIndicator) refreshing = true else loading = true + error = null + try { + val res = repo.withAuth { + it.listEvents( + page = page, + pageSize = 30, + hostId = hostId, + includeHidden = true, + ) + } + items = res.items + total = res.total + } catch (e: Exception) { + error = e.message + } finally { + loading = false + refreshing = false + } + } + } + + LaunchedEffect(page, hostId) { load() } + + Column( + Modifier + .fillMaxSize() + .padding(16.dp), + ) { + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = { load(showRefreshIndicator = true) }, + modifier = Modifier.weight(1f), + ) { + if (items.isEmpty() && loading) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } else { + LazyColumn { + items(items, key = { it.id }) { event -> + Card( + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable { onOpenEvent(event.id) }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Column(Modifier.padding(12.dp)) { + Text( + "${event.severity} · ${event.title}", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + SacDateTimes.format(event.occurredAt, dateMode), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + BackArrowButton(onClick = { if (page > 1) page-- }, enabled = page > 1) + Text("Стр. $page · всего $total", color = MaterialTheme.colorScheme.onSurface) + ForwardArrowButton(onClick = { page++ }, enabled = page * 30 < total) + } + } +} + +@Composable +fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) { + var baseUrl by remember { mutableStateOf(null) } + var username by remember { mutableStateOf(null) } + var role by remember { mutableStateOf(null) } + var deviceId by remember { mutableStateOf(0) } + val context = LocalContext.current + val notificationMode by repo.session.notificationMode.collectAsState(initial = NotificationMode.PUSH_SOUND) + val soundSettings by repo.session.notificationSoundSettings.collectAsStateWithLifecycle( + initialValue = NotificationSoundSettings(NotificationSoundSource.SYSTEM, null), + ) + val dateTimeMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( + initialValue = DateTimeDisplayMode.SLASH, + ) + val scope = rememberCoroutineScope() + val activity = context as? FragmentActivity + val latestSoundSettings by rememberUpdatedState(soundSettings) + + suspend fun applySoundChannels(settings: NotificationSoundSettings) { + NotificationHelper.ensureChannels( + context, + NotificationSoundResolver.resolve(context, settings), + ) + } + + LaunchedEffect(soundSettings, notificationMode) { + if (notificationMode == NotificationMode.PUSH_SOUND) { + val normalized = repo.session.let { + NotificationSoundResolver.normalizeStoredSettings(context, it) + } + applySoundChannels(normalized) + } + } + + fun openRingtonePicker() { + if (activity == null) { + Toast.makeText(context, "Не удалось открыть выбор звука", Toast.LENGTH_SHORT).show() + return + } + val currentUri = NotificationSoundResolver.resolve(context, latestSoundSettings) + try { + RingtonePicker.launch(activity, currentUri) { uri -> + if (uri == null) { + repo.session.setNotificationSound(NotificationSoundSource.SYSTEM, null) + } else { + val stored = NotificationSoundResolver.importCustomSound(context, uri) + if (stored == null) { + Toast.makeText(context, "Не удалось сохранить звук", Toast.LENGTH_LONG).show() + return@launch + } + repo.session.setNotificationSound(NotificationSoundSource.CUSTOM, stored) + } + applySoundChannels(repo.session.getNotificationSoundSettings()) + } + } catch (_: ActivityNotFoundException) { + Toast.makeText(context, "На устройстве нет выбора звука уведомлений", Toast.LENGTH_LONG).show() + } + } + + fun previewSound() { + RingtoneManager.getRingtone(context, NotificationSoundResolver.resolve(context, soundSettings))?.play() + } + + LaunchedEffect(Unit) { + baseUrl = repo.session.getBaseUrl() + username = repo.session.getUsername() + role = repo.session.getRole() + deviceId = repo.session.getDeviceId() + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + .navigationBarsPadding(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Моё устройство", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Field("Сервер", baseUrl ?: "—") + Field("Пользователь", username ?: "—") + 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, + color = MaterialTheme.colorScheme.primary, + 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, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + + if (notificationMode == NotificationMode.PUSH_SOUND) { + Text( + stringResource(R.string.notification_sound_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 8.dp), + ) + NotificationSoundSource.entries.forEach { source -> + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = soundSettings.source == source, + onClick = { + when (source) { + NotificationSoundSource.SYSTEM -> { + scope.launch { + repo.session.setNotificationSound(source, null) + applySoundChannels(repo.session.getNotificationSoundSettings()) + } + } + NotificationSoundSource.CUSTOM -> { + if (soundSettings.customUri.isNullOrBlank()) { + openRingtonePicker() + } else { + scope.launch { + repo.session.setNotificationSound(source, soundSettings.customUri) + applySoundChannels(repo.session.getNotificationSoundSettings()) + } + } + } + } + }, + ) + Text( + source.label, + modifier = Modifier.padding(start = 8.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + Text( + stringResource( + R.string.notification_sound_current, + NotificationSoundResolver.displayName(context, soundSettings), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (soundSettings.source == NotificationSoundSource.CUSTOM) { + Button(onClick = { openRingtonePicker() }) { + Text(stringResource(R.string.notification_sound_pick)) + } + } + Button(onClick = { previewSound() }) { + Text(stringResource(R.string.notification_sound_preview)) + } + } + } + + Text( + stringResource(R.string.date_time_format_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 8.dp), + ) + DateTimeDisplayMode.entries.forEach { mode -> + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = dateTimeMode == mode, + onClick = { + scope.launch { repo.session.setDateTimeDisplayMode(mode) } + }, + ) + Text( + mode.label, + modifier = Modifier.padding(start = 8.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + + Button( + onClick = { + scope.launch { + repo.logout() + onLogout() + } + }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Выйти") } + } + } +} + +@Composable +private fun DetailFrame( + title: String, + loading: Boolean, + error: String?, + onBack: () -> Unit, + content: @Composable () -> Unit, +) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + .navigationBarsPadding(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + BackArrowButton(onClick = onBack) + Text( + title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 4.dp), + ) + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + if (loading) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } else { + content() + } + } +} + +@Composable +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(), + ) + } +} + +private fun JsonElement.toPrettyString(): String = toString() 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 new file mode 100644 index 0000000..0bb8e16 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt @@ -0,0 +1,577 @@ +package ru.kalinamall.seaca.ui.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.TextButton +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.LifecycleResumeEffect +import kotlinx.coroutines.launch +import retrofit2.HttpException +import ru.kalinamall.seaca.data.DateTimeDisplayMode +import ru.kalinamall.seaca.data.DashboardSummary +import ru.kalinamall.seaca.data.EventListResponse +import ru.kalinamall.seaca.data.EventSummary +import ru.kalinamall.seaca.data.HostSummary +import ru.kalinamall.seaca.data.ProblemSummary +import ru.kalinamall.seaca.data.SacRepository +import ru.kalinamall.seaca.ui.components.BackArrowButton +import ru.kalinamall.seaca.ui.components.EventSessionTerminateButton +import ru.kalinamall.seaca.ui.components.RdgAccessBadge +import ru.kalinamall.seaca.ui.components.RdgFlapBadge +import ru.kalinamall.seaca.ui.components.RdgQwinstaButton +import ru.kalinamall.seaca.ui.components.RdgQwinstaDialog +import ru.kalinamall.seaca.ui.components.rememberRdgQwinstaController +import ru.kalinamall.seaca.ui.components.ForwardArrowButton +import ru.kalinamall.seaca.ui.util.SacDateTimes +import ru.kalinamall.seaca.ui.util.hasRdgFlapUi +import ru.kalinamall.seaca.ui.util.rdgQwinstaEventId +import ru.kalinamall.seaca.ui.util.eventSupportsSessionTerminate +import ru.kalinamall.seaca.ui.util.sessionTerminateLabel + +private val REPORT_TYPE_OPTIONS = listOf( + "report.daily.ssh" to "Отчёты ssh клиентов", + "report.daily.rdp" to "Отчёты RDP клиентов", +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) { + var data by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(false) } + var refreshing by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val qwinsta = rememberRdgQwinstaController(repo) + val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( + initialValue = DateTimeDisplayMode.SLASH, + ) + val scrollState = rememberScrollState() + + fun load(showRefreshIndicator: Boolean = false) { + scope.launch { + if (showRefreshIndicator) { + refreshing = true + } else { + loading = true + } + error = null + try { + data = repo.withAuth { it.dashboardSummary() } + } catch (e: Exception) { + error = e.message + } finally { + loading = false + refreshing = false + } + } + } + + LaunchedEffect(refreshKey) { load() } + + LifecycleResumeEffect(refreshKey) { + load(showRefreshIndicator = data != null) + onPauseOrDispose { } + } + + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = { load(showRefreshIndicator = true) }, + modifier = Modifier.fillMaxSize(), + ) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + data?.let { d -> + StatCard("События 24ч", d.eventsLast24h.toString()) + StatCard("Проблемы open", d.problemsOpen.toString()) + StatCard("Хосты", "${d.hostsTotal} (stale: ${d.hostsStale})") + Text( + "Последние события", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + d.recentEvents.take(8).forEach { e -> + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (!e.rdgAccessPath.isNullOrBlank()) { + RdgAccessBadge(path = e.rdgAccessPath) + } + if (hasRdgFlapUi(e)) { + RdgFlapBadge(pairEventId = e.rdgFlapPairEventId) + } + Text( + dashboardRecentEventLine(e), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + rdgQwinstaEventId(e)?.let { + RdgQwinstaButton( + controller = qwinsta, + onClick = { qwinsta.openFor(e, scope) }, + ) + } + } + Text( + SacDateTimes.format(e.occurredAt, dateMode), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + if (data == null && loading) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } + } + } + RdgQwinstaDialog(qwinsta) +} + +private fun dashboardRecentEventLine(event: EventSummary): String { + val body = if (event.type.startsWith("agent.")) { + "${event.hostname}: ${event.title}" + } else { + event.title + } + return "${event.severity}: $body" +} + +@Composable +private fun StatCard(label: String, value: String) { + Card( + Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Column(Modifier.padding(12.dp)) { + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { + var hostname by remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + val qwinsta = rememberRdgQwinstaController(repo) + var terminateLoadingId by remember { mutableStateOf(null) } + var terminateConfirm by remember { mutableStateOf(null) } + var terminateError by remember { mutableStateOf(null) } + var locallyTerminatedIds by remember { mutableStateOf(setOf()) } + val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( + initialValue = DateTimeDisplayMode.SLASH, + ) + + terminateConfirm?.let { event -> + AlertDialog( + onDismissRequest = { terminateConfirm = null }, + title = { Text("Завершить сессию?") }, + text = { Text("Завершить сессию пользователя ${sessionTerminateLabel(event)}?") }, + confirmButton = { + Button( + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + onClick = { + val target = event + terminateConfirm = null + scope.launch { + terminateLoadingId = target.id + terminateError = null + try { + val res = repo.withAuth { it.postEventTerminateSession(target.id) } + if (!res.ok) { + terminateError = res.message + } else { + locallyTerminatedIds = locallyTerminatedIds + target.id + } + } catch (e: Exception) { + terminateError = if (e is HttpException) SacRepository.parseError(e) else e.message + } finally { + terminateLoadingId = null + } + } + }, + ) { Text("Завершить") } + }, + dismissButton = { + TextButton(onClick = { terminateConfirm = null }) { Text("Отмена") } + }, + ) + } + + PagedListScreen( + title = "События", + loadPage = { page, severity, host -> + val res = repo.withAuth { + it.listEvents( + page = page, + pageSize = 30, + severity = severity, + hostname = host, + ) + } + res.items to res.total + }, + itemKey = EventSummary::id, + itemLabel = { "${it.severity} · ${it.title}" }, + itemSub = { "${it.hostname} · ${SacDateTimes.format(it.occurredAt, dateMode)}" }, + itemRdgAccessPath = { it.rdgAccessPath }, + itemRdgFlap = { hasRdgFlapUi(it) }, + itemRdgFlapPairId = { it.rdgFlapPairEventId }, + itemQwinsta = { rdgQwinstaEventId(it) != null }, + onQwinsta = { qwinsta.openFor(it, scope) }, + qwinstaLoading = qwinsta.loading, + itemTerminate = { + eventSupportsSessionTerminate(it) && it.id !in locallyTerminatedIds + }, + onTerminate = { terminateConfirm = it }, + terminateLoadingId = terminateLoadingId, + onOpen = onOpen, + severities = listOf(null, "info", "warning", "high", "critical"), + hostnameFilter = hostname, + headerContent = { + terminateError?.let { + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + OutlinedTextField( + value = hostname, + onValueChange = { hostname = it }, + label = { Text("Хост") }, + placeholder = { Text("Фильтр по имени хоста") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + }, + ) + RdgQwinstaDialog(qwinsta) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProblemsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { + PagedListScreen( + title = "Проблемы", + loadPage = { page, filter, _ -> + val res = repo.withAuth { + it.listProblems(page = page, pageSize = 30, status = filter ?: "open") + } + res.items to res.total + }, + itemKey = ProblemSummary::id, + itemLabel = { "${it.severity} · ${it.title}" }, + itemSub = { "${it.status} · ${it.hostname ?: "—"}" }, + onOpen = onOpen, + severities = listOf("open", "acknowledged", "resolved"), + filterLabel = "Статус", + ) +} + +@Composable +fun HostsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { + PagedListScreen( + title = "Хосты", + loadPage = { page, _, _ -> + val res = repo.withAuth { it.listHosts(page = page, pageSize = 30) } + res.items to res.total + }, + itemKey = HostSummary::id, + itemLabel = { it.hostname }, + itemSub = { "${it.agentStatus} · ${it.product}" }, + onOpen = onOpen, + severities = emptyList(), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReportsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { + var reportType by remember { mutableStateOf("report.daily.ssh") } + var expanded by remember { mutableStateOf(false) } + var page by remember { mutableIntStateOf(1) } + var data by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(true) } + var refreshing by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( + initialValue = DateTimeDisplayMode.SLASH, + ) + + fun load(showRefreshIndicator: Boolean = false) { + scope.launch { + if (showRefreshIndicator) refreshing = true else loading = true + try { + data = repo.withAuth { it.listEvents(page = page, pageSize = 30, type = reportType) } + } catch (e: Exception) { + error = e.message + } finally { + loading = false + refreshing = false + } + } + } + + 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 = reportTypeLabel, + onValueChange = {}, + label = { Text("Тип отчёта") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + 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) } + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = { load(showRefreshIndicator = true) }, + modifier = Modifier.weight(1f), + ) { + LazyColumn { + items(data?.items.orEmpty(), key = { it.id }) { item -> + ListRow( + title = item.summary, + subtitle = "${item.hostname} · ${SacDateTimes.format(item.occurredAt, dateMode)}", + onClick = { onOpen(item.id) }, + ) + } + } + } + PagerBar(page, data?.total ?: 0, 30, onPrev = { if (page > 1) page-- }, onNext = { page++ }) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PagedListScreen( + title: String, + loadPage: suspend (Int, String?, String?) -> Pair, Int>, + itemKey: (T) -> Long, + itemLabel: (T) -> String, + itemSub: (T) -> String, + onOpen: (Long) -> Unit, + severities: List, + itemRdgAccessPath: ((T) -> String?)? = null, + itemRdgFlap: ((T) -> Boolean)? = null, + itemRdgFlapPairId: ((T) -> Long?)? = null, + itemQwinsta: ((T) -> Boolean)? = null, + onQwinsta: ((T) -> Unit)? = null, + qwinstaLoading: Boolean = false, + itemTerminate: ((T) -> Boolean)? = null, + onTerminate: ((T) -> Unit)? = null, + terminateLoadingId: Long? = null, + filterLabel: String = "Severity", + hostnameFilter: String = "", + headerContent: @Composable () -> Unit = {}, +) { + var page by remember { mutableIntStateOf(1) } + var filter by remember { mutableStateOf(severities.firstOrNull()) } + var items by remember { mutableStateOf>(emptyList()) } + var total by remember { mutableIntStateOf(0) } + var loading by remember { mutableStateOf(true) } + var refreshing by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + val hostQuery = hostnameFilter.trim().ifBlank { null } + + fun load(showRefreshIndicator: Boolean = false) { + scope.launch { + if (showRefreshIndicator) refreshing = true else loading = true + try { + val (pageItems, pageTotal) = loadPage(page, filter, hostQuery) + items = pageItems + total = pageTotal + } catch (e: Exception) { + error = e.message + } finally { + loading = false + refreshing = false + } + } + } + + LaunchedEffect(hostQuery) { + if (page != 1) page = 1 + } + + LaunchedEffect(page, filter, hostQuery) { load() } + + Column(Modifier.fillMaxSize().padding(16.dp)) { + Text(title, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface) + headerContent() + if (severities.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + severities.forEach { s -> + FilterChip( + selected = filter == s, + onClick = { filter = s; page = 1 }, + label = { Text(s ?: "все") }, + ) + } + } + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = { load(showRefreshIndicator = true) }, + modifier = Modifier.weight(1f), + ) { + LazyColumn { + items(items, key = { itemKey(it) }) { item -> + ListRow( + title = itemLabel(item), + subtitle = itemSub(item), + onClick = { onOpen(itemKey(item)) }, + rdgAccessPath = itemRdgAccessPath?.invoke(item), + rdgFlap = itemRdgFlap?.invoke(item) == true, + rdgFlapPairId = itemRdgFlapPairId?.invoke(item), + showQwinsta = itemQwinsta?.invoke(item) == true, + onQwinstaClick = onQwinsta?.let { handler -> { handler(item) } }, + qwinstaLoading = qwinstaLoading, + showTerminate = itemTerminate?.invoke(item) == true, + onTerminateClick = onTerminate?.let { handler -> { handler(item) } }, + terminateLoading = terminateLoadingId == itemKey(item), + ) + } + } + } + PagerBar(page, total, 30, onPrev = { if (page > 1) page-- }, onNext = { if (page * 30 < total) page++ }) + } +} + +@Composable +private fun ListRow( + title: String, + subtitle: String, + onClick: () -> Unit, + rdgAccessPath: String? = null, + rdgFlap: Boolean = false, + rdgFlapPairId: Long? = null, + showQwinsta: Boolean = false, + onQwinstaClick: (() -> Unit)? = null, + qwinstaLoading: Boolean = false, + showTerminate: Boolean = false, + onTerminateClick: (() -> Unit)? = null, + terminateLoading: Boolean = false, +) { + Card( + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (!rdgAccessPath.isNullOrBlank()) { + RdgAccessBadge(path = rdgAccessPath) + } + if (rdgFlap) { + RdgFlapBadge(pairEventId = rdgFlapPairId) + } + Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (showQwinsta && onQwinstaClick != null) { + OutlinedButton( + enabled = !qwinstaLoading, + onClick = onQwinstaClick, + ) { Text(if (qwinstaLoading) "…" else "Оборвать сессию") } + } + if (showTerminate && onTerminateClick != null) { + EventSessionTerminateButton( + enabled = !terminateLoading, + loading = terminateLoading, + onClick = onTerminateClick, + ) + } + } + } +} + +@Composable +private fun PagerBar(page: Int, total: Int, pageSize: Int, onPrev: () -> Unit, onNext: () -> Unit) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + BackArrowButton(onClick = onPrev, enabled = page > 1) + Text("Стр. $page · всего $total", color = MaterialTheme.colorScheme.onSurface) + ForwardArrowButton(onClick = onNext, enabled = page * pageSize < total) + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/theme/Theme.kt b/app/src/main/java/ru/kalinamall/seaca/ui/theme/Theme.kt new file mode 100644 index 0000000..07e7113 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/theme/Theme.kt @@ -0,0 +1,44 @@ +package ru.kalinamall.seaca.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val SacDark = darkColorScheme( + primary = Color(0xFF3DB8FF), + onPrimary = Color(0xFF002A42), + background = Color(0xFF0F1419), + onBackground = Color(0xFFC5D0DC), + surface = Color(0xFF15202B), + onSurface = Color(0xFFE8EEF4), + surfaceContainer = Color(0xFF1C2834), + surfaceContainerLow = Color(0xFF182330), + surfaceContainerHigh = Color(0xFF243240), + onSurfaceVariant = Color(0xFFB0BEC9), + error = Color(0xFFFF8A8A), +) + +private val SacLight = lightColorScheme( + primary = Color(0xFF1A6FA0), + onPrimary = Color(0xFFFFFFFF), + background = Color(0xFFF4F6F8), + onBackground = Color(0xFF1A1F24), + surface = Color(0xFFFFFFFF), + onSurface = Color(0xFF1A1F24), + surfaceContainer = Color(0xFFEEF2F5), + surfaceContainerLow = Color(0xFFF4F6F8), + surfaceContainerHigh = Color(0xFFE4E9ED), + onSurfaceVariant = Color(0xFF4A5560), +) + +@Composable +fun SeacaTheme(content: @Composable () -> Unit) { + val dark = isSystemInDarkTheme() + MaterialTheme( + colorScheme = if (dark) SacDark else SacLight, + content = content, + ) +} 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..b6ef159 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/util/DetailFormat.kt @@ -0,0 +1,359 @@ +package ru.kalinamall.seaca.ui.util + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +data class FormattedEventDetails( + val fields: List> = emptyList(), + val multilineLabel: String? = null, + val multilineText: String? = null, +) { + val hasContent: Boolean + get() = fields.isNotEmpty() || !multilineText.isNullOrBlank() +} + +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 +} + +private fun isLoginEventType(type: String): Boolean = + type.startsWith("rdp.login.") || + type.startsWith("ssh.login.") || + type.startsWith("agent.login.") + +/** Текст ежедневного отчёта из 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() + +fun formatEventDetails( + type: String, + details: JsonElement?, + payload: JsonElement? = null, +): FormattedEventDetails? { + val obj = details as? JsonObject ?: return null + return when (type) { + "agent.lifecycle" -> formatLifecycleDetails(obj, payload) + "agent.inventory" -> formatInventoryEventDetails(obj) + "rdp.bruteforce.burst" -> formatBruteforceBurstDetails(obj) + else -> when { + isLoginEventType(type) -> formatLoginDetails(obj) + else -> { + val fields = extractStructuredDetailFields(obj) + if (fields.isEmpty()) null else FormattedEventDetails(fields = fields) + } + } + } +} + +private fun formatLifecycleDetails(obj: JsonObject, payload: JsonElement?): FormattedEventDetails { + jsonString(obj, "notification_body")?.let { body -> + return FormattedEventDetails( + multilineLabel = "Сообщение", + multilineText = normalizeReportPlainText(body), + ) + } + val fields = mutableListOf>() + jsonString(obj, "lifecycle")?.let { fields += "Событие" to lifecycleLabel(it) } + jsonString(obj, "trigger")?.let { fields += "Причина" to lifecycleTriggerLabel(it) } + sourceProductLine(payload)?.let { fields += it } + return FormattedEventDetails(fields = fields) +} + +private fun formatInventoryEventDetails(obj: JsonObject): FormattedEventDetails { + val changes = obj["hardware_changes"]?.jsonArray + if (changes != null && changes.isNotEmpty()) { + val fields = changes.mapIndexedNotNull { index, element -> + val change = element.jsonObject + val field = jsonString(change, "field") ?: return@mapIndexedNotNull null + val old = primitiveDisplay(change["old"]) ?: "—" + val new = primitiveDisplay(change["new"]) ?: "—" + "Изменение ${index + 1}" to "${hardwareFieldLabel(field)}: $old → $new" + } + return FormattedEventDetails(fields = fields) + } + val inventory = obj["inventory"] + if (inventory != null) { + val lines = inventoryFieldLines(inventory) + if (lines.isNotEmpty()) return FormattedEventDetails(fields = lines) + } + val fallback = extractStructuredDetailFields(obj) + return if (fallback.isEmpty()) FormattedEventDetails() else FormattedEventDetails(fields = fallback) +} + +private fun formatBruteforceBurstDetails(obj: JsonObject): FormattedEventDetails { + val fields = mutableListOf>() + jsonString(obj, "tier")?.let { fields += "Уровень" to it } + primitiveDisplay(obj["count"])?.let { fields += "Количество" to it } + jsonString(obj, "bucket_key")?.let { fields += "Ключ" to it } + jsonString(obj, "ip_address", "source_ip", "ip")?.let { fields += "IP" to it } + jsonString(obj, "user", "username")?.let { fields += "Пользователь" to it } + if (fields.isEmpty()) { + return FormattedEventDetails(fields = extractStructuredDetailFields(obj)) + } + return FormattedEventDetails(fields = fields) +} + +private fun formatLoginDetails(obj: JsonObject): FormattedEventDetails { + jsonString(obj, "notification_body")?.let { body -> + return FormattedEventDetails( + multilineLabel = "Сообщение", + multilineText = normalizeReportPlainText(body), + ) + } + val fields = mutableListOf>() + jsonString(obj, "user", "username")?.let { fields += "Пользователь" to it } + jsonString(obj, "ip_address", "source_ip", "ip")?.let { fields += "IP" to it } + jsonString(obj, "workstation_name", "computer_name")?.let { fields += "Рабочая станция" to it } + jsonString(obj, "process_name", "process")?.let { fields += "Процесс" to it } + jsonPrimitiveString(obj, "logon_type")?.let { fields += "Тип входа" to logonTypeLabel(it) } + jsonPrimitiveString(obj, "port")?.let { fields += "Порт" to it } + val attempt = jsonPrimitiveString(obj, "attempt_number") + val maxAttempts = jsonPrimitiveString(obj, "max_attempts") + if (attempt != null && maxAttempts != null) { + fields += "Попытка" to "$attempt / $maxAttempts" + } + jsonPrimitiveString(obj, "event_id_windows")?.let { fields += "Event ID Windows" to it } + if (fields.isEmpty()) { + return FormattedEventDetails(fields = extractStructuredDetailFields(obj)) + } + return FormattedEventDetails(fields = fields) +} + +private fun extractStructuredDetailFields(obj: JsonObject): List> { + val fields = mutableListOf>() + val consumed = mutableSetOf() + + fun add(label: String, vararg keys: String) { + jsonString(obj, *keys)?.let { + fields += label to it + keys.forEach { key -> consumed += key } + } + jsonPrimitiveString(obj, *keys)?.let { + fields += label to it + keys.forEach { key -> consumed += key } + } + } + + add("Пользователь", "user", "username", "shadower_user", "target_user") + add("IP", "ip_address", "source_ip", "ip", "external_ip", "internal_ip") + add("Рабочая станция", "workstation_name", "computer_name") + add("Процесс", "process_name", "process") + add("Порт", "port") + add("Команда", "command") + add("Share", "share_name") + add("Путь", "share_path") + add("ResourceUri", "resource_uri") + add("Session ID", "session_id", "target_session_id") + add("Event ID Windows", "event_id_windows") + add("Уровень", "tier", "risk_level") + add("Количество", "count") + add("Ключ", "bucket_key") + + jsonPrimitiveString(obj, "logon_type")?.let { + fields += "Тип входа" to logonTypeLabel(it) + consumed += "logon_type" + } + + obj.forEach { (key, value) -> + if (key in consumed || key in SKIP_DETAIL_KEYS) return@forEach + if (value is JsonPrimitive) { + val text = primitiveDisplay(value) ?: return@forEach + fields += detailKeyLabel(key) to text + } + } + + return fields +} + +private val SKIP_DETAIL_KEYS = setOf( + "telegram_via", + "generated_by", + "report_body", + "report_html", + "inventory", + "hardware_changes", + "notification_body", + "lifecycle", + "trigger", +) + +private fun detailKeyLabel(key: String): String = when (key) { + "user" -> "Пользователь" + "username" -> "Пользователь" + "source_ip" -> "IP" + "ip_address" -> "IP" + "workstation_name" -> "Рабочая станция" + "event_id_windows" -> "Event ID Windows" + "logon_type" -> "Тип входа" + "attempt_number" -> "Попытка" + "max_attempts" -> "Макс. попыток" + else -> key.replace('_', ' ').replaceFirstChar { it.uppercase() } +} + +private fun lifecycleLabel(value: String): String = when (value.lowercase()) { + "started" -> "Запущен" + "stopped" -> "Остановлен" + "settings_reloaded" -> "Настройки перечитаны" + else -> value +} + +private fun lifecycleTriggerLabel(value: String): String = when (value.lowercase()) { + "boot" -> "Загрузка ОС / планировщик" + "deploy_recycle" -> "Обновление скрипта (recycle)" + "settings_reload" -> "Graceful restart (settings)" + "manual_recycle" -> "Ручной recycle" + "shutdown" -> "Остановка процесса" + else -> value +} + +private fun logonTypeLabel(raw: String): String { + val code = raw.trim().toIntOrNull() + val name = when (code) { + 2 -> "Interactive" + 3 -> "Network" + 4 -> "Batch" + 5 -> "Service" + 7 -> "Unlock" + 8 -> "NetworkCleartext" + 9 -> "NewCredentials" + 10 -> "RemoteInteractive" + 11 -> "CachedInteractive" + else -> null + } + return when { + name != null && code != null -> "$name ($code)" + else -> raw + } +} + +private fun hardwareFieldLabel(field: String): String = when (field) { + "memory_gb" -> "Память (GB)" + "processor" -> "Процессор" + else -> field +} + +private fun sourceProductLine(payload: JsonElement?): Pair? { + val source = payload?.jsonObject?.get("source")?.jsonObject ?: return null + val product = jsonString(source, "product") + val version = jsonString(source, "product_version") + val line = listOfNotNull(product, version).joinToString(" ").trim() + return if (line.isEmpty()) null else "Агент" to line +} + +private fun jsonString(obj: JsonObject, vararg keys: String): String? { + for (key in keys) { + val value = obj[key]?.jsonPrimitive?.contentOrNull?.trim() + if (!value.isNullOrEmpty()) return value + } + return null +} + +private fun jsonPrimitiveString(obj: JsonObject, vararg keys: String): String? { + for (key in keys) { + val el = obj[key] ?: continue + primitiveDisplay(el)?.let { return it } + } + return null +} + +private fun primitiveDisplay(el: JsonElement?): String? = + when (el) { + is JsonPrimitive -> el.contentOrNull?.trim()?.takeIf { it.isNotEmpty() } + else -> null + } + +private fun jsonInt(obj: JsonObject, key: String): Int? = + obj[key]?.jsonPrimitive?.contentOrNull?.toIntOrNull() + +/** Плоский список полей инвентаризации для карточки хоста / agent.inventory. */ +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/java/ru/kalinamall/seaca/ui/util/RdgQwinsta.kt b/app/src/main/java/ru/kalinamall/seaca/ui/util/RdgQwinsta.kt new file mode 100644 index 0000000..b26720b --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/util/RdgQwinsta.kt @@ -0,0 +1,88 @@ +package ru.kalinamall.seaca.ui.util + +import ru.kalinamall.seaca.data.EventDetail +import ru.kalinamall.seaca.data.EventSummary + +data class QwinstaSessionRow( + val sessionName: String, + val userName: String, + val id: Int, + val state: String, +) + +fun hasRdgFlapUi(event: EventSummary): Boolean = + event.rdgFlap || event.rdgFlapQwinstaEventId != null + +fun hasRdgFlapUi(event: EventDetail): Boolean = + event.rdgFlap || event.rdgFlapQwinstaEventId != null + +fun hasRdgAccessPath(event: EventSummary): Boolean = + !event.rdgAccessPath.isNullOrBlank() + +fun hasRdgAccessPath(event: EventDetail): Boolean = + !event.rdgAccessPath.isNullOrBlank() + +/** Как SAC rdgQwinstaEventId: кнопка только при rdg_qwinsta_enabled от API. */ +fun rdgQwinstaEventId(event: EventSummary): Long? { + if (!event.rdgQwinstaEnabled) return null + return event.rdgFlapQwinstaEventId ?: event.id +} + +fun rdgQwinstaEventId(event: EventDetail): Long? { + if (!event.rdgQwinstaEnabled) return null + return event.rdgFlapQwinstaEventId ?: event.id +} + +/** @deprecated use rdgQwinstaEventId */ +fun rdgFlapQwinstaEventId(event: EventSummary): Long? = rdgQwinstaEventId(event) + +/** @deprecated use rdgQwinstaEventId */ +fun rdgFlapQwinstaEventId(event: EventDetail): Long? = rdgQwinstaEventId(event) + +fun parseQwinstaSessions(stdout: String, actorUser: String?): List { + val lines = stdout.lines().map { it.trim() }.filter { it.isNotEmpty() } + val rows = mutableListOf() + for (line in lines) { + if (line.startsWith("SESSION", ignoreCase = true) || line.startsWith("---")) continue + val parts = line.split(Regex("\\s+")) + if (parts.size < 4) continue + val id = parts[2].toIntOrNull() ?: continue + val userName = parts[1] + if (!actorUser.isNullOrBlank()) { + val norm: (String) -> String = { it.replace(Regex("^B26\\\\", RegexOption.IGNORE_CASE), "").lowercase() } + if (!norm(userName).contains(norm(actorUser))) continue + } + rows.add( + QwinstaSessionRow( + sessionName = parts[0].removePrefix(">").trim(), + userName = userName, + id = id, + state = parts.drop(3).joinToString(" "), + ), + ) + } + if (rows.isEmpty() && stdout.isNotBlank()) { + for (line in lines) { + if (line.startsWith("SESSION", ignoreCase = true) || line.startsWith("---")) continue + val parts = line.split(Regex("\\s+")) + if (parts.size < 4) continue + val id = parts[2].toIntOrNull() ?: continue + rows.add( + QwinstaSessionRow( + sessionName = parts[0].removePrefix(">").trim(), + userName = parts[1], + id = id, + state = parts.drop(3).joinToString(" "), + ), + ) + } + } + return rows +} + +fun agentCommandError(cmd: ru.kalinamall.seaca.data.AgentCommandResponse): String? { + if (cmd.status != "failed") return null + return cmd.resultStderr?.takeIf { it.isNotBlank() } + ?: cmd.resultStdout?.takeIf { it.isNotBlank() } + ?: "Команда завершилась с ошибкой" +} diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/util/SacDateTimes.kt b/app/src/main/java/ru/kalinamall/seaca/ui/util/SacDateTimes.kt new file mode 100644 index 0000000..1cc759f --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/util/SacDateTimes.kt @@ -0,0 +1,46 @@ +package ru.kalinamall.seaca.ui.util + +import ru.kalinamall.seaca.data.DateTimeDisplayMode +import java.time.Instant +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale + +object SacDateTimes { + fun format(raw: String?, mode: DateTimeDisplayMode): String { + if (raw.isNullOrBlank()) return "—" + val zoned = parse(raw) ?: return raw.trim() + val local = zoned.withZoneSameInstant(ZoneId.systemDefault()) + return when (mode) { + DateTimeDisplayMode.LOCALE -> { + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.MEDIUM) + .withLocale(Locale.getDefault()) + .format(local) + } + else -> { + val pattern = mode.pattern ?: DateTimeDisplayMode.SLASH.pattern!! + DateTimeFormatter.ofPattern(pattern, Locale.getDefault()).format(local) + } + } + } + + private fun parse(raw: String): ZonedDateTime? { + val trimmed = raw.trim() + val normalised = trimmed.replace(Regex(" ([+-]\\d{2}:\\d{2})$"), "$1") + val candidates = listOf(normalised, trimmed) + for (candidate in candidates) { + runCatching { return OffsetDateTime.parse(candidate).toZonedDateTime() } + runCatching { return ZonedDateTime.parse(candidate) } + runCatching { return Instant.parse(candidate).atZone(ZoneId.systemDefault()) } + runCatching { + return LocalDateTime.parse(candidate, DateTimeFormatter.ISO_LOCAL_DATE_TIME) + .atZone(ZoneId.systemDefault()) + } + } + return null + } +} diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/util/SessionActions.kt b/app/src/main/java/ru/kalinamall/seaca/ui/util/SessionActions.kt new file mode 100644 index 0000000..93f6bff --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/util/SessionActions.kt @@ -0,0 +1,26 @@ +package ru.kalinamall.seaca.ui.util + +import ru.kalinamall.seaca.data.EventDetail +import ru.kalinamall.seaca.data.EventSummary + +private val LINUX_SESSION_EVENT_TYPES = setOf( + "session.logind.new", + "ssh.login.success", + "privilege.sudo.command", +) + +private val WINDOWS_SESSION_EVENT_TYPES = setOf("rdp.login.success") + +fun eventSupportsSessionTerminate(event: EventSummary): Boolean = + (event.type in LINUX_SESSION_EVENT_TYPES || event.type in WINDOWS_SESSION_EVENT_TYPES) && + !event.sessionTerminated + +fun eventSupportsSessionTerminate(event: EventDetail): Boolean = + (event.type in LINUX_SESSION_EVENT_TYPES || event.type in WINDOWS_SESSION_EVENT_TYPES) && + !event.sessionTerminated + +fun sessionTerminateLabel(event: EventSummary): String = + event.actorUser?.takeIf { it.isNotBlank() } ?: event.title + +fun sessionTerminateLabel(event: EventDetail): String = + event.actorUser?.takeIf { it.isNotBlank() } ?: event.title diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..620debc --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..aa9db9e --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #15202B + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..5772175 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,16 @@ + + + Seaca + SAC уведомления + SAC: важные + SAC: критические + SAC уведомления (без звука) + SAC: важные (без звука) + SAC: критические (без звука) + Push-уведомления + Формат даты и времени + Звук уведомлений + Выбрать звук + Проверить звук + Сейчас: %1$s + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..4f5deb7 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +