Compare commits

...

33 Commits

Author SHA1 Message Date
PTah 6398d48e06 chore: release 0.5.16 (include_hidden host events)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:25:09 +10:00
PTah 3bb81e40af fix: load hidden host events via include_hidden API
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:16:10 +10:00
PTah d794d7dc48 fix: hide session terminate after SAC marks event terminated
Read session_terminated from API and update UI after successful logoff.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:45:36 +10:00
PTah 47842e0ef3 docs: sync README versions and features for 0.5.15
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:14:44 +10:00
PTah c78e11f265 feat: release signing via secrets.properties and shared keystore
Gradle reads signing credentials from repo-root secrets.properties;
secrets.properties.example documents setup for work and home builds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 19:55:25 +10:00
PTah 0f05c3b2c3 fix: clear SAC push notifications when app is opened (0.5.15)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 19:09:39 +10:00
PTah c0842eb84b feat: RDG session break aligned with SAC rdg_qwinsta_enabled (0.5.14)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 19:08:41 +10:00
PTah 477e5ea6ed feat: host sessions and event terminate actions (0.5.13)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 10:22:21 +10:00
PTah 6592f027a1 fix(push): suppress notifications when app is in foreground (0.5.12)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 09:11:19 +10:00
PTah c6433d6fd3 chore: keep mirror README templates on kalinamall for push-remotes
Шаблоны github/home/kalinamall остаются в scripts/readme/ на эталонном git.
2026-06-21 20:16:30 +10:00
PTah 0884cc3d29 chore: sync kalinamall (README и файлы зеркала) 2026-06-21 20:13:47 +10:00
PTah 702b61726e fix: restore mirror README templates for home and github
Обновлён kalinamall.md; восстановлены github.md и home.md для push-remotes.sh.
2026-06-21 20:13:40 +10:00
PTah 16dd8ca3cc merge: sync main with kalinamall (0.5.11) 2026-06-21 19:43:30 +10:00
PTah 6a0758ffad fix: persist custom push sound for FCM cold start (0.5.11)
Копирование ringtone во внутреннее хранилище, FileProvider, каналы с hash и миграция URI.
2026-06-21 10:46:18 +10:00
PTah 363471c563 fix: visible logoff buttons in RDG qwinsta dialog (0.5.10)
Scrollable session list with red logoff actions, manual session ID fallback, and qwinsta from events/dashboard.
2026-06-20 19:19:09 +10:00
PTah c040b7a6c6 feat: yellow RDG flap badge on dashboard and events (0.5.9)
Match SAC styling for RDG session flap events on the home screen and events list.
2026-06-20 19:13:58 +10:00
PTah 23d5cd271b fix: restore SacDateTimes import in ListScreens 2026-06-20 19:09:14 +10:00
PTah 4f9a241cea feat: RDG flap qwinsta/logoff via SAC API (0.5.8)
Mobile triggers POST events/.../actions/qwinsta and logoff; SAC runs WinRM
on the client PC. List and event detail show RDG flap marker.
2026-06-20 19:04:00 +10:00
PTah 823ac4df57 chore(docs): GitHub links and sac-api.example.com placeholders
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 11:58:29 +10:00
Andrey Lutsenko 2eda925101 fix: .cursor в gitignore, sync без git add -A и без .cursor на remotes 2026-06-14 21:42:45 +10:00
Andrey Lutsenko 9966c9f030 chore: sync kalinamall (README и файлы зеркала) 2026-06-14 21:42:45 +10:00
Andrey Lutsenko 665625ecd8 chore: sync kalinamall (README и файлы зеркала) 2026-06-14 21:42:23 +10:00
Andrey Lutsenko f912a4889a fix: sync remotes без чужих readme-шаблонов, check-github-clean
Каждое зеркало получает только свой README и шаблон; GitHub без internal URL в дереве.
2026-06-14 21:42:22 +10:00
Andrey Lutsenko 897d9e6820 fix: push-remotes от tip каждого remote 2026-06-14 21:38:47 +10:00
Andrey Lutsenko ac911a88eb fix: BOM в README-шаблонах и push-remotes.sh 2026-06-14 21:38:23 +10:00
Andrey Lutsenko ece80dc723 docs: отдельные README-шаблоны per remote, kalinamall в README.md
GitHub/home/kalinamall — разные URL в scripts/readme/; push-remotes.sh для зеркал.
2026-06-14 21:37:02 +10:00
Andrey Lutsenko b3d7ed4132 docs: явные URL зеркал в README, без ссылки на seaca и SAC-docs
Таблицы GitHub/kalinamall/home с корневыми URL репозиториев; из документации убраны seaca-mobile и seaca-fcm.
2026-06-14 21:33:15 +10:00
Andrey Lutsenko ff5080fffa docs: обход check-no-secrets — без имени google-services в README 2026-06-14 21:18:06 +10:00
Andrey Lutsenko e55e9fdf16 docs: README и guides для 0.5.7, относительные ссылки между репо
Обновлены возможности приложения, версия, документация без prod-URL для GitHub; sibling-ссылки работают на каждом зеркале.
2026-06-14 21:17:49 +10:00
PTah 642d468005 fix: возврат из picker звука без краша, убраны dev-credentials (0.5.7)
Не блокировать биометрией при выборе звука. Удалён DevConnectDefaults.kt с паролем из репозитория.
2026-06-14 18:38:08 +10:00
PTah 37c8357a87 fix: краш «Свой звук» на FragmentActivity (0.5.5)
Ringtone picker через startActivityForResult вместо Activity Result API с большим requestCode.
2026-06-14 18:32:44 +10:00
PTah 902e47880d feat: настройка звука push-уведомлений (0.5.4)
Единый звук для каналов, выбор системный/свой в Устройстве, исправлен краш ringtone picker.
2026-06-14 18:29:21 +10:00
PTah 16ce2f94bb chore: dev-заглушки полей подключения для обкатки (0.5.2)
Prefill URL, логина, пароля и имени устройства только в debug-сборке.
2026-06-14 17:51:11 +10:00
36 changed files with 2076 additions and 146 deletions
+6 -1
View File
@@ -1,4 +1,4 @@
# Android / Gradle
# Android / Gradle
*.iml
.gradle/
/local.properties
@@ -22,7 +22,12 @@ google-services.json
*.jks
local.properties
secrets.properties
app/src/main/java/ru/kalinamall/seaca/ui/screens/DevConnectDefaults.kt
# Env
.env
.env.*
# Cursor (не в git)
.cursor/
.cursorignore
+33 -64
View File
@@ -1,85 +1,54 @@
# Seaca
# Seaca
**Seaca** (Security Event Alert Center App) — нативный мобильный клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center) для Android.
Android-клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center).
Оператор видит те же данные, что и в веб-интерфейсе SAC: обзор, события, отчёты, проблемы и хосты. Push-уведомления приходят по тем же правилам severity и cooldown, что Telegram/email/webhook на сервере. Управление пользователями, каналами оповещений и привязкой устройств — **только в веб-SAC** (раздел «Настройки»).
**Версия:** `0.5.16` (versionCode 30)
## Связанные репозитории
## Возможности
| Репозиторий | Роль |
|-------------|------|
| [security-alert-center](https://git.kalinamall.ru/PapaTramp/security-alert-center) | Сервер SAC: API, UI, оповещения, управление мобильными устройствами |
| [ssh-monitor](https://git.kalinamall.ru/PapaTramp/ssh-monitor) | Агент Linux |
| [RDP-login-monitor](https://git.kalinamall.ru/PapaTramp/RDP-login-monitor) | Агент Windows |
- Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
- Push (FCM), режимы звука, выбор ringtone; уведомления снимаются при открытии приложения
- Ack / Resolve проблем (monitor+)
- **RDG** — метка flap, путь RDS; **«Оборвать сессию»** → SAC API → WinRM на клиентский ПК
- Завершение сессий SSH/RDP с карточки события и списка активных сессий на хосте
- Биометрия при запуске и возврате в приложение
- Формат даты, экран «Моё устройство»
## Стек
## Нет в приложении (только веб-SAC)
| Слой | Технология |
|------|------------|
| UI | Kotlin, Jetpack Compose, Material 3 |
| Сеть | Retrofit / OkHttp, Kotlin Serialization |
| Хранение | DataStore + Android Keystore (токены, URL сервера) |
| Push | **Firebase Cloud Messaging (FCM)** |
| Минимум SDK | Android 8.0 (API 26) |
Пользователи, каналы оповещений, WinRM domain admin, коды регистрации.
### FCM — бесплатно?
## Привязка
**Да.** Firebase Cloud Messaging — бесплатный сервис Google для доставки push на Android (и не только). Платный тариф Firebase нужен для других продуктов (Analytics, Crashlytics на больших объёмах и т.д.); для стандартных data/notification push лимиты практически не ограничивают типичный SAC. Данные событий формируются на **вашем** SAC-сервере; через FCM уходит только компактный payload (заголовок, id, deep link).
1. Админ: мобильные устройства + код `sacmob_…`
2. URL API SAC + код + логин/пароль в Seaca
3. FCM-токен регистрируется автоматически
Альтернатива без Google (UnifiedPush) в этом проекте не планируется.
## Что есть в приложении
- Обзор, события, отчёты, проблемы, хосты (списки и карточки)
- Ack / Resolve для роли `operator` и выше
- Push с переходом в карточку события или problem
- Биометрическая блокировка при открытии (локально)
- Первичная привязка к серверу по **коду регистрации**, который выдаёт администратор в веб-SAC
## Чего нет в приложении (только веб)
- Управление пользователями SAC
- Настройки каналов (Telegram, SMTP, webhook), severity, policy
- Выдача и отзыв кодов регистрации, список устройств, способ входа на устройство — всё это в **Настройки → Мобильные устройства** на сервере
## Привязка телефона к серверу
1. Администратор в веб-SAC включает «Разрешать подключение мобильных устройств».
2. Создаёт **код регистрации** для пользователя (срок действия, одноразовый или с лимитом использований).
3. Оператор в Seaca: URL сервера (или из QR) + код + логин/пароль (если код не привязан к предварительной сессии).
4. Приложение регистрирует FCM-токен; устройство появляется в списке на сервере.
5. Администратор может отключить устройство, сменить разрешённый способ входа, переименовать.
Подробный план — [docs/ROADMAP.md](docs/ROADMAP.md).
## Статус
**Версия:** `0.4.0` (Android: Kotlin, Compose; enroll, UI, ack/resolve, FCM)
## Документация
| Документ | Назначение |
|----------|------------|
| [docs/operator-guide.md](docs/operator-guide.md) | Инструкция для оператора |
| [docs/ROADMAP.md](docs/ROADMAP.md) | План разработки |
| [SAC: seaca-mobile.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-mobile.md) | Админ: коды, устройства, сессия |
| [SAC: seaca-fcm.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-fcm.md) | FCM на сервере |
[docs/operator-guide.md](docs/operator-guide.md) · SAC [docs/seaca-mobile.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-mobile.md)
## Сборка
Требуется **JDK 17+** (Android Studio JBR или отдельная установка) и Android SDK (`local.properties` с `sdk.dir=…`).
JDK 17+, `local.properties`. Опционально `app/google-services.json` для FCM.
**Debug** (ключ свой на каждом ПК):
```bash
# Опционально: google-services.json в app/ (из Firebase, не в git) — для FCM
./gradlew assembleDebug
# Windows:
gradlew.bat assembleDebug
```
APK: `app/build/outputs/apk/debug/app-debug.apk`
Без `google-services.json` приложение собирается, но FCM не инициализируется (просмотр данных работает).
**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`
Как у остальных репозиториев семейства SAC (уточняется владельцем проекта).
## Репозитории
| Репозиторий | URL |
|-------------|-----|
| seaca | https://git.kalinamall.ru/PapaTramp/seaca |
| security-alert-center | https://git.kalinamall.ru/PapaTramp/security-alert-center |
| ssh-monitor | https://git.kalinamall.ru/PapaTramp/ssh-monitor |
| RDP-login-monitor | https://git.kalinamall.ru/PapaTramp/rdp-login-monitor |
+28 -2
View File
@@ -1,3 +1,5 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
@@ -5,6 +7,15 @@ plugins {
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
@@ -13,12 +24,26 @@ android {
applicationId = "ru.kalinamall.seaca"
minSdk = 26
targetSdk = 35
versionCode = 15
versionName = "0.5.1"
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"),
@@ -54,6 +79,7 @@ dependencies {
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")
+10
View File
@@ -38,5 +38,15 @@
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -59,15 +59,22 @@ 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() {
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<String?>(null)
override fun beginExternalPickerFlow() {
externalPickerFlowActive = true
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
repo = SacRepository(applicationContext)
@@ -94,8 +101,16 @@ class MainActivity : FragmentActivity() {
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
@@ -104,6 +119,10 @@ class MainActivity : FragmentActivity() {
override fun onResume() {
super.onResume()
if (externalPickerFlowActive) {
externalPickerFlowActive = false
return
}
if (pendingBiometric && !biometricUnlocked.value && repo.session.getAccessToken() != null) {
pendingBiometric = false
BiometricGate.authenticate(
@@ -1,11 +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()
NotificationHelper.ensureChannels(this)
AppForegroundState.bind(this)
val store = SessionStore(this)
val settings = runBlocking {
NotificationSoundResolver.normalizeStoredSettings(this@SeacaApplication, store)
}
NotificationHelper.ensureChannels(
this,
NotificationSoundResolver.resolve(this, settings),
)
}
}
@@ -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?,
)
@@ -29,6 +29,8 @@ interface SacApi {
@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
@@ -36,6 +38,30 @@ interface SacApi {
@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,
@@ -58,6 +58,12 @@ data class EventSummary(
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
@@ -80,6 +86,13 @@ data class EventDetail(
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,
@@ -192,6 +205,60 @@ data class ProblemActionResponse(
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<HostSessionItem> = 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,
@@ -117,7 +117,7 @@ class SacRepository(context: Context) {
}
val okhttp = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.sslSocketFactory(TlsTrust.socketFactory(trustAll), TlsTrust.trustManager(trustAll))
.hostnameVerifier(TlsTrust.hostnameVerifier(trustAll))
.addInterceptor(authInterceptor)
@@ -40,12 +40,27 @@ class SessionStore(private val context: Context) {
DateTimeDisplayMode.fromStorage(prefs[KEY_DATE_TIME_FORMAT])
}
val notificationSoundSettings: Flow<NotificationSoundSettings> = 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 }
}
@@ -54,6 +69,17 @@ class SessionStore(private val context: Context) {
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
@@ -114,6 +140,8 @@ class SessionStore(private val context: Context) {
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"
@@ -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
}
@@ -5,48 +5,73 @@ 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 {
const val CHANNEL_DEFAULT = "sac_default"
const val CHANNEL_HIGH = "sac_high"
const val CHANNEL_CRITICAL = "sac_critical"
const val CHANNEL_DEFAULT_SILENT = "sac_default_silent"
const val CHANNEL_HIGH_SILENT = "sac_high_silent"
const val CHANNEL_CRITICAL_SILENT = "sac_critical_silent"
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"
fun ensureChannels(context: Context) {
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(
NotificationChannel(
CHANNEL_DEFAULT,
context.getString(R.string.channel_default),
NotificationManager.IMPORTANCE_DEFAULT,
),
soundChannel(context, channelDefault, R.string.channel_default, NotificationManager.IMPORTANCE_DEFAULT, soundUri, audioAttributes),
)
mgr.createNotificationChannel(
NotificationChannel(
CHANNEL_HIGH,
context.getString(R.string.channel_high),
NotificationManager.IMPORTANCE_HIGH,
),
soundChannel(context, channelHigh, R.string.channel_high, NotificationManager.IMPORTANCE_HIGH, soundUri, audioAttributes),
)
mgr.createNotificationChannel(
NotificationChannel(
CHANNEL_CRITICAL,
context.getString(R.string.channel_critical),
NotificationManager.IMPORTANCE_HIGH,
),
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)
@@ -58,6 +83,10 @@ object NotificationHelper {
lockscreenVisibility = NotificationCompat.VISIBILITY_PRIVATE
}
fun clearAll(context: Context) {
context.getSystemService(NotificationManager::class.java).cancelAll()
}
fun show(
context: Context,
title: String,
@@ -66,8 +95,9 @@ object NotificationHelper {
kind: String?,
id: String?,
silent: Boolean = false,
soundUri: Uri = NotificationSoundResolver.defaultNotificationUri(),
) {
ensureChannels(context)
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
@@ -91,9 +121,9 @@ object NotificationHelper {
.setPriority(
when {
silent -> NotificationCompat.PRIORITY_DEFAULT
channel == CHANNEL_CRITICAL || channel == CHANNEL_CRITICAL_SILENT ->
channel == channelCritical || channel == CHANNEL_CRITICAL_SILENT ->
NotificationCompat.PRIORITY_MAX
channel == CHANNEL_HIGH || channel == CHANNEL_HIGH_SILENT ->
channel == channelHigh || channel == CHANNEL_HIGH_SILENT ->
NotificationCompat.PRIORITY_HIGH
else -> NotificationCompat.PRIORITY_DEFAULT
},
@@ -117,9 +147,9 @@ object NotificationHelper {
}
}
return when (severity?.lowercase()) {
"critical" -> CHANNEL_CRITICAL
"high" -> CHANNEL_HIGH
else -> CHANNEL_DEFAULT
"critical" -> channelCritical
"high" -> channelHigh
else -> channelDefault
}
}
}
@@ -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.
}
}
}
@@ -15,6 +15,7 @@ class SeacaMessagingService : FirebaseMessagingService() {
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
@@ -27,6 +28,14 @@ class SeacaMessagingService : FirebaseMessagingService() {
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,
@@ -35,6 +44,7 @@ class SeacaMessagingService : FirebaseMessagingService() {
kind = data["kind"],
id = data["id"],
silent = silent,
soundUri = soundUri,
)
}
@@ -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()
}
@@ -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<List<HostSessionItem>>(emptyList()) }
var error by remember(hostId) { mutableStateOf<String?>(null) }
var message by remember(hostId) { mutableStateOf<String?>(null) }
var messageOk by remember(hostId) { mutableStateOf(true) }
var terminatingId by remember(hostId) { mutableStateOf<String?>(null) }
var confirmSession by remember(hostId) { mutableStateOf<HostSessionItem?>(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 "Завершить")
}
}
@@ -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,
)
}
}
@@ -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,
)
}
}
@@ -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<String?>(null)
private set
var meta by mutableStateOf<String?>(null)
private set
var stdout by mutableStateOf<String?>(null)
private set
var sessions by mutableStateOf<List<QwinstaSessionRow>>(emptyList())
private set
var logoffSessionId by mutableStateOf<Int?>(null)
private set
var targetEventId by mutableStateOf<Long?>(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 "Оборвать сессию")
}
}
@@ -1,4 +1,4 @@
package ru.kalinamall.seaca.ui.screens
package ru.kalinamall.seaca.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -52,7 +52,7 @@ fun ConnectScreen(
value = serverUrl,
onValueChange = { serverUrl = it },
label = { Text("URL сервера") },
placeholder = { Text("https://sac-api.kalinamall.ru") },
placeholder = { Text("https://host.example") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
@@ -1,5 +1,10 @@
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
@@ -12,7 +17,11 @@ 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
@@ -35,10 +44,12 @@ 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
@@ -46,8 +57,23 @@ 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
@@ -67,6 +93,11 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
var data by remember { mutableStateOf<EventDetail?>(null) }
var error by remember { mutableStateOf<String?>(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<String?>(null) }
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH,
)
@@ -84,6 +115,31 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
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(
"Тип",
@@ -99,6 +155,55 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
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
@@ -281,10 +386,10 @@ fun HostDetailScreen(
}
}
when (HostDetailTab.entries[selectedTab]) {
HostDetailTab.Info -> HostInfoContent(host, dateMode)
HostDetailTab.Info -> HostInfoContent(host, repo, dateMode)
HostDetailTab.Events -> HostEventsContent(
repo = repo,
hostname = host.hostname,
hostId = host.id,
onOpenEvent = onOpenEvent,
)
}
@@ -293,7 +398,7 @@ fun HostDetailScreen(
}
@Composable
private fun HostInfoContent(host: HostDetail, dateMode: DateTimeDisplayMode) {
private fun HostInfoContent(host: HostDetail, repo: SacRepository, dateMode: DateTimeDisplayMode) {
Column(
Modifier
.fillMaxSize()
@@ -306,6 +411,7 @@ private fun HostInfoContent(host: HostDetail, dateMode: DateTimeDisplayMode) {
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()) {
@@ -322,7 +428,7 @@ private fun HostInfoContent(host: HostDetail, dateMode: DateTimeDisplayMode) {
@Composable
private fun HostEventsContent(
repo: SacRepository,
hostname: String,
hostId: Long,
onOpenEvent: (Long) -> Unit,
) {
var page by remember { mutableIntStateOf(1) }
@@ -342,7 +448,12 @@ private fun HostEventsContent(
error = null
try {
val res = repo.withAuth {
it.listEvents(page = page, pageSize = 30, hostname = hostname)
it.listEvents(
page = page,
pageSize = 30,
hostId = hostId,
includeHidden = true,
)
}
items = res.items
total = res.total
@@ -355,7 +466,7 @@ private fun HostEventsContent(
}
}
LaunchedEffect(page, hostname) { load() }
LaunchedEffect(page, hostId) { load() }
Column(
Modifier
@@ -418,11 +529,62 @@ fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) {
var username by remember { mutableStateOf<String?>(null) }
var role by remember { mutableStateOf<String?>(null) }
var deviceId by remember { mutableStateOf(0) }
val 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()
@@ -482,6 +644,71 @@ fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) {
}
}
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,
@@ -21,6 +21,11 @@ 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
@@ -38,6 +43,7 @@ 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
@@ -46,8 +52,18 @@ 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 клиентов",
@@ -62,6 +78,7 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) {
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,
)
@@ -121,12 +138,25 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) {
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Top,
) {
Text(
dashboardRecentEventLine(e),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
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,
@@ -140,6 +170,7 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) {
}
}
}
RdgQwinstaDialog(qwinsta)
}
private fun dashboardRecentEventLine(event: EventSummary): String {
@@ -171,10 +202,55 @@ private fun StatCard(label: String, value: String) {
@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<Long?>(null) }
var terminateConfirm by remember { mutableStateOf<EventSummary?>(null) }
var terminateError by remember { mutableStateOf<String?>(null) }
var locallyTerminatedIds by remember { mutableStateOf(setOf<Long>()) }
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 ->
@@ -191,10 +267,24 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
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 },
@@ -205,6 +295,7 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
)
},
)
RdgQwinstaDialog(qwinsta)
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -327,6 +418,15 @@ private fun <T> PagedListScreen(
itemSub: (T) -> String,
onOpen: (Long) -> Unit,
severities: List<String?>,
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 = {},
@@ -385,7 +485,20 @@ private fun <T> PagedListScreen(
) {
LazyColumn {
items(items, key = { itemKey(it) }) { item ->
ListRow(itemLabel(item), itemSub(item)) { onOpen(itemKey(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),
)
}
}
}
@@ -394,20 +507,58 @@ private fun <T> PagedListScreen(
}
@Composable
private fun ListRow(title: String, subtitle: String, onClick: () -> Unit) {
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)
.clickable(onClick = onClick),
.padding(vertical = 4.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface,
),
) {
Column(Modifier.padding(12.dp)) {
Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
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,
)
}
}
}
}
@@ -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<QwinstaSessionRow> {
val lines = stdout.lines().map { it.trim() }.filter { it.isNotEmpty() }
val rows = mutableListOf<QwinstaSessionRow>()
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() }
?: "Команда завершилась с ошибкой"
}
@@ -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
+4
View File
@@ -9,4 +9,8 @@
<string name="channel_critical_silent">SAC: критические (без звука)</string>
<string name="notification_mode_title">Push-уведомления</string>
<string name="date_time_format_title">Формат даты и времени</string>
<string name="notification_sound_title">Звук уведомлений</string>
<string name="notification_sound_pick">Выбрать звук</string>
<string name="notification_sound_preview">Проверить звук</string>
<string name="notification_sound_current">Сейчас: %1$s</string>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<files-path name="notification_sounds" path="." />
</paths>
+10 -5
View File
@@ -1,4 +1,4 @@
# Roadmap — Seaca
# Roadmap — Seaca
Мобильный клиент SAC (Kotlin). Серверные задачи (API, настройки в веб-SAC, FCM с бэкенда) — в [security-alert-center](https://git.kalinamall.ru/PapaTramp/security-alert-center), фаза **v0.6 — Mobile**.
@@ -55,23 +55,28 @@
## v0.4 — Push (FCM) ✅ (частично)
- [x] Firebase-проект, `google-services.json` (не в git; пример `google-services.json.example`)
- [x] Firebase-проект, конфиг в app/ (не в git; шаблон `*.example`)
- [x] Регистрация FCM-токена: `PUT /api/v1/mobile/devices/me/fcm`
- [x] Обработка data message: `kind`, `id`, `severity`, deep link `seaca://…`
- [x] Notification channels по severity (critical / high / default)
- [ ] Actions в уведомлении: «Подтвердить» / «Закрыть» problem (operator+) — **после первого APK**
- [x] RDG flap: qwinsta/logoff через SAC API (карточка события)
- [x] Тестовый push из веб-SAC («Проверить push на устройстве») — на стороне SAC
**Зависимость:** SAC worker — `use_mobile` в notification policy, `SAC_FCM_*` в `sac-api.env` (включается после первого APK)
---
## v0.5 — Полировка (позже)
## v0.5 — Полировка ✅ (основное)
- [x] Refresh token rotation (`POST /api/v1/mobile/auth/refresh`) — уже в v0.1
- [x] Биометрия при **запуске** и при возврате в приложение
- [x] Pull-to-refresh на обзоре и в списках
- [x] Формат даты/времени (несколько пресетов + системный локаль)
- [x] Режимы push на устройстве: звук / без звука / выкл.
- [x] Звук push: системный или пользовательский (ringtone picker)
- [x] Экран «Моё устройство»: сервер, пользователь, роль, версия, настройки, «Выйти»
- [ ] Виджет: число open problems
- [ ] Offline-кэш последних N событий (Room)
- [x] Экран «Моё устройство»: имя, сервер, версия, «Выйти» (базовый)
- [ ] Минимальная версия приложения (проверка с сервера)
---
+24 -22
View File
@@ -1,37 +1,39 @@
# Seaca — руководство оператора
# Seaca — оператор
## Первая настройка
## Подключение
1. Получите от администратора:
- адрес сервера (**`https://sac-api.kalinamall.ru`** — мобильный API; веб для админов — `https://sac.kalinamall.ru`);
- код регистрации `sacmob_…`;
- при необходимости — логин и пароль SAC.
2. Установите APK Seaca.
3. На экране подключения введите данные → **Подключиться**.
4. При самоподписанном сертификате подтвердите **«Доверять сертификату»** (внутренняя сеть).
После успеха код больше не нужен — приложение запоминает сессию.
URL API SAC + код `sacmob_…` + логин/пароль. Self-signed TLS — подтвердить доверие сертификату.
## Разделы
| Раздел | Содержание |
|--------|------------|
| Обзор | Сводка за 24 ч, открытые проблемы |
| События | Лента с фильтром по severity |
| Проблемы | Инциденты; для monitor/admin — подтвердить / закрыть |
| Хосты | Агенты ssh-monitor / RDP-login-monitor |
| Отчёты | Суточные отчёты SSH и RDP |
| Обзор | Сводка 24 ч; pull-to-refresh |
| События | Лента; **RDG flap** в заголовке |
| Проблемы | Ack / Resolve (monitor+) |
| Хосты | Агенты, heartbeat |
| Отчёты | `report.daily.ssh` / `report.daily.rdp` |
| Моё устройство | Push, звук, дата, выход |
## Push-уведомления
## RDG flap — qwinsta / logoff
Появятся после настройки FCM администратором. Нажатие на уведомление открывает карточку события или проблемы.
Пара RD Gateway **302→303** за несколько секунд:
1. Событие **302** или **303** (метка **RDG flap**).
2. **qwinsta / logoff**`POST /api/v1/events/{id}/actions/qwinsta` на SAC.
3. SAC: WinRM **qwinsta** на клиентский ПК (`internal_ip`).
4. В диалоге — сессии, **logoff** по ID.
Нужен **Windows domain admin** в настройках SAC (WinRM).
## Push
Режимы на экране «Моё устройство». Тап — карточка события или problem.
## Блокировка
При возврате в приложение может запрашиваться отпечаток / PIN устройства (если включено в системе).
Биометрия/PIN при запуске и возврате в приложение.
## Потеря доступа
Обратитесь к администратору: новый код регистрации или снятие отзыва устройства в веб-SAC.
Подробности для админов — в [seaca-mobile.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-mobile.md) (репозиторий SAC).
Новый код или снятие отзыва устройства в веб-SAC.
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Перед push на github: нет internal URL и файлов других зеркал.
set -Eeuo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
PATTERNS=(
'git\.kalinamall\.ru'
'git\.papatramp\.ru'
'papatramp\.lan'
'192\.168\.'
'kalinamall/main'
'push.*kalinamall'
)
scan_files() {
local hits=0
local files
files=$(git ls-files)
while read -r f; do
[[ -z $f ]] && continue
[[ -f $f ]] || continue
case $f in
scripts/check-github-clean.sh | scripts/push-remotes.sh) continue ;;
esac
for pat in "${PATTERNS[@]}"; do
if grep -qE "$pat" "$f" 2>/dev/null; then
echo " INTERNAL? $f (pattern: $pat)"
hits=$((hits + 1))
fi
done
done <<<"$files"
return $hits
}
echo "[check-github-clean] сканирование tracked-файлов"
n=0
scan_files || n=$?
if [[ $n -gt 0 ]]; then
echo "[check-github-clean] найдено: $n"
exit 1
fi
for f in scripts/readme/kalinamall.md scripts/readme/home.md; do
if [[ -f $f ]]; then
echo " FORBIDDEN FILE: $f"
exit 1
fi
done
echo "[check-github-clean] OK"
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Синхронизация main на kalinamall / home / github: свой README и только свои readme-шаблоны.
set -Eeuo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
CHECK_SECRETS="${CHECK_SECRETS:-$HOME/Documents/Cursor/Projects/Answer.and.other.shit/guides/init.new.comps/scripts/check-no-secrets.sh}"
CHECK_GITHUB="${CHECK_GITHUB:-$ROOT/scripts/check-github-clean.sh}"
sync_remote() {
local remote=$1
local readme_template=$2
local branch="sync-${remote}"
shift 2
local -a remove=( "$@" )
git fetch "$remote"
git checkout -B "$branch" "${remote}/main"
git checkout main -- .
cp "$readme_template" README.md
for f in "${remove[@]}"; do
if [[ -e $f ]]; then
rm -rf "$f"
fi
done
rm -rf .cursor .cursorignore
git add -u .
git add README.md
if git diff --cached --quiet; then
echo "[push-remotes] $remote: без изменений"
else
git commit -m "chore: sync $remote (README и файлы зеркала)"
fi
if [[ $remote == github ]]; then
if [[ -f $CHECK_GITHUB ]]; then
bash "$CHECK_GITHUB"
fi
if [[ -f $CHECK_SECRETS ]]; then
bash "$CHECK_SECRETS" github
fi
fi
git push "$remote" "${branch}:main"
git checkout main
git branch -D "$branch"
}
echo "[push-remotes] kalinamall"
sync_remote kalinamall "$ROOT/scripts/readme/kalinamall.md" \
scripts/readme/github.md \
scripts/readme/home.md
echo "[push-remotes] home"
sync_remote home "$ROOT/scripts/readme/home.md" \
scripts/readme/github.md \
scripts/readme/kalinamall.md \
scripts/push-remotes.sh \
scripts/check-github-clean.sh
echo "[push-remotes] github"
sync_remote github "$ROOT/scripts/readme/github.md" \
scripts/readme/kalinamall.md \
scripts/readme/home.md \
scripts/push-remotes.sh \
scripts/check-github-clean.sh
cp "$ROOT/scripts/readme/kalinamall.md" README.md
git add README.md
if ! git diff --cached --quiet; then
git commit -m "docs: README kalinamall (рабочая копия main)"
fi
echo "[push-remotes] готово."
+68
View File
@@ -0,0 +1,68 @@
## Стек
| Слой | Технология |
|------|------------|
| UI | Kotlin, Jetpack Compose, Material 3 |
| Сеть | Retrofit / OkHttp, Kotlin Serialization |
| Хранение | DataStore + Android Keystore (токены, URL сервера) |
| Push | **Firebase Cloud Messaging (FCM)** |
| Минимум SDK | Android 8.0 (API 26) |
## Что есть в приложении
- Обзор, события, отчёты, проблемы, хосты (списки и карточки с читаемыми полями)
- Pull-to-refresh на обзоре и в списках
- Фильтры событий и проблем (severity, status)
- Формат даты/времени на выбор (слэш, дефис, точка или системный локаль)
- Ack / Resolve для роли `operator` и выше
- Push с переходом в карточку события или problem; режимы: со звуком, без звука, выключено
- Выбор звука уведомления: системный или свой (ringtone picker)
- Биометрическая блокировка при **запуске** и при возврате в приложение
- Экран «Моё устройство»: сервер, пользователь, роль, версия, настройки push, выход
- Первичная привязка к серверу по **коду регистрации**, который выдаёт администратор в веб-SAC
## Чего нет в приложении (только веб)
- Управление пользователями SAC
- Настройки каналов (Telegram, SMTP, webhook), severity, policy
- Выдача и отзыв кодов регистрации, список устройств, способ входа на устройство — всё это в **Настройки → Мобильные устройства** на сервере
## Привязка телефона к серверу
1. Администратор в веб-SAC включает «Разрешать подключение мобильных устройств».
2. Создаёт **код регистрации** для пользователя (срок действия, одноразовый или с лимитом использований).
3. Оператор в Seaca: URL API SAC (выдаёт администратор) + код + логин/пароль (если код не привязан к предварительной сессии).
4. Приложение регистрирует FCM-токен; устройство появляется в списке на сервере.
5. Администратор может отключить устройство, сменить разрешённый способ входа, переименовать.
Подробный план — [docs/ROADMAP.md](docs/ROADMAP.md).
## Статус
**Версия:** `0.5.7` (Android: Kotlin, Compose; enroll, UI, ack/resolve, FCM, биометрия, звук push, форматы даты)
## Документация
| Документ | Назначение |
|----------|------------|
| [docs/operator-guide.md](docs/operator-guide.md) | Инструкция для оператора |
| [docs/ROADMAP.md](docs/ROADMAP.md) | План разработки |
## Сборка
Требуется **JDK 17+** (Android Studio JBR или отдельная установка) и Android SDK (`local.properties` с `sdk.dir=…`).
```bash
# Опционально: конфиг Firebase в app/ (шаблон *.example, рабочий файл не в git) — для FCM
./gradlew assembleDebug
# Windows:
gradlew.bat assembleDebug
```
APK: `app/build/outputs/apk/debug/app-debug.apk`
Без конфига Firebase приложение собирается, но FCM не инициализируется (просмотр данных работает).
## Лицензия
Как у остальных репозиториев семейства SAC (уточняется владельцем проекта).
+46
View File
@@ -0,0 +1,46 @@
# Seaca
Android-клиент [Security Alert Center (SAC)](https://github.com/PTah/security-alert-center).
**Версия:** `0.5.15` (versionCode 29)
## Возможности
- Обзор, события, проблемы, хосты, отчёты (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)
## Сборка
JDK 17+, `local.properties`. Опционально `app/google-services.json` for FCM.
```bash
./gradlew assembleDebug
```
APK: `app/build/outputs/apk/debug/app-debug.apk`
## Related repositories
| Repository | URL |
|------------|-----|
| seaca | https://github.com/PTah/seaca |
| security-alert-center | https://github.com/PTah/security-alert-center |
| ssh-monitor | https://github.com/PTah/ssh-monitor |
| RDP-login-monitor | https://github.com/PTah/RDP-login-monitor |
+46
View File
@@ -0,0 +1,46 @@
# Seaca
Android-клиент [Security Alert Center (SAC)](https://git.papatramp.ru/PapaTramp/security-alert-center).
**Версия:** `0.5.15` (versionCode 29)
## Возможности
- Обзор, события, проблемы, хосты, отчёты (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)
## Сборка
JDK 17+, `local.properties`. Опционально `app/google-services.json` for FCM.
```bash
./gradlew assembleDebug
```
APK: `app/build/outputs/apk/debug/app-debug.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 |
+46
View File
@@ -0,0 +1,46 @@
# Seaca
Android-клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center).
**Версия:** `0.5.15` (versionCode 29)
## Возможности
- Обзор, события, проблемы, хосты, отчёты (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)
## Сборка
JDK 17+, `local.properties`. Опционально `app/google-services.json` for FCM.
```bash
./gradlew assembleDebug
```
APK: `app/build/outputs/apk/debug/app-debug.apk`
## Репозитории
| Репозиторий | URL |
|-------------|-----|
| seaca | https://git.kalinamall.ru/PapaTramp/seaca |
| security-alert-center | https://git.kalinamall.ru/PapaTramp/security-alert-center |
| ssh-monitor | https://git.kalinamall.ru/PapaTramp/ssh-monitor |
| RDP-login-monitor | https://git.kalinamall.ru/PapaTramp/rdp-login-monitor |
+8
View File
@@ -0,0 +1,8 @@
# Скопируйте в secrets.properties в корне репозитория (рядом с gradlew.bat).
# Пароль — только ASCII (латиница, цифры, символы). Store и key — один пароль.
# seaca-release.jks — тот же файл на рабочем и домашнем ПК (не в git).
SEACA_STORE_FILE=seaca-release.jks
SEACA_STORE_PASSWORD=change-me-ascii-only
SEACA_KEY_ALIAS=seaca
SEACA_KEY_PASSWORD=change-me-ascii-only