feat: Android client v0.4.0 — enroll, UI, ack/resolve, FCM

Kotlin Compose app: подключение по коду SAC, списки и карточки,
действия оператора, биометрия, push и deep links.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-06-10 15:34:34 +10:00
parent c9fb5eefec
commit 23eb1ba64c
35 changed files with 2147 additions and 34 deletions
+25 -1
View File
@@ -54,7 +54,31 @@
## Статус
**Версия:** `0.1.0` (документация и план; код приложения — в разработке)
**Версия:** `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 на сервере |
## Сборка
Требуется **JDK 17+** (Android Studio JBR или отдельная установка) и Android SDK (`local.properties` с `sdk.dir=…`).
```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 не инициализируется (просмотр данных работает).
## Лицензия
+77
View File
@@ -0,0 +1,77 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
namespace = "ru.kalinamall.seaca"
compileSdk = 35
defaultConfig {
applicationId = "ru.kalinamall.seaca"
minSdk = 26
targetSdk = 35
versionCode = 4
versionName = "0.4.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.10.01")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.navigation:navigation-compose:2.8.3")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("androidx.biometric:biometric:1.1.0")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0")
implementation(platform("com.google.firebase:firebase-bom:33.5.1"))
implementation("com.google.firebase:firebase-messaging-ktx")
debugImplementation("androidx.compose.ui:ui-tooling")
}
if (file("google-services.json").exists()) {
apply(plugin = "com.google.gms.google-services")
}
+29
View File
@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "000000000000",
"project_id": "REPLACE_ME",
"storage_bucket": "REPLACE_ME.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:000000000000:android:0000000000000000000000",
"android_client_info": {
"package_name": "ru.kalinamall.seaca"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "REPLACE_ME"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
+1
View File
@@ -0,0 +1 @@
# Seaca defaults sufficient for debug/release without minify
+42
View File
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<application
android:name=".SeacaApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Seaca">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Seaca">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="seaca" android:host="event" />
<data android:scheme="seaca" android:host="problem" />
</intent-filter>
</activity>
<service
android:name=".push.SeacaMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>
@@ -0,0 +1,281 @@
package ru.kalinamall.seaca
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Computer
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf as composeMutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import kotlinx.coroutines.launch
import ru.kalinamall.seaca.data.SacRepository
import ru.kalinamall.seaca.push.FcmRegistrar
import ru.kalinamall.seaca.security.BiometricGate
import ru.kalinamall.seaca.ui.screens.ConnectScreen
import ru.kalinamall.seaca.ui.screens.DashboardScreen
import ru.kalinamall.seaca.ui.screens.DeviceScreen
import ru.kalinamall.seaca.ui.screens.EventDetailScreen
import ru.kalinamall.seaca.ui.screens.EventsScreen
import ru.kalinamall.seaca.ui.screens.HostDetailScreen
import ru.kalinamall.seaca.ui.screens.HostsScreen
import ru.kalinamall.seaca.ui.screens.ProblemDetailScreen
import ru.kalinamall.seaca.ui.screens.ProblemsScreen
import ru.kalinamall.seaca.ui.screens.ReportsScreen
import ru.kalinamall.seaca.ui.theme.SeacaTheme
class MainActivity : FragmentActivity() {
private lateinit var repo: SacRepository
private var needsBiometric = false
private var biometricPassed = true
private val deepLinkState = composeMutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
repo = SacRepository(applicationContext)
requestNotificationPermission()
deepLinkState.value = extractDeepLink(intent)
setContent {
SeacaApp(
repo = repo,
deepLink = deepLinkState.value,
onDeepLinkConsumed = { deepLinkState.value = null },
biometricUnlocked = biometricPassed || !needsBiometric,
)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
deepLinkState.value = extractDeepLink(intent)
}
override fun onStop() {
super.onStop()
if (repo.session.getAccessToken() != null) {
needsBiometric = true
biometricPassed = false
}
}
override fun onResume() {
super.onResume()
if (needsBiometric && repo.session.getAccessToken() != null && !biometricPassed) {
BiometricGate.authenticate(
activity = this,
onSuccess = {
biometricPassed = true
needsBiometric = false
},
onFailure = { finish() },
)
}
}
private fun extractDeepLink(intent: Intent?): String? {
intent?.getStringExtra("deep_link")?.let { return it }
val uri: Uri = intent?.data ?: return null
val id = uri.lastPathSegment ?: return null
return when (uri.host) {
"event" -> "event/$id"
"problem" -> "problem/$id"
else -> null
}
}
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED
) {
return
}
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1001)
}
}
@Composable
private fun SeacaApp(
repo: SacRepository,
deepLink: String?,
onDeepLinkConsumed: () -> Unit,
biometricUnlocked: Boolean,
) {
val context = LocalContext.current
val loggedIn by repo.session.isLoggedIn.collectAsStateWithLifecycle(initialValue = false)
val nav = rememberNavController()
val scope = rememberCoroutineScope()
LaunchedEffect(loggedIn) {
if (loggedIn) {
FcmRegistrar.registerIfPossible(context, repo)
}
}
LaunchedEffect(deepLink, loggedIn, biometricUnlocked) {
if (!loggedIn || !biometricUnlocked || deepLink.isNullOrBlank()) return@LaunchedEffect
when {
deepLink.startsWith("event/") -> {
nav.navigate("event/${deepLink.removePrefix("event/")}")
onDeepLinkConsumed()
}
deepLink.startsWith("problem/") -> {
nav.navigate("problem/${deepLink.removePrefix("problem/")}")
onDeepLinkConsumed()
}
}
}
val canOperate = remember(loggedIn) {
val role = repo.session.getRole()
role == "operator" || role == "admin"
}
SeacaTheme {
if (!loggedIn) {
ConnectScreen(
repo = repo,
onConnected = {
scope.launch { FcmRegistrar.registerIfPossible(context, repo) }
},
)
return@SeacaTheme
}
if (!biometricUnlocked) {
Text("Подтвердите доступ…")
return@SeacaTheme
}
NavHost(navController = nav, startDestination = "main") {
composable("main") {
MainShell(
repo = repo,
onOpenEvent = { nav.navigate("event/$it") },
onOpenProblem = { nav.navigate("problem/$it") },
onOpenHost = { nav.navigate("host/$it") },
onOpenDevice = { nav.navigate("device") },
)
}
composable(
"event/{id}",
arguments = listOf(navArgument("id") { type = NavType.LongType }),
) { entry ->
EventDetailScreen(repo, entry.arguments?.getLong("id") ?: 0L) { nav.popBackStack() }
}
composable(
"problem/{id}",
arguments = listOf(navArgument("id") { type = NavType.LongType }),
) { entry ->
ProblemDetailScreen(
repo = repo,
id = entry.arguments?.getLong("id") ?: 0L,
canOperate = canOperate,
onBack = { nav.popBackStack() },
onChanged = { },
)
}
composable(
"host/{id}",
arguments = listOf(navArgument("id") { type = NavType.LongType }),
) { entry ->
HostDetailScreen(repo, entry.arguments?.getLong("id") ?: 0L) { nav.popBackStack() }
}
composable("device") {
DeviceScreen(repo) { nav.popBackStack("main", false) }
}
}
}
}
private enum class MainTab(val label: String) {
Dashboard("Обзор"),
Events("События"),
Problems("Проблемы"),
Hosts("Хосты"),
Reports("Отчёты"),
}
@Composable
private fun MainShell(
repo: SacRepository,
onOpenEvent: (Long) -> Unit,
onOpenProblem: (Long) -> Unit,
onOpenHost: (Long) -> Unit,
onOpenDevice: () -> Unit,
) {
var tab by remember { mutableStateOf(MainTab.Dashboard) }
Scaffold(
bottomBar = {
NavigationBar {
MainTab.entries.forEach { item ->
NavigationBarItem(
selected = tab == item,
onClick = { tab = item },
icon = {
Icon(
when (item) {
MainTab.Dashboard -> Icons.Default.Dashboard
MainTab.Events -> Icons.Default.Warning
MainTab.Problems -> Icons.Default.Error
MainTab.Hosts -> Icons.Default.Computer
MainTab.Reports -> Icons.Default.Description
},
contentDescription = item.label,
)
},
label = { Text(item.label) },
)
}
NavigationBarItem(
selected = false,
onClick = onOpenDevice,
icon = { Icon(Icons.Default.PhoneAndroid, contentDescription = "Устройство") },
label = { Text("Устройство") },
)
}
},
) { padding ->
Box(Modifier.padding(padding)) {
when (tab) {
MainTab.Dashboard -> DashboardScreen(repo)
MainTab.Events -> EventsScreen(repo, onOpenEvent)
MainTab.Problems -> ProblemsScreen(repo, onOpenProblem)
MainTab.Hosts -> HostsScreen(repo, onOpenHost)
MainTab.Reports -> ReportsScreen(repo, onOpenEvent)
}
}
}
}
@@ -0,0 +1,11 @@
package ru.kalinamall.seaca
import android.app.Application
import ru.kalinamall.seaca.push.NotificationHelper
class SeacaApplication : Application() {
override fun onCreate() {
super.onCreate()
NotificationHelper.ensureChannels(this)
}
}
@@ -0,0 +1,66 @@
package ru.kalinamall.seaca.data
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
interface SacApi {
@GET("health")
suspend fun health(): HealthResponse
@POST("api/v1/mobile/enroll")
suspend fun enroll(@Body body: EnrollRequest): AuthResponse
@POST("api/v1/mobile/auth/refresh")
suspend fun refresh(@Body body: RefreshRequest): AuthResponse
@PUT("api/v1/mobile/devices/me/fcm")
suspend fun updateFcmToken(@Body body: FcmTokenRequest)
@GET("api/v1/dashboards/summary")
suspend fun dashboardSummary(): DashboardSummary
@GET("api/v1/events")
suspend fun listEvents(
@Query("page") page: Int = 1,
@Query("page_size") pageSize: Int = 30,
@Query("severity") severity: String? = null,
@Query("type") type: String? = null,
@Query("hostname") hostname: String? = null,
@Query("q") q: String? = null,
): EventListResponse
@GET("api/v1/events/{id}")
suspend fun getEvent(@Path("id") id: Long): EventDetail
@GET("api/v1/problems")
suspend fun listProblems(
@Query("page") page: Int = 1,
@Query("page_size") pageSize: Int = 30,
@Query("status") status: String? = null,
@Query("severity") severity: String? = null,
@Query("hostname") hostname: String? = null,
): ProblemListResponse
@GET("api/v1/problems/{id}")
suspend fun getProblem(@Path("id") id: Long): ProblemDetail
@POST("api/v1/problems/{id}/ack")
suspend fun ackProblem(@Path("id") id: Long): ProblemActionResponse
@POST("api/v1/problems/{id}/resolve")
suspend fun resolveProblem(@Path("id") id: Long): ProblemActionResponse
@GET("api/v1/hosts")
suspend fun listHosts(
@Query("page") page: Int = 1,
@Query("page_size") pageSize: Int = 30,
@Query("hostname") hostname: String? = null,
): HostListResponse
@GET("api/v1/hosts/{id}")
suspend fun getHost(@Path("id") id: Long): HostDetail
}
@@ -0,0 +1,198 @@
package ru.kalinamall.seaca.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
data class HealthResponse(
val status: String = "",
val database: String? = null,
val version: String? = null,
)
@Serializable
data class EnrollRequest(
@SerialName("enrollment_code") val enrollmentCode: String,
val username: String? = null,
val password: String? = null,
@SerialName("device_uuid") val deviceUuid: String,
@SerialName("display_name") val displayName: String = "Android",
val platform: String = "android",
@SerialName("app_version") val appVersion: String,
@SerialName("fcm_token") val fcmToken: String? = null,
)
@Serializable
data class RefreshRequest(
@SerialName("refresh_token") val refreshToken: String,
@SerialName("device_uuid") val deviceUuid: String,
)
@Serializable
data class AuthResponse(
@SerialName("access_token") val accessToken: String,
@SerialName("refresh_token") val refreshToken: String,
@SerialName("token_type") val tokenType: String = "bearer",
@SerialName("device_id") val deviceId: Int,
val username: String,
val role: String,
)
@Serializable
data class FcmTokenRequest(
@SerialName("fcm_token") val fcmToken: String,
)
@Serializable
data class EventSummary(
val id: Long,
@SerialName("event_id") val eventId: String,
@SerialName("host_id") val hostId: Long,
val hostname: String,
@SerialName("display_name") val displayName: String? = null,
@SerialName("occurred_at") val occurredAt: String,
@SerialName("received_at") val receivedAt: String? = null,
val type: String,
val severity: String,
val title: String,
val summary: String,
@SerialName("actor_user") val actorUser: String? = null,
)
@Serializable
data class EventListResponse(
val items: List<EventSummary>,
val total: Int,
val page: Int,
@SerialName("page_size") val pageSize: Int,
)
@Serializable
data class EventDetail(
val id: Long,
@SerialName("event_id") val eventId: String,
@SerialName("host_id") val hostId: Long,
val hostname: String,
@SerialName("display_name") val displayName: String? = null,
@SerialName("occurred_at") val occurredAt: String,
val type: String,
val severity: String,
val title: String,
val summary: String,
val details: JsonElement? = null,
val raw: JsonElement? = null,
val payload: JsonElement? = null,
)
@Serializable
data class ProblemSummary(
val id: Long,
@SerialName("host_id") val hostId: Long? = null,
val hostname: String? = null,
val title: String,
val summary: String,
val severity: String,
val status: String,
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String? = null,
)
@Serializable
data class ProblemListResponse(
val items: List<ProblemSummary>,
val total: Int,
val page: Int,
@SerialName("page_size") val pageSize: Int,
)
@Serializable
data class ProblemEventItem(
val id: Long,
@SerialName("event_id") val eventId: String,
@SerialName("occurred_at") val occurredAt: String,
val type: String,
val severity: String,
val title: String,
val summary: String,
)
@Serializable
data class ProblemDetail(
val id: Long,
val title: String,
val summary: String,
val severity: String,
val status: String,
val hostname: String? = null,
val events: List<ProblemEventItem> = emptyList(),
)
@Serializable
data class HostSummary(
val id: Long,
val hostname: String,
@SerialName("display_name") val displayName: String? = null,
@SerialName("os_family") val osFamily: String,
val product: String,
@SerialName("agent_status") val agentStatus: String,
@SerialName("last_seen_at") val lastSeenAt: String,
)
@Serializable
data class HostListResponse(
val items: List<HostSummary>,
val total: Int,
val page: Int,
@SerialName("page_size") val pageSize: Int,
)
@Serializable
data class HostDetail(
val id: Long,
val hostname: String,
@SerialName("display_name") val displayName: String? = null,
@SerialName("os_family") val osFamily: String,
@SerialName("os_version") val osVersion: String? = null,
val product: String,
@SerialName("product_version") val productVersion: String? = null,
@SerialName("agent_status") val agentStatus: String,
@SerialName("last_seen_at") val lastSeenAt: String,
val inventory: JsonElement? = null,
)
@Serializable
data class TopHostItem(
@SerialName("host_id") val hostId: Long,
val hostname: String,
val count: Int,
)
@Serializable
data class TopTypeItem(
val type: String,
val count: Int,
)
@Serializable
data class DashboardSummary(
@SerialName("events_last_24h") val eventsLast24h: Int,
@SerialName("hosts_total") val hostsTotal: Int,
@SerialName("hosts_stale") val hostsStale: Int,
@SerialName("problems_open") val problemsOpen: Int,
@SerialName("severity_24h") val severity24h: Map<String, Int> = emptyMap(),
@SerialName("top_hosts") val topHosts: List<TopHostItem> = emptyList(),
@SerialName("top_event_types") val topEventTypes: List<TopTypeItem> = emptyList(),
@SerialName("recent_events") val recentEvents: List<EventSummary> = emptyList(),
)
@Serializable
data class ProblemActionResponse(
val id: Long,
val status: String,
)
@Serializable
data class ApiErrorBody(
val detail: String? = null,
)
@@ -0,0 +1,152 @@
package ru.kalinamall.seaca.data
import android.content.Context
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import ru.kalinamall.seaca.BuildConfig
import ru.kalinamall.seaca.security.TlsTrust
import java.util.concurrent.TimeUnit
class SacRepository(context: Context) {
val session = SessionStore(context.applicationContext)
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
@Volatile
private var api: SacApi? = null
@Volatile
private var baseUrl: String? = null
suspend fun ensureClient(): SacApi {
val url = session.getBaseUrl() ?: error("Не задан URL сервера")
if (api == null || baseUrl != url) {
baseUrl = url
api = buildApi(url, session.trustAllCerts())
}
return api!!
}
suspend fun checkHealth(serverUrl: String, trustAll: Boolean): HealthResponse {
val normalized = normalizeUrl(serverUrl)
return buildApi(normalized, trustAll).health()
}
suspend fun enroll(
serverUrl: String,
enrollmentCode: String,
username: String?,
password: String?,
displayName: String,
fcmToken: String?,
trustAll: Boolean,
): AuthResponse {
val normalized = normalizeUrl(serverUrl)
val deviceUuid = session.getDeviceUuid()
val response = buildApi(normalized, trustAll).enroll(
EnrollRequest(
enrollmentCode = enrollmentCode.trim(),
username = username?.trim()?.ifBlank { null },
password = password?.ifBlank { null },
deviceUuid = deviceUuid,
displayName = displayName,
appVersion = BuildConfig.VERSION_NAME,
fcmToken = fcmToken,
),
)
session.saveSession(normalized, response)
if (trustAll) session.setTrustAllCerts(true)
api = buildApi(normalized, trustAll)
baseUrl = normalized
return response
}
suspend fun <T> withAuth(block: suspend (SacApi) -> T): T {
val client = ensureClient()
return try {
block(client)
} catch (e: retrofit2.HttpException) {
if (e.code() == 401) {
refreshSession()
block(ensureClient())
} else {
throw e
}
}
}
private suspend fun refreshSession() {
val refresh = session.getRefreshToken() ?: throw IllegalStateException("Сессия истекла")
val uuid = session.getDeviceUuid()
val url = session.getBaseUrl() ?: error("Нет URL")
val response = buildApi(url, session.trustAllCerts()).refresh(
RefreshRequest(refreshToken = refresh, deviceUuid = uuid),
)
session.updateTokens(response)
api = buildApi(url, session.trustAllCerts())
}
suspend fun registerFcmToken(token: String) {
withAuth { it.updateFcmToken(FcmTokenRequest(token)) }
}
suspend fun logout() {
api = null
baseUrl = null
session.clear()
}
private fun buildApi(url: String, trustAll: Boolean): SacApi {
val authInterceptor = Interceptor { chain ->
val token = session.getAccessToken()
val request = if (token != null) {
chain.request().newBuilder()
.addHeader("Authorization", "Bearer $token")
.build()
} else {
chain.request()
}
chain.proceed(request)
}
val logging = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC else HttpLoggingInterceptor.Level.NONE
}
val okhttp = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.sslSocketFactory(TlsTrust.socketFactory(trustAll), TlsTrust.trustManager(trustAll))
.hostnameVerifier(TlsTrust.hostnameVerifier(trustAll))
.addInterceptor(authInterceptor)
.addInterceptor(logging)
.build()
return Retrofit.Builder()
.baseUrl(url.trimEnd('/') + "/")
.client(okhttp)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(SacApi::class.java)
}
private fun normalizeUrl(raw: String): String {
var u = raw.trim()
if (!u.startsWith("http://") && !u.startsWith("https://")) {
u = "https://$u"
}
return u.removeSuffix("/")
}
companion object {
fun parseError(e: retrofit2.HttpException): String {
val body = e.response()?.errorBody()?.string().orEmpty()
return try {
Json.decodeFromString<ApiErrorBody>(body).detail ?: body
} catch (_: Exception) {
body.ifBlank { "HTTP ${e.code()}" }
}
}
}
}
@@ -0,0 +1,99 @@
package ru.kalinamall.seaca.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import java.util.UUID
private val Context.dataStore by preferencesDataStore("seaca_prefs")
class SessionStore(private val context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val securePrefs = EncryptedSharedPreferences.create(
context,
"seaca_secure",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
val isLoggedIn: Flow<Boolean> = context.dataStore.data.map { prefs ->
!prefs[KEY_BASE_URL].isNullOrBlank() && securePrefs.contains(KEY_ACCESS)
}
suspend fun getBaseUrl(): String? = context.dataStore.data.first()[KEY_BASE_URL]
suspend fun trustAllCerts(): Boolean = context.dataStore.data.first()[KEY_TRUST_ALL] ?: false
suspend fun setTrustAllCerts(value: Boolean) {
context.dataStore.edit { it[KEY_TRUST_ALL] = value }
}
suspend fun getDeviceUuid(): String {
val existing = context.dataStore.data.first()[KEY_DEVICE_UUID]
if (!existing.isNullOrBlank()) return existing
val fresh = UUID.randomUUID().toString()
context.dataStore.edit { it[KEY_DEVICE_UUID] = fresh }
return fresh
}
fun getAccessToken(): String? = securePrefs.getString(KEY_ACCESS, null)
fun getRefreshToken(): String? = securePrefs.getString(KEY_REFRESH, null)
fun getDeviceId(): Int = securePrefs.getInt(KEY_DEVICE_ID, 0)
fun getUsername(): String? = securePrefs.getString(KEY_USERNAME, null)
fun getRole(): String? = securePrefs.getString(KEY_ROLE, null)
suspend fun saveSession(baseUrl: String, auth: AuthResponse) {
val normalized = baseUrl.trim().removeSuffix("/")
context.dataStore.edit { it[KEY_BASE_URL] = normalized }
securePrefs.edit()
.putString(KEY_ACCESS, auth.accessToken)
.putString(KEY_REFRESH, auth.refreshToken)
.putInt(KEY_DEVICE_ID, auth.deviceId)
.putString(KEY_USERNAME, auth.username)
.putString(KEY_ROLE, auth.role)
.apply()
}
suspend fun updateTokens(auth: AuthResponse) {
securePrefs.edit()
.putString(KEY_ACCESS, auth.accessToken)
.putString(KEY_REFRESH, auth.refreshToken)
.putInt(KEY_DEVICE_ID, auth.deviceId)
.apply()
}
suspend fun clear() {
context.dataStore.edit {
it.remove(KEY_BASE_URL)
it.remove(KEY_TRUST_ALL)
}
securePrefs.edit().clear().apply()
}
companion object {
private val KEY_BASE_URL = stringPreferencesKey("base_url")
private val KEY_DEVICE_UUID = stringPreferencesKey("device_uuid")
private val KEY_TRUST_ALL = booleanPreferencesKey("trust_all_certs")
private const val KEY_ACCESS = "access_token"
private const val KEY_REFRESH = "refresh_token"
private const val KEY_DEVICE_ID = "device_id"
private const val KEY_USERNAME = "username"
private const val KEY_ROLE = "role"
}
}
@@ -0,0 +1,26 @@
package ru.kalinamall.seaca.push
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.tasks.await
import ru.kalinamall.seaca.data.SacRepository
object FcmRegistrar {
suspend fun registerIfPossible(context: android.content.Context, repo: SacRepository) {
if (repo.session.getAccessToken() == null) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val granted = ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
if (!granted) return
}
runCatching {
val token = FirebaseMessaging.getInstance().token.await()
repo.registerFcmToken(token)
}
}
}
@@ -0,0 +1,76 @@
package ru.kalinamall.seaca.push
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.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"
fun ensureChannels(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val mgr = context.getSystemService(NotificationManager::class.java)
mgr.createNotificationChannel(
NotificationChannel(CHANNEL_DEFAULT, context.getString(R.string.channel_default), NotificationManager.IMPORTANCE_DEFAULT),
)
mgr.createNotificationChannel(
NotificationChannel(CHANNEL_HIGH, context.getString(R.string.channel_high), NotificationManager.IMPORTANCE_HIGH),
)
mgr.createNotificationChannel(
NotificationChannel(CHANNEL_CRITICAL, context.getString(R.string.channel_critical), NotificationManager.IMPORTANCE_HIGH),
)
}
fun show(
context: Context,
title: String,
body: String,
severity: String?,
kind: String?,
id: String?,
) {
ensureChannels(context)
val channel = when (severity?.lowercase()) {
"critical" -> CHANNEL_CRITICAL
"high" -> CHANNEL_HIGH
else -> CHANNEL_DEFAULT
}
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
when (kind) {
"event" -> putExtra("deep_link", "event/$id")
"problem" -> putExtra("deep_link", "problem/$id")
}
}
val pending = PendingIntent.getActivity(
context,
(id ?: title).hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(context, channel)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.setContentIntent(pending)
.setPriority(
when (channel) {
CHANNEL_CRITICAL -> NotificationCompat.PRIORITY_MAX
CHANNEL_HIGH -> NotificationCompat.PRIORITY_HIGH
else -> NotificationCompat.PRIORITY_DEFAULT
},
)
.build()
context.getSystemService(NotificationManager::class.java)
.notify((id ?: title).hashCode(), notification)
}
}
@@ -0,0 +1,38 @@
package ru.kalinamall.seaca.push
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import ru.kalinamall.seaca.data.SacRepository
class SeacaMessagingService : FirebaseMessagingService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val repo by lazy { SacRepository(applicationContext) }
override fun onMessageReceived(message: RemoteMessage) {
val data = message.data
val title = message.notification?.title ?: data["title"] ?: "SAC"
val body = message.notification?.body ?: data["body"] ?: ""
NotificationHelper.show(
context = this,
title = title,
body = body,
severity = data["severity"],
kind = data["kind"],
id = data["id"],
)
}
override fun onNewToken(token: String) {
scope.launch {
runCatching {
if (repo.session.getAccessToken() != null) {
repo.registerFcmToken(token)
}
}
}
}
}
@@ -0,0 +1,48 @@
package ru.kalinamall.seaca.security
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
object BiometricGate {
fun canAuthenticate(activity: FragmentActivity): Boolean {
val mgr = BiometricManager.from(activity)
return mgr.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
BiometricManager.BIOMETRIC_SUCCESS
}
fun authenticate(
activity: FragmentActivity,
onSuccess: () -> Unit,
onFailure: () -> Unit,
) {
if (!canAuthenticate(activity)) {
onSuccess()
return
}
val executor = ContextCompat.getMainExecutor(activity)
val prompt = BiometricPrompt(
activity,
executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
onSuccess()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
onFailure()
}
override fun onAuthenticationFailed() = Unit
},
)
prompt.authenticate(
BiometricPrompt.PromptInfo.Builder()
.setTitle("Seaca")
.setSubtitle("Подтвердите доступ")
.setNegativeButtonText("Отмена")
.build(),
)
}
}
@@ -0,0 +1,38 @@
package ru.kalinamall.seaca.security
import java.security.SecureRandom
import java.security.cert.X509Certificate
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
object TlsTrust {
private val permissiveManager = object : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) = Unit
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) = Unit
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
private val defaultManager: X509TrustManager by lazy {
val tmf = javax.net.ssl.TrustManagerFactory.getInstance(
javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm(),
)
tmf.init(null as java.security.KeyStore?)
tmf.trustManagers.filterIsInstance<X509TrustManager>().first()
}
fun trustManager(trustAll: Boolean): X509TrustManager = if (trustAll) permissiveManager else defaultManager
fun socketFactory(trustAll: Boolean): SSLSocketFactory {
val ctx = SSLContext.getInstance("TLS")
ctx.init(null, arrayOf<TrustManager>(trustManager(trustAll)), SecureRandom())
return ctx.socketFactory
}
fun hostnameVerifier(trustAll: Boolean): HostnameVerifier =
if (trustAll) HostnameVerifier { _, _ -> true } else HostnameVerifier { host, session ->
javax.net.ssl.HttpsURLConnection.getDefaultHostnameVerifier().verify(host, session)
}
}
@@ -0,0 +1,125 @@
package ru.kalinamall.seaca.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import ru.kalinamall.seaca.data.SacRepository
@Composable
fun ConnectScreen(
repo: SacRepository,
onConnected: () -> Unit,
) {
var serverUrl by remember { mutableStateOf("") }
var enrollmentCode by remember { mutableStateOf("") }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var displayName by remember { mutableStateOf(android.os.Build.MODEL) }
var trustCert by remember { mutableStateOf(false) }
var loading by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Seaca", style = MaterialTheme.typography.headlineMedium)
Text("Подключение к Security Alert Center", style = MaterialTheme.typography.bodyMedium)
OutlinedTextField(
value = serverUrl,
onValueChange = { serverUrl = it },
label = { Text("URL сервера") },
placeholder = { Text("https://sac.example.com") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = enrollmentCode,
onValueChange = { enrollmentCode = it },
label = { Text("Код регистрации") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Логин SAC (если нужен)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Пароль SAC") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = displayName,
onValueChange = { displayName = it },
label = { Text("Имя устройства") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Column(horizontalAlignment = Alignment.Start) {
Checkbox(checked = trustCert, onCheckedChange = { trustCert = it })
Text("Доверять сертификату (внутренняя сеть / self-signed)")
}
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Button(
onClick = {
loading = true
error = null
scope.launch {
try {
repo.checkHealth(serverUrl, trustCert)
repo.enroll(
serverUrl = serverUrl,
enrollmentCode = enrollmentCode,
username = username,
password = password,
displayName = displayName,
fcmToken = null,
trustAll = trustCert,
)
onConnected()
} catch (e: Exception) {
error = when (e) {
is retrofit2.HttpException -> SacRepository.parseError(e)
else -> e.message ?: "Ошибка подключения"
}
} finally {
loading = false
}
}
},
enabled = !loading && serverUrl.isNotBlank() && enrollmentCode.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text(if (loading) "Подключение…" else "Подключиться")
}
}
}
@@ -0,0 +1,245 @@
package ru.kalinamall.seaca.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonElement
import ru.kalinamall.seaca.data.EventDetail
import ru.kalinamall.seaca.data.HostDetail
import ru.kalinamall.seaca.data.ProblemDetail
import ru.kalinamall.seaca.data.SacRepository
@Composable
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) }
LaunchedEffect(id) {
loading = true
try {
data = repo.withAuth { it.getEvent(id) }
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
DetailFrame(title = data?.title ?: "Событие", loading, error, onBack) {
val d = data ?: return@DetailFrame
Field("Severity", d.severity)
Field("Тип", d.type)
Field("Хост", d.hostname)
Field("Время", d.occurredAt)
Field("Кратко", d.summary)
d.details?.let { Field("Детали", it.toPrettyString()) }
}
}
@Composable
fun ProblemDetailScreen(
repo: SacRepository,
id: Long,
canOperate: Boolean,
onBack: () -> Unit,
onChanged: () -> Unit,
) {
var data by remember { mutableStateOf<ProblemDetail?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(true) }
var acting by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
fun load() {
scope.launch {
loading = true
error = null
try {
data = repo.withAuth { it.getProblem(id) }
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
}
LaunchedEffect(id) { load() }
DetailFrame(title = data?.title ?: "Проблема", loading, error, onBack) {
val d = data ?: return@DetailFrame
Field("Статус", d.status)
Field("Severity", d.severity)
Field("Хост", d.hostname ?: "")
Field("Кратко", d.summary)
if (canOperate && d.status in listOf("open", "acknowledged")) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (d.status == "open") {
Button(
enabled = !acting,
onClick = {
acting = true
scope.launch {
try {
repo.withAuth { it.ackProblem(id) }
load()
onChanged()
} catch (e: Exception) {
error = e.message
} finally {
acting = false
}
}
},
) { Text("Подтвердить") }
}
Button(
enabled = !acting,
onClick = {
acting = true
scope.launch {
try {
repo.withAuth { it.resolveProblem(id) }
load()
onChanged()
} catch (e: Exception) {
error = e.message
} finally {
acting = false
}
}
},
) { Text("Закрыть") }
}
}
if (d.events.isNotEmpty()) {
Text("Связанные события", style = MaterialTheme.typography.titleMedium)
d.events.forEach { e ->
Text("${e.severity} · ${e.title}\n${e.occurredAt}", style = MaterialTheme.typography.bodySmall)
}
}
}
}
@Composable
fun HostDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
var data by remember { mutableStateOf<HostDetail?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(true) }
LaunchedEffect(id) {
loading = true
try {
data = repo.withAuth { it.getHost(id) }
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
DetailFrame(title = data?.hostname ?: "Хост", loading, error, onBack) {
val d = data ?: return@DetailFrame
Field("Продукт", d.product)
Field("Версия", d.productVersion ?: "")
Field("ОС", "${d.osFamily} ${d.osVersion.orEmpty()}".trim())
Field("Агент", d.agentStatus)
Field("Последний контакт", d.lastSeenAt)
d.inventory?.let { Field("Inventory", it.toPrettyString()) }
}
}
@Composable
fun DeviceScreen(repo: SacRepository, onLogout: () -> Unit) {
var baseUrl by remember { mutableStateOf<String?>(null) }
var username by remember { mutableStateOf<String?>(null) }
var role by remember { mutableStateOf<String?>(null) }
var deviceId by remember { mutableStateOf(0) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
baseUrl = repo.session.getBaseUrl()
username = repo.session.getUsername()
role = repo.session.getRole()
deviceId = repo.session.getDeviceId()
}
Column(
Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Моё устройство", style = MaterialTheme.typography.titleLarge)
Field("Сервер", baseUrl ?: "")
Field("Пользователь", username ?: "")
Field("Роль", role ?: "")
Field("Device ID", deviceId.toString())
Field("Версия", ru.kalinamall.seaca.BuildConfig.VERSION_NAME)
Button(
onClick = {
scope.launch {
repo.logout()
onLogout()
}
},
modifier = Modifier.fillMaxWidth(),
) { Text("Выйти") }
}
}
@Composable
private fun DetailFrame(
title: String,
loading: Boolean,
error: String?,
onBack: () -> Unit,
content: @Composable () -> Unit,
) {
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(onClick = onBack) { Text("Назад") }
Text(title, style = MaterialTheme.typography.titleLarge)
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (loading) {
CircularProgressIndicator()
} else {
content()
}
}
}
@Composable
private fun Field(label: String, value: String) {
Column(Modifier.fillMaxWidth()) {
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
Text(value, style = MaterialTheme.typography.bodyMedium)
}
}
private fun JsonElement.toPrettyString(): String = toString()
@@ -0,0 +1,303 @@
package ru.kalinamall.seaca.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.FilterChip
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import ru.kalinamall.seaca.data.DashboardSummary
import ru.kalinamall.seaca.data.EventListResponse
import ru.kalinamall.seaca.data.EventSummary
import ru.kalinamall.seaca.data.HostListResponse
import ru.kalinamall.seaca.data.HostSummary
import ru.kalinamall.seaca.data.ProblemListResponse
import ru.kalinamall.seaca.data.ProblemSummary
import ru.kalinamall.seaca.data.SacRepository
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardScreen(repo: SacRepository) {
var data by remember { mutableStateOf<DashboardSummary?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(true) }
val scope = rememberCoroutineScope()
fun load() {
scope.launch {
loading = true
error = null
try {
data = repo.withAuth { it.dashboardSummary() }
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
}
LaunchedEffect(Unit) { load() }
PullToRefreshBox(isRefreshing = loading, onRefresh = { load() }, modifier = Modifier.fillMaxSize()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
data?.let { d ->
StatCard("События 24ч", d.eventsLast24h.toString())
StatCard("Проблемы open", d.problemsOpen.toString())
StatCard("Хосты", "${d.hostsTotal} (stale: ${d.hostsStale})")
Text("Последние события", style = MaterialTheme.typography.titleMedium)
d.recentEvents.take(8).forEach { e ->
Text("${e.severity} · ${e.title}", style = MaterialTheme.typography.bodySmall)
}
} ?: if (loading) CircularProgressIndicator()
}
}
}
@Composable
private fun StatCard(label: String, value: String) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(label, style = MaterialTheme.typography.labelMedium)
Text(value, style = MaterialTheme.typography.headlineSmall)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
PagedListScreen(
title = "События",
loadPage = { page, severity ->
repo.withAuth { it.listEvents(page = page, pageSize = 30, severity = severity) }
},
itemKey = { it.id },
itemLabel = { "${it.severity} · ${it.title}" },
itemSub = { "${it.hostname} · ${it.occurredAt}" },
onOpen = onOpen,
severities = listOf(null, "info", "warning", "high", "critical"),
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProblemsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
PagedListScreen(
title = "Проблемы",
loadPage = { page, filter ->
repo.withAuth {
it.listProblems(page = page, pageSize = 30, status = filter ?: "open")
}
},
itemKey = { it.id },
itemLabel = { "${it.severity} · ${it.title}" },
itemSub = { "${it.status} · ${it.hostname ?: "—"}" },
onOpen = onOpen,
severities = listOf("open", "acknowledged", "resolved"),
filterLabel = "Статус",
)
}
@Composable
fun HostsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
PagedListScreen(
title = "Хосты",
loadPage = { page, _ ->
repo.withAuth { it.listHosts(page = page, pageSize = 30) }
},
itemKey = { it.id },
itemLabel = { it.hostname },
itemSub = { "${it.agentStatus} · ${it.product}" },
onOpen = onOpen,
severities = emptyList(),
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReportsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
var reportType by remember { mutableStateOf("report.daily.ssh") }
var expanded by remember { mutableStateOf(false) }
var page by remember { mutableIntStateOf(1) }
var data by remember { mutableStateOf<EventListResponse?>(null) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
fun load() {
scope.launch {
loading = true
try {
data = repo.withAuth { it.listEvents(page = page, pageSize = 30, type = reportType) }
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
}
LaunchedEffect(page, reportType) { load() }
Column(Modifier.fillMaxSize().padding(16.dp)) {
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField(
readOnly = true,
value = reportType,
onValueChange = {},
label = { Text("Тип отчёта") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
modifier = Modifier.menuAnchor().fillMaxWidth(),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text("report.daily.ssh") },
onClick = { reportType = "report.daily.ssh"; expanded = false; page = 1 },
)
DropdownMenuItem(
text = { Text("report.daily.rdp") },
onClick = { reportType = "report.daily.rdp"; expanded = false; page = 1 },
)
}
}
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
PullToRefreshBox(isRefreshing = loading, onRefresh = { load() }, modifier = Modifier.weight(1f)) {
LazyColumn {
items(data?.items.orEmpty(), key = { it.id }) { item ->
ListRow(
title = item.summary,
subtitle = "${item.hostname} · ${item.occurredAt}",
onClick = { onOpen(item.id) },
)
}
}
}
PagerBar(page, data?.total ?: 0, 30, onPrev = { if (page > 1) page-- }, onNext = { page++ })
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun <T> PagedListScreen(
title: String,
loadPage: suspend (Int, String?) -> Any,
itemKey: (T) -> Long,
itemLabel: (T) -> String,
itemSub: (T) -> String,
onOpen: (Long) -> Unit,
severities: List<String?>,
filterLabel: String = "Severity",
) {
var page by remember { mutableIntStateOf(1) }
var filter by remember { mutableStateOf(severities.firstOrNull()) }
var items by remember { mutableStateOf<List<T>>(emptyList()) }
var total by remember { mutableIntStateOf(0) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
fun load() {
scope.launch {
loading = true
try {
@Suppress("UNCHECKED_CAST")
when (val res = loadPage(page, filter)) {
is EventListResponse -> {
items = res.items as List<T>
total = res.total
}
is ProblemListResponse -> {
items = res.items as List<T>
total = res.total
}
is HostListResponse -> {
items = res.items as List<T>
total = res.total
}
}
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
}
LaunchedEffect(page, filter) { load() }
Column(Modifier.fillMaxSize().padding(16.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge)
if (severities.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
severities.forEach { s ->
FilterChip(
selected = filter == s,
onClick = { filter = s; page = 1 },
label = { Text(s ?: "все") },
)
}
}
}
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
PullToRefreshBox(isRefreshing = loading, onRefresh = { load() }, modifier = Modifier.weight(1f)) {
LazyColumn {
items(items, key = { itemKey(it) }) { item ->
ListRow(itemLabel(item), itemSub(item)) { onOpen(itemKey(item)) }
}
}
}
PagerBar(page, total, 30, onPrev = { if (page > 1) page-- }, onNext = { if (page * 30 < total) page++ })
}
}
@Composable
private fun ListRow(title: String, subtitle: String, onClick: () -> Unit) {
Card(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable(onClick = onClick),
) {
Column(Modifier.padding(12.dp)) {
Text(title, style = MaterialTheme.typography.bodyLarge)
Text(subtitle, style = MaterialTheme.typography.bodySmall)
}
}
}
@Composable
private fun PagerBar(page: Int, total: Int, pageSize: Int, onPrev: () -> Unit, onNext: () -> Unit) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Button(onClick = onPrev, enabled = page > 1) { Text("Назад") }
Text("Стр. $page · всего $total")
Button(onClick = onNext, enabled = page * pageSize < total) { Text("Вперёд") }
}
}
@@ -0,0 +1,32 @@
package ru.kalinamall.seaca.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val SacDark = darkColorScheme(
primary = Color(0xFF3DB8FF),
background = Color(0xFF0F1419),
surface = Color(0xFF15202B),
onBackground = Color(0xFFC5D0DC),
onSurface = Color(0xFFC5D0DC),
error = Color(0xFFFF8A8A),
)
private val SacLight = lightColorScheme(
primary = Color(0xFF1A6FA0),
background = Color(0xFFF4F6F8),
surface = Color(0xFFFFFFFF),
)
@Composable
fun SeacaTheme(content: @Composable () -> Unit) {
val dark = isSystemInDarkTheme()
MaterialTheme(
colorScheme = if (dark) SacDark else SacLight,
content = content,
)
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DB8FF"
android:pathData="M54,30 L78,54 L54,78 L30,54 Z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#15202B</color>
</resources>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Seaca</string>
<string name="channel_default">SAC уведомления</string>
<string name="channel_high">SAC: важные</string>
<string name="channel_critical">SAC: критические</string>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Seaca" parent="android:Theme.Material.NoActionBar" />
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
</network-security-config>
+7
View File
@@ -0,0 +1,7 @@
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
id("com.google.gms.google-services") version "4.4.2" apply false
}
+33 -33
View File
@@ -17,61 +17,61 @@
---
## v0.1 — Каркас приложения
## v0.1 — Каркас приложения
- [ ] Gradle-проект: Compose, Navigation, Hilt/Koin, Retrofit, DataStore
- [ ] Экран «Подключение»: URL SAC, код регистрации, логин/пароль
- [ ] `POST /api/v1/auth/login` + обмен кода на `device_id` (когда API готов на SAC)
- [ ] Secure storage: `base_url`, access/refresh token, `device_id`
- [ ] Экран «Ошибка / нет сети» и проверка `GET /health`
- [ ] Базовая навигация: Обзор, События, Проблемы, Хосты, Отчёты
- [ ] CI: сборка debug APK (опционально — позже)
- [x] Gradle-проект: Compose, Navigation, Retrofit, DataStore (DI-фреймворк не используем — `SacRepository` вручную)
- [x] Экран «Подключение»: URL SAC, код регистрации, логин/пароль
- [x] `POST /api/v1/mobile/enroll` + refresh-сессия
- [x] Secure storage: `base_url`, access/refresh token, `device_id`
- [x] Ошибки сети / `GET /health` перед enroll
- [x] Базовая навигация: Обзор, События, Проблемы, Хосты, Отчёты
- [ ] CI: сборка debug APK (локально — `gradlew assembleDebug`, JDK 17+)
**Зависимость:** SAC **≥ 0.9.0** — `mobile_devices_allowed`, enrollment codes, `POST /api/v1/mobile/enroll`
---
## v0.2 — Паритет с веб-UI (чтение)
## v0.2 — Паритет с веб-UI (чтение)
- [ ] Списки с пагинацией: events, problems, hosts, reports
- [ ] Карточки: event, problem (+ связанные events), host (+ inventory)
- [ ] Dashboard: виджеты как `GET /api/v1/dashboards/summary`
- [ ] Фильтры events/problems (severity, type, host) — упрощённый набор
- [ ] Pull-to-refresh; polling в foreground (SSE на Android в фоне не используем)
- [ ] Тёмная тема (как веб)
- [ ] Локализация: русский
- [x] Списки с пагинацией: events, problems, hosts, reports
- [x] Карточки: event, problem (+ связанные events), host (+ inventory)
- [x] Dashboard: виджеты как `GET /api/v1/dashboards/summary`
- [x] Фильтры events/problems (severity, status) — упрощённый набор
- [x] Pull-to-refresh
- [x] Тёмная тема (системная)
- [x] Локализация: русский
---
## v0.3 — Действия оператора
## v0.3 — Действия оператора
- [ ] `POST /api/v1/problems/{id}/ack` и `/resolve`
- [ ] Обновление списков после действия
- [ ] Скрытие admin-only API (403) — без попыток открыть settings/users
- [ ] Биометрический замок при возврате в приложение
- [ ] Certificate pinning / «Доверять сертификату» для внутреннего TLS
- [x] `POST /api/v1/problems/{id}/ack` и `/resolve`
- [x] Обновление карточки после действия
- [x] Admin-only API не вызываем из приложения
- [x] Биометрический замок при возврате в приложение
- [x] «Доверять сертификату» для внутреннего TLS (self-signed)
---
## v0.4 — Push (FCM)
## v0.4 — Push (FCM) ✅ (частично)
- [ ] Firebase-проект, `google-services.json` (не в git)
- [ ] Регистрация FCM-токена: `PUT /api/v1/mobile/devices/{id}/fcm`
- [ ] Обработка data message: `kind`, `id`, `severity`, deep link `seaca://…`
- [ ] Notification channels по severity (critical / high / default)
- [ ] Actions в уведомлении: «Подтвердить» / «Закрыть» problem (operator+)
- [ ] Тестовый push из веб-SAC («Проверить push на устройстве»)
- [x] Firebase-проект, `google-services.json` (не в git; пример `google-services.json.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] Тестовый push из веб-SAC («Проверить push на устройстве») — на стороне SAC
**Зависимость:** SAC worker — `use_push_mobile` в notification policy, `push_notify.py`
**Зависимость:** SAC worker — `use_mobile` в notification policy, `SAC_FCM_*` в `sac-api.env` (включается после первого APK)
---
## v0.5 — Полировка
## v0.5 — Полировка (позже)
- [ ] Refresh token rotation (`POST /api/v1/auth/refresh`)
- [x] Refresh token rotation (`POST /api/v1/mobile/auth/refresh`) — уже в v0.1
- [ ] Виджет: число open problems
- [ ] Offline-кэш последних N событий (Room)
- [ ] Экран «Моё устройство»: имя, сервер, версия, «Выйти»
- [x] Экран «Моё устройство»: имя, сервер, версия, «Выйти» (базовый)
- [ ] Минимальная версия приложения (проверка с сервера)
---
+37
View File
@@ -0,0 +1,37 @@
# Seaca — руководство оператора
## Первая настройка
1. Получите от администратора:
- адрес сервера (например `https://sac.kalinamall.ru`);
- код регистрации `sacmob_…`;
- при необходимости — логин и пароль SAC.
2. Установите APK Seaca.
3. На экране подключения введите данные → **Подключиться**.
4. При самоподписанном сертификате подтвердите **«Доверять сертификату»** (внутренняя сеть).
После успеха код больше не нужен — приложение запоминает сессию.
## Разделы
| Раздел | Содержание |
|--------|------------|
| Обзор | Сводка за 24 ч, открытые проблемы |
| События | Лента с фильтром по severity |
| Проблемы | Инциденты; для monitor/admin — подтвердить / закрыть |
| Хосты | Агенты ssh-monitor / RDP-login-monitor |
| Отчёты | Суточные отчёты SSH и RDP |
## Push-уведомления
Появятся после настройки FCM администратором. Нажатие на уведомление открывает карточку события или проблемы.
## Блокировка
При возврате в приложение может запрашиваться отпечаток / PIN устройства (если включено в системе).
## Потеря доступа
Обратитесь к администратору: новый код регистрации или снятие отзыва устройства в веб-SAC.
Подробности для админов — в [seaca-mobile.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-mobile.md) (репозиторий SAC).
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+92
View File
@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+16
View File
@@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Seaca"
include(":app")