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:
@@ -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 не инициализируется (просмотр данных работает).
|
||||
|
||||
## Лицензия
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
# Seaca — defaults sufficient for debug/release without minify
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#15202B</color>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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] Экран «Моё устройство»: имя, сервер, версия, «Выйти» (базовый)
|
||||
- [ ] Минимальная версия приложения (проверка с сервера)
|
||||
|
||||
---
|
||||
|
||||
@@ -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).
|
||||
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
Vendored
BIN
Binary file not shown.
+7
@@ -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
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user