merge: sync main with kalinamall (0.5.11)
This commit is contained in:
+1
-1
@@ -28,6 +28,6 @@ app/src/main/java/ru/kalinamall/seaca/ui/screens/DevConnectDefaults.kt
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Cursor (не в git — см. repo-policy)
|
||||
# Cursor (не в git)
|
||||
.cursor/
|
||||
.cursorignore
|
||||
|
||||
@@ -1,87 +1,43 @@
|
||||
# Seaca
|
||||
|
||||
**Seaca** (Security Event Alert Center App) — нативный мобильный клиент **Security Alert Center (SAC)** для Android.
|
||||
Android-клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center).
|
||||
|
||||
Оператор видит те же данные, что и в веб-интерфейсе SAC: обзор, события, отчёты, проблемы и хосты. Push-уведомления приходят по тем же правилам severity и cooldown, что Telegram/email/webhook на сервере. Управление пользователями, каналами оповещений и привязкой устройств — **только в веб-SAC** (раздел «Настройки»).
|
||||
**Версия:** `0.5.11` (versionCode 25)
|
||||
|
||||
## Связанные репозитории
|
||||
## Возможности
|
||||
|
||||
| Репозиторий | Роль |
|
||||
|-------------|------|
|
||||
| security-alert-center | Сервер SAC: API, UI, оповещения, управление мобильными устройствами |
|
||||
| ssh-monitor | Агент Linux |
|
||||
| RDP-login-monitor | Агент Windows |
|
||||
- Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
|
||||
- Push (FCM), режимы звука, выбор ringtone
|
||||
- Ack / Resolve проблем (monitor+)
|
||||
- **RDG flap** — метка в списке; **qwinsta / logoff** → SAC API → WinRM на клиентский ПК
|
||||
- Биометрия при запуске и возврате в приложение
|
||||
- Формат даты, экран «Моё устройство»
|
||||
|
||||
| Репозиторий | URL |
|
||||
|-------------|-----|
|
||||
| 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 |
|
||||
## Стек
|
||||
## Нет в приложении (только веб-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, коды регистрации.
|
||||
|
||||
## Что есть в приложении
|
||||
## Привязка
|
||||
|
||||
- Обзор, события, отчёты, проблемы, хосты (списки и карточки с читаемыми полями)
|
||||
- Pull-to-refresh на обзоре и в списках
|
||||
- Фильтры событий и проблем (severity, status)
|
||||
- Формат даты/времени на выбор (слэш, дефис, точка или системный локаль)
|
||||
- Ack / Resolve для роли `operator` и выше
|
||||
- Push с переходом в карточку события или problem; режимы: со звуком, без звука, выключено
|
||||
- Выбор звука уведомления: системный или свой (ringtone picker)
|
||||
- Биометрическая блокировка при **запуске** и при возврате в приложение
|
||||
- Экран «Моё устройство»: сервер, пользователь, роль, версия, настройки push, выход
|
||||
- Первичная привязка к серверу по **коду регистрации**, который выдаёт администратор в веб-SAC
|
||||
1. Админ: мобильные устройства + код `sacmob_…`
|
||||
2. URL API SAC + код + логин/пароль в Seaca
|
||||
3. FCM-токен регистрируется автоматически
|
||||
|
||||
## Чего нет в приложении (только веб)
|
||||
|
||||
- Управление пользователями 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) | План разработки |
|
||||
[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.
|
||||
|
||||
```bash
|
||||
# Опционально: конфиг Firebase в app/ (шаблон *.example, рабочий файл не в git) — для FCM
|
||||
./gradlew assembleDebug
|
||||
# Windows:
|
||||
gradlew.bat assembleDebug
|
||||
```
|
||||
|
||||
APK: `app/build/outputs/apk/debug/app-debug.apk`
|
||||
|
||||
Без конфига Firebase приложение собирается, но FCM не инициализируется (просмотр данных работает).
|
||||
## Репозитории
|
||||
|
||||
## Лицензия
|
||||
|
||||
Как у остальных репозиториев семейства SAC (уточняется владельцем проекта).
|
||||
| Репозиторий | Роль |
|
||||
|-------------|------|
|
||||
| security-alert-center | Сервер API |
|
||||
| ssh-monitor / RDP-login-monitor | Агенты |
|
||||
|
||||
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "ru.kalinamall.seaca"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 21
|
||||
versionName = "0.5.7"
|
||||
versionCode = 25
|
||||
versionName = "0.5.11"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
package ru.kalinamall.seaca
|
||||
|
||||
import android.app.Application
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import ru.kalinamall.seaca.data.SessionStore
|
||||
import ru.kalinamall.seaca.push.NotificationHelper
|
||||
|
||||
import ru.kalinamall.seaca.push.NotificationSoundResolver
|
||||
|
||||
class SeacaApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
NotificationHelper.ensureChannels(this, NotificationSoundResolver.defaultNotificationUri())
|
||||
val store = SessionStore(this)
|
||||
val settings = runBlocking {
|
||||
NotificationSoundResolver.normalizeStoredSettings(this@SeacaApplication, store)
|
||||
}
|
||||
NotificationHelper.ensureChannels(
|
||||
this,
|
||||
NotificationSoundResolver.resolve(this, settings),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,15 @@ 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
|
||||
|
||||
@GET("api/v1/problems")
|
||||
suspend fun listProblems(
|
||||
@Query("page") page: Int = 1,
|
||||
|
||||
@@ -58,6 +58,9 @@ 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,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -80,6 +83,10 @@ 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,
|
||||
val details: JsonElement? = null,
|
||||
val raw: JsonElement? = null,
|
||||
val payload: JsonElement? = null,
|
||||
@@ -192,6 +199,23 @@ data class ProblemActionResponse(
|
||||
val status: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LogoffActionRequest(
|
||||
@SerialName("session_id") val sessionId: Int,
|
||||
)
|
||||
|
||||
@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)
|
||||
|
||||
@@ -13,36 +13,47 @@ 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"
|
||||
|
||||
private val legacySoundChannelIds = listOf("sac_default", "sac_high", "sac_critical")
|
||||
|
||||
private val soundChannelIds = listOf(CHANNEL_DEFAULT, CHANNEL_HIGH, CHANNEL_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) {
|
||||
soundChannelIds.forEach { mgr.deleteNotificationChannel(it) }
|
||||
legacySoundChannelIds.forEach { mgr.deleteNotificationChannel(it) }
|
||||
if (appliedSoundKey != null) {
|
||||
mgr.deleteNotificationChannel(channelDefault)
|
||||
mgr.deleteNotificationChannel(channelHigh)
|
||||
mgr.deleteNotificationChannel(channelCritical)
|
||||
}
|
||||
appliedSoundKey = soundKey
|
||||
soundChannelSuffix = soundKey.hashCode().toUInt().toString(16).take(8)
|
||||
channelDefault = "sac_default_$soundChannelSuffix"
|
||||
channelHigh = "sac_high_$soundChannelSuffix"
|
||||
channelCritical = "sac_critical_$soundChannelSuffix"
|
||||
}
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build()
|
||||
mgr.createNotificationChannel(
|
||||
soundChannel(context, CHANNEL_DEFAULT, R.string.channel_default, NotificationManager.IMPORTANCE_DEFAULT, soundUri, audioAttributes),
|
||||
soundChannel(context, channelDefault, R.string.channel_default, NotificationManager.IMPORTANCE_DEFAULT, soundUri, audioAttributes),
|
||||
)
|
||||
mgr.createNotificationChannel(
|
||||
soundChannel(context, CHANNEL_HIGH, R.string.channel_high, NotificationManager.IMPORTANCE_HIGH, soundUri, audioAttributes),
|
||||
soundChannel(context, channelHigh, R.string.channel_high, NotificationManager.IMPORTANCE_HIGH, soundUri, audioAttributes),
|
||||
)
|
||||
mgr.createNotificationChannel(
|
||||
soundChannel(context, CHANNEL_CRITICAL, R.string.channel_critical, NotificationManager.IMPORTANCE_HIGH, soundUri, audioAttributes),
|
||||
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))
|
||||
@@ -106,9 +117,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
|
||||
},
|
||||
@@ -132,9 +143,9 @@ object NotificationHelper {
|
||||
}
|
||||
}
|
||||
return when (severity?.lowercase()) {
|
||||
"critical" -> CHANNEL_CRITICAL
|
||||
"high" -> CHANNEL_HIGH
|
||||
else -> CHANNEL_DEFAULT
|
||||
"critical" -> channelCritical
|
||||
"high" -> channelHigh
|
||||
else -> channelDefault
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,19 +5,37 @@ 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 -> {
|
||||
settings.customUri?.let { Uri.parse(it) } ?: defaultNotificationUri()
|
||||
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)
|
||||
|
||||
@@ -30,15 +48,62 @@ object NotificationSoundResolver {
|
||||
}
|
||||
}
|
||||
|
||||
fun persistCustomUri(context: Context, uri: Uri?) {
|
||||
if (uri == null || uri.scheme != ContentResolver.SCHEME_CONTENT) return
|
||||
/**
|
||||
* 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) {
|
||||
// Picker may not grant persistable permission for some URIs.
|
||||
// Best-effort; internal copy is the reliable path.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,9 @@ class SeacaMessagingService : FirebaseMessagingService() {
|
||||
val soundUri = if (silent) {
|
||||
NotificationSoundResolver.defaultNotificationUri()
|
||||
} else {
|
||||
val settings = runBlocking { repo.session.getNotificationSoundSettings() }
|
||||
val settings = runBlocking {
|
||||
NotificationSoundResolver.normalizeStoredSettings(applicationContext, repo.session)
|
||||
}
|
||||
NotificationSoundResolver.resolve(this, settings)
|
||||
}
|
||||
NotificationHelper.show(
|
||||
|
||||
@@ -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.rdgFlapQwinstaEventId
|
||||
|
||||
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 = rdgFlapQwinstaEventId(event) ?: return
|
||||
targetEventId = targetId
|
||||
actorUser = event.actorUser
|
||||
open = true
|
||||
runQwinsta(scope)
|
||||
}
|
||||
|
||||
fun openFor(event: EventDetail, scope: kotlinx.coroutines.CoroutineScope) {
|
||||
val targetId = rdgFlapQwinstaEventId(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("qwinsta / logoff", 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) "qwinsta…" else "qwinsta")
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,10 @@ 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.OutlinedButton
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -55,7 +58,13 @@ 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.util.hasRdgFlapUi
|
||||
import ru.kalinamall.seaca.ui.util.rdgFlapQwinstaEventId
|
||||
import ru.kalinamall.seaca.push.NotificationHelper
|
||||
import ru.kalinamall.seaca.push.NotificationSoundResolver
|
||||
import ru.kalinamall.seaca.ui.RingtonePicker
|
||||
@@ -78,6 +87,8 @@ 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)
|
||||
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
|
||||
initialValue = DateTimeDisplayMode.SLASH,
|
||||
)
|
||||
@@ -95,6 +106,17 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
|
||||
|
||||
DetailFrame(title = data?.title ?: "Событие", loading, error, onBack) {
|
||||
val d = data ?: return@DetailFrame
|
||||
if (hasRdgFlapUi(d)) {
|
||||
Field("RDG flap", "302→303 — возможна зависшая сессия на ПК пользователя")
|
||||
d.rdgFlapPairEventId?.let { Field("Пара", "#$it") }
|
||||
rdgFlapQwinstaEventId(d)?.let {
|
||||
RdgQwinstaButton(
|
||||
controller = qwinsta,
|
||||
onClick = { qwinsta.openFor(d, scope) },
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Field("Severity", d.severity)
|
||||
Field(
|
||||
"Тип",
|
||||
@@ -110,6 +132,8 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
|
||||
EventDetailsSection(d.type, d.details, d.payload)
|
||||
}
|
||||
}
|
||||
|
||||
RdgQwinstaDialog(qwinsta)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -448,9 +472,12 @@ fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) {
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(soundSettings) {
|
||||
LaunchedEffect(soundSettings, notificationMode) {
|
||||
if (notificationMode == NotificationMode.PUSH_SOUND) {
|
||||
applySoundChannels(soundSettings)
|
||||
val normalized = repo.session.let {
|
||||
NotificationSoundResolver.normalizeStoredSettings(context, it)
|
||||
}
|
||||
applySoundChannels(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,11 +489,15 @@ fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) {
|
||||
val currentUri = NotificationSoundResolver.resolve(context, latestSoundSettings)
|
||||
try {
|
||||
RingtonePicker.launch(activity, currentUri) { uri ->
|
||||
NotificationSoundResolver.persistCustomUri(context, uri)
|
||||
if (uri == null) {
|
||||
repo.session.setNotificationSound(NotificationSoundSource.SYSTEM, null)
|
||||
} else {
|
||||
repo.session.setNotificationSound(NotificationSoundSource.CUSTOM, uri.toString())
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MenuAnchorType
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
@@ -46,8 +47,14 @@ 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.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.rdgFlapQwinstaEventId
|
||||
|
||||
private val REPORT_TYPE_OPTIONS = listOf(
|
||||
"report.daily.ssh" to "Отчёты ssh клиентов",
|
||||
@@ -62,6 +69,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 +129,22 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) {
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (hasRdgFlapUi(e)) {
|
||||
RdgFlapBadge(pairEventId = e.rdgFlapPairEventId)
|
||||
}
|
||||
Text(
|
||||
dashboardRecentEventLine(e),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (rdgFlapQwinstaEventId(e) != null) {
|
||||
RdgQwinstaButton(
|
||||
controller = qwinsta,
|
||||
onClick = { qwinsta.openFor(e, scope) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
SacDateTimes.format(e.occurredAt, dateMode),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -140,6 +158,7 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) {
|
||||
}
|
||||
}
|
||||
}
|
||||
RdgQwinstaDialog(qwinsta)
|
||||
}
|
||||
|
||||
private fun dashboardRecentEventLine(event: EventSummary): String {
|
||||
@@ -171,6 +190,8 @@ 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)
|
||||
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
|
||||
initialValue = DateTimeDisplayMode.SLASH,
|
||||
)
|
||||
@@ -191,6 +212,11 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
|
||||
itemKey = EventSummary::id,
|
||||
itemLabel = { "${it.severity} · ${it.title}" },
|
||||
itemSub = { "${it.hostname} · ${SacDateTimes.format(it.occurredAt, dateMode)}" },
|
||||
itemRdgFlap = { hasRdgFlapUi(it) },
|
||||
itemRdgFlapPairId = { it.rdgFlapPairEventId },
|
||||
itemQwinsta = { rdgFlapQwinstaEventId(it) != null },
|
||||
onQwinsta = { qwinsta.openFor(it, scope) },
|
||||
qwinstaLoading = qwinsta.loading,
|
||||
onOpen = onOpen,
|
||||
severities = listOf(null, "info", "warning", "high", "critical"),
|
||||
hostnameFilter = hostname,
|
||||
@@ -205,6 +231,7 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
|
||||
)
|
||||
},
|
||||
)
|
||||
RdgQwinstaDialog(qwinsta)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -327,6 +354,11 @@ private fun <T> PagedListScreen(
|
||||
itemSub: (T) -> String,
|
||||
onOpen: (Long) -> Unit,
|
||||
severities: List<String?>,
|
||||
itemRdgFlap: ((T) -> Boolean)? = null,
|
||||
itemRdgFlapPairId: ((T) -> Long?)? = null,
|
||||
itemQwinsta: ((T) -> Boolean)? = null,
|
||||
onQwinsta: ((T) -> Unit)? = null,
|
||||
qwinstaLoading: Boolean = false,
|
||||
filterLabel: String = "Severity",
|
||||
hostnameFilter: String = "",
|
||||
headerContent: @Composable () -> Unit = {},
|
||||
@@ -385,7 +417,16 @@ 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)) },
|
||||
rdgFlap = itemRdgFlap?.invoke(item) == true,
|
||||
rdgFlapPairId = itemRdgFlapPairId?.invoke(item),
|
||||
showQwinsta = itemQwinsta?.invoke(item) == true,
|
||||
onQwinstaClick = onQwinsta?.let { handler -> { handler(item) } },
|
||||
qwinstaLoading = qwinstaLoading,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -394,21 +435,45 @@ private fun <T> PagedListScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListRow(title: String, subtitle: String, onClick: () -> Unit) {
|
||||
private fun ListRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit,
|
||||
rdgFlap: Boolean = false,
|
||||
rdgFlapPairId: Long? = null,
|
||||
showQwinsta: Boolean = false,
|
||||
onQwinstaClick: (() -> Unit)? = null,
|
||||
qwinstaLoading: 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)) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
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) "qwinsta…" else "qwinsta / logoff") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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 rdgFlapQwinstaEventId(event: EventSummary): Long? =
|
||||
event.rdgFlapQwinstaEventId ?: if (event.rdgFlap) event.id else null
|
||||
|
||||
fun hasRdgFlapUi(event: EventDetail): Boolean =
|
||||
event.rdgFlap || event.rdgFlapQwinstaEventId != null
|
||||
|
||||
fun rdgFlapQwinstaEventId(event: EventDetail): Long? =
|
||||
event.rdgFlapQwinstaEventId ?: if (event.rdgFlap) event.id else null
|
||||
|
||||
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,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<files-path name="notification_sounds" path="." />
|
||||
</paths>
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Roadmap — Seaca
|
||||
|
||||
Мобильный клиент SAC (Kotlin). Серверные задачи (API, настройки в веб-SAC, FCM с бэкенда) — в репозитории **security-alert-center**, фаза **v0.6 — Mobile**.
|
||||
Мобильный клиент SAC (Kotlin). Серверные задачи (API, настройки в веб-SAC, FCM с бэкенда) — в [security-alert-center](https://git.kalinamall.ru/PapaTramp/security-alert-center), фаза **v0.6 — Mobile**.
|
||||
|
||||
---
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
- [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)
|
||||
|
||||
+22
-29
@@ -1,46 +1,39 @@
|
||||
# Seaca — руководство оператора
|
||||
# Seaca — оператор
|
||||
|
||||
## Первая настройка
|
||||
## Подключение
|
||||
|
||||
1. Получите от администратора:
|
||||
- **URL API SAC** для мобильного приложения (HTTPS, путь `/api/v1/…`);
|
||||
- код регистрации `sacmob_…`;
|
||||
- при необходимости — логин и пароль SAC.
|
||||
2. Установите APK Seaca.
|
||||
3. На экране подключения введите данные → **Подключиться**.
|
||||
4. При самоподписанном сертификате подтвердите **«Доверять сертификату»** (внутренняя сеть).
|
||||
|
||||
После успеха код больше не нужен — приложение запоминает сессию.
|
||||
URL API SAC + код `sacmob_…` + логин/пароль. Self-signed TLS — подтвердить доверие сертификату.
|
||||
|
||||
## Разделы
|
||||
|
||||
| Раздел | Содержание |
|
||||
|--------|------------|
|
||||
| Обзор | Сводка за 24 ч, открытые проблемы; потяните вниз для обновления |
|
||||
| События | Лента с фильтром по severity |
|
||||
| Проблемы | Инциденты; для operator и выше — подтвердить / закрыть |
|
||||
| Хосты | Агенты ssh-monitor / RDP-login-monitor |
|
||||
| Отчёты | Суточные отчёты SSH и RDP |
|
||||
| Моё устройство | Сервер, пользователь, режим push, звук, формат даты/времени, выход |
|
||||
| Обзор | Сводка 24 ч; pull-to-refresh |
|
||||
| События | Лента; **RDG flap** в заголовке |
|
||||
| Проблемы | Ack / Resolve (monitor+) |
|
||||
| Хосты | Агенты, heartbeat |
|
||||
| Отчёты | `report.daily.ssh` / `report.daily.rdp` |
|
||||
| Моё устройство | Push, звук, дата, выход |
|
||||
|
||||
## Push-уведомления
|
||||
## RDG flap — qwinsta / logoff
|
||||
|
||||
Настраиваются на экране **Моё устройство**:
|
||||
Пара RD Gateway **302→303** за несколько секунд:
|
||||
|
||||
| Режим | Поведение |
|
||||
|-------|-----------|
|
||||
| Push и звук | Уведомление со звуком (системным или выбранным) |
|
||||
| Push без звука | Только banner, без звука |
|
||||
| Без push и звука | Данные в приложении по запросу |
|
||||
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.
|
||||
|
||||
Подробности для админов — в репозитории **security-alert-center**, файл `docs/seaca-mobile.md`.
|
||||
Новый код или снятие отзыва устройства в веб-SAC.
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
# Seaca
|
||||
|
||||
**Seaca** (Security Event Alert Center App) — нативный мобильный клиент **Security Alert Center (SAC)** для Android.
|
||||
|
||||
Оператор видит те же данные, что и в веб-интерфейсе SAC: обзор, события, отчёты, проблемы и хосты. Push-уведомления приходят по тем же правилам severity и cooldown, что Telegram/email/webhook на сервере. Управление пользователями, каналами оповещений и привязкой устройств — **только в веб-SAC** (раздел «Настройки»).
|
||||
|
||||
## Связанные репозитории
|
||||
|
||||
| Репозиторий | Роль |
|
||||
|-------------|------|
|
||||
| security-alert-center | Сервер SAC: API, UI, оповещения, управление мобильными устройствами |
|
||||
| ssh-monitor | Агент Linux |
|
||||
| RDP-login-monitor | Агент Windows |
|
||||
|
||||
| Репозиторий | URL |
|
||||
|-------------|-----|
|
||||
| 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 |
|
||||
## Стек
|
||||
|
||||
| Слой | Технология |
|
||||
|------|------------|
|
||||
| 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 (уточняется владельцем проекта).
|
||||
@@ -1,87 +0,0 @@
|
||||
# Seaca
|
||||
|
||||
**Seaca** (Security Event Alert Center App) — нативный мобильный клиент **Security Alert Center (SAC)** для Android.
|
||||
|
||||
Оператор видит те же данные, что и в веб-интерфейсе SAC: обзор, события, отчёты, проблемы и хосты. Push-уведомления приходят по тем же правилам severity и cooldown, что Telegram/email/webhook на сервере. Управление пользователями, каналами оповещений и привязкой устройств — **только в веб-SAC** (раздел «Настройки»).
|
||||
|
||||
## Связанные репозитории
|
||||
|
||||
| Репозиторий | Роль |
|
||||
|-------------|------|
|
||||
| security-alert-center | Сервер SAC: API, UI, оповещения, управление мобильными устройствами |
|
||||
| ssh-monitor | Агент Linux |
|
||||
| RDP-login-monitor | Агент Windows |
|
||||
|
||||
| Репозиторий | URL |
|
||||
|-------------|-----|
|
||||
| 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 |
|
||||
## Стек
|
||||
|
||||
| Слой | Технология |
|
||||
|------|------------|
|
||||
| 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 (уточняется владельцем проекта).
|
||||
Reference in New Issue
Block a user