main commit

This commit is contained in:
2025-10-06 09:40:51 +09:00
parent b1de55d253
commit 79256cd9fc
2375 changed files with 370050 additions and 4033 deletions

View File

@@ -2,15 +2,22 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Разрешения согласно ТЗ -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Разрешения для сигналлинга и WebRTC -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Hardware features согласно ТЗ -->
<!-- Разрешения для камеры и микрофона -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- Hardware features -->
<uses-feature android:name="android.hardware.camera" android:required="true" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-feature android:name="android.hardware.microphone" android:required="true" />
@@ -26,9 +33,9 @@
android:theme="@style/Theme.GodEye"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true"
tools:targetApi="31">
tools:targetApi="35">
<!-- MainActivity - главный экран согласно ТЗ -->
<!-- MainActivity с Compose интерфейсом -->
<activity
android:name=".MainActivity"
android:exported="true"
@@ -41,7 +48,7 @@
</intent-filter>
</activity>
<!-- LegacyMainActivity - упрощенная версия для Android 9 -->
<!-- Legacy активности для совместимости -->
<activity
android:name=".LegacyMainActivity"
android:exported="false"
@@ -49,7 +56,6 @@
android:screenOrientation="portrait"
android:launchMode="singleTop" />
<!-- LegacyCameraActivity - камера для Android 9 -->
<activity
android:name=".LegacyCameraActivity"
android:exported="false"
@@ -57,12 +63,24 @@
android:screenOrientation="portrait"
android:launchMode="singleTop" />
<!-- SocketService - WebSocket соединение согласно ТЗ -->
<!-- Сервисы -->
<service
android:name=".services.SocketService"
android:enabled="true"
android:exported="false" />
<service
android:name=".services.SignalingService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
<service
android:name=".services.ConnectionService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>

View File

@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Разрешения согласно ТЗ -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- Hardware features согласно ТЗ -->
<uses-feature android:name="android.hardware.camera" android:required="true" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-feature android:name="android.hardware.microphone" android:required="true" />
<application
android:name=".GodEyeApplication"
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.GodEye"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<!-- MainActivity - главный экран согласно ТЗ -->
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.GodEye"
android:screenOrientation="portrait"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- LegacyMainActivity - упрощенная версия для Android 9 -->
<activity
android:name=".LegacyMainActivity"
android:exported="false"
android:theme="@style/Theme.GodEye"
android:screenOrientation="portrait"
android:launchMode="singleTop" />
<!-- LegacyCameraActivity - камера для Android 9 -->
<activity
android:name=".LegacyCameraActivity"
android:exported="false"
android:theme="@style/Theme.GodEye"
android:screenOrientation="portrait"
android:launchMode="singleTop" />
<!-- SocketService - WebSocket соединение согласно ТЗ -->
<service
android:name=".services.SocketService"
android:enabled="true"
android:exported="false" />
</application>
</manifest>

View File

@@ -6,6 +6,7 @@ import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.widget.Toast
@@ -128,7 +129,11 @@ class LegacyMainActivity : AppCompatActivity() {
private fun startAndBindService() {
try {
val intent = Intent(this, SocketService::class.java)
startForegroundService(intent)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
Logger.step("LEGACY_SERVICE_BIND", "Binding to SocketService")
} catch (e: Exception) {

View File

@@ -1,431 +1,176 @@
package com.example.godeye
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.example.godeye.camera.CameraScreen
import com.example.godeye.managers.PermissionManager
import androidx.lifecycle.lifecycleScope
import com.example.godeye.managers.ConnectionManager
import com.example.godeye.managers.AutoApprovalManager
import com.example.godeye.models.*
import com.example.godeye.services.SocketService
import com.example.godeye.services.ConnectionService
import com.example.godeye.ui.components.*
import com.example.godeye.ui.theme.GodEyeColors
import com.example.godeye.ui.screens.MainScreen
import com.example.godeye.ui.screens.SettingsScreen
import com.example.godeye.ui.theme.GodEyeTheme
import com.example.godeye.utils.ErrorHandler
import com.example.godeye.utils.Logger
import com.example.godeye.utils.PermissionHelper
import com.example.godeye.utils.PreferenceManager
import kotlinx.coroutines.launch
/**
* MainActivity - упрощенная версия для Android 9
* БЕЗ сложных анимаций и градиентов
*/
@OptIn(ExperimentalMaterial3Api::class)
class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModels()
private val errorHandler = ErrorHandler()
private var socketService: SocketService? = null
// Подключение к SocketService
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
Logger.step("SERVICE_CONNECTED", "SocketService connected to MainActivity")
val binder = service as SocketService.LocalBinder
socketService = binder.getService()
viewModel.bindToSocketService(socketService!!)
}
override fun onServiceDisconnected(name: ComponentName?) {
Logger.step("SERVICE_DISCONNECTED", "SocketService disconnected from MainActivity")
socketService = null
}
}
// Обработка разрешений
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
Logger.step("PERMISSIONS_RESULT", "Permission request result received")
val allGranted = permissions.values.all { it }
if (allGranted) {
Logger.step("PERMISSIONS_ALL_GRANTED", "All permissions granted")
viewModel.onPermissionsGranted()
} else {
val denied = permissions.filterValues { !it }.keys
Logger.step("PERMISSIONS_DENIED", "Some permissions denied: ${denied.joinToString()}")
val permissionManager = PermissionManager(this)
val hasCritical = denied.any { it in PermissionManager.CRITICAL_PERMISSIONS }
if (hasCritical) {
errorHandler.handleError(AppError.CameraPermissionDenied, this)
}
}
}
// Убираем SignalingManager - используем только ConnectionManager + AutoApprovalManager
// private lateinit var signalingManager: SignalingManager
private lateinit var connectionManager: ConnectionManager
private lateinit var autoApprovalManager: AutoApprovalManager
private lateinit var permissionHelper: PermissionHelper
private lateinit var preferenceManager: PreferenceManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
try {
Logger.step("ACTIVITY_CREATE", "MainActivity onCreate simplified for Android 9")
Logger.d("Device: ${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}")
Logger.d("Android: ${android.os.Build.VERSION.RELEASE}")
// Инициализируем менеджеры (БЕЗ SignalingManager)
preferenceManager = PreferenceManager(this)
// signalingManager = SignalingManager(this) // УБИРАЕМ
connectionManager = ConnectionManager(this, preferenceManager)
autoApprovalManager = AutoApprovalManager(this, preferenceManager, connectionManager)
permissionHelper = PermissionHelper(this)
// Запуск SocketService
startAndBindSocketService()
setContent {
GodEyeTheme {
var showSettings by remember { mutableStateOf(false) }
var showCamera by remember { mutableStateOf(false) }
val cameraRequest by viewModel.cameraRequest.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
// Автоматическое принятие запросов камеры
LaunchedEffect(cameraRequest) {
val currentRequest = cameraRequest
if (currentRequest != null) {
Logger.step("AUTO_ACCEPT_CAMERA_REQUEST", "Auto-accepting camera request")
showCamera = true
viewModel.acceptCameraRequest(currentRequest.sessionId, "Auto-accepted")
} else {
showCamera = false
}
}
// Обработка ошибок
val connectionState by viewModel.connectionState.collectAsState()
LaunchedEffect(connectionState) {
if (connectionState == ConnectionState.ERROR) {
errorHandler.handleError(AppError.NetworkError, this@MainActivity, scope, snackbarHostState)
}
}
Box(modifier = Modifier.fillMaxSize()) {
when {
showCamera && cameraRequest != null -> {
Logger.step("UI_RENDERING_CAMERA", "Rendering simplified CameraScreen")
CameraScreen(
onBackPressed = {
Logger.step("CAMERA_BACK_PRESSED", "User pressed back")
showCamera = false
viewModel.clearCameraRequest()
},
sessionId = cameraRequest!!.sessionId,
operatorId = cameraRequest!!.operatorId
)
}
showSettings -> {
Logger.step("UI_RENDERING_SETTINGS", "Rendering SettingsScreen")
SettingsScreen(
onBackPressed = { showSettings = false },
onServerConfigSaved = { url ->
viewModel.updateServerUrl(url)
showSettings = false
viewModel.connectToServer()
}
)
}
else -> {
Logger.step("UI_RENDERING_MAIN", "Rendering simplified MainScreen")
SimplifiedMainScreen(
onSettingsClick = { showSettings = true },
onCameraAccept = { showCamera = true },
snackbarHostState = snackbarHostState
)
}
}
// Простой Snackbar для ошибок
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
setContent {
GodEyeTheme {
AppContent()
}
// Проверка разрешений
checkRequiredPermissions()
Logger.step("ACTIVITY_CREATE_COMPLETE", "MainActivity simplified onCreate complete")
} catch (e: Exception) {
Logger.error("ACTIVITY_CREATE_ERROR", "Error in MainActivity onCreate", e)
errorHandler.handleError(AppError.UnknownError(e), this)
}
}
private fun startAndBindSocketService() {
Logger.step("SOCKET_SERVICE_START", "Starting SocketService")
val intent = Intent(this, SocketService::class.java)
startForegroundService(intent)
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
// Проверяем разрешения при запуске
checkPermissionsAndStart()
private fun checkRequiredPermissions() {
Logger.step("PERMISSION_CHECK", "Checking permissions")
val permissionManager = PermissionManager(this)
if (!permissionManager.checkPermissions()) {
val missingPermissions = permissionManager.getMissingPermissions()
Logger.step("PERMISSIONS_MISSING", "Requesting: ${missingPermissions.joinToString()}")
permissionLauncher.launch(missingPermissions)
} else {
Logger.step("PERMISSIONS_OK", "All permissions granted")
viewModel.onPermissionsGranted()
}
// Запускаем фоновый сервис
startConnectionService()
}
@Composable
private fun SimplifiedMainScreen(
onSettingsClick: () -> Unit,
onCameraAccept: () -> Unit,
snackbarHostState: SnackbarHostState
) {
val connectionState by viewModel.connectionState.collectAsState()
val serverUrl by viewModel.serverUrl.collectAsState()
val deviceId by viewModel.deviceId.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val cameraRequest by viewModel.cameraRequest.collectAsState()
val isStreaming by viewModel.isStreaming.collectAsState()
val activeSessions by viewModel.activeSessions.collectAsState()
val permissionsGranted by viewModel.permissionsGranted.collectAsState()
val scope = rememberCoroutineScope()
private fun AppContent() {
var currentScreen by remember { mutableStateOf(Screen.Main) }
// УПРОЩЕННЫЙ UI ДЛЯ ANDROID 9 - БЕЗ СЛОЖНЫХ АНИМАЦИЙ
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Простой заголовок
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color.Gray.copy(alpha = 0.3f))
) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "GodEye Signal Center",
style = MaterialTheme.typography.headlineMedium,
color = Color.White
)
Text(
text = "Android Client v1.0 (Simplified)",
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray
)
}
}
// Разрешения
if (!permissionsGranted) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color.Red.copy(alpha = 0.7f))
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("⚠️ Требуются разрешения", color = Color.White)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
val permissionManager = PermissionManager(this@MainActivity)
permissionLauncher.launch(permissionManager.getMissingPermissions())
}
) {
Text("Предоставить разрешения")
}
}
}
}
// Device ID
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color.Blue.copy(alpha = 0.3f))
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("📱 Device ID", color = Color.White)
Text(deviceId.take(16) + "...", color = Color.Gray)
Text("${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}", color = Color.Gray)
}
}
// Подключение к серверу
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = when (connectionState) {
ConnectionState.CONNECTED -> Color.Green.copy(alpha = 0.3f)
ConnectionState.ERROR -> Color.Red.copy(alpha = 0.3f)
else -> Color.Yellow.copy(alpha = 0.3f)
}
when (currentScreen) {
Screen.Main -> {
MainScreen(
connectionManager = connectionManager,
autoApprovalManager = autoApprovalManager,
preferenceManager = preferenceManager,
permissionHelper = permissionHelper,
onOpenSettings = { currentScreen = Screen.Settings }
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("🌐 Сервер", color = Color.White)
Text(
when (connectionState) {
ConnectionState.CONNECTED -> "✅ Подключено"
ConnectionState.CONNECTING -> "🔄 Подключение..."
ConnectionState.ERROR -> "❌ Ошибка"
else -> "⚪ Отключено"
},
color = Color.White
)
Text("$serverUrl", color = Color.Gray)
Spacer(modifier = Modifier.height(8.dp))
if (connectionState == ConnectionState.DISCONNECTED) {
Button(
onClick = {
if (permissionsGranted) {
viewModel.connectToServer()
} else {
scope.launch {
snackbarHostState.showSnackbar("Нужны разрешения")
}
}
},
enabled = !isLoading,
modifier = Modifier.fillMaxWidth()
) {
Text(if (isLoading) "Подключение..." else "🔗 Подключиться")
}
} else {
Button(
onClick = viewModel::disconnect,
modifier = Modifier.fillMaxWidth()
) {
Text("❌ Отключиться")
}
}
}
}
// Статус трансляции
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (isStreaming) Color.Green.copy(alpha = 0.3f) else Color.Gray.copy(alpha = 0.3f)
Screen.Settings -> {
SettingsScreen(
preferenceManager = preferenceManager,
permissionHelper = permissionHelper,
onNavigateBack = { currentScreen = Screen.Main }
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("📹 Трансляция", color = Color.White)
Text(
if (isStreaming) "🔴 Активна: ${activeSessions.size} сессий" else "⚪ Неактивна",
color = Color.White
)
}
}
}
}
// Кнопки управления
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = onCameraAccept,
enabled = permissionsGranted,
modifier = Modifier.weight(1f)
) {
Text("📷 Камера")
}
Button(
onClick = onSettingsClick,
modifier = Modifier.weight(1f)
) {
Text("⚙️ Настройки")
}
}
private enum class Screen {
Main, Settings
}
// Кнопка для запуска Legacy версии
Button(
onClick = {
Logger.step("LAUNCH_LEGACY_VERSION", "Launching LegacyMainActivity")
val intent = Intent(this@MainActivity, LegacyMainActivity::class.java)
startActivity(intent)
},
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF9C27B0) // Фиолетовый цвет для выделения
)
) {
Text("📱 Legacy версия (Android 9)")
}
// Запрос от оператора
cameraRequest?.let { request ->
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color.Yellow.copy(alpha = 0.8f))
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("📞 Запрос от оператора", color = Color.Black)
Text("Сессия: ${request.sessionId.take(8)}...", color = Color.Black)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
viewModel.acceptCameraRequest(request.sessionId, "Принято")
onCameraAccept()
},
modifier = Modifier.weight(1f)
) {
Text("✅ Принять")
}
Button(
onClick = {
viewModel.rejectCameraRequest(request.sessionId, "Отклонено")
},
modifier = Modifier.weight(1f)
) {
Text("❌ Отклонить")
}
}
}
private fun checkPermissionsAndStart() {
if (permissionHelper.hasAllPermissions()) {
// Все разрешения уже есть, можно работать
Log.d("MainActivity", "All permissions granted")
startApplication()
} else {
// Запрашиваем разрешения
Log.d("MainActivity", "Requesting permissions")
permissionHelper.requestPermissions { granted ->
if (granted) {
Log.d("MainActivity", "Permissions granted, starting application")
startApplication()
} else {
Log.w("MainActivity", "Some permissions denied")
}
}
}
}
private fun startApplication() {
// Инициализируем автоматическое подключение если включено
if (preferenceManager.getAutoConnect()) {
connectionManager.connect()
}
Log.d("MainActivity", "Application started with settings: ${preferenceManager.getAllSettings()}")
}
/**
* Запуск фонового сервиса для поддержания подключения
*/
private fun startConnectionService() {
val serviceIntent = Intent(this, ConnectionService::class.java).apply {
action = ConnectionService.ACTION_START
}
// Проверяем версию Android для совместимости
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
startForegroundService(serviceIntent)
} else {
startService(serviceIntent)
}
Log.d("MainActivity", "Connection service started")
}
/**
* Остановка фонового сервиса
*/
private fun stopConnectionService() {
val serviceIntent = Intent(this, ConnectionService::class.java).apply {
action = ConnectionService.ACTION_STOP
}
stopService(serviceIntent)
Log.d("MainActivity", "Connection service stopped")
}
override fun onResume() {
super.onResume()
Log.d("MainActivity", "Activity resumed")
// Проверяем настройки и автоподключение
if (permissionHelper.hasAllPermissions() && preferenceManager.getAutoConnect()) {
connectionManager.connect()
}
}
override fun onPause() {
super.onPause()
Log.d("MainActivity", "Activity paused - service continues in background")
}
override fun onDestroy() {
Logger.step("ACTIVITY_DESTROY", "MainActivity destroyed")
try {
unbindService(serviceConnection)
} catch (e: Exception) {
Logger.error("UNBIND_SERVICE_ERROR", "Error unbinding SocketService", e)
}
super.onDestroy()
// Очищаем ресурсы Activity, но оставляем сервис работать
connectionManager.cleanup()
autoApprovalManager.cleanup()
// Останавливаем сервис только если приложение действительно закрывается
if (isFinishing) {
stopConnectionService()
}
Log.d("MainActivity", "MainActivity destroyed, resources cleaned up")
}
}

View File

@@ -15,7 +15,6 @@ import com.example.godeye.services.SocketService
import com.example.godeye.utils.Logger
import com.example.godeye.utils.generateDeviceId
import com.example.godeye.utils.getPreferences
import com.example.godeye.webrtc.WebRTCManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -131,53 +130,62 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
/**
* Настройка наблюдателей за SocketService
* Настройка наблюдателей за SocketService - исправленная версия
*/
private fun setupServiceObservers() {
val service = socketService ?: return
// Простое наблюдение за состоянием подключения без внутренних API
viewModelScope.launch {
// Наблюдение за состоянием подключения
service.connectionState.collect { state ->
_connectionState.value = state
Logger.step("CONNECTION_STATE_CHANGED", "Connection state: $state")
}
}
viewModelScope.launch {
// Наблюдение за запросами камеры от операторов
service.cameraRequests.collect { request ->
if (request != null) {
Logger.step("CAMERA_REQUEST_RECEIVED",
"Camera request from ${request.operatorId} for ${request.cameraType}")
_cameraRequest.value = request
while (true) {
try {
val state = service.connectionState.value
if (_connectionState.value != state) {
_connectionState.value = state
Logger.step("CONNECTION_STATE_CHANGED", "Connection state: $state")
}
kotlinx.coroutines.delay(1000) // Проверяем каждую секунду
} catch (e: Exception) {
Logger.error("CONNECTION_STATE_ERROR", "Error checking connection state", e)
break
}
}
}
// Простое наблюдение за запросами камеры
viewModelScope.launch {
// Наблюдение за WebRTC событиями
service.webRTCEvents.collect { event ->
event?.let { handleWebRTCEvent(it) }
while (true) {
try {
val request = service.cameraRequests.value
if (_cameraRequest.value != request && request != null) {
Logger.step("CAMERA_REQUEST_RECEIVED",
"Camera request from ${request.operatorId} for ${request.cameraType}")
_cameraRequest.value = request
}
kotlinx.coroutines.delay(500) // Проверяем каждые полсекунды
} catch (e: Exception) {
Logger.error("CAMERA_REQUEST_ERROR", "Error checking camera requests", e)
break
}
}
}
// Простое наблюдение за WebRTC событиями
viewModelScope.launch {
// Наблюдение за сессиями
sessionManager.sessions.collect { sessions ->
val sessionInfo = sessions.mapValues { (sessionId, session) ->
SessionInfo(
sessionId = sessionId,
deviceId = _deviceId.value,
operatorId = session.operatorId,
cameraType = session.cameraType,
status = if (session.webRTCConnected) "Connected" else "Connecting",
createdAt = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault())
.format(java.util.Date(session.startTime))
)
while (true) {
try {
val event = service.webRTCEvents.value
if (event != null) {
handleWebRTCEvent(event)
// Очищаем событие после обработки
service.webRTCEvents as MutableStateFlow
(service.webRTCEvents as MutableStateFlow).value = null
}
kotlinx.coroutines.delay(100) // Проверяем каждые 100мс
} catch (e: Exception) {
Logger.error("WEBRTC_EVENT_ERROR", "Error checking WebRTC events", e)
break
}
_activeSessions.value = sessionInfo
_isStreaming.value = sessions.values.any { it.isActive }
}
}
}
@@ -186,10 +194,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
* Инициализация WebRTC согласно ТЗ
*/
private fun initializeWebRTC() {
webRTCManager = WebRTCManager(context) { message ->
// Обработка сигнальных сообщений через SocketService
Logger.step("WEBRTC_SIGNALING", "WebRTC signaling message: ${message.getString("type")}")
}
webRTCManager = WebRTCManager(context)
}
/**
@@ -257,7 +262,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
socketService?.sendCameraResponse(sessionId, true, reason)
// 3. Инициализация WebRTC соединения
webRTCManager?.startStreaming(sessionId, request.cameraType)
webRTCManager?.startStreaming(request.operatorId, request.cameraType)
// 4. Очистка запроса
_cameraRequest.value = null
@@ -287,21 +292,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
*/
private fun handleWebRTCEvent(event: com.example.godeye.services.WebRTCEvent) {
when (event) {
is com.example.godeye.services.WebRTCEvent.Offer -> {
Logger.step("WEBRTC_OFFER", "Processing WebRTC offer for session: ${event.sessionId}")
webRTCManager?.handleOffer(event.sessionId, event.offer)
is com.example.godeye.services.WebRTCEvent.Connected -> {
Logger.step("WEBRTC_CONNECTED", "WebRTC connected")
}
is com.example.godeye.services.WebRTCEvent.Answer -> {
Logger.step("WEBRTC_ANSWER", "Processing WebRTC answer for session: ${event.sessionId}")
webRTCManager?.handleAnswer(event.sessionId, event.answer)
is com.example.godeye.services.WebRTCEvent.Disconnected -> {
Logger.step("WEBRTC_DISCONNECTED", "WebRTC disconnected")
}
is com.example.godeye.services.WebRTCEvent.IceCandidate -> {
Logger.step("WEBRTC_ICE", "Processing ICE candidate for session: ${event.sessionId}")
webRTCManager?.handleIceCandidate(event.sessionId, event.candidate, event.sdpMid, event.sdpMLineIndex)
is com.example.godeye.services.WebRTCEvent.Error -> {
Logger.step("WEBRTC_ERROR", "WebRTC error: ${event.message}")
}
is com.example.godeye.services.WebRTCEvent.SwitchCamera -> {
Logger.step("WEBRTC_SWITCH_CAMERA", "Switching camera to: ${event.cameraType}")
switchCamera(event.cameraType)
else -> {
Logger.step("WEBRTC_EVENT", "Unknown WebRTC event: $event")
}
}
}
@@ -335,7 +336,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
try {
Logger.step("SWITCH_CAMERA", "Switching camera to: $cameraType")
webRTCManager?.switchCamera(cameraType)
webRTCManager?.switchCamera()
// Обновляем тип камеры в активных сессиях
val updatedSessions = _activeSessions.value.mapValues { (_, sessionInfo) ->
@@ -358,7 +359,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
Logger.step("END_SESSION", "Ending camera session: $sessionId")
sessionManager.endSession(sessionId, "Ended by user")
webRTCManager?.endSession(sessionId)
webRTCManager?.endSession()
Logger.step("SESSION_ENDED", "Session ended: $sessionId")
}
@@ -404,34 +405,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
permissionManager.checkPermissions()
}
/**
* Связывание с SocketService
*/
fun bindToSocketService(service: SocketService) {
Logger.step("VIEWMODEL_BIND_SERVICE", "Binding ViewModel to SocketService")
viewModelScope.launch {
// Наблюдение за состоянием подключения
service.connectionState.collect { state ->
_connectionState.value = state
}
}
viewModelScope.launch {
// Наблюдение за запросами камеры
service.cameraRequests.collect { request ->
_cameraRequest.value = request
}
}
viewModelScope.launch {
// Наблюдение за WebRTC событиями
service.webRTCEvents.collect { event ->
event?.let { handleWebRTCEvent(it) }
}
}
}
/**
* Callback при получении разрешений
*/
@@ -471,14 +444,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
// Инициализируем WebRTC если нужно
if (webRTCManager == null) {
webRTCManager = WebRTCManager(context) { message ->
// Обработка сигналинга для тестового режима
Logger.d("Test signaling message: $message")
}
webRTCManager = WebRTCManager(context)
}
// Запускаем стриминг
webRTCManager?.startStreaming(testSessionId, "back")
webRTCManager?.startStreaming("test_operator", "back")
Logger.step("START_TEST_STREAMING_SUCCESS", "Test streaming started successfully")

View File

@@ -1,364 +0,0 @@
package com.example.godeye
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import com.example.godeye.ui.theme.GodEyeColors
import com.example.godeye.utils.getPreferences
/**
* Экран настроек GodEye с расширенными параметрами согласно ТЗ
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBackPressed: () -> Unit,
onServerConfigSaved: (String) -> Unit
) {
val context = LocalContext.current
val prefs = context.getPreferences()
// Состояния настроек
var serverUrl by remember {
mutableStateOf(prefs.getString("server_url", "http://192.168.219.108:3001") ?: "")
}
var deviceName by remember {
mutableStateOf(prefs.getString("device_name", "${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}") ?: "")
}
var autoConnect by remember {
mutableStateOf(prefs.getBoolean("auto_connect", false))
}
var autoAcceptRequests by remember {
mutableStateOf(prefs.getBoolean("auto_accept_requests", true))
}
var enableNotifications by remember {
mutableStateOf(prefs.getBoolean("enable_notifications", true))
}
var keepScreenOn by remember {
mutableStateOf(prefs.getBoolean("keep_screen_on", false))
}
var preferredCamera by remember {
mutableStateOf(prefs.getString("preferred_camera", "back") ?: "back")
}
var streamQuality by remember {
mutableStateOf(prefs.getString("stream_quality", "720p") ?: "720p")
}
Column(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding()
) {
// Шапка экрана
TopAppBar(
title = {
Text(
text = "Настройки GodEye",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Medium,
color = GodEyeColors.IvoryPure
)
},
navigationIcon = {
IconButton(onClick = onBackPressed) {
Icon(
Icons.Default.ArrowBack,
contentDescription = "Назад",
tint = GodEyeColors.IvoryPure
)
}
},
actions = {
TextButton(
onClick = {
// Сохраняем все настройки
prefs.edit {
putString("server_url", serverUrl)
putString("device_name", deviceName)
putBoolean("auto_connect", autoConnect)
putBoolean("auto_accept_requests", autoAcceptRequests)
putBoolean("enable_notifications", enableNotifications)
putBoolean("keep_screen_on", keepScreenOn)
putString("preferred_camera", preferredCamera)
putString("stream_quality", streamQuality)
}
onServerConfigSaved(serverUrl)
}
) {
Text(
"Сохранить",
color = GodEyeColors.SuccessGreen,
fontWeight = FontWeight.Medium
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = GodEyeColors.BlackSoft.copy(alpha = 0.9f)
)
)
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Секция "Сервер"
item {
SettingsSection(title = "Подключение к серверу") {
OutlinedTextField(
value = serverUrl,
onValueChange = { serverUrl = it },
label = { Text("URL сервера") },
placeholder = { Text("http://192.168.1.100:3001") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = GodEyeColors.NavyLight,
unfocusedBorderColor = GodEyeColors.IvorySoft.copy(alpha = 0.5f),
focusedTextColor = GodEyeColors.IvoryPure,
unfocusedTextColor = GodEyeColors.IvorySoft,
focusedLabelColor = GodEyeColors.NavyLight,
unfocusedLabelColor = GodEyeColors.IvorySoft
),
leadingIcon = {
Icon(
Icons.Default.Language,
contentDescription = null,
tint = GodEyeColors.NavyLight
)
}
)
Spacer(modifier = Modifier.height(8.dp))
SettingsSwitchCard(
title = "Автоматическое подключение",
subtitle = "Подключаться к серверу при запуске приложения",
checked = autoConnect,
onCheckedChange = { autoConnect = it },
icon = Icons.Default.AutoAwesome
)
}
}
// Секция "Устройство"
item {
SettingsSection(title = "Устройство") {
OutlinedTextField(
value = deviceName,
onValueChange = { deviceName = it },
label = { Text("Имя устройства") },
placeholder = { Text("Android Device") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = GodEyeColors.NavyLight,
unfocusedBorderColor = GodEyeColors.IvorySoft.copy(alpha = 0.5f),
focusedTextColor = GodEyeColors.IvoryPure,
unfocusedTextColor = GodEyeColors.IvorySoft,
focusedLabelColor = GodEyeColors.NavyLight,
unfocusedLabelColor = GodEyeColors.IvorySoft
),
leadingIcon = {
Icon(
Icons.Default.Smartphone,
contentDescription = null,
tint = GodEyeColors.NavyLight
)
}
)
Text(
text = "Это имя будет отображаться операторам при подключении",
style = MaterialTheme.typography.bodySmall,
color = GodEyeColors.IvorySoft,
modifier = Modifier.padding(start = 48.dp, top = 4.dp)
)
}
}
// Секция "Автоматизация"
item {
SettingsSection(title = "Автоматизация") {
SettingsSwitchCard(
title = "Автоматическое принятие запросов",
subtitle = "Автоматически принимать запросы от операторов",
checked = autoAcceptRequests,
onCheckedChange = { autoAcceptRequests = it },
icon = Icons.Default.AutoAwesome
)
Spacer(modifier = Modifier.height(8.dp))
SettingsSwitchCard(
title = "Уведомления",
subtitle = "Показывать уведомления о входящих запросах",
checked = enableNotifications,
onCheckedChange = { enableNotifications = it },
icon = Icons.Default.Notifications
)
Spacer(modifier = Modifier.height(8.dp))
SettingsSwitchCard(
title = "Не выключать экран",
subtitle = "Экран остается включенным во время сессии",
checked = keepScreenOn,
onCheckedChange = { keepScreenOn = it },
icon = Icons.Default.ScreenLockPortrait
)
}
}
// Секция "О приложении"
item {
SettingsSection(title = "О приложении") {
InfoCard(
title = "GodEye Android Client",
subtitle = "Версия 1.0.0 (Build 1)",
icon = Icons.Default.Info
)
Spacer(modifier = Modifier.height(8.dp))
InfoCard(
title = "Device ID",
subtitle = context.getPreferences().getString("device_id", "Неизвестно") ?: "Неизвестно",
icon = Icons.Default.Fingerprint
)
}
}
}
}
}
@Composable
fun SettingsSection(
title: String,
content: @Composable ColumnScope.() -> Unit
) {
Column {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = GodEyeColors.IvoryPure,
modifier = Modifier.padding(bottom = 12.dp)
)
content()
}
}
@Composable
fun SettingsSwitchCard(
title: String,
subtitle: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
icon: androidx.compose.ui.graphics.vector.ImageVector
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = GodEyeColors.NavyDark.copy(alpha = 0.3f)
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = if (checked) GodEyeColors.SuccessGreen else GodEyeColors.IvorySoft,
modifier = Modifier.size(24.dp)
)
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
color = GodEyeColors.IvoryPure
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = GodEyeColors.IvorySoft
)
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
colors = SwitchDefaults.colors(
checkedThumbColor = GodEyeColors.IvoryPure,
checkedTrackColor = GodEyeColors.SuccessGreen,
uncheckedThumbColor = GodEyeColors.IvorySoft,
uncheckedTrackColor = GodEyeColors.NavyDark
)
)
}
}
}
@Composable
fun InfoCard(
title: String,
subtitle: String,
icon: androidx.compose.ui.graphics.vector.ImageVector
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = GodEyeColors.NavyDark.copy(alpha = 0.3f)
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = GodEyeColors.NavyLight,
modifier = Modifier.size(24.dp)
)
Column {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
color = GodEyeColors.IvoryPure
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = GodEyeColors.IvorySoft
)
}
}
}
}

View File

@@ -1,236 +0,0 @@
package com.example.godeye.camera
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import com.example.godeye.utils.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
/**
* CameraManager - безопасное управление камерой для предпросмотра
* Исправлены проблемы с освобождением ресурсов и утечками памяти
*/
class CameraManager(private val context: Context) {
private var cameraProvider: ProcessCameraProvider? = null
private var camera: Camera? = null
private var preview: Preview? = null
private var imageCapture: ImageCapture? = null
private val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
private var currentCameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
private var isReleased = false
/**
* Проверка разрешений камеры
*/
fun hasPermissions(): Boolean {
return ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
}
/**
* Безопасная настройка камеры с предпросмотром
*/
fun setupCamera(
previewView: PreviewView,
lifecycleOwner: LifecycleOwner,
cameraSelector: CameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
) {
if (isReleased) {
Logger.error("CAMERA_SETUP_ERROR", "Camera manager already released")
return
}
try {
// Сначала безопасно освобождаем предыдущие ресурсы
safeCameraCleanup()
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProvider = cameraProviderFuture.get()
currentCameraSelector = cameraSelector
// Создание preview use case с безопасными настройками
preview = Preview.Builder()
.setTargetRotation(previewView.display.rotation)
.build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
// Создание image capture use case с оптимизацией для Android 9
imageCapture = ImageCapture.Builder()
.setTargetRotation(previewView.display.rotation)
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
.build()
// Отвязка всех use cases перед привязкой новых
cameraProvider?.unbindAll()
// Безопасная привязка use cases к lifecycle
camera = cameraProvider?.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview,
imageCapture
)
Logger.step("CAMERA_SETUP", "Camera setup completed safely")
} catch (e: Exception) {
Logger.error("CAMERA_SETUP_ERROR", "Failed to setup camera safely", e)
safeCameraCleanup()
throw e
}
}
/**
* Безопасная очистка ресурсов камеры
*/
private fun safeCameraCleanup() {
try {
cameraProvider?.unbindAll()
preview = null
imageCapture = null
camera = null
} catch (e: Exception) {
Logger.error("CAMERA_CLEANUP_ERROR", "Error during camera cleanup", e)
}
}
/**
* Переключение камеры с безопасной обработкой
*/
fun switchCamera(): CameraSelector {
if (isReleased) return currentCameraSelector
currentCameraSelector = if (currentCameraSelector == CameraSelector.DEFAULT_BACK_CAMERA) {
CameraSelector.DEFAULT_FRONT_CAMERA
} else {
CameraSelector.DEFAULT_BACK_CAMERA
}
return currentCameraSelector
}
/**
* Безопасное включение/выключение вспышки
*/
fun toggleFlash() {
if (isReleased) return
try {
camera?.let { camera ->
if (camera.cameraInfo.hasFlashUnit()) {
val flashMode = camera.cameraInfo.torchState.value
camera.cameraControl.enableTorch(flashMode == TorchState.OFF)
}
}
} catch (e: Exception) {
Logger.error("FLASH_TOGGLE_ERROR", "Error toggling flash", e)
}
}
/**
* Безопасная съемка фото
*/
fun takePhoto(
outputFile: File,
onPhotoTaken: (File) -> Unit,
onError: (Exception) -> Unit
) {
if (isReleased) {
onError(Exception("Camera manager released"))
return
}
val imageCapture = imageCapture ?: run {
onError(Exception("ImageCapture not initialized"))
return
}
try {
val outputOptions = ImageCapture.OutputFileOptions.Builder(outputFile).build()
imageCapture.takePicture(
outputOptions,
ContextCompat.getMainExecutor(context),
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
onPhotoTaken(outputFile)
Logger.step("PHOTO_TAKEN", "Photo saved safely to: ${outputFile.absolutePath}")
}
override fun onError(exception: ImageCaptureException) {
onError(exception)
Logger.error("PHOTO_ERROR", "Photo capture failed safely", exception)
}
}
)
} catch (e: Exception) {
onError(e)
Logger.error("PHOTO_SETUP_ERROR", "Failed to setup photo capture", e)
}
}
/**
* Начало записи видео - заглушка для совместимости
*/
fun startRecording(
outputFile: File,
onRecordingStarted: () -> Unit,
onError: (Exception) -> Unit
) {
onError(Exception("Video recording not supported on this device for stability"))
}
/**
* Остановка записи видео - заглушка для совместимости
*/
fun stopRecording() {
Logger.step("VIDEO_RECORDING_STOPPED", "Video recording stop requested (not supported)")
}
/**
* Безопасное освобождение ресурсов
*/
fun release() {
if (isReleased) return
try {
Logger.step("CAMERA_MANAGER_RELEASING", "Starting safe camera manager release")
isReleased = true
// Безопасная очистка камеры
safeCameraCleanup()
// Безопасное завершение executor
cameraExecutor.shutdown()
try {
if (!cameraExecutor.awaitTermination(1, TimeUnit.SECONDS)) {
cameraExecutor.shutdownNow()
}
} catch (e: InterruptedException) {
cameraExecutor.shutdownNow()
Thread.currentThread().interrupt()
}
cameraProvider = null
Logger.step("CAMERA_MANAGER_RELEASED", "Camera manager resources released safely")
} catch (e: Exception) {
Logger.error("CAMERA_RELEASE_ERROR", "Error during camera manager release", e)
}
}
}

View File

@@ -1,315 +0,0 @@
package com.example.godeye.camera
import android.Manifest
import android.content.Context
import android.view.SurfaceView
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import com.example.godeye.ui.components.*
import com.example.godeye.ui.theme.GodEyeColors
import com.example.godeye.utils.Logger
import kotlinx.coroutines.launch
import java.io.File
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@Composable
fun CameraScreen(
onBackPressed: () -> Unit,
sessionId: String = "",
@Suppress("UNUSED_PARAMETER") operatorId: String = ""
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var hasPermissions by remember { mutableStateOf(false) }
var showError by remember { mutableStateOf<String?>(null) }
// Упрощенный CameraManager без сложных анимаций
val cameraManager = remember {
try {
CameraManager(context)
} catch (e: Exception) {
Logger.error("CAMERA_MANAGER_CREATE_ERROR", "Failed to create camera manager", e)
null
}
}
val previewView = remember {
try {
PreviewView(context).apply {
scaleType = PreviewView.ScaleType.FILL_CENTER
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
}
} catch (e: Exception) {
Logger.error("PREVIEW_VIEW_CREATE_ERROR", "Failed to create preview view", e)
null
}
}
// Проверка разрешений
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
hasPermissions = permissions.values.all { it }
if (!hasPermissions) {
showError = "Необходимы разрешения для работы с камерой"
}
}
LaunchedEffect(Unit) {
hasPermissions = cameraManager?.hasPermissions() ?: false
if (!hasPermissions) {
permissionLauncher.launch(
arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO
)
)
}
}
// Простая инициализация камеры БЕЗ сложных анимаций
LaunchedEffect(hasPermissions) {
if (hasPermissions && cameraManager != null && previewView != null) {
try {
Logger.step("CAMERA_INIT_START", "Starting simple camera initialization")
cameraManager.setupCamera(previewView, lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA)
Logger.step("CAMERA_INIT_SUCCESS", "Simple camera initialized successfully")
} catch (e: Exception) {
showError = "Ошибка инициализации камеры: ${e.message}"
Logger.error("CAMERA_INIT_ERROR", "Camera initialization failed", e)
}
}
}
// Безопасное освобождение ресурсов при закрытии
DisposableEffect(cameraManager) {
onDispose {
try {
Logger.step("CAMERA_SCREEN_DISPOSE", "Disposing camera screen safely")
cameraManager?.release()
} catch (e: Exception) {
Logger.error("CAMERA_DISPOSE_ERROR", "Error disposing camera", e)
}
}
}
// УПРОЩЕННЫЙ UI БЕЗ СЛОЖНЫХ АНИМАЦИЙ
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
if (hasPermissions && previewView != null && cameraManager != null) {
// Простой preview БЕЗ сложных эффектов
AndroidView(
factory = { previewView },
modifier = Modifier.fillMaxSize()
)
// Простая верхняя панель БЕЗ анимаций
Card(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(16.dp)
.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = Color.Black.copy(alpha = 0.7f)
),
shape = RoundedCornerShape(8.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = onBackPressed,
colors = ButtonDefaults.buttonColors(
containerColor = Color.Gray
)
) {
Text("← Назад", color = Color.White)
}
Text(
text = "GodEye Camera",
color = Color.White,
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.width(60.dp))
}
}
// Простая нижняя панель БЕЗ анимаций
Card(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(16.dp)
.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = Color.Black.copy(alpha = 0.7f)
),
shape = RoundedCornerShape(8.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
// Простая кнопка фото БЕЗ анимаций
Button(
onClick = {
try {
val photoFile = File(
context.externalCacheDir,
"photo_${System.currentTimeMillis()}.jpg"
)
cameraManager.takePhoto(
photoFile,
onPhotoTaken = {
Logger.step("PHOTO_TAKEN", "Photo taken successfully")
},
onError = { error ->
showError = "Ошибка съемки: ${error.message}"
}
)
} catch (e: Exception) {
showError = "Ошибка съемки: ${e.message}"
}
},
colors = ButtonDefaults.buttonColors(
containerColor = Color.Blue
)
) {
Text("📷 Фото", color = Color.White)
}
// Простая кнопка переключения камеры БЕЗ анимаций
Button(
onClick = {
try {
val newCameraSelector = cameraManager.switchCamera()
// Простое пересоздание камеры с новым селектором
cameraManager.setupCamera(previewView, lifecycleOwner, newCameraSelector)
} catch (e: Exception) {
showError = "Ошибка переключения камеры"
}
},
colors = ButtonDefaults.buttonColors(
containerColor = Color.Green
)
) {
Text("🔄 Камера", color = Color.White)
}
}
}
} else {
// Простой экран разрешений БЕЗ анимаций
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = if (cameraManager == null || previewView == null) "Ошибка камеры" else "Требуются разрешения",
style = MaterialTheme.typography.headlineMedium,
color = Color.White,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
if (cameraManager != null && previewView != null) {
Button(
onClick = {
permissionLauncher.launch(
arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO
)
)
},
colors = ButtonDefaults.buttonColors(
containerColor = Color.Blue
)
) {
Text("Предоставить разрешения", color = Color.White)
}
} else {
Button(
onClick = onBackPressed,
colors = ButtonDefaults.buttonColors(
containerColor = Color.Red
)
) {
Text("Вернуться назад", color = Color.White)
}
}
}
}
// Простое отображение ошибок БЕЗ анимаций
showError?.let { error ->
Card(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(16.dp)
.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = Color.Red.copy(alpha = 0.9f)
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = error,
color = Color.White,
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { showError = null },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.2f)
)
) {
Text("Закрыть", color = Color.White)
}
}
}
}
}
}

View File

@@ -0,0 +1,760 @@
package com.example.godeye.managers
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import com.example.godeye.R
import com.example.godeye.utils.PreferenceManager
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONObject
/**
* Менеджер для автоматической обработки запросов доступа к камере
*/
class AutoApprovalManager(
private val context: Context,
private val preferenceManager: PreferenceManager,
private val connectionManager: ConnectionManager
) {
companion object {
private const val TAG = "AutoApprovalManager"
private const val NOTIFICATION_CHANNEL_ID = "camera_requests"
private const val NOTIFICATION_ID = 1001
}
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Активные сессии камеры с дополнительными метриками для мониторинга
data class CameraSession(
val sessionId: String,
val operatorId: String,
val cameraType: String,
val startTime: Long = System.currentTimeMillis(),
val isAutoApproved: Boolean = false,
// Дополнительные поля для архитектуры GodEye Signal Center
val deviceId: String,
val quality: String = "720p",
var lastHeartbeat: Long = System.currentTimeMillis(),
var streamingState: StreamingState = StreamingState.INITIALIZING
)
enum class StreamingState {
INITIALIZING,
CONNECTING,
STREAMING,
PAUSED,
ERROR,
DISCONNECTED
}
// Состояние системы GodEye Signal Center
private val _systemState = MutableStateFlow(SystemState.READY)
val systemState: StateFlow<SystemState> = _systemState.asStateFlow()
enum class SystemState {
READY,
PROCESSING_REQUEST,
STREAMING_ACTIVE,
ERROR,
MAINTENANCE
}
private val _activeSessions = MutableStateFlow<List<CameraSession>>(emptyList())
val activeSessions: StateFlow<List<CameraSession>> = _activeSessions.asStateFlow()
// Слушатели для UI
private val _pendingRequest = MutableStateFlow<JSONObject?>(null)
val pendingRequest: StateFlow<JSONObject?> = _pendingRequest.asStateFlow()
init {
createNotificationChannel()
setupConnectionManagerListeners()
}
/**
* Настройка слушателей событий от ConnectionManager
* Поддерживает обе конвенции именования: с двоеточиями (desktop/web) и дефисами (android)
*/
private fun setupConnectionManagerListeners() {
Log.d(TAG, "Setting up connection manager listeners with dual naming convention support")
// Обработка запросов доступа к камере (поддержка обеих конвенций)
val cameraRequestHandler: (Array<Any>) -> Unit = { data: Array<Any> ->
try {
Log.d(TAG, "Received camera request event with ${data.size} elements")
if (data.isNotEmpty()) {
val requestData = data[0] as? JSONObject
Log.d(TAG, "Request data: $requestData")
requestData?.let { handleCameraRequest(it) }
} else {
Log.w(TAG, "camera request event received but data is empty")
}
} catch (e: Exception) {
Log.e(TAG, "Error processing camera request event", e)
}
}
// Регистрируем обработчики для обеих конвенций
connectionManager.registerEventListener("camera:request", cameraRequestHandler) // Desktop/Web format
connectionManager.registerEventListener("camera-request", cameraRequestHandler) // Android format
// Обработка автоматически одобренных запросов
val autoApprovedHandler: (Array<Any>) -> Unit = { data: Array<Any> ->
try {
if (data.isNotEmpty()) {
val requestData = data[0] as? JSONObject
requestData?.let { handleAutoApprovedRequest(it) }
}
} catch (e: Exception) {
Log.e(TAG, "Error processing camera auto-approved event", e)
}
}
connectionManager.registerEventListener("camera:auto-approved", autoApprovedHandler)
connectionManager.registerEventListener("camera-auto-approved", autoApprovedHandler)
// Обработка отключения камеры
val disconnectHandler: (Array<Any>) -> Unit = { data: Array<Any> ->
try {
if (data.isNotEmpty()) {
val requestData = data[0] as? JSONObject
requestData?.let { handleCameraDisconnect(it) }
}
} catch (e: Exception) {
Log.e(TAG, "Error processing camera disconnect event", e)
}
}
connectionManager.registerEventListener("camera:disconnect", disconnectHandler)
connectionManager.registerEventListener("camera-disconnect", disconnectHandler)
// Обработка WebRTC answer
val webrtcAnswerHandler: (Array<Any>) -> Unit = { data: Array<Any> ->
try {
if (data.isNotEmpty()) {
val requestData = data[0] as? JSONObject
requestData?.let {
val sessionId = it.optString("sessionId")
val answer = it.optString("answer")
if (sessionId.isNotEmpty() && answer.isNotEmpty()) {
Log.d(TAG, "Received WebRTC answer for session: $sessionId")
connectionManager.handleWebRTCAnswer(sessionId, answer)
} else {
Log.w(TAG, "Invalid WebRTC answer data: sessionId=$sessionId, answer length=${answer.length}")
}
}
}
} catch (e: Exception) {
Log.e(TAG, "Error processing webrtc answer event", e)
}
}
connectionManager.registerEventListener("webrtc:answer", webrtcAnswerHandler)
connectionManager.registerEventListener("webrtc-answer", webrtcAnswerHandler)
// Добавляем слушатель для простого "answer" от signaling сервера
connectionManager.registerEventListener("answer", webrtcAnswerHandler)
// Обработка WebRTC ICE candidates
val iceCandidateHandler: (Array<Any>) -> Unit = { data: Array<Any> ->
try {
if (data.isNotEmpty()) {
val requestData = data[0] as? JSONObject
requestData?.let {
val sessionId = it.optString("sessionId")
val candidate = it.optString("candidate")
if (sessionId.isNotEmpty() && candidate.isNotEmpty()) {
Log.d(TAG, "Received ICE candidate for session: $sessionId")
connectionManager.handleWebRTCIceCandidate(sessionId, candidate)
} else {
Log.w(TAG, "Invalid ICE candidate data: sessionId=$sessionId, candidate length=${candidate.length}")
}
}
}
} catch (e: Exception) {
Log.e(TAG, "Error processing webrtc ice-candidate event", e)
}
}
connectionManager.registerEventListener("webrtc:ice-candidate", iceCandidateHandler)
connectionManager.registerEventListener("webrtc-ice-candidate", iceCandidateHandler)
connectionManager.registerEventListener("ice:candidate", iceCandidateHandler) // Alternative format
connectionManager.registerEventListener("ice-candidate", iceCandidateHandler) // Alternative format
// Добавляем слушатель для простого "ice_candidate" от signaling сервера
connectionManager.registerEventListener("ice_candidate", iceCandidateHandler)
}
/**
* Обработка запроса доступа к камере
*/
private fun handleCameraRequest(data: JSONObject) {
// Проверяем, что JSON не пустой
if (data.length() == 0) {
Log.e(TAG, "Empty JSON object received for camera request")
return
}
val sessionId = data.optString("sessionId")
val operatorId = data.optString("operatorId")
val cameraType = data.optString("cameraType", "back")
// Расширенная валидация входных данных
when {
sessionId.isBlank() -> {
Log.e(TAG, "Invalid camera request: sessionId is empty or blank")
return
}
operatorId.isBlank() -> {
Log.e(TAG, "Invalid camera request: operatorId is empty or blank")
return
}
cameraType.isBlank() -> {
Log.e(TAG, "Invalid camera request: cameraType is empty or blank, using default 'back'")
// Не возвращаемся, а используем значение по умолчанию
}
}
// Проверяем, нет ли уже активной сессии с этим sessionId
if (_activeSessions.value.any { it.sessionId == sessionId }) {
Log.w(TAG, "Session $sessionId already exists, ignoring duplicate request")
return
}
Log.d(TAG, "Processing camera request - Session: $sessionId, Operator: $operatorId, Camera: $cameraType")
Log.d(TAG, "Auto approve setting: ${preferenceManager.getAutoApprove()}")
if (preferenceManager.getAutoApprove()) {
// Автоматическое подтверждение включено
Log.d(TAG, "Auto approve is ENABLED - automatically approving request")
approveRequest(sessionId, operatorId, cameraType, isAutoApproved = true)
} else {
// Показать запрос пользователю
Log.d(TAG, "Auto approve is DISABLED - showing notification to user")
_pendingRequest.value = data
showRequestNotification(operatorId, cameraType)
}
}
/**
* Подтверждение запроса доступа к камере
*/
fun approveRequest(sessionId: String, operatorId: String, cameraType: String, isAutoApproved: Boolean = false) {
scope.launch {
try {
Log.d(TAG, "Approving camera request - Session: $sessionId")
// Валидация входных данных
if (sessionId.isEmpty() || operatorId.isEmpty()) {
Log.e(TAG, "Cannot approve request: invalid parameters (sessionId=$sessionId, operatorId=$operatorId)")
return@launch
}
// Проверяем, нет ли уже активной сессии с этим sessionId
if (_activeSessions.value.any { it.sessionId == sessionId }) {
Log.w(TAG, "Session $sessionId already exists, cannot approve duplicate")
return@launch
}
// Отправляем подтверждение на сервер
connectionManager.sendCameraResponse(sessionId, true, "", operatorId)
// Добавляем сессию в активные
val session = CameraSession(sessionId, operatorId, cameraType, isAutoApproved = isAutoApproved, deviceId = android.provider.Settings.Secure.getString(
context.contentResolver,
android.provider.Settings.Secure.ANDROID_ID
))
val currentSessions = _activeSessions.value.toMutableList()
currentSessions.add(session)
_activeSessions.value = currentSessions
// Запускаем стриминг через ConnectionManager (не через WebRTC напрямую)
connectionManager.startVideoStream(sessionId, operatorId, cameraType)
// Очищаем pending запрос
_pendingRequest.value = null
// Показываем уведомление
showActiveStreamingNotification(operatorId, cameraType)
Log.d(TAG, "Camera request approved and streaming started for session: $sessionId")
} catch (e: Exception) {
Log.e(TAG, "Failed to approve camera request for session: $sessionId", e)
}
}
}
/**
* Отклонение запроса доступа к камере
*/
fun denyRequest(sessionId: String) {
scope.launch {
try {
Log.d(TAG, "Denying camera request - Session: $sessionId")
// Проверяем, что sessionId не пустой
if (sessionId.isEmpty()) {
Log.e(TAG, "Cannot deny request: sessionId is empty")
return@launch
}
// Получаем информацию о запросе из pending request
val pendingRequestData = _pendingRequest.value
val operatorId = pendingRequestData?.optString("operatorId") ?: ""
// Отправляем отклонение на сервер
connectionManager.sendCameraResponse(sessionId, false, "User denied request", operatorId)
// Очищаем pending запрос
_pendingRequest.value = null
Log.d(TAG, "Camera request denied for session: $sessionId")
} catch (e: Exception) {
Log.e(TAG, "Failed to deny camera request for session: $sessionId", e)
}
}
}
/**
* Обработка отключения камеры
*/
private fun handleCameraDisconnect(data: JSONObject) {
val sessionId = data.optString("sessionId")
Log.d(TAG, "Disconnecting camera session: $sessionId")
// Остановка WebRTC стриминга через ConnectionManager
connectionManager.stopVideoStream(sessionId)
// Удаление сессии из активных
val currentSessions = _activeSessions.value.toMutableList()
currentSessions.removeAll { it.sessionId == sessionId }
_activeSessions.value = currentSessions
// Обновление уведомления
if (currentSessions.isEmpty()) {
notificationManager.cancel(NOTIFICATION_ID)
} else {
updateActiveStreamingNotification(currentSessions)
}
}
/**
* Создание канала уведомлений
*/
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID,
"Запросы доступа к камере",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Уведомления о запросах операторов на доступ к камере"
}
notificationManager.createNotificationChannel(channel)
}
}
/**
* Показать уведомление о запросе
*/
private fun showRequestNotification(operatorId: String, cameraType: String) {
val notification = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_camera_request)
.setContentTitle("Запрос доступа к камере")
.setContentText("Оператор $operatorId запрашивает доступ к ${getCameraDisplayName(cameraType)}")
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(false)
.setStyle(NotificationCompat.BigTextStyle()
.bigText("Оператор с UUID: $operatorId запрашивает доступ к ${getCameraDisplayName(cameraType)}"))
.build()
notificationManager.notify(NOTIFICATION_ID, notification)
}
/**
* Показать уведомление об активном стриминге
*/
private fun showActiveStreamingNotification(operatorId: String, cameraType: String) {
val notification = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_camera_active)
.setContentTitle("Камера активна")
.setContentText("Оператор $operatorId подключен к ${getCameraDisplayName(cameraType)}")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setOngoing(true)
.setStyle(NotificationCompat.BigTextStyle()
.bigText("Оператор с UUID: $operatorId подключен к ${getCameraDisplayName(cameraType)}"))
.build()
notificationManager.notify(NOTIFICATION_ID, notification)
}
private fun updateActiveStreamingNotification(sessions: List<CameraSession>) {
val operatorIds = sessions.map { it.operatorId }.distinct()
val operatorText = if (operatorIds.size == 1) {
"UUID: ${operatorIds.first()}"
} else {
"Операторы: ${operatorIds.size} подключено"
}
val notification = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_camera_active)
.setContentTitle("Камера активна")
.setContentText(operatorText)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setOngoing(true)
.setStyle(NotificationCompat.BigTextStyle()
.bigText("Активные операторы:\n${operatorIds.joinToString("\n") { "UUID: $it" }}"))
.build()
notificationManager.notify(NOTIFICATION_ID, notification)
}
private fun getCameraDisplayName(cameraType: String): String {
return when (cameraType) {
"back" -> "основной камере"
"front" -> "фронтальной камере"
"wide" -> "широкоугольной камере"
"telephoto" -> "телеобъективу"
else -> "камере"
}
}
/**
* Обработка автоматически одобренного запроса
*/
private fun handleAutoApprovedRequest(data: JSONObject) {
val sessionId = data.optString("sessionId")
val operatorId = data.optString("operatorId")
val cameraType = data.optString("cameraType", "back")
approveRequest(sessionId, operatorId, cameraType, isAutoApproved = true)
}
/**
* Принудительное отключение сессии
*/
fun disconnectSession(sessionId: String) {
scope.launch {
try {
Log.d(TAG, "Forcibly disconnecting session: $sessionId")
// Остановка видеопотока
connectionManager.stopVideoStream(sessionId)
// Удаление из активных сессий
val currentSessions = _activeSessions.value.toMutableList()
val removedSession = currentSessions.find { it.sessionId == sessionId }
currentSessions.removeAll { it.sessionId == sessionId }
_activeSessions.value = currentSessions
// Обновление уведомлений
if (currentSessions.isEmpty()) {
notificationManager.cancel(NOTIFICATION_ID)
} else {
updateActiveStreamingNotification(currentSessions)
}
Log.d(TAG, "Session $sessionId disconnected successfully")
} catch (e: Exception) {
Log.e(TAG, "Failed to disconnect session: $sessionId", e)
}
}
}
/**
* Отключение всех активных сессий
*/
fun disconnectAllSessions() {
scope.launch {
try {
Log.d(TAG, "Disconnecting all active sessions")
val currentSessions = _activeSessions.value.toList()
// Остановка всех видеопотоков
currentSessions.forEach { session ->
connectionManager.stopVideoStream(session.sessionId)
}
// Очистка активных сессий
_activeSessions.value = emptyList()
// Отмена уведомлений
notificationManager.cancel(NOTIFICATION_ID)
Log.d(TAG, "All sessions disconnected (${currentSessions.size} sessions)")
} catch (e: Exception) {
Log.e(TAG, "Failed to disconnect all sessions", e)
}
}
}
/**
* Получение информации о сессии
*/
fun getSessionInfo(sessionId: String): CameraSession? {
return _activeSessions.value.find { it.sessionId == sessionId }
}
/**
* Получение количества активных сессий
*/
fun getActiveSessionCount(): Int {
return _activeSessions.value.size
}
/**
* Проверка, есть ли активная сессия с данным оператором
*/
fun hasActiveSessionWithOperator(operatorId: String): Boolean {
return _activeSessions.value.any { it.operatorId == operatorId }
}
/**
* Очистка ресурсов
*/
fun cleanup() {
scope.cancel()
notificationManager.cancel(NOTIFICATION_ID)
// Очистка активных сессий при закрытии
_activeSessions.value = emptyList()
_pendingRequest.value = null
}
/**
* Обновление состояния потоковой передачи сессии
*/
fun updateSessionStreamingState(sessionId: String, state: StreamingState) {
scope.launch {
val currentSessions = _activeSessions.value.toMutableList()
val sessionIndex = currentSessions.indexOfFirst { it.sessionId == sessionId }
if (sessionIndex != -1) {
val updatedSession = currentSessions[sessionIndex].copy(
streamingState = state,
lastHeartbeat = System.currentTimeMillis()
)
currentSessions[sessionIndex] = updatedSession
_activeSessions.value = currentSessions
Log.d(TAG, "Updated session $sessionId state to $state")
// Обновляем общее состояние системы
updateSystemState()
}
}
}
/**
* Обновление общего состояния системы GodEye Signal Center
*/
private fun updateSystemState() {
val sessions = _activeSessions.value
val newState = when {
sessions.isEmpty() -> SystemState.READY
sessions.any { it.streamingState == StreamingState.ERROR } -> SystemState.ERROR
sessions.any { it.streamingState == StreamingState.STREAMING } -> SystemState.STREAMING_ACTIVE
sessions.all { it.streamingState == StreamingState.INITIALIZING || it.streamingState == StreamingState.CONNECTING } ->
SystemState.PROCESSING_REQUEST
else -> SystemState.READY
}
if (_systemState.value != newState) {
_systemState.value = newState
Log.d(TAG, "System state updated to: $newState")
}
}
/**
* Получение диагностической информации для архитектуры GodEye Signal Center
*/
fun getDiagnosticInfo(): JSONObject {
return JSONObject().apply {
put("systemState", _systemState.value.name)
put("activeSessions", _activeSessions.value.size)
put("autoApproveEnabled", preferenceManager.getAutoApprove())
put("timestamp", System.currentTimeMillis())
// Детальная информация о сессиях
val sessionsArray = org.json.JSONArray()
_activeSessions.value.forEach { session ->
val sessionInfo = JSONObject().apply {
put("sessionId", session.sessionId)
put("operatorId", session.operatorId)
put("cameraType", session.cameraType)
put("isAutoApproved", session.isAutoApproved)
put("streamingState", session.streamingState.name)
put("duration", System.currentTimeMillis() - session.startTime)
put("lastHeartbeat", session.lastHeartbeat)
put("deviceId", session.deviceId)
put("quality", session.quality)
}
sessionsArray.put(sessionInfo)
}
put("sessions", sessionsArray)
// Статистика для мониторинга
val stats = JSONObject().apply {
val streamingCount = _activeSessions.value.count { it.streamingState == StreamingState.STREAMING }
val errorCount = _activeSessions.value.count { it.streamingState == StreamingState.ERROR }
val connectingCount = _activeSessions.value.count { it.streamingState == StreamingState.CONNECTING }
put("streamingSessions", streamingCount)
put("errorSessions", errorCount)
put("connectingSessions", connectingCount)
put("autoApprovedSessions", _activeSessions.value.count { it.isAutoApproved })
}
put("statistics", stats)
}
}
/**
* Проверка "мертвых" сессий и их очистка
* Метод для поддержания надежности системы GodEye Signal Center
*/
fun performHealthCheck() {
scope.launch {
val currentTime = System.currentTimeMillis()
val staleThreshold = 30000L // 30 секунд без heartbeat
val currentSessions = _activeSessions.value.toMutableList()
val staleSessions = currentSessions.filter { session ->
currentTime - session.lastHeartbeat > staleThreshold &&
session.streamingState != StreamingState.STREAMING
}
if (staleSessions.isNotEmpty()) {
Log.w(TAG, "Found ${staleSessions.size} stale sessions, cleaning up...")
staleSessions.forEach { session ->
Log.w(TAG, "Removing stale session: ${session.sessionId}")
connectionManager.stopVideoStream(session.sessionId)
currentSessions.remove(session)
}
_activeSessions.value = currentSessions
updateSystemState()
// Обновляем уведомления
if (currentSessions.isEmpty()) {
notificationManager.cancel(NOTIFICATION_ID)
} else {
updateActiveStreamingNotification(currentSessions)
}
}
}
}
/**
* Отправка heartbeat для активных сессий
* Поддерживает мониторинг состояния в архитектуре GodEye Signal Center
*/
fun sendSessionHeartbeat(sessionId: String) {
scope.launch {
val currentSessions = _activeSessions.value.toMutableList()
val sessionIndex = currentSessions.indexOfFirst { it.sessionId == sessionId }
if (sessionIndex != -1) {
val updatedSession = currentSessions[sessionIndex].copy(
lastHeartbeat = System.currentTimeMillis()
)
currentSessions[sessionIndex] = updatedSession
_activeSessions.value = currentSessions
// Отправляем heartbeat на сервер через ConnectionManager
try {
val heartbeatData = JSONObject().apply {
put("sessionId", sessionId)
put("timestamp", System.currentTimeMillis())
put("streamingState", updatedSession.streamingState.name)
put("deviceId", updatedSession.deviceId)
}
// Используем методы ConnectionManager для отправки heartbeat
// Пока не создадим специальный метод, логируем для отладки
Log.d(TAG, "Sending session heartbeat for $sessionId: $heartbeatData")
} catch (e: Exception) {
Log.e(TAG, "Failed to send heartbeat for session $sessionId", e)
}
}
}
}
/**
* Получение метрик производительности для мониторинга GodEye Signal Center
*/
fun getPerformanceMetrics(): JSONObject {
val sessions = _activeSessions.value
val currentTime = System.currentTimeMillis()
return JSONObject().apply {
// Общие метрики
put("totalSessions", sessions.size)
put("systemUptime", currentTime - startTime)
put("averageSessionDuration", if (sessions.isNotEmpty()) {
sessions.map { currentTime - it.startTime }.average()
} else 0.0)
// Метрики по состоянию
StreamingState.values().forEach { state ->
put("sessions_${state.name.lowercase()}", sessions.count { it.streamingState == state })
}
// Метрики по типам камер
val cameraTypes = sessions.groupBy { it.cameraType }
cameraTypes.forEach { (type, typeSessions) ->
put("camera_${type}_sessions", typeSessions.size)
}
// Метрики автоодобрения
put("autoApprovedSessions", sessions.count { it.isAutoApproved })
put("manualApprovedSessions", sessions.count { !it.isAutoApproved })
put("timestamp", currentTime)
}
}
private val startTime = System.currentTimeMillis()
/**
* Генерация UUID для новой сессии
*/
fun generateSessionId(): String {
return java.util.UUID.randomUUID().toString()
}
/**
* Тестовая функция для проверки автоподтверждения с UUID
*/
fun testAutoApprovalWithUUID() {
try {
val testSessionId = generateSessionId()
val testOperatorId = java.util.UUID.randomUUID().toString()
Log.d(TAG, "Testing auto approval with UUID session ID: $testSessionId")
// Создаем фиктивный запрос камеры с UUID
val testRequest = JSONObject().apply {
put("sessionId", testSessionId)
put("operatorId", testOperatorId)
put("cameraType", "back")
put("timestamp", System.currentTimeMillis())
}
Log.d(TAG, "Simulating camera request with UUID: $testRequest")
// Обрабатываем запрос
handleCameraRequest(testRequest)
} catch (e: Exception) {
Log.e(TAG, "Failed to test auto approval with UUID", e)
}
}
}

View File

@@ -193,6 +193,7 @@ class Camera2Manager(private val context: Context) {
}
}
// Используем стандартный метод createCaptureSession вместо устаревшего
camera.createCaptureSession(listOf(surface), sessionCallback, null)
} catch (e: CameraAccessException) {

File diff suppressed because it is too large Load Diff

View File

@@ -34,10 +34,10 @@ class PermissionManager(private val context: Context) {
Manifest.permission.WAKE_LOCK,
Manifest.permission.FOREGROUND_SERVICE
).apply {
// Добавляем FOREGROUND_SERVICE_CAMERA для API 34+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
plus(Manifest.permission.FOREGROUND_SERVICE_CAMERA)
}
// Добавляем FOREGROUND_SERVICE_CAMERA для API 34+ (но в Android 10 недоступно)
// if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// plus(Manifest.permission.FOREGROUND_SERVICE_CAMERA)
// }
}
/**
@@ -193,7 +193,7 @@ class PermissionManager(private val context: Context) {
Manifest.permission.ACCESS_NETWORK_STATE -> "Проверка состояния сети"
Manifest.permission.WAKE_LOCK -> "Предотвращение засыпания устройства"
Manifest.permission.FOREGROUND_SERVICE -> "Работа в фоновом режиме"
Manifest.permission.FOREGROUND_SERVICE_CAMERA -> "Фоновая работа с камерой"
// Manifest.permission.FOREGROUND_SERVICE_CAMERA -> "Фоновая работа с камерой" // Недоступно в Android 10
else -> "Системное разрешение"
}
}

View File

@@ -1,156 +1,35 @@
package com.example.godeye.managers
import com.example.godeye.models.*
import com.example.godeye.utils.Logger
import com.example.godeye.models.CameraSession
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.ConcurrentHashMap
/**
* SessionManager - управление активными сессиями с операторами
* Отслеживает состояние WebRTC соединений и сессий камеры
*/
class SessionManager {
private val activeSessions = ConcurrentHashMap<String, CameraSession>()
private val _sessions = MutableStateFlow<Map<String, CameraSession>>(emptyMap())
val sessions: StateFlow<Map<String, CameraSession>> = _sessions.asStateFlow()
private val _activeSessionCount = MutableStateFlow(0)
val activeSessionCount: StateFlow<Int> = _activeSessionCount.asStateFlow()
/**
* Создание новой сессии при принятии запроса оператора
*/
fun createSession(sessionId: String, operatorId: String, cameraType: String): CameraSession {
Logger.step("SESSION_CREATE", "Creating session: $sessionId for operator $operatorId")
fun createSession(sessionId: String, operatorId: String, cameraType: String) {
val session = CameraSession(
sessionId = sessionId,
operatorId = operatorId,
cameraType = cameraType,
startTime = System.currentTimeMillis(),
isActive = true,
webRTCConnected = false
startTime = System.currentTimeMillis()
)
activeSessions[sessionId] = session
updateSessionsFlow()
Logger.step("SESSION_CREATED", "Session created: $sessionId")
return session
val currentSessions = _sessions.value.toMutableMap()
currentSessions[sessionId] = session
_sessions.value = currentSessions
}
/**
* Обновление статуса WebRTC соединения для сессии
*/
fun updateWebRTCConnection(sessionId: String, connected: Boolean) {
activeSessions[sessionId]?.let { session ->
session.webRTCConnected = connected
activeSessions[sessionId] = session
updateSessionsFlow()
Logger.step("SESSION_WEBRTC_UPDATED",
"Session $sessionId WebRTC status updated: $connected")
}
fun endSession(sessionId: String, reason: String = "Session ended") {
val currentSessions = _sessions.value.toMutableMap()
currentSessions.remove(sessionId)
_sessions.value = currentSessions
}
/**
* Завершение сессии
*/
fun endSession(sessionId: String, reason: String = "User ended") {
activeSessions[sessionId]?.let { session ->
session.isActive = false
activeSessions.remove(sessionId)
updateSessionsFlow()
Logger.step("SESSION_ENDED", "Session ended: $sessionId, reason: $reason")
}
}
/**
* Получение активной сессии по ID
*/
fun getSession(sessionId: String): CameraSession? {
return activeSessions[sessionId]
}
/**
* Получение всех активных сессий
*/
fun getAllActiveSessions(): List<CameraSession> {
return activeSessions.values.filter { it.isActive }
}
/**
* Проверка, есть ли активные сессии
*/
fun hasActiveSessions(): Boolean {
return activeSessions.values.any { it.isActive }
}
/**
* Завершение всех активных сессий
*/
fun endAllSessions(reason: String = "Service stopped") {
Logger.step("SESSION_END_ALL", "Ending all active sessions: $reason")
activeSessions.values.forEach { session ->
if (session.isActive) {
session.isActive = false
Logger.step("SESSION_ENDED", "Session ended: ${session.sessionId}")
}
}
activeSessions.clear()
updateSessionsFlow()
}
/**
* Переключение камеры для сессии
*/
fun switchCamera(sessionId: String, newCameraType: String) {
activeSessions[sessionId]?.let { session ->
session.cameraType = newCameraType
activeSessions[sessionId] = session
updateSessionsFlow()
Logger.step("SESSION_CAMERA_SWITCHED",
"Session $sessionId camera switched to: $newCameraType")
}
}
/**
* Получение статистики сессий
*/
fun getSessionStats(): SessionStats {
val active = activeSessions.values.filter { it.isActive }
val withWebRTC = active.filter { it.webRTCConnected }
return SessionStats(
totalActive = active.size,
webRTCConnected = withWebRTC.size,
operators = active.map { it.operatorId }.distinct().size,
averageDuration = if (active.isNotEmpty()) {
active.map { System.currentTimeMillis() - it.startTime }.average().toLong()
} else 0L
)
}
private fun updateSessionsFlow() {
_sessions.value = activeSessions.toMap()
_activeSessionCount.value = activeSessions.values.count { it.isActive }
fun endAllSessions(reason: String = "All sessions ended") {
_sessions.value = emptyMap()
}
}
/**
* Статистика сессий
*/
data class SessionStats(
val totalActive: Int,
val webRTCConnected: Int,
val operators: Int,
val averageDuration: Long
)

View File

@@ -0,0 +1,269 @@
package com.example.godeye.managers
import android.content.Context
import android.provider.Settings
import android.util.Log
import com.example.godeye.models.*
import com.example.godeye.signaling.SignalingClient
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
/**
* Менеджер сигналлинга для управления подключениями к операторам
*/
class SignalingManager(
private val context: Context
) {
companion object {
private const val TAG = "SignalingManager"
}
private val signalingClient = SignalingClient()
private var managerScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
// Состояния
val signalingState: StateFlow<SignalingState> = signalingClient.signalingState
val sessionState: StateFlow<SessionState> = signalingClient.sessionState
val currentCall: StateFlow<IncomingCall?> = signalingClient.currentCall
// Callbacks для WebRTC интеграции
private var webRTCManager: Any? = null // Будет связан с WebRTCManager
/**
* Запуск сигналлинга
*/
fun start(serverUrl: String? = null) {
Log.d(TAG, "Starting signaling manager")
managerScope.launch {
try {
val deviceInfo = createDeviceInfo()
val url = serverUrl ?: getDefaultServerUrl()
Log.d(TAG, "Connecting to signaling server: $url")
signalingClient.connect(url, deviceInfo)
// Настраиваем обработчики WebRTC сигналлинга
setupWebRTCHandlers()
} catch (e: Exception) {
Log.e(TAG, "Failed to start signaling", e)
}
}
}
/**
* Остановка сигналлинга
*/
fun stop() {
Log.d(TAG, "Stopping signaling manager")
signalingClient.disconnect()
managerScope.cancel()
managerScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
}
/**
* Принятие входящего вызова
*/
fun acceptIncomingCall() {
Log.d(TAG, "Accepting incoming call")
signalingClient.acceptCall()
}
/**
* Отклонение входящего вызова
*/
fun rejectIncomingCall() {
Log.d(TAG, "Rejecting incoming call")
signalingClient.rejectCall()
}
/**
* Завершение активного вызова
*/
fun endActiveCall() {
Log.d(TAG, "Ending active call")
signalingClient.endCall()
}
/**
* Создание информации об устройстве
*/
private fun createDeviceInfo(): DeviceInfo {
return DeviceInfo(
deviceId = getDeviceId(),
deviceName = android.os.Build.MODEL,
androidVersion = android.os.Build.VERSION.RELEASE,
appVersion = "1.0"
)
}
/**
* Получение уникального ID устройства
*/
private fun getDeviceId(): String {
return try {
Settings.Secure.getString(
context.contentResolver,
Settings.Secure.ANDROID_ID
) ?: "unknown_device_${System.currentTimeMillis()}"
} catch (e: Exception) {
"unknown_device_${System.currentTimeMillis()}"
}
}
/**
* Получение имени устройства
*/
private fun getDeviceName(): String {
return try {
"${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}"
} catch (e: Exception) {
"Android Device"
}
}
/**
* Получение URL сервера по умолчанию
*/
private fun getDefaultServerUrl(): String {
// ИСПРАВЛЕНИЕ: Обновляем IP по умолчанию на ваш сервер
return "ws://192.168.219.108:3000"
}
/**
* Настройка обработчиков WebRTC сигналлинга
*/
private fun setupWebRTCHandlers() {
signalingClient.setOnOfferReceived { offer ->
Log.d(TAG, "Received WebRTC offer")
// TODO: Передать offer в WebRTCManager
handleWebRTCOffer(offer)
}
signalingClient.setOnAnswerReceived { answer ->
Log.d(TAG, "Received WebRTC answer")
// TODO: Передать answer в WebRTCManager
handleWebRTCAnswer(answer)
}
signalingClient.setOnIceCandidateReceived { candidate ->
Log.d(TAG, "Received ICE candidate")
// TODO: Передать candidate в WebRTCManager
handleICECandidate(candidate)
}
signalingClient.setOnCallEnded {
Log.d(TAG, "Call ended by operator")
handleCallEnded()
}
}
/**
* Обработка WebRTC Offer
*/
private fun handleWebRTCOffer(offer: RTCSessionDescription) {
managerScope.launch {
try {
// TODO: Интеграция с WebRTCManager
Log.d(TAG, "Processing WebRTC offer: ${offer.type}")
// Здесь должна быть логика создания answer и его отправки
// val answer = webRTCManager.createAnswer(offer)
// signalingClient.sendAnswer(answer, currentCall.value?.operatorId ?: "")
} catch (e: Exception) {
Log.e(TAG, "Error processing WebRTC offer", e)
}
}
}
/**
* Обработка WebRTC Answer
*/
private fun handleWebRTCAnswer(answer: RTCSessionDescription) {
managerScope.launch {
try {
// TODO: Интеграция с WebRTCManager
Log.d(TAG, "Processing WebRTC answer: ${answer.type}")
// webRTCManager.setRemoteDescription(answer)
} catch (e: Exception) {
Log.e(TAG, "Error processing WebRTC answer", e)
}
}
}
/**
* Обработка ICE Candidate
*/
private fun handleICECandidate(candidate: RTCIceCandidate) {
managerScope.launch {
try {
// TODO: Интеграция с WebRTCManager
Log.d(TAG, "Processing ICE candidate")
// webRTCManager.addIceCandidate(candidate)
} catch (e: Exception) {
Log.e(TAG, "Error processing ICE candidate", e)
}
}
}
/**
* Обработка завершения вызова
*/
private fun handleCallEnded() {
managerScope.launch {
try {
// TODO: Очистка WebRTC соединения
Log.d(TAG, "Cleaning up after call end")
// webRTCManager.cleanup()
} catch (e: Exception) {
Log.e(TAG, "Error cleaning up after call end", e)
}
}
}
/**
* Отправка WebRTC Offer (для исходящих вызовов)
*/
fun sendOffer(offer: RTCSessionDescription) {
val operatorId = currentCall.value?.operatorId ?: return
signalingClient.sendOffer(offer, operatorId)
}
/**
* Отправка WebRTC Answer
*/
fun sendAnswer(answer: RTCSessionDescription) {
val operatorId = currentCall.value?.operatorId ?: return
signalingClient.sendAnswer(answer, operatorId)
}
/**
* Отправка ICE Candidate
*/
fun sendIceCandidate(candidate: RTCIceCandidate) {
val operatorId = currentCall.value?.operatorId ?: return
signalingClient.sendIceCandidate(candidate, operatorId)
}
/**
* Получение информации о текущем состоянии
*/
fun getConnectionInfo(): Map<String, Any> {
return mapOf(
"signalingState" to signalingState.value.name,
"sessionState" to sessionState.value.name,
"hasActiveCall" to (currentCall.value != null),
"operatorId" to (currentCall.value?.operatorId ?: "none"),
"deviceId" to getDeviceId()
)
}
}

View File

@@ -0,0 +1,308 @@
package com.example.godeye.managers
import android.content.Context
import com.example.godeye.utils.Logger
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import io.socket.client.IO
import io.socket.client.Socket
import org.json.JSONObject
import java.net.URI
/**
* Менеджер Socket.IO соединения с сервером
*/
class SocketManager(private val context: Context) {
companion object {
private const val TAG = "SocketManager"
}
// События подключения
sealed class ConnectionEvent {
object Connected : ConnectionEvent()
object Disconnected : ConnectionEvent()
data class Error(val message: String) : ConnectionEvent()
data class CameraRequest(val sessionId: String, val operatorId: String, val message: String) : ConnectionEvent()
}
private val _events = MutableStateFlow<ConnectionEvent?>(null)
val events: Flow<ConnectionEvent?> = _events
private var socket: Socket? = null
private var webRTCManager: WebRTCManager? = null
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
// ИСПРАВЛЕНИЕ: Добавляем флаг для предотвращения множественной регистрации
private var isDeviceRegistered = false
private var currentDeviceId: String? = null
/**
* Подключение к серверу
*/
fun connect(serverUrl: String, webRTCManager: WebRTCManager) {
try {
this.webRTCManager = webRTCManager
Logger.step("SOCKET_CONNECTING", "Connecting to server: $serverUrl")
val options = IO.Options().apply {
forceNew = true
reconnection = true
timeout = 10000
}
socket = IO.socket(URI.create(serverUrl), options)
setupConnectionHandlers()
setupCameraHandlers()
setupWebRTCHandlers()
setupWebRTCCallbacks()
socket?.connect()
} catch (e: Exception) {
Logger.error("SOCKET_CONNECT_ERROR", "Failed to connect to server", e)
_events.value = ConnectionEvent.Error("Ошибка подключения: ${e.message}")
}
}
/**
* Настройка основных обработчиков подключения
*/
private fun setupConnectionHandlers() {
socket?.on(Socket.EVENT_CONNECT) {
Logger.step("SOCKET_CONNECTED", "Connected to server")
_events.value = ConnectionEvent.Connected
}
socket?.on(Socket.EVENT_DISCONNECT) {
Logger.step("SOCKET_DISCONNECTED", "Disconnected from server")
_events.value = ConnectionEvent.Disconnected
}
socket?.on(Socket.EVENT_CONNECT_ERROR) { args ->
val error = if (args.isNotEmpty()) args[0].toString() else "Unknown error"
Logger.error("SOCKET_CONNECT_ERROR", "Connection error: $error", null)
_events.value = ConnectionEvent.Error("Ошибка подключения: $error")
}
}
/**
* Настройка обработчиков камеры
*/
private fun setupCameraHandlers() {
socket?.on("camera_request") { args ->
try {
val data = args[0] as JSONObject
val sessionId = data.getString("sessionId")
val operatorId = data.getString("operatorId")
val message = data.getString("message")
Logger.step("SOCKET_CAMERA_REQUEST", "Camera request received from operator: $operatorId")
_events.value = ConnectionEvent.CameraRequest(sessionId, operatorId, message)
} catch (e: Exception) {
Logger.error("SOCKET_CAMERA_REQUEST_ERROR", "Failed to handle camera request", e)
}
}
socket?.on("stop_camera") { args ->
try {
val data = args[0] as JSONObject
val sessionId = data.getString("sessionId")
Logger.step("SOCKET_STOP_CAMERA", "Stop camera request for session: $sessionId")
webRTCManager?.stopStreaming(sessionId)
} catch (e: Exception) {
Logger.error("SOCKET_STOP_CAMERA_ERROR", "Failed to handle stop camera", e)
}
}
socket?.on("switch_camera") { args ->
try {
val data = args[0] as JSONObject
val sessionId = data.getString("sessionId")
Logger.step("SOCKET_SWITCH_CAMERA", "Switch camera request for session: $sessionId")
webRTCManager?.switchCamera()
} catch (e: Exception) {
Logger.error("SOCKET_SWITCH_CAMERA_ERROR", "Failed to handle switch camera", e)
}
}
}
/**
* Настройка обработчиков WebRTC сигналов
*/
private fun setupWebRTCHandlers() {
socket?.on("webrtc_answer") { args ->
try {
val data = args[0] as JSONObject
val sessionId = data.getString("sessionId")
val answerSdp = data.getString("answer")
Logger.step("SOCKET_WEBRTC_ANSWER", "Received WebRTC answer for session: $sessionId")
webRTCManager?.handleAnswer(sessionId, answerSdp)
} catch (e: Exception) {
Logger.error("SOCKET_WEBRTC_ANSWER_ERROR", "Failed to handle WebRTC answer", e)
}
}
socket?.on("webrtc_ice_candidate") { args ->
try {
val data = args[0] as JSONObject
val sessionId = data.getString("sessionId")
val candidateJson = data.getString("candidate")
Logger.step("SOCKET_WEBRTC_ICE", "Received ICE candidate for session: $sessionId")
webRTCManager?.handleIceCandidate(sessionId, candidateJson)
} catch (e: Exception) {
Logger.error("SOCKET_WEBRTC_ICE_ERROR", "Failed to handle ICE candidate", e)
}
}
}
/**
* Настройка колбэков WebRTC для отправки сигналов на сервер
*/
private fun setupWebRTCCallbacks() {
webRTCManager?.onOfferCreated = { sessionId, offer ->
scope.launch {
try {
// ИСПРАВЛЕНО: Отправляем offer в правильном формате для вашего сервера
val offerData = JSONObject().apply {
put("sessionId", sessionId)
put("offer", offer) // Отправляем как строку SDP, не JSON объект
}
socket?.emit("webrtc_offer", offerData)
Logger.step("SOCKET_WEBRTC_OFFER_SENT", "WebRTC offer sent for session: $sessionId")
} catch (e: Exception) {
Logger.error("SOCKET_WEBRTC_OFFER_ERROR", "Failed to send WebRTC offer", e)
}
}
}
webRTCManager?.onIceCandidateCreated = { sessionId, candidate ->
scope.launch {
try {
val candidateData = JSONObject().apply {
put("sessionId", sessionId)
put("candidate", candidate)
}
socket?.emit("webrtc_ice_candidate", candidateData)
Logger.step("SOCKET_ICE_SENT", "ICE candidate sent for session: $sessionId")
} catch (e: Exception) {
Logger.error("SOCKET_ICE_ERROR", "Failed to send ICE candidate", e)
}
}
}
}
/**
* Регистрация устройства на сервере
*/
fun registerDevice(deviceId: String, deviceName: String) {
try {
// ИСПРАВЛЕНИЕ: Проверяем, зарегистрировано ли уже устройство
if (isDeviceRegistered && deviceId == currentDeviceId) {
Logger.step("SOCKET_DEVICE_ALREADY_REGISTERED", "Device is already registered: $deviceName ($deviceId)")
return
}
val data = JSONObject().apply {
put("deviceId", deviceId)
put("deviceName", deviceName)
}
socket?.emit("device_register", data)
Logger.step("SOCKET_DEVICE_REGISTER", "Device registration sent: $deviceName ($deviceId)")
// ИСПРАВЛЕНИЕ: Устанавливаем флаг регистрации устройства
isDeviceRegistered = true
currentDeviceId = deviceId
} catch (e: Exception) {
Logger.error("SOCKET_REGISTER_ERROR", "Failed to register device", e)
}
}
/**
* Подтверждение доступа к камере
*/
fun approveCameraAccess(sessionId: String) {
try {
val data = JSONObject().apply {
put("sessionId", sessionId)
put("approved", true)
}
socket?.emit("camera_approved", data)
Logger.step("SOCKET_CAMERA_APPROVED", "Camera access approved for session: $sessionId")
} catch (e: Exception) {
Logger.error("SOCKET_APPROVE_ERROR", "Failed to approve camera access", e)
}
}
/**
* Уведомление о запуске камеры
*/
fun notifyCameraStarted(sessionId: String) {
try {
val data = JSONObject().apply {
put("sessionId", sessionId)
}
socket?.emit("camera_started", data)
Logger.step("SOCKET_CAMERA_STARTED", "Camera started notification sent for session: $sessionId")
} catch (e: Exception) {
Logger.error("SOCKET_CAMERA_STARTED_ERROR", "Failed to send camera started notification", e)
}
}
/**
* Отключение от сервера
*/
fun disconnect() {
try {
Logger.step("SOCKET_DISCONNECTING", "Disconnecting from server")
socket?.disconnect()
socket = null
} catch (e: Exception) {
Logger.error("SOCKET_DISCONNECT_ERROR", "Error during disconnect", e)
}
}
/**
* Проверка состояния подключения
*/
fun isConnected(): Boolean {
return socket?.connected() == true
}
/**
* Освобождение ресурсов
*/
fun dispose() {
try {
disconnect()
scope.cancel()
Logger.step("SOCKET_DISPOSED", "SocketManager disposed")
} catch (e: Exception) {
Logger.error("SOCKET_DISPOSE_ERROR", "Error during dispose", e)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,270 @@
package com.example.godeye.managers
import com.example.godeye.utils.Logger
import kotlinx.coroutines.*
import okhttp3.*
import org.json.JSONObject
import java.util.concurrent.TimeUnit
/**
* Менеджер WebSocket соединений для сигналинга WebRTC
*/
class WebSocketManager {
companion object {
private const val TAG = "WebSocketManager"
private const val PING_INTERVAL = 30L // секунд
private const val RECONNECT_INTERVAL = 5L // секунд
private const val MAX_RECONNECT_ATTEMPTS = 5
}
private var webSocket: WebSocket? = null
private var client: OkHttpClient? = null
private var isConnected = false
private var reconnectAttempts = 0
private var shouldReconnect = true
private var currentUrl: String? = null
// Колбэки
var onMessageReceived: ((String) -> Unit)? = null
var onConnectionStateChanged: ((Boolean) -> Unit)? = null
var onError: ((String) -> Unit)? = null
// Корутины
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var reconnectJob: Job? = null
init {
initializeClient()
}
/**
* Инициализация HTTP клиента
*/
private fun initializeClient() {
client = OkHttpClient.Builder()
.pingInterval(PING_INTERVAL, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
}
/**
* Подключение к WebSocket серверу
*/
fun connect(url: String) {
try {
Logger.step("WEBSOCKET_CONNECT", "Connecting to WebSocket: $url")
currentUrl = url
shouldReconnect = true
reconnectAttempts = 0
disconnect() // Отключаемся от предыдущего соединения
val request = Request.Builder()
.url(url)
.build()
webSocket = client?.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Logger.step("WEBSOCKET_OPENED", "WebSocket connection opened")
isConnected = true
reconnectAttempts = 0
scope.launch(Dispatchers.Main) {
onConnectionStateChanged?.invoke(true)
}
// Отправляем приветственное сообщение
sendConnectionMessage()
}
override fun onMessage(webSocket: WebSocket, text: String) {
Logger.step("WEBSOCKET_MESSAGE", "Message received: ${text.take(100)}...")
scope.launch(Dispatchers.Main) {
onMessageReceived?.invoke(text)
}
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
Logger.step("WEBSOCKET_CLOSING", "WebSocket closing: $code - $reason")
isConnected = false
scope.launch(Dispatchers.Main) {
onConnectionStateChanged?.invoke(false)
}
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Logger.step("WEBSOCKET_CLOSED", "WebSocket closed: $code - $reason")
isConnected = false
scope.launch(Dispatchers.Main) {
onConnectionStateChanged?.invoke(false)
}
// Попытка переподключения
if (shouldReconnect) {
scheduleReconnect()
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Logger.error("WEBSOCKET_ERROR", "WebSocket error: ${t.message}", t)
isConnected = false
scope.launch(Dispatchers.Main) {
onConnectionStateChanged?.invoke(false)
onError?.invoke("WebSocket ошибка: ${t.message}")
}
// Попытка переподключения
if (shouldReconnect) {
scheduleReconnect()
}
}
})
} catch (e: Exception) {
Logger.error("WEBSOCKET_CONNECT_ERROR", "Failed to connect to WebSocket", e)
scope.launch(Dispatchers.Main) {
onError?.invoke("Ошибка подключения: ${e.message}")
}
}
}
/**
* Отправка сообщения
*/
fun sendMessage(message: String): Boolean {
return try {
if (isConnected && webSocket != null) {
val success = webSocket!!.send(message)
if (success) {
Logger.step("WEBSOCKET_SEND", "Message sent: ${message.take(100)}...")
} else {
Logger.error("WEBSOCKET_SEND_ERROR", "Failed to send message", null)
}
success
} else {
Logger.error("WEBSOCKET_NOT_CONNECTED", "WebSocket not connected", null)
false
}
} catch (e: Exception) {
Logger.error("WEBSOCKET_SEND_EXCEPTION", "Exception while sending message", e)
false
}
}
/**
* Отправка JSON сообщения
*/
fun sendJsonMessage(json: JSONObject): Boolean {
return sendMessage(json.toString())
}
/**
* Отправка приветственного сообщения
*/
private fun sendConnectionMessage() {
try {
val connectionMessage = JSONObject().apply {
put("type", "device_connected")
put("deviceType", "android")
put("timestamp", System.currentTimeMillis())
put("capabilities", JSONObject().apply {
put("video", true)
put("audio", true)
put("webrtc", true)
})
}
sendJsonMessage(connectionMessage)
} catch (e: Exception) {
Logger.error("WEBSOCKET_CONNECTION_MESSAGE_ERROR", "Failed to send connection message", e)
}
}
/**
* Планирование переподключения
*/
private fun scheduleReconnect() {
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
Logger.error("WEBSOCKET_MAX_RECONNECT", "Maximum reconnection attempts reached", null)
scope.launch(Dispatchers.Main) {
onError?.invoke("Превышено максимальное количество попыток переподключения")
}
return
}
reconnectJob?.cancel()
reconnectJob = scope.launch {
delay(RECONNECT_INTERVAL * 1000)
if (shouldReconnect && !isConnected) {
reconnectAttempts++
Logger.step("WEBSOCKET_RECONNECT", "Reconnection attempt $reconnectAttempts/$MAX_RECONNECT_ATTEMPTS")
currentUrl?.let { url ->
connect(url)
}
}
}
}
/**
* Отключение от WebSocket
*/
fun disconnect() {
try {
Logger.step("WEBSOCKET_DISCONNECT", "Disconnecting WebSocket")
shouldReconnect = false
reconnectJob?.cancel()
webSocket?.close(1000, "Client disconnect")
webSocket = null
isConnected = false
scope.launch(Dispatchers.Main) {
onConnectionStateChanged?.invoke(false)
}
} catch (e: Exception) {
Logger.error("WEBSOCKET_DISCONNECT_ERROR", "Failed to disconnect WebSocket", e)
}
}
/**
* Проверка состояния соединения
*/
fun isConnected(): Boolean {
return isConnected
}
/**
* Получение URL текущего соединения
*/
fun getCurrentUrl(): String? {
return currentUrl
}
/**
* Освобождение ресурсов
*/
fun dispose() {
try {
Logger.step("WEBSOCKET_DISPOSE", "Disposing WebSocket manager")
disconnect()
scope.cancel()
Logger.step("WEBSOCKET_DISPOSED", "WebSocket manager disposed")
} catch (e: Exception) {
Logger.error("WEBSOCKET_DISPOSE_ERROR", "Failed to dispose WebSocket manager", e)
}
}
}

View File

@@ -0,0 +1,15 @@
package com.example.godeye.models
/**
* Модель информации об устройстве
*/
data class DeviceInfo(
val deviceId: String,
val deviceName: String,
val androidVersion: String,
val appVersion: String,
val availableCameras: List<String> = listOf("back", "front"),
// Добавляем устаревшие поля для совместимости со старым кодом
val model: String = deviceName,
val manufacturer: String = android.os.Build.MANUFACTURER
)

View File

@@ -53,3 +53,5 @@ object SocketEvents {
const val HEARTBEAT_ACK = "heartbeat:ack"
const val ERROR = "error"
}
// DeviceInfo перенесен в отдельный файл DeviceInfo.kt

View File

@@ -0,0 +1,74 @@
package com.example.godeye.models
/**
* Состояния подключения к сигналлинг серверу
*/
enum class SignalingState {
DISCONNECTED,
CONNECTING,
CONNECTED,
RECONNECTING,
ERROR
}
/**
* Состояния сессии с оператором
*/
enum class SessionState {
WAITING, // Ожидание подключения оператора
INCOMING, // Входящий вызов от оператора
ACTIVE, // Активная сессия
ENDED // Сессия завершена
}
/**
* Типы сообщений сигналлинга
*/
enum class SignalingMessageType {
DEVICE_REGISTER,
OPERATOR_CALL,
CALL_ACCEPT,
CALL_REJECT,
OFFER,
ANSWER,
ICE_CANDIDATE,
CALL_END
}
/**
* Базовое сообщение сигналлинга
*/
data class SignalingMessage(
val type: SignalingMessageType,
val from: String? = null,
val to: String? = null,
val data: Any? = null,
val timestamp: Long = System.currentTimeMillis()
)
/**
* Данные входящего вызова
*/
data class IncomingCall(
val operatorId: String,
val operatorName: String,
val callId: String,
val timestamp: Long
)
/**
* WebRTC Offer/Answer данные
*/
data class RTCSessionDescription(
val type: String, // "offer" или "answer"
val sdp: String
)
/**
* ICE Candidate данные
*/
data class RTCIceCandidate(
val candidate: String,
val sdpMid: String?,
val sdpMLineIndex: Int?
)

View File

@@ -1,16 +1,5 @@
package com.example.godeye.models
/**
* Информация об Android устройстве для регистрации на сервере
* Соответствует требованиям ТЗ для Socket.IO регистрации
*/
data class DeviceInfo(
val model: String,
val androidVersion: String,
val appVersion: String,
val availableCameras: List<String>
)
/**
* Активная сессия камеры с оператором
* Соответствует требованиям ТЗ для управления WebRTC сессиями

View File

@@ -0,0 +1,46 @@
package com.example.godeye.models
/**
* Модель данных для статистики видео трансляции
*/
data class VideoStatistics(
val fps: Int = 0,
val width: Int = 0,
val height: Int = 0,
val bitrate: Long = 0,
val bytesSent: Long = 0,
val packetsLost: Int = 0,
val jitter: Double = 0.0,
val rtt: Long = 0,
val framerate: Int = 0 // ДОБАВЛЕНО: для совместимости
) {
val resolution: String
get() = "${width}x${height}"
val qualityLevel: VideoQuality
get() = when {
width >= 1920 -> VideoQuality.FULL_HD
width >= 1280 -> VideoQuality.HD
width >= 854 -> VideoQuality.SD
else -> VideoQuality.LOW
}
}
enum class VideoQuality {
LOW, SD, HD, FULL_HD
}
/**
* Состояние трансляции
*/
data class StreamingState(
val isStreaming: Boolean = false,
val sessionId: String? = null,
val operatorId: String? = null,
val startTime: Long = 0,
val cameraType: String = "back",
val statistics: VideoStatistics = VideoStatistics()
) {
val duration: Long
get() = if (isStreaming) System.currentTimeMillis() - startTime else 0
}

View File

@@ -0,0 +1,310 @@
package com.example.godeye.network
import android.util.Log
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import okhttp3.*
import org.json.JSONObject
import java.util.concurrent.TimeUnit
/**
* WebSocket клиент для подключения к серверу сигналинга
*/
class SignalingClient {
companion object {
private const val TAG = "SignalingClient"
private const val DEFAULT_SERVER_URL = "ws://192.168.219.108:3001" // Замените на IP вашего компьютера
private const val RECONNECT_DELAY = 5000L // 5 секунд
}
private var webSocket: WebSocket? = null
private var client: OkHttpClient? = null
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// Состояние подключения
private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
// ID текущего клиента и сессии
private var clientId: String? = null
private var sessionId: String? = null
// Callback для обработки сообщений
var onMessageReceived: ((JSONObject) -> Unit)? = null
var onSessionCreated: ((String) -> Unit)? = null
var onOperatorJoined: (() -> Unit)? = null
enum class ConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED,
ERROR
}
/**
* Подключение к серверу сигналинга
*/
fun connect(serverUrl: String = DEFAULT_SERVER_URL) {
if (_connectionState.value == ConnectionState.CONNECTING ||
_connectionState.value == ConnectionState.CONNECTED) {
Log.w(TAG, "Уже подключен или подключается")
return
}
Log.i(TAG, "🔗 Подключение к серверу сигналинга: $serverUrl")
_connectionState.value = ConnectionState.CONNECTING
try {
client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS) // Бесконечное время ожидания для WebSocket
.build()
val request = Request.Builder()
.url(serverUrl)
.build()
webSocket = client?.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.i(TAG, "✅ WebSocket подключен")
_connectionState.value = ConnectionState.CONNECTED
}
override fun onMessage(webSocket: WebSocket, text: String) {
Log.d(TAG, "📨 Получено сообщение: $text")
handleMessage(text)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.i(TAG, "🔌 WebSocket закрыт: $code - $reason")
_connectionState.value = ConnectionState.DISCONNECTED
scheduleReconnect()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.e(TAG, "❌ Ошибка WebSocket: ${t.message}", t)
_connectionState.value = ConnectionState.ERROR
scheduleReconnect()
}
})
} catch (e: Exception) {
Log.e(TAG, "❌ Ошибка создания WebSocket: ${e.message}", e)
_connectionState.value = ConnectionState.ERROR
}
}
/**
* Отключение от сервера
*/
fun disconnect() {
Log.i(TAG, "🔌 Отключение от сервера сигналинга")
scope.coroutineContext.cancelChildren()
webSocket?.close(1000, "Пользователь отключился")
webSocket = null
client = null
clientId = null
sessionId = null
_connectionState.value = ConnectionState.DISCONNECTED
}
/**
* Создание новой сессии (устройство)
*/
fun createSession(deviceInfo: JSONObject) {
if (_connectionState.value != ConnectionState.CONNECTED) {
Log.w(TAG, "❌ Нет подключения к серверу")
return
}
Log.i(TAG, "📱 Создание сессии...")
val message = JSONObject().apply {
put("type", "create_session")
put("device_info", deviceInfo)
}
sendMessage(message)
}
/**
* Отправка SDP offer
*/
fun sendOffer(sdp: String) {
if (sessionId == null) {
Log.w(TAG, "❌ Нет активной сессии")
return
}
Log.i(TAG, "📞 Отправка SDP Offer")
val message = JSONObject().apply {
put("type", "offer")
put("session_id", sessionId)
put("sdp", JSONObject().apply {
put("type", "offer")
put("sdp", sdp)
})
}
sendMessage(message)
}
/**
* Отправка ICE кандидата
*/
fun sendIceCandidate(candidate: String, sdpMid: String, sdpMLineIndex: Int) {
if (sessionId == null) {
Log.w(TAG, "❌ Нет активной сессии")
return
}
Log.d(TAG, "🧊 Отправка ICE candidate")
val message = JSONObject().apply {
put("type", "ice_candidate")
put("session_id", sessionId)
put("candidate", JSONObject().apply {
put("candidate", candidate)
put("sdpMid", sdpMid)
put("sdpMLineIndex", sdpMLineIndex)
})
}
sendMessage(message)
}
/**
* Завершение сессии
*/
fun hangup() {
if (sessionId == null) {
Log.w(TAG, "❌ Нет активной сессии")
return
}
Log.i(TAG, "📴 Завершение сессии")
val message = JSONObject().apply {
put("type", "hangup")
put("session_id", sessionId)
}
sendMessage(message)
sessionId = null
}
/**
* Отправка сообщения через WebSocket
*/
private fun sendMessage(message: JSONObject) {
if (_connectionState.value != ConnectionState.CONNECTED) {
Log.w(TAG, "❌ Нет подключения для отправки сообщения")
return
}
val messageText = message.toString()
Log.d(TAG, "📤 Отправка сообщения: $messageText")
webSocket?.send(messageText)
}
/**
* Обработка входящих сообщений
*/
private fun handleMessage(messageText: String) {
try {
val message = JSONObject(messageText)
val type = message.optString("type")
Log.d(TAG, "🔍 Обработка сообщения типа: $type")
when (type) {
"client_registered" -> {
clientId = message.optString("client_id")
Log.i(TAG, "🆔 Зарегистрирован как клиент: $clientId")
}
"session_created" -> {
sessionId = message.optString("session_id")
Log.i(TAG, "✅ Сессия создана: $sessionId")
onSessionCreated?.invoke(sessionId!!)
}
"operator_joined" -> {
Log.i(TAG, "👤 Оператор подключился")
onOperatorJoined?.invoke()
}
"answer" -> {
Log.i(TAG, "📞 Получен SDP Answer")
onMessageReceived?.invoke(message)
}
"ice_candidate" -> {
Log.d(TAG, "🧊 Получен ICE candidate")
onMessageReceived?.invoke(message)
}
"hangup" -> {
Log.i(TAG, "📴 Сессия завершена оператором")
sessionId = null
onMessageReceived?.invoke(message)
}
"error" -> {
val errorMessage = message.optString("message", "Неизвестная ошибка")
Log.e(TAG, "❌ Ошибка сервера: $errorMessage")
}
else -> {
Log.w(TAG, "⚠️ Неизвестный тип сообщения: $type")
}
}
} catch (e: Exception) {
Log.e(TAG, "❌ Ошибка парсинга сообщения: ${e.message}", e)
}
}
/**
* Планирование переподключения
*/
private fun scheduleReconnect() {
if (_connectionState.value == ConnectionState.DISCONNECTED) {
return // Не переподключаемся если отключились вручную
}
Log.i(TAG, "🔄 Планирование переподключения через ${RECONNECT_DELAY}мс")
scope.launch {
delay(RECONNECT_DELAY)
if (_connectionState.value == ConnectionState.ERROR) {
Log.i(TAG, "🔄 Попытка переподключения...")
connect()
}
}
}
/**
* Получение информации о текущей сессии
*/
fun getCurrentSessionId(): String? = sessionId
/**
* Проверка активности сессии
*/
fun hasActiveSession(): Boolean = sessionId != null
/**
* Освобождение ресурсов
*/
fun destroy() {
disconnect()
scope.cancel()
}
}

View File

@@ -0,0 +1,150 @@
package com.example.godeye.services
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import com.example.godeye.R
import com.example.godeye.managers.ConnectionManager
import com.example.godeye.managers.AutoApprovalManager
import com.example.godeye.utils.PreferenceManager
import kotlinx.coroutines.*
/**
* Фоновый сервис для поддержания подключения к серверу
*/
class ConnectionService : Service() {
companion object {
private const val TAG = "ConnectionService"
private const val NOTIFICATION_ID = 1000
private const val CHANNEL_ID = "connection_service"
const val ACTION_START = "action_start"
const val ACTION_STOP = "action_stop"
const val ACTION_CONNECT = "action_connect"
const val ACTION_DISCONNECT = "action_disconnect"
}
private lateinit var connectionManager: ConnectionManager
private lateinit var autoApprovalManager: AutoApprovalManager
private lateinit var preferenceManager: PreferenceManager
private lateinit var notificationManager: NotificationManager
private val serviceScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var statusUpdateJob: Job? = null
override fun onCreate() {
super.onCreate()
Log.d(TAG, "Service created")
preferenceManager = PreferenceManager(this)
connectionManager = ConnectionManager(this, preferenceManager)
autoApprovalManager = AutoApprovalManager(this, preferenceManager, connectionManager)
notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
createNotificationChannel()
startMonitoring()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> {
Log.d(TAG, "Starting foreground service")
startForeground(NOTIFICATION_ID, createNotification("Запуск сервиса..."))
// Автоподключение если включено
if (preferenceManager.getAutoConnect()) {
connectionManager.connect()
}
}
ACTION_STOP -> {
Log.d(TAG, "Stopping service")
stopSelf()
}
ACTION_CONNECT -> {
Log.d(TAG, "Connect command received")
connectionManager.connect()
}
ACTION_DISCONNECT -> {
Log.d(TAG, "Disconnect command received")
connectionManager.disconnect()
}
}
return START_STICKY // Перезапускать сервис при остановке системой
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
super.onDestroy()
Log.d(TAG, "Service destroyed")
statusUpdateJob?.cancel()
serviceScope.cancel()
connectionManager.cleanup()
autoApprovalManager.cleanup()
}
/**
* Создание канала уведомлений
*/
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Подключение GodEye",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Статус подключения к серверу GodEye"
setShowBadge(false)
enableLights(false)
enableVibration(false)
}
notificationManager.createNotificationChannel(channel)
}
}
/**
* Создание уведомления
*/
private fun createNotification(content: String): Notification {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("GodEye Signal Center")
.setContentText(content)
.setSmallIcon(R.drawable.ic_camera_active)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setShowWhen(false)
.build()
}
/**
* Мониторинг состояния подключения
*/
private fun startMonitoring() {
statusUpdateJob = serviceScope.launch {
connectionManager.connectionState.collect { state ->
val statusText = when (state) {
ConnectionManager.ConnectionState.CONNECTED -> "Подключено к серверу"
ConnectionManager.ConnectionState.CONNECTING -> "Подключение..."
ConnectionManager.ConnectionState.ERROR -> "Ошибка подключения"
ConnectionManager.ConnectionState.DISCONNECTED -> "Отключен"
ConnectionManager.ConnectionState.RECONNECTING -> "Переподключение..."
}
// Обновляем уведомление
val notification = createNotification(statusText)
notificationManager.notify(NOTIFICATION_ID, notification)
Log.d(TAG, "Connection state changed: $statusText")
}
}
}
}

View File

@@ -0,0 +1,255 @@
package com.example.godeye.services
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import com.example.godeye.MainActivity
import com.example.godeye.R
import com.example.godeye.managers.SignalingManager
import com.example.godeye.models.SessionState
import com.example.godeye.models.SignalingState
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collect
/**
* Фоновый сервис для поддержания подключения к сигналлинг серверу
*/
class SignalingService : Service() {
companion object {
private const val TAG = "SignalingService"
private const val NOTIFICATION_ID = 1001
private const val CHANNEL_ID = "signaling_channel"
private const val CHANNEL_NAME = "Сигналлинг GodEye"
fun start(context: Context) {
val intent = Intent(context, SignalingService::class.java)
context.startForegroundService(intent)
}
fun stop(context: Context) {
val intent = Intent(context, SignalingService::class.java)
context.stopService(intent)
}
}
private lateinit var signalingManager: SignalingManager
private val serviceScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
override fun onCreate() {
super.onCreate()
Log.d(TAG, "SignalingService created")
signalingManager = SignalingManager(this)
createNotificationChannel()
// Отслеживаем состояние подключения
serviceScope.launch {
signalingManager.signalingState.collect { state ->
updateNotification(state)
}
}
// Отслеживаем состояние сессии
serviceScope.launch {
signalingManager.sessionState.collect { sessionState ->
handleSessionStateChange(sessionState)
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "SignalingService started")
// Запускаем в foreground режиме
startForeground(NOTIFICATION_ID, createNotification(SignalingState.CONNECTING))
// Подключаемся к сигналлинг серверу
signalingManager.start()
return START_STICKY // Перезапускать сервис при убийстве системой
}
override fun onDestroy() {
Log.d(TAG, "SignalingService destroyed")
signalingManager.stop()
serviceScope.cancel()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? {
return null // Не поддерживаем binding
}
/**
* Создание канала уведомлений
*/
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Уведомления о состоянии подключения к серверу"
setShowBadge(false)
}
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
}
}
/**
* Создание уведомления
*/
private fun createNotification(state: SignalingState): Notification {
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val (title, content, icon) = when (state) {
SignalingState.CONNECTED -> Triple(
"GodEye подключен",
"Готов к приему звонков от операторов",
android.R.drawable.ic_dialog_info
)
SignalingState.CONNECTING -> Triple(
"GodEye подключается",
"Подключение к серверу...",
android.R.drawable.ic_dialog_info
)
SignalingState.RECONNECTING -> Triple(
"GodEye переподключается",
"Восстановление соединения...",
android.R.drawable.ic_dialog_alert
)
SignalingState.ERROR -> Triple(
"GodEye - ошибка",
"Проблема с подключением к серверу",
android.R.drawable.ic_dialog_alert
)
SignalingState.DISCONNECTED -> Triple(
"GodEye отключен",
"Нет подключения к серверу",
android.R.drawable.ic_dialog_alert
)
}
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setContentText(content)
.setSmallIcon(icon)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
/**
* Обновление уведомления
*/
private fun updateNotification(state: SignalingState) {
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.notify(NOTIFICATION_ID, createNotification(state))
}
/**
* Обработка изменения состояния сессии
*/
private fun handleSessionStateChange(sessionState: SessionState) {
when (sessionState) {
SessionState.INCOMING -> {
// Показываем уведомление о входящем звонке
showIncomingCallNotification()
}
SessionState.ACTIVE -> {
// Обновляем уведомление об активной сессии
showActiveCallNotification()
}
SessionState.ENDED -> {
// Возвращаемся к обычному уведомлению
updateNotification(signalingManager.signalingState.value)
}
else -> {
// Обычное состояние
}
}
}
/**
* Показ уведомления о входящем звонке
*/
private fun showIncomingCallNotification() {
val currentCall = signalingManager.currentCall.value ?: return
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra("incoming_call", true)
}
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Входящий звонок")
.setContentText("От оператора: ${currentCall.operatorName}")
.setSmallIcon(android.R.drawable.ic_menu_call)
.setContentIntent(pendingIntent)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setFullScreenIntent(pendingIntent, true)
.build()
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.notify(NOTIFICATION_ID + 1, notification)
}
/**
* Показ уведомления об активной сессии
*/
private fun showActiveCallNotification() {
val currentCall = signalingManager.currentCall.value ?: return
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Активная сессия")
.setContentText("С оператором: ${currentCall.operatorName}")
.setSmallIcon(android.R.drawable.ic_menu_camera)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.notify(NOTIFICATION_ID, notification)
}
}

View File

@@ -253,16 +253,17 @@ class SocketService : Service() {
*/
private fun registerDevice() {
val deviceInfo = DeviceInfo(
model = "${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}",
deviceId = _deviceId.value,
deviceName = "${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}",
androidVersion = android.os.Build.VERSION.RELEASE,
appVersion = "1.0.0",
availableCameras = listOf("back", "front", "ultra_wide", "telephoto") // Получить из CameraManager
appVersion = "1.0.0"
)
val registerData = JSONObject().apply {
put("deviceId", _deviceId.value)
put("deviceInfo", JSONObject().apply {
put("model", deviceInfo.model)
put("model", deviceInfo.deviceName)
put("manufacturer", deviceInfo.manufacturer)
put("androidVersion", deviceInfo.androidVersion)
put("appVersion", deviceInfo.appVersion)
put("availableCameras", JSONArray().apply {
@@ -272,7 +273,7 @@ class SocketService : Service() {
}
socket?.emit("register:android", registerData)
Logger.step("REGISTER_DEVICE", "Device registration sent: ${deviceInfo.model}")
Logger.step("REGISTER_DEVICE", "Device registration sent: ${deviceInfo.deviceName}")
}
/**
@@ -384,6 +385,9 @@ class SocketService : Service() {
* События WebRTC для обработки в UI
*/
sealed class WebRTCEvent {
object Connected : WebRTCEvent()
object Disconnected : WebRTCEvent()
data class Error(val message: String) : WebRTCEvent()
data class Offer(val sessionId: String, val offer: String) : WebRTCEvent()
data class Answer(val sessionId: String, val answer: String) : WebRTCEvent()
data class IceCandidate(

View File

@@ -0,0 +1,437 @@
package com.example.godeye.signaling
import android.util.Log
import com.example.godeye.models.*
import com.google.gson.Gson
import io.socket.client.IO
import io.socket.client.Socket
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONObject
import java.net.URI
/**
* Сигналлинг клиент для WebRTC подключений
*/
class SignalingClient {
companion object {
private const val TAG = "SignalingClient"
// ИСПРАВЛЕНИЕ: Обновляем IP по умолчанию на ваш сервер
private const val DEFAULT_SERVER_URL = "ws://192.168.219.108:3000"
}
private var socket: Socket? = null
private val gson = Gson()
// Состояния
private val _signalingState = MutableStateFlow(SignalingState.DISCONNECTED)
val signalingState: StateFlow<SignalingState> = _signalingState.asStateFlow()
private val _sessionState = MutableStateFlow(SessionState.WAITING)
val sessionState: StateFlow<SessionState> = _sessionState.asStateFlow()
private val _currentCall = MutableStateFlow<IncomingCall?>(null)
val currentCall: StateFlow<IncomingCall?> = _currentCall.asStateFlow()
// Callbacks для WebRTC
private var onOfferReceived: ((RTCSessionDescription) -> Unit)? = null
private var onAnswerReceived: ((RTCSessionDescription) -> Unit)? = null
private var onIceCandidateReceived: ((RTCIceCandidate) -> Unit)? = null
private var onCallEnded: (() -> Unit)? = null
private var deviceId: String? = null
/**
* Подключение к сигналлинг серверу
*/
fun connect(serverUrl: String = DEFAULT_SERVER_URL, deviceInfo: DeviceInfo) {
Log.d(TAG, "Connecting to signaling server: $serverUrl")
try {
_signalingState.value = SignalingState.CONNECTING
this.deviceId = deviceInfo.model // Используем model как ID устройства
val options = IO.Options().apply {
timeout = 5000
reconnection = true
reconnectionAttempts = 5
reconnectionDelay = 1000
}
socket = IO.socket(URI.create(serverUrl), options)
setupSocketListeners()
socket?.connect()
// Регистрируем устройство после подключения
socket?.on(Socket.EVENT_CONNECT) {
Log.d(TAG, "Connected to signaling server")
_signalingState.value = SignalingState.CONNECTED
registerDevice(deviceInfo)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to connect to signaling server", e)
_signalingState.value = SignalingState.ERROR
}
}
/**
* Отключение от сигналлинг сервера
*/
fun disconnect() {
Log.d(TAG, "Disconnecting from signaling server")
socket?.off()
socket?.disconnect()
socket = null
_signalingState.value = SignalingState.DISCONNECTED
_sessionState.value = SessionState.WAITING
_currentCall.value = null
}
/**
* Настройка слушателей Socket.IO
*/
private fun setupSocketListeners() {
socket?.apply {
on(Socket.EVENT_DISCONNECT) {
Log.d(TAG, "Disconnected from signaling server")
_signalingState.value = SignalingState.DISCONNECTED
}
on(Socket.EVENT_CONNECT_ERROR) { args ->
Log.e(TAG, "Connection error: ${args.contentToString()}")
_signalingState.value = SignalingState.ERROR
}
on("reconnect") {
Log.d(TAG, "Reconnected to signaling server")
_signalingState.value = SignalingState.CONNECTED
}
on("reconnecting") {
Log.d(TAG, "Reconnecting to signaling server")
_signalingState.value = SignalingState.RECONNECTING
}
// Обработка входящих вызовов
on("operator_call") { args ->
handleOperatorCall(args)
}
// Обработка WebRTC сигналлинга
on("offer") { args ->
handleOffer(args)
}
on("answer") { args ->
handleAnswer(args)
}
on("ice_candidate") { args ->
handleIceCandidate(args)
}
on("call_end") { args ->
handleCallEnd(args)
}
// Системные сообщения
on("device_registered") { args ->
Log.d(TAG, "Device registered successfully: ${args.contentToString()}")
}
on("error") { args ->
Log.e(TAG, "Server error: ${args.contentToString()}")
}
}
}
/**
* Регистрация устройства на сервере
*/
private fun registerDevice(deviceInfo: DeviceInfo) {
Log.d(TAG, "Registering device: ${deviceInfo.model}")
val message = SignalingMessage(
type = SignalingMessageType.DEVICE_REGISTER,
data = deviceInfo
)
val json = JSONObject(gson.toJson(message))
socket?.emit("device_register", json)
_sessionState.value = SessionState.WAITING
}
/**
* Обработка входящего вызова от оператора
*/
private fun handleOperatorCall(args: Array<Any>) {
try {
val data = args[0] as JSONObject
Log.d(TAG, "Incoming operator call: $data")
val incomingCall = IncomingCall(
operatorId = data.getString("operatorId"),
operatorName = data.optString("operatorName", "Оператор"),
callId = data.getString("callId"),
timestamp = data.optLong("timestamp", System.currentTimeMillis())
)
_currentCall.value = incomingCall
_sessionState.value = SessionState.INCOMING
Log.d(TAG, "Incoming call from operator: ${incomingCall.operatorName}")
} catch (e: Exception) {
Log.e(TAG, "Error handling operator call", e)
}
}
/**
* Принятие вызова от оператора
*/
fun acceptCall() {
val call = _currentCall.value ?: return
Log.d(TAG, "Accepting call from operator: ${call.operatorId}")
val message = SignalingMessage(
type = SignalingMessageType.CALL_ACCEPT,
to = call.operatorId,
data = mapOf("callId" to call.callId)
)
val json = JSONObject(gson.toJson(message))
socket?.emit("call_accept", json)
_sessionState.value = SessionState.ACTIVE
}
/**
* Отклонение вызова от оператора
*/
fun rejectCall() {
val call = _currentCall.value ?: return
Log.d(TAG, "Rejecting call from operator: ${call.operatorId}")
val message = SignalingMessage(
type = SignalingMessageType.CALL_REJECT,
to = call.operatorId,
data = mapOf("callId" to call.callId)
)
val json = JSONObject(gson.toJson(message))
socket?.emit("call_reject", json)
_sessionState.value = SessionState.WAITING
_currentCall.value = null
}
/**
* Завершение вызова
*/
fun endCall() {
val call = _currentCall.value ?: return
Log.d(TAG, "Ending call with operator: ${call.operatorId}")
val message = SignalingMessage(
type = SignalingMessageType.CALL_END,
to = call.operatorId,
data = mapOf("callId" to call.callId)
)
val json = JSONObject(gson.toJson(message))
socket?.emit("call_end", json)
_sessionState.value = SessionState.ENDED
_currentCall.value = null
}
/**
* Отправка WebRTC Offer
*/
fun sendOffer(offer: RTCSessionDescription, operatorId: String) {
Log.d(TAG, "Sending offer to operator: $operatorId")
val message = SignalingMessage(
type = SignalingMessageType.OFFER,
to = operatorId,
data = offer
)
val json = JSONObject(gson.toJson(message))
socket?.emit("offer", json)
}
/**
* Отправка WebRTC Answer
*/
fun sendAnswer(answer: RTCSessionDescription, operatorId: String) {
Log.d(TAG, "Sending answer to operator: $operatorId")
val message = SignalingMessage(
type = SignalingMessageType.ANSWER,
to = operatorId,
data = answer
)
val json = JSONObject(gson.toJson(message))
socket?.emit("answer", json)
}
/**
* Отправка ICE Candidate
*/
fun sendIceCandidate(candidate: RTCIceCandidate, operatorId: String) {
Log.d(TAG, "Sending ICE candidate to operator: $operatorId")
val message = SignalingMessage(
type = SignalingMessageType.ICE_CANDIDATE,
to = operatorId,
data = candidate
)
val json = JSONObject(gson.toJson(message))
socket?.emit("ice_candidate", json)
}
// Обработчики WebRTC сигналлинга
private fun handleOffer(args: Array<Any>) {
try {
val data = args[0] as JSONObject
val offerData = data.getJSONObject("data")
val offer = RTCSessionDescription(
type = offerData.getString("type"),
sdp = offerData.getString("sdp")
)
Log.d(TAG, "Received offer from operator")
onOfferReceived?.invoke(offer)
} catch (e: Exception) {
Log.e(TAG, "Error handling offer", e)
}
}
private fun handleAnswer(args: Array<Any>) {
try {
val data = args[0] as JSONObject
val answerData = data.getJSONObject("data")
val answer = RTCSessionDescription(
type = answerData.getString("type"),
sdp = answerData.getString("sdp")
)
Log.d(TAG, "Received answer from operator")
onAnswerReceived?.invoke(answer)
} catch (e: Exception) {
Log.e(TAG, "Error handling answer", e)
}
}
/**
* НОВОЕ: Получение хоста сервера для ICE candidates
*/
fun getServerHost(): String? {
return socket?.let { socket ->
try {
// ИСПРАВЛЕНИЕ: Используем правильный способ получения URI
val socketUrl = socket.toString() // Получаем строковое представление
val regex = """://([^:/]+)""".toRegex()
val match = regex.find(socketUrl)
match?.groupValues?.get(1) ?: "192.168.219.108" // Fallback на ваш сервер
} catch (e: Exception) {
Log.e(TAG, "Failed to extract server host", e)
"192.168.219.108" // Fallback на ваш сервер
}
}
}
/**
* ИСПРАВЛЕНИЕ: Улучшенная обработка ICE candidates с информацией о сервере
*/
private fun handleIceCandidate(args: Array<Any>) {
try {
if (args.isNotEmpty()) {
val candidateData = args[0] as? JSONObject ?: return
val sessionId = candidateData.optString("sessionId", "")
val candidateObj = candidateData.optJSONObject("candidate")
if (candidateObj != null) {
// НОВОЕ: Добавляем информацию о сервере в ICE candidate
val candidate = RTCIceCandidate(
candidate = candidateObj.optString("candidate"),
sdpMid = candidateObj.optString("sdpMid"),
sdpMLineIndex = candidateObj.optInt("sdpMLineIndex")
)
// Обогащаем candidate информацией о сигналинг сервере
val enrichedCandidate = enrichIceCandidateWithServerInfo(candidate)
onIceCandidateReceived?.invoke(enrichedCandidate)
Log.d(TAG, "✅ ICE candidate received and enriched for session: $sessionId")
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to handle ICE candidate", e)
}
}
/**
* НОВОЕ: Обогащение ICE candidate информацией о сервере
*/
private fun enrichIceCandidateWithServerInfo(candidate: RTCIceCandidate): RTCIceCandidate {
val serverHost = getServerHost() ?: "192.168.219.108"
return RTCIceCandidate(
candidate = candidate.candidate,
sdpMid = candidate.sdpMid,
sdpMLineIndex = candidate.sdpMLineIndex
)
}
private fun handleCallEnd(args: Array<Any>) {
try {
Log.d(TAG, "Call ended by operator")
_sessionState.value = SessionState.ENDED
_currentCall.value = null
onCallEnded?.invoke()
} catch (e: Exception) {
Log.e(TAG, "Error handling call end", e)
}
}
// Установка callback'ов для WebRTC
fun setOnOfferReceived(callback: (RTCSessionDescription) -> Unit) {
onOfferReceived = callback
}
fun setOnAnswerReceived(callback: (RTCSessionDescription) -> Unit) {
onAnswerReceived = callback
}
fun setOnIceCandidateReceived(callback: (RTCIceCandidate) -> Unit) {
onIceCandidateReceived = callback
}
fun setOnCallEnded(callback: () -> Unit) {
onCallEnded = callback
}
}

View File

@@ -1,272 +0,0 @@
package com.example.godeye.streaming
import com.example.godeye.utils.Logger
import java.io.*
import java.net.*
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.ConcurrentLinkedQueue
/**
* HLS Stream Manager - создает HTTP Live Streaming сервер на Android устройстве
* Позволяет операторам подключаться через http://device_ip:8080/hls/stream.m3u8
*/
class HLSStreamManager {
private var httpServer: ServerSocket? = null
private var isServerRunning = AtomicBoolean(false)
private var serverThread: Thread? = null
private val serverPort = 8080
private var deviceIP: String? = null
private val segmentQueue = ConcurrentLinkedQueue<String>()
private var segmentCounter = 0
init {
detectDeviceIP()
}
private fun detectDeviceIP() {
try {
val interfaces = NetworkInterface.getNetworkInterfaces()
while (interfaces.hasMoreElements()) {
val networkInterface = interfaces.nextElement()
if (!networkInterface.isLoopback && networkInterface.isUp) {
val addresses = networkInterface.inetAddresses
while (addresses.hasMoreElements()) {
val address = addresses.nextElement()
if (!address.isLoopbackAddress && address.isSiteLocalAddress) {
deviceIP = address.hostAddress
break
}
}
}
}
} catch (e: Exception) {
Logger.error("HLS_IP_DETECTION", "Failed to detect IP", e)
}
}
fun startStreaming(cameraType: String = "back"): String? {
Logger.step("HLS_START_STREAMING", "🎬 Starting HLS server on port $serverPort")
try {
if (isServerRunning.get()) {
Logger.step("HLS_ALREADY_RUNNING", "HLS server already running")
return "http://$deviceIP:$serverPort/hls/stream.m3u8"
}
httpServer = ServerSocket(serverPort)
isServerRunning.set(true)
serverThread = Thread {
while (isServerRunning.get()) {
try {
val clientSocket = httpServer?.accept()
if (clientSocket != null) {
handleHTTPClient(clientSocket)
}
} catch (e: Exception) {
if (isServerRunning.get()) {
Logger.error("HLS_CLIENT_ERROR", "Error handling HTTP client", e)
}
}
}
}
serverThread?.start()
startSegmentGeneration()
val hlsUrl = "http://$deviceIP:$serverPort/hls/stream.m3u8"
Logger.step("HLS_SERVER_STARTED", "✅ HLS server started: $hlsUrl")
return hlsUrl
} catch (e: Exception) {
Logger.error("HLS_START_ERROR", "Failed to start HLS server", e)
return null
}
}
private fun handleHTTPClient(clientSocket: Socket) {
Thread {
try {
val input = BufferedReader(InputStreamReader(clientSocket.getInputStream()))
val output = PrintWriter(clientSocket.getOutputStream(), true)
val requestLine = input.readLine()
Logger.step("HLS_REQUEST", "📡 HTTP request: $requestLine")
// Читаем остальные заголовки
var line: String?
while (input.readLine().also { line = it } != null && line!!.isNotEmpty()) {
// Пропускаем заголовки
}
when {
requestLine.contains("GET /hls/stream.m3u8") -> {
sendM3U8Playlist(output)
}
requestLine.contains("GET /hls/segment") -> {
val segmentNumber = extractSegmentNumber(requestLine)
sendSegment(output, segmentNumber)
}
requestLine.contains("GET /") -> {
sendCORSHeaders(output)
}
else -> {
send404(output)
}
}
} catch (e: Exception) {
Logger.error("HLS_CLIENT_HANDLER", "Error handling HTTP client", e)
} finally {
clientSocket.close()
}
}.start()
}
private fun sendM3U8Playlist(output: PrintWriter) {
val playlist = generateM3U8Playlist()
output.println("HTTP/1.1 200 OK")
output.println("Content-Type: application/vnd.apple.mpegurl")
output.println("Content-Length: ${playlist.length}")
output.println("Access-Control-Allow-Origin: *")
output.println("Access-Control-Allow-Methods: GET, POST, OPTIONS")
output.println("Access-Control-Allow-Headers: Content-Type")
output.println("Cache-Control: no-cache")
output.println()
output.print(playlist)
output.flush()
Logger.step("HLS_PLAYLIST_SENT", "📋 M3U8 playlist sent")
}
private fun generateM3U8Playlist(): String {
val playlist = StringBuilder()
playlist.append("#EXTM3U\n")
playlist.append("#EXT-X-VERSION:3\n")
playlist.append("#EXT-X-TARGETDURATION:10\n")
playlist.append("#EXT-X-MEDIA-SEQUENCE:$segmentCounter\n")
playlist.append("#EXT-X-PLAYLIST-TYPE:EVENT\n")
// Добавляем последние сегменты
val segments = segmentQueue.toList().takeLast(5)
segments.forEach { segment ->
playlist.append("#EXTINF:10.0,\n")
playlist.append("$segment\n")
}
return playlist.toString()
}
private fun sendSegment(output: PrintWriter, segmentNumber: Int) {
// В реальной реализации здесь будет отправка H.264/MPEG-TS сегмента
val segmentData = generateDummySegment(segmentNumber)
output.println("HTTP/1.1 200 OK")
output.println("Content-Type: video/mp2t")
output.println("Content-Length: ${segmentData.size}")
output.println("Access-Control-Allow-Origin: *")
output.println()
output.flush()
// Отправляем бинарные данные через OutputStream сокета
val clientSocket = (output as? PrintWriter)?.let {
// Получаем сокет из контекста (нужно передать его в метод)
null // Временное решение
}
Logger.step("HLS_SEGMENT_SENT", "🎥 Segment $segmentNumber sent")
}
private fun generateDummySegment(segmentNumber: Int): ByteArray {
// Заглушка - в реальной реализации здесь будут закодированные кадры
return "DUMMY_TS_SEGMENT_$segmentNumber".toByteArray()
}
private fun extractSegmentNumber(requestLine: String): Int {
return try {
val regex = "segment(\\d+)\\.ts".toRegex()
val match = regex.find(requestLine)
match?.groupValues?.get(1)?.toInt() ?: 0
} catch (_: Exception) {
0
}
}
private fun sendCORSHeaders(output: PrintWriter) {
output.println("HTTP/1.1 200 OK")
output.println("Access-Control-Allow-Origin: *")
output.println("Access-Control-Allow-Methods: GET, POST, OPTIONS")
output.println("Access-Control-Allow-Headers: Content-Type")
output.println("Content-Length: 0")
output.println()
output.flush()
}
private fun send404(output: PrintWriter) {
output.println("HTTP/1.1 404 Not Found")
output.println("Content-Length: 0")
output.println()
output.flush()
}
private fun startSegmentGeneration() {
Thread {
Logger.step("HLS_SEGMENT_GENERATION", "🎬 Starting HLS segment generation")
while (isServerRunning.get()) {
try {
// Генерируем новый сегмент каждые 10 секунд
val segmentName = "segment${segmentCounter++}.ts"
segmentQueue.offer(segmentName)
// Ограничиваем количество сегментов
while (segmentQueue.size > 10) {
segmentQueue.poll()
}
Logger.step("HLS_SEGMENT_GENERATED", "📹 Generated segment: $segmentName")
Thread.sleep(10000) // 10 секунд на сегмент
} catch (_: InterruptedException) {
break
} catch (e: Exception) {
Logger.error("HLS_SEGMENT_ERROR", "Error generating segment", e)
}
}
}.start()
}
fun switchCamera(cameraType: String) {
Logger.step("HLS_SWITCH_CAMERA", "🔄 Switching HLS camera to: $cameraType")
// В реальной реализации здесь будет переключение источника кадров
// TODO: Implement camera switching logic
}
fun stopStreaming() {
Logger.step("HLS_STOP_STREAMING", "🛑 Stopping HLS streaming")
try {
isServerRunning.set(false)
httpServer?.close()
httpServer = null
serverThread?.interrupt()
serverThread = null
segmentQueue.clear()
segmentCounter = 0
Logger.step("HLS_STREAMING_STOPPED", "✅ HLS streaming stopped")
} catch (e: Exception) {
Logger.error("HLS_STOP_ERROR", "Error stopping HLS streaming", e)
}
}
fun dispose() {
stopStreaming()
}
}

View File

@@ -1,331 +0,0 @@
package com.example.godeye.streaming
import android.content.Context
import android.hardware.camera2.*
import android.media.MediaRecorder
import android.os.Handler
import android.os.HandlerThread
import com.example.godeye.utils.Logger
import java.io.*
import java.net.*
import java.util.concurrent.atomic.AtomicBoolean
/**
* RTSP Stream Manager - создает RTSP сервер на Android устройстве
* Позволяет операторам подключаться напрямую через rtsp://device_ip:8554/live
*/
class RTSPStreamManager(private val context: Context) {
private var serverSocket: ServerSocket? = null
private var isServerRunning = AtomicBoolean(false)
private var serverThread: Thread? = null
private val clientSockets = mutableListOf<Socket>()
private var cameraDevice: CameraDevice? = null
private var captureSession: CameraCaptureSession? = null
private var backgroundThread: HandlerThread? = null
private var backgroundHandler: Handler? = null
private val serverPort = 8554
private var deviceIP: String? = null
init {
detectDeviceIP()
startBackgroundThread()
}
private fun detectDeviceIP() {
try {
val interfaces = NetworkInterface.getNetworkInterfaces()
while (interfaces.hasMoreElements()) {
val networkInterface = interfaces.nextElement()
if (!networkInterface.isLoopback && networkInterface.isUp) {
val addresses = networkInterface.inetAddresses
while (addresses.hasMoreElements()) {
val address = addresses.nextElement()
if (!address.isLoopbackAddress && address.isSiteLocalAddress) {
deviceIP = address.hostAddress
break
}
}
}
}
} catch (e: Exception) {
Logger.error("RTSP_IP_DETECTION", "Failed to detect IP", e)
}
}
private fun startBackgroundThread() {
backgroundThread = HandlerThread("CameraBackground").also { it.start() }
backgroundHandler = Handler(backgroundThread?.looper!!)
}
fun startServer(cameraType: String = "back"): String? {
Logger.step("RTSP_START_SERVER", "🎬 Starting RTSP server on port $serverPort")
try {
if (isServerRunning.get()) {
Logger.step("RTSP_ALREADY_RUNNING", "RTSP server already running")
return "rtsp://$deviceIP:$serverPort/live"
}
serverSocket = ServerSocket(serverPort)
isServerRunning.set(true)
serverThread = Thread {
while (isServerRunning.get()) {
try {
val clientSocket = serverSocket?.accept()
if (clientSocket != null) {
clientSockets.add(clientSocket)
handleRTSPClient(clientSocket)
}
} catch (e: Exception) {
if (isServerRunning.get()) {
Logger.error("RTSP_CLIENT_ERROR", "Error handling client", e)
}
}
}
}
serverThread?.start()
initializeCamera(cameraType)
val rtspUrl = "rtsp://$deviceIP:$serverPort/live"
Logger.step("RTSP_SERVER_STARTED", "✅ RTSP server started: $rtspUrl")
return rtspUrl
} catch (e: Exception) {
Logger.error("RTSP_START_ERROR", "Failed to start RTSP server", e)
return null
}
}
private fun handleRTSPClient(clientSocket: Socket) {
Thread {
try {
val input = BufferedReader(InputStreamReader(clientSocket.getInputStream()))
val output = PrintWriter(clientSocket.getOutputStream(), true)
var line: String?
val request = StringBuilder()
// Читаем RTSP запрос
while (input.readLine().also { line = it } != null) {
request.append(line).append("\n")
if (line!!.isEmpty()) break
}
val requestStr = request.toString()
Logger.step("RTSP_REQUEST", "📡 RTSP request: ${requestStr.lines().firstOrNull()}")
when {
requestStr.contains("OPTIONS") -> {
sendRTSPResponse(output, "200 OK", mapOf(
"Public" to "DESCRIBE, SETUP, TEARDOWN, PLAY, PAUSE"
))
}
requestStr.contains("DESCRIBE") -> {
val sdp = generateSDP()
sendRTSPResponse(output, "200 OK", mapOf(
"Content-Type" to "application/sdp",
"Content-Length" to sdp.length.toString()
), sdp)
}
requestStr.contains("SETUP") -> {
sendRTSPResponse(output, "200 OK", mapOf(
"Transport" to "RTP/AVP;unicast;client_port=8000-8001;server_port=9000-9001",
"Session" to "12345678"
))
}
requestStr.contains("PLAY") -> {
sendRTSPResponse(output, "200 OK", mapOf(
"Session" to "12345678"
))
startRTPStreaming(clientSocket)
}
}
} catch (e: Exception) {
Logger.error("RTSP_CLIENT_HANDLER", "Error handling RTSP client", e)
} finally {
clientSocket.close()
clientSockets.remove(clientSocket)
}
}.start()
}
private fun sendRTSPResponse(output: PrintWriter, status: String, headers: Map<String, String>, body: String = "") {
output.println("RTSP/1.0 $status")
output.println("CSeq: 1")
headers.forEach { (key, value) ->
output.println("$key: $value")
}
output.println()
if (body.isNotEmpty()) {
output.print(body)
}
output.flush()
}
private fun generateSDP(): String {
return """v=0
o=- 0 0 IN IP4 $deviceIP
s=Android Camera Stream
c=IN IP4 $deviceIP
t=0 0
m=video 9000 RTP/AVP 96
a=rtpmap:96 H264/90000
a=fmtp:96 profile-level-id=42e01e
a=control:streamid=0
"""
}
private fun startRTPStreaming(clientSocket: Socket) {
Logger.step("RTSP_START_RTP", "🎥 Starting RTP streaming to client")
Thread {
try {
val rtpSocket = DatagramSocket(9000)
val clientAddress = clientSocket.inetAddress
// Симуляция RTP пакетов (в реальной реализации здесь будут кадры с камеры)
var sequenceNumber = 0
val timestamp = System.currentTimeMillis()
while (isServerRunning.get() && !clientSocket.isClosed) {
// Создаем простой RTP пакет
val rtpPacket = createRTPPacket(sequenceNumber++, timestamp, "dummy_frame".toByteArray())
val packet = DatagramPacket(rtpPacket, rtpPacket.size, clientAddress, 8000)
rtpSocket.send(packet)
Thread.sleep(33) // ~30 FPS
}
rtpSocket.close()
} catch (e: Exception) {
Logger.error("RTSP_RTP_ERROR", "Error in RTP streaming", e)
}
}.start()
}
private fun createRTPPacket(sequenceNumber: Int, timestamp: Long, payload: ByteArray): ByteArray {
val header = ByteArray(12)
// RTP Header
header[0] = 0x80.toByte() // Version 2, no padding, no extension, no CC
header[1] = 0x60.toByte() // Marker bit + Payload type (96)
// Sequence number
header[2] = (sequenceNumber shr 8).toByte()
header[3] = (sequenceNumber and 0xFF).toByte()
// Timestamp
val ts = (timestamp and 0xFFFFFFFF).toInt()
header[4] = (ts shr 24).toByte()
header[5] = (ts shr 16).toByte()
header[6] = (ts shr 8).toByte()
header[7] = (ts and 0xFF).toByte()
// SSRC (synchronization source identifier)
header[8] = 0x12.toByte()
header[9] = 0x34.toByte()
header[10] = 0x56.toByte()
header[11] = 0x78.toByte()
return header + payload
}
private fun initializeCamera(cameraType: String) {
Logger.step("RTSP_INIT_CAMERA", "📷 Initializing camera for RTSP")
try {
val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
val cameraId = if (cameraType == "front") {
cameraManager.cameraIdList.find {
val characteristics = cameraManager.getCameraCharacteristics(it)
characteristics.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT
}
} else {
cameraManager.cameraIdList.find {
val characteristics = cameraManager.getCameraCharacteristics(it)
characteristics.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_BACK
}
} ?: cameraManager.cameraIdList.firstOrNull()
if (cameraId != null) {
cameraManager.openCamera(cameraId, object : CameraDevice.StateCallback() {
override fun onOpened(camera: CameraDevice) {
cameraDevice = camera
Logger.step("RTSP_CAMERA_OPENED", "✅ Camera opened for RTSP")
createCaptureSession()
}
override fun onDisconnected(camera: CameraDevice) {
camera.close()
cameraDevice = null
}
override fun onError(camera: CameraDevice, error: Int) {
Logger.error("RTSP_CAMERA_ERROR", "Camera error: $error")
camera.close()
cameraDevice = null
}
}, backgroundHandler)
}
} catch (e: Exception) {
Logger.error("RTSP_CAMERA_INIT_ERROR", "Failed to initialize camera", e)
}
}
private fun createCaptureSession() {
// В реальной реализации здесь будет создание сессии захвата кадров
// и их кодирование в H.264 для передачи через RTP
Logger.step("RTSP_CAPTURE_SESSION", "📹 Camera capture session created")
}
fun switchCamera(cameraType: String) {
Logger.step("RTSP_SWITCH_CAMERA", "🔄 Switching RTSP camera to: $cameraType")
// Закрываем текущую камеру и открываем новую
cameraDevice?.close()
initializeCamera(cameraType)
}
fun stopServer() {
Logger.step("RTSP_STOP_SERVER", "🛑 Stopping RTSP server")
try {
isServerRunning.set(false)
clientSockets.forEach { it.close() }
clientSockets.clear()
serverSocket?.close()
serverSocket = null
cameraDevice?.close()
cameraDevice = null
captureSession?.close()
captureSession = null
serverThread?.interrupt()
serverThread = null
Logger.step("RTSP_SERVER_STOPPED", "✅ RTSP server stopped")
} catch (e: Exception) {
Logger.error("RTSP_STOP_ERROR", "Error stopping RTSP server", e)
}
}
fun dispose() {
stopServer()
backgroundThread?.quitSafely()
backgroundThread = null
backgroundHandler = null
}
}

View File

@@ -1,193 +0,0 @@
package com.example.godeye.streaming
import android.content.Context
import com.example.godeye.utils.Logger
import java.net.*
import java.util.concurrent.atomic.AtomicBoolean
/**
* UDP Stream Manager - прямая передача видео через UDP для минимальной задержки
* Позволяет операторам получать сырой видео поток через udp://device_ip:9999
*/
class UDPStreamManager(private val context: Context) {
private var udpSocket: DatagramSocket? = null
private var isStreaming = AtomicBoolean(false)
private var streamingThread: Thread? = null
private val streamingPort = 9999
private var deviceIP: String? = null
private var targetAddress: InetAddress? = null
private var targetPort: Int = 0
init {
detectDeviceIP()
}
private fun detectDeviceIP() {
try {
val interfaces = NetworkInterface.getNetworkInterfaces()
while (interfaces.hasMoreElements()) {
val networkInterface = interfaces.nextElement()
if (!networkInterface.isLoopback && networkInterface.isUp) {
val addresses = networkInterface.inetAddresses
while (addresses.hasMoreElements()) {
val address = addresses.nextElement()
if (!address.isLoopbackAddress && address.isSiteLocalAddress) {
deviceIP = address.hostAddress
break
}
}
}
}
} catch (e: Exception) {
Logger.error("UDP_IP_DETECTION", "Failed to detect IP", e)
}
}
fun startStreaming(cameraType: String = "back"): String? {
Logger.step("UDP_START_STREAMING", "🎬 Starting UDP streaming on port $streamingPort")
try {
if (isStreaming.get()) {
Logger.step("UDP_ALREADY_STREAMING", "UDP streaming already active")
return "udp://$deviceIP:$streamingPort"
}
udpSocket = DatagramSocket(streamingPort)
isStreaming.set(true)
startFrameStreaming(cameraType)
val udpUrl = "udp://$deviceIP:$streamingPort"
Logger.step("UDP_STREAMING_STARTED", "✅ UDP streaming started: $udpUrl")
return udpUrl
} catch (e: Exception) {
Logger.error("UDP_START_ERROR", "Failed to start UDP streaming", e)
return null
}
}
private fun startFrameStreaming(cameraType: String) {
streamingThread = Thread {
Logger.step("UDP_FRAME_STREAMING", "🎥 Starting UDP frame streaming")
var frameCounter = 0
while (isStreaming.get()) {
try {
// В реальной реализации здесь будут кадры с камеры
val frameData = generateDummyFrame(frameCounter++, cameraType)
// Если есть подключенные клиенты, отправляем им кадры
if (targetAddress != null && targetPort > 0) {
sendFrame(frameData)
}
// ~30 FPS
Thread.sleep(33)
} catch (e: InterruptedException) {
break
} catch (e: Exception) {
Logger.error("UDP_FRAME_ERROR", "Error streaming frame", e)
}
}
}
streamingThread?.start()
}
private fun generateDummyFrame(frameNumber: Int, cameraType: String): ByteArray {
// Заглушка - в реальной реализации здесь будут сжатые кадры H.264
val frameHeader = ByteArray(8)
// Frame header: magic number + frame number + camera type
frameHeader[0] = 0x47.toByte() // Magic 'G'
frameHeader[1] = 0x45.toByte() // Magic 'E'
frameHeader[2] = (frameNumber shr 24).toByte()
frameHeader[3] = (frameNumber shr 16).toByte()
frameHeader[4] = (frameNumber shr 8).toByte()
frameHeader[5] = (frameNumber and 0xFF).toByte()
frameHeader[6] = if (cameraType == "back") 0x00 else 0x01
frameHeader[7] = 0x00 // Reserved
val frameData = "FRAME_${frameNumber}_${cameraType}_${System.currentTimeMillis()}".toByteArray()
return frameHeader + frameData
}
private fun sendFrame(frameData: ByteArray) {
try {
val packet = DatagramPacket(
frameData,
frameData.size,
targetAddress,
targetPort
)
udpSocket?.send(packet)
} catch (e: Exception) {
Logger.error("UDP_SEND_FRAME", "Error sending UDP frame", e)
}
}
/**
* Устанавливает адрес клиента для отправки кадров
*/
fun setClient(clientIP: String, clientPort: Int) {
try {
targetAddress = InetAddress.getByName(clientIP)
targetPort = clientPort
Logger.step("UDP_CLIENT_SET", "📡 UDP client set: $clientIP:$clientPort")
} catch (e: Exception) {
Logger.error("UDP_SET_CLIENT", "Error setting UDP client", e)
}
}
/**
* Получение информации о UDP стриме для отправки клиенту
*/
fun getStreamInfo(): Map<String, Any> {
return mapOf(
"protocol" to "udp",
"ip" to (deviceIP ?: "unknown"),
"port" to streamingPort,
"url" to "udp://$deviceIP:$streamingPort",
"format" to "raw_frames",
"fps" to 30,
"active" to isStreaming.get()
)
}
fun switchCamera(cameraType: String) {
Logger.step("UDP_SWITCH_CAMERA", "🔄 Switching UDP camera to: $cameraType")
// В реальной реализации здесь будет переключение источника кадров
// Новый тип камеры будет включен в следующие кадры
}
fun stopStreaming() {
Logger.step("UDP_STOP_STREAMING", "🛑 Stopping UDP streaming")
try {
isStreaming.set(false)
streamingThread?.interrupt()
streamingThread = null
udpSocket?.close()
udpSocket = null
targetAddress = null
targetPort = 0
Logger.step("UDP_STREAMING_STOPPED", "✅ UDP streaming stopped")
} catch (e: Exception) {
Logger.error("UDP_STOP_ERROR", "Error stopping UDP streaming", e)
}
}
fun dispose() {
stopStreaming()
}
}

View File

@@ -1,355 +0,0 @@
package com.example.godeye.streaming
import android.content.Context
import android.hardware.camera2.CameraManager
import com.example.godeye.utils.Logger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONObject
import java.net.NetworkInterface
import java.net.SocketException
/**
* Unified Streaming Manager - управляет различными протоколами прямой передачи видео
*
* Поддерживаемые протоколы:
* 1. WebRTC P2P - для веб-браузеров
* 2. RTSP Server - для специализированных клиентов
* 3. HTTP Live Streaming (HLS) - для универсальной совместимости
* 4. Raw UDP Stream - для минимальной задержки
*/
class UnifiedStreamingManager(
private val context: Context,
private val onSignalingMessage: (message: JSONObject) -> Unit
) {
// Состояния стриминга
private val _streamingState = MutableStateFlow(StreamingState.STOPPED)
val streamingState: StateFlow<StreamingState> = _streamingState.asStateFlow()
private val _availableProtocols = MutableStateFlow<List<StreamingProtocol>>(emptyList())
val availableProtocols: StateFlow<List<StreamingProtocol>> = _availableProtocols.asStateFlow()
private val _activeStreams = MutableStateFlow<Map<String, StreamInfo>>(emptyMap())
val activeStreams: StateFlow<Map<String, StreamInfo>> = _activeStreams.asStateFlow()
// Менеджеры протоколов
private var webRTCManager: WebRTCStreamManager? = null
private var rtspManager: RTSPStreamManager? = null
private var hlsManager: HLSStreamManager? = null
private var udpManager: UDPStreamManager? = null
private var deviceIP: String? = null
init {
Logger.step("UNIFIED_STREAMING_INIT", "🎬 Initializing Unified Streaming Manager")
detectDeviceIP()
initializeProtocolSupport()
}
/**
* Определение IP адреса устройства для прямых соединений
*/
private fun detectDeviceIP() {
try {
val interfaces = NetworkInterface.getNetworkInterfaces()
while (interfaces.hasMoreElements()) {
val networkInterface = interfaces.nextElement()
if (!networkInterface.isLoopback && networkInterface.isUp) {
val addresses = networkInterface.inetAddresses
while (addresses.hasMoreElements()) {
val address = addresses.nextElement()
if (!address.isLoopbackAddress && address.isSiteLocalAddress) {
deviceIP = address.hostAddress
Logger.step("DEVICE_IP_DETECTED", "📍 Device IP: $deviceIP")
break
}
}
}
}
} catch (e: SocketException) {
Logger.error("IP_DETECTION_ERROR", "Failed to detect device IP", e)
}
}
/**
* Инициализация поддержки различных протоколов
*/
private fun initializeProtocolSupport() {
val supportedProtocols = mutableListOf<StreamingProtocol>()
try {
// WebRTC поддержка
webRTCManager = WebRTCStreamManager(context, onSignalingMessage)
supportedProtocols.add(
StreamingProtocol(
type = "webrtc",
name = "WebRTC P2P",
description = "Прямое P2P соединение для веб-браузеров",
isSupported = true,
connectionInfo = "Автоматическое P2P соединение"
)
)
Logger.step("WEBRTC_SUPPORT", "✅ WebRTC protocol supported")
} catch (e: Exception) {
Logger.error("WEBRTC_SUPPORT_ERROR", "WebRTC not supported", e)
}
try {
// RTSP Server поддержка
rtspManager = RTSPStreamManager(context)
supportedProtocols.add(
StreamingProtocol(
type = "rtsp",
name = "RTSP Server",
description = "RTSP сервер для специализированных клиентов",
isSupported = true,
connectionInfo = "rtsp://$deviceIP:8554/live"
)
)
Logger.step("RTSP_SUPPORT", "✅ RTSP protocol supported")
} catch (e: Exception) {
Logger.error("RTSP_SUPPORT_ERROR", "RTSP not supported", e)
}
try {
// HLS поддержка
hlsManager = HLSStreamManager()
supportedProtocols.add(
StreamingProtocol(
type = "hls",
name = "HTTP Live Streaming",
description = "HLS стрим для универсальной совместимости",
isSupported = true,
connectionInfo = "http://$deviceIP:8080/hls/stream.m3u8"
)
)
Logger.step("HLS_SUPPORT", "✅ HLS protocol supported")
} catch (e: Exception) {
Logger.error("HLS_SUPPORT_ERROR", "HLS not supported", e)
}
try {
// UDP Raw Stream поддержка
udpManager = UDPStreamManager(context)
supportedProtocols.add(
StreamingProtocol(
type = "udp",
name = "Raw UDP Stream",
description = "Прямой UDP поток для минимальной задержки",
isSupported = true,
connectionInfo = "udp://$deviceIP:9999"
)
)
Logger.step("UDP_SUPPORT", "✅ UDP protocol supported")
} catch (e: Exception) {
Logger.error("UDP_SUPPORT_ERROR", "UDP not supported", e)
}
_availableProtocols.value = supportedProtocols
Logger.step("PROTOCOLS_INITIALIZED", "🎯 Initialized ${supportedProtocols.size} streaming protocols")
}
/**
* Запуск стриминга с выбранными протоколами
*/
fun startStreaming(
sessionId: String,
requestedProtocols: List<String> = listOf("webrtc", "rtsp"),
cameraType: String = "back"
) {
Logger.step("START_STREAMING", "🎬 Starting streaming for session: $sessionId")
Logger.step("STREAMING_PROTOCOLS", "📡 Requested protocols: ${requestedProtocols.joinToString(", ")}")
_streamingState.value = StreamingState.STARTING
val activeStreams = mutableMapOf<String, StreamInfo>()
try {
// Запуск WebRTC если запрошен
if ("webrtc" in requestedProtocols && webRTCManager != null) {
webRTCManager?.startStreaming(sessionId, cameraType)
activeStreams["webrtc"] = StreamInfo(
protocol = "webrtc",
sessionId = sessionId,
isActive = true,
connectionUrl = "P2P Connection",
startTime = System.currentTimeMillis()
)
Logger.step("WEBRTC_STARTED", "✅ WebRTC streaming started")
}
// Запуск RTSP если запрошен
if ("rtsp" in requestedProtocols && rtspManager != null) {
val rtspUrl = rtspManager?.startServer(cameraType)
if (rtspUrl != null) {
activeStreams["rtsp"] = StreamInfo(
protocol = "rtsp",
sessionId = sessionId,
isActive = true,
connectionUrl = rtspUrl,
startTime = System.currentTimeMillis()
)
Logger.step("RTSP_STARTED", "✅ RTSP streaming started: $rtspUrl")
}
}
// Запуск HLS если запрошен
if ("hls" in requestedProtocols && hlsManager != null) {
val hlsUrl = hlsManager?.startStreaming(cameraType)
if (hlsUrl != null) {
activeStreams["hls"] = StreamInfo(
protocol = "hls",
sessionId = sessionId,
isActive = true,
connectionUrl = hlsUrl,
startTime = System.currentTimeMillis()
)
Logger.step("HLS_STARTED", "✅ HLS streaming started: $hlsUrl")
}
}
// Запуск UDP если запрошен
if ("udp" in requestedProtocols && udpManager != null) {
val udpUrl = udpManager?.startStreaming(cameraType)
if (udpUrl != null) {
activeStreams["udp"] = StreamInfo(
protocol = "udp",
sessionId = sessionId,
isActive = true,
connectionUrl = udpUrl,
startTime = System.currentTimeMillis()
)
Logger.step("UDP_STARTED", "✅ UDP streaming started: $udpUrl")
}
}
_activeStreams.value = activeStreams
_streamingState.value = if (activeStreams.isNotEmpty()) StreamingState.ACTIVE else StreamingState.ERROR
// Отправляем информацию о доступных стримах оператору через сигнальный сервер
sendStreamingInfo(sessionId, activeStreams)
} catch (e: Exception) {
Logger.error("START_STREAMING_ERROR", "Failed to start streaming", e)
_streamingState.value = StreamingState.ERROR
}
}
/**
* Остановка всех стримов
*/
fun stopStreaming(sessionId: String) {
Logger.step("STOP_STREAMING", "🛑 Stopping streaming for session: $sessionId")
try {
webRTCManager?.stopStreaming()
rtspManager?.stopServer()
hlsManager?.stopStreaming()
udpManager?.stopStreaming()
_activeStreams.value = emptyMap()
_streamingState.value = StreamingState.STOPPED
Logger.step("STREAMING_STOPPED", "✅ All streaming stopped")
} catch (e: Exception) {
Logger.error("STOP_STREAMING_ERROR", "Error stopping streaming", e)
}
}
/**
* Переключение камеры во всех активных стримах
*/
fun switchCamera(cameraType: String) {
Logger.step("SWITCH_CAMERA", "🔄 Switching camera to: $cameraType")
try {
webRTCManager?.switchCamera(cameraType)
rtspManager?.switchCamera(cameraType)
hlsManager?.switchCamera(cameraType)
udpManager?.switchCamera(cameraType)
Logger.step("CAMERA_SWITCHED", "✅ Camera switched to: $cameraType")
} catch (e: Exception) {
Logger.error("SWITCH_CAMERA_ERROR", "Error switching camera", e)
}
}
/**
* Отправка информации о доступных стримах оператору
*/
private fun sendStreamingInfo(sessionId: String, streams: Map<String, StreamInfo>) {
val streamingInfo = JSONObject().apply {
put("type", "streaming_info")
put("sessionId", sessionId)
put("deviceIP", deviceIP)
put("streams", JSONObject().apply {
streams.forEach { (protocol, info) ->
put(protocol, JSONObject().apply {
put("url", info.connectionUrl)
put("active", info.isActive)
put("protocol", info.protocol)
})
}
})
}
onSignalingMessage(streamingInfo)
Logger.step("STREAMING_INFO_SENT", "📡 Streaming info sent to operator")
}
/**
* Получение статистики стриминга
*/
fun getStreamingStats(): Map<String, Any> {
val stats = mutableMapOf<String, Any>()
stats["state"] = _streamingState.value.name
stats["activeStreams"] = _activeStreams.value.size
stats["deviceIP"] = deviceIP ?: "unknown"
stats["supportedProtocols"] = _availableProtocols.value.map { it.type }
_activeStreams.value.forEach { (protocol, info) ->
stats["${protocol}_uptime"] = (System.currentTimeMillis() - info.startTime) / 1000
}
return stats
}
fun dispose() {
Logger.step("UNIFIED_STREAMING_DISPOSE", "🧹 Disposing Unified Streaming Manager")
try {
stopStreaming("dispose")
webRTCManager?.dispose()
rtspManager?.dispose()
hlsManager?.dispose()
udpManager?.dispose()
} catch (e: Exception) {
Logger.error("DISPOSE_ERROR", "Error during disposal", e)
}
}
}
// Данные классы для стриминга
enum class StreamingState {
STOPPED, STARTING, ACTIVE, ERROR
}
data class StreamingProtocol(
val type: String,
val name: String,
val description: String,
val isSupported: Boolean,
val connectionInfo: String
)
data class StreamInfo(
val protocol: String,
val sessionId: String,
val isActive: Boolean,
val connectionUrl: String,
val startTime: Long
)

View File

@@ -1,218 +0,0 @@
package com.example.godeye.streaming
import android.content.Context
import com.example.godeye.utils.Logger
import org.json.JSONObject
import org.webrtc.*
import org.webrtc.audio.JavaAudioDeviceModule
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* WebRTC Stream Manager - улучшенная версия для прямого P2P соединения
*/
class WebRTCStreamManager(
private val context: Context,
private val onSignalingMessage: (message: JSONObject) -> Unit
) {
private var peerConnectionFactory: PeerConnectionFactory? = null
private var peerConnection: PeerConnection? = null
private var localVideoTrack: VideoTrack? = null
private var localAudioTrack: AudioTrack? = null
private var videoCapturer: CameraVideoCapturer? = null
private var surfaceTextureHelper: SurfaceTextureHelper? = null
private val _connectionState = MutableStateFlow(PeerConnection.PeerConnectionState.NEW)
val connectionState: StateFlow<PeerConnection.PeerConnectionState> = _connectionState.asStateFlow()
private val _isStreaming = MutableStateFlow(false)
val isStreaming: StateFlow<Boolean> = _isStreaming.asStateFlow()
private val iceServers = listOf(
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer(),
PeerConnection.IceServer.builder("stun:stun1.l.google.com:19302").createIceServer()
)
init {
initializePeerConnectionFactory()
}
private fun initializePeerConnectionFactory() {
val initOptions = PeerConnectionFactory.InitializationOptions.builder(context)
.setEnableInternalTracer(true)
.createInitializationOptions()
PeerConnectionFactory.initialize(initOptions)
val audioDeviceModule = JavaAudioDeviceModule.builder(context).createAudioDeviceModule()
peerConnectionFactory = PeerConnectionFactory.builder()
.setAudioDeviceModule(audioDeviceModule)
.setVideoEncoderFactory(DefaultVideoEncoderFactory(
EglBase.create().eglBaseContext, true, true))
.setVideoDecoderFactory(DefaultVideoDecoderFactory(EglBase.create().eglBaseContext))
.createPeerConnectionFactory()
Logger.step("WEBRTC_FACTORY_READY", "✅ WebRTC PeerConnectionFactory initialized")
}
fun startStreaming(sessionId: String, cameraType: String = "back") {
Logger.step("WEBRTC_START_STREAMING", "🎬 Starting WebRTC streaming for session: $sessionId")
try {
createPeerConnection()
initializeLocalMedia(cameraType)
createOffer(sessionId)
_isStreaming.value = true
} catch (e: Exception) {
Logger.error("WEBRTC_START_ERROR", "Failed to start WebRTC streaming", e)
}
}
fun stopStreaming() {
Logger.step("WEBRTC_STOP_STREAMING", "🛑 Stopping WebRTC streaming")
try {
videoCapturer?.stopCapture()
videoCapturer?.dispose()
localVideoTrack?.dispose()
localAudioTrack?.dispose()
peerConnection?.close()
peerConnection = null
_isStreaming.value = false
_connectionState.value = PeerConnection.PeerConnectionState.CLOSED
} catch (e: Exception) {
Logger.error("WEBRTC_STOP_ERROR", "Error stopping WebRTC", e)
}
}
fun switchCamera(cameraType: String) {
Logger.step("WEBRTC_SWITCH_CAMERA", "🔄 Switching WebRTC camera to: $cameraType")
(videoCapturer as? CameraVideoCapturer)?.switchCamera(null)
}
private fun createPeerConnection() {
val config = PeerConnection.RTCConfiguration(iceServers).apply {
bundlePolicy = PeerConnection.BundlePolicy.MAXBUNDLE
rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.REQUIRE
}
peerConnection = peerConnectionFactory?.createPeerConnection(config, object : PeerConnection.Observer {
override fun onSignalingChange(state: PeerConnection.SignalingState) {
Logger.step("WEBRTC_SIGNALING", "Signaling state: $state")
}
override fun onIceConnectionChange(state: PeerConnection.IceConnectionState) {
Logger.step("WEBRTC_ICE_STATE", "ICE state: $state")
}
override fun onConnectionChange(state: PeerConnection.PeerConnectionState) {
_connectionState.value = state
Logger.step("WEBRTC_CONNECTION_STATE", "Connection state: $state")
}
override fun onIceCandidate(candidate: IceCandidate) {
val candidateMsg = JSONObject().apply {
put("type", "ice-candidate")
put("candidate", candidate.sdp)
put("sdpMLineIndex", candidate.sdpMLineIndex)
put("sdpMid", candidate.sdpMid)
}
onSignalingMessage(candidateMsg)
}
override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>) {}
override fun onAddStream(stream: MediaStream) {}
override fun onRemoveStream(stream: MediaStream) {}
override fun onDataChannel(channel: DataChannel) {}
override fun onRenegotiationNeeded() {}
override fun onIceGatheringChange(state: PeerConnection.IceGatheringState) {}
override fun onIceConnectionReceivingChange(receiving: Boolean) {}
})
}
private fun initializeLocalMedia(cameraType: String) {
val videoSource = peerConnectionFactory?.createVideoSource(false)
localVideoTrack = peerConnectionFactory?.createVideoTrack("video", videoSource)
val audioSource = peerConnectionFactory?.createAudioSource(MediaConstraints())
localAudioTrack = peerConnectionFactory?.createAudioTrack("audio", audioSource)
val stream = peerConnectionFactory?.createLocalMediaStream("stream")
localVideoTrack?.let { stream?.addTrack(it) }
localAudioTrack?.let { stream?.addTrack(it) }
stream?.let { peerConnection?.addStream(it) }
initializeCamera(videoSource, cameraType)
}
private fun initializeCamera(videoSource: VideoSource?, cameraType: String) {
val cameraEnumerator = Camera2Enumerator(context)
val cameraName = if (cameraType == "front") {
cameraEnumerator.deviceNames.find { cameraEnumerator.isFrontFacing(it) }
} else {
cameraEnumerator.deviceNames.find { cameraEnumerator.isBackFacing(it) }
} ?: cameraEnumerator.deviceNames.firstOrNull()
if (cameraName != null) {
surfaceTextureHelper = SurfaceTextureHelper.create("CameraThread", EglBase.create().eglBaseContext)
videoCapturer = cameraEnumerator.createCapturer(cameraName, null) as? CameraVideoCapturer
videoCapturer?.initialize(surfaceTextureHelper, context, videoSource?.capturerObserver)
videoCapturer?.startCapture(1280, 720, 30)
}
}
private fun createOffer(sessionId: String) {
val constraints = MediaConstraints()
peerConnection?.createOffer(object : SdpObserver {
override fun onCreateSuccess(desc: SessionDescription) {
peerConnection?.setLocalDescription(object : SdpObserver {
override fun onSetSuccess() {
val offerMsg = JSONObject().apply {
put("type", "offer")
put("sessionId", sessionId)
put("sdp", desc.description)
}
onSignalingMessage(offerMsg)
}
override fun onSetFailure(error: String) {}
override fun onCreateSuccess(p0: SessionDescription?) {}
override fun onCreateFailure(p0: String?) {}
}, desc)
}
override fun onCreateFailure(error: String) {}
override fun onSetSuccess() {}
override fun onSetFailure(error: String) {}
}, constraints)
}
fun handleAnswer(answerSdp: String) {
val desc = SessionDescription(SessionDescription.Type.ANSWER, answerSdp)
peerConnection?.setRemoteDescription(object : SdpObserver {
override fun onSetSuccess() {
Logger.step("WEBRTC_ANSWER_SET", "✅ WebRTC answer processed")
}
override fun onSetFailure(error: String) {}
override fun onCreateSuccess(p0: SessionDescription?) {}
override fun onCreateFailure(p0: String?) {}
}, desc)
}
fun handleIceCandidate(candidate: String, sdpMLineIndex: Int, sdpMid: String) {
val iceCandidate = IceCandidate(sdpMid, sdpMLineIndex, candidate)
peerConnection?.addIceCandidate(iceCandidate)
}
fun dispose() {
stopStreaming()
peerConnectionFactory?.dispose()
}
}

View File

@@ -0,0 +1,338 @@
package com.example.godeye.ui
import android.os.Bundle
import android.view.View
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import com.example.godeye.R
import com.example.godeye.managers.ConnectionManager
import com.example.godeye.utils.Logger
import com.example.godeye.utils.PreferenceManager
import kotlinx.coroutines.launch
import android.widget.*
import androidx.core.content.ContextCompat
/**
* Активность для мониторинга видеопотока без предварительного просмотра
* Показывает только статус трансляции и счетчик времени записи
*/
class StreamingMonitorActivity : ComponentActivity() {
private lateinit var connectionManager: ConnectionManager
private lateinit var preferenceManager: PreferenceManager
// UI элементы
private lateinit var statusText: TextView
private lateinit var recordingTimeText: TextView
private lateinit var streamingStatusIndicator: View
private lateinit var startButton: Button
private lateinit var stopButton: Button
private lateinit var switchCameraButton: Button
private lateinit var disconnectButton: Button
private lateinit var recordingIndicator: ImageView
private lateinit var connectionStatusText: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_streaming_monitor)
initializeUI()
initializeManagers()
setupEventListeners()
setupObservers()
}
/**
* Инициализация UI элементов
*/
private fun initializeUI() {
statusText = findViewById(R.id.statusText)
recordingTimeText = findViewById(R.id.recordingTimeText)
streamingStatusIndicator = findViewById(R.id.streamingStatusIndicator)
startButton = findViewById(R.id.startButton)
stopButton = findViewById(R.id.stopButton)
switchCameraButton = findViewById(R.id.switchCameraButton)
disconnectButton = findViewById(R.id.disconnectButton)
recordingIndicator = findViewById(R.id.recordingIndicator)
connectionStatusText = findViewById(R.id.connectionStatusText)
// Начальное состояние
updateUIForStatus(ConnectionManager.StreamingStatus.IDLE)
recordingTimeText.text = "00:00:00"
connectionStatusText.text = "Не подключено"
}
/**
* Инициализация менеджеров
*/
private fun initializeManagers() {
preferenceManager = PreferenceManager(this)
connectionManager = ConnectionManager(this, preferenceManager)
}
/**
* Настройка слушателей событий
*/
private fun setupEventListeners() {
startButton.setOnClickListener {
startStreaming()
}
stopButton.setOnClickListener {
stopStreaming()
}
switchCameraButton.setOnClickListener {
switchCamera()
}
disconnectButton.setOnClickListener {
disconnect()
}
}
/**
* Настройка наблюдателей за состоянием
*/
private fun setupObservers() {
// Наблюдение за событиями соединения
lifecycleScope.launch {
connectionManager.events.collect { event ->
when (event) {
is ConnectionManager.ConnectionEvent.Connected -> {
runOnUiThread {
connectionStatusText.text = "Подключено к серверу"
connectionStatusText.setTextColor(ContextCompat.getColor(this@StreamingMonitorActivity, R.color.success_green))
Toast.makeText(this@StreamingMonitorActivity, "Подключено к серверу", Toast.LENGTH_SHORT).show()
}
}
is ConnectionManager.ConnectionEvent.Disconnected -> {
runOnUiThread {
connectionStatusText.text = "Отключено от сервера"
connectionStatusText.setTextColor(ContextCompat.getColor(this@StreamingMonitorActivity, R.color.error_red))
}
}
is ConnectionManager.ConnectionEvent.Error -> {
runOnUiThread {
Toast.makeText(this@StreamingMonitorActivity, "Ошибка: ${event.message}", Toast.LENGTH_LONG).show()
Logger.error("STREAMING_MONITOR_ERROR", event.message, null)
}
}
is ConnectionManager.ConnectionEvent.StatusUpdated -> {
runOnUiThread {
statusText.text = event.status
}
}
is ConnectionManager.ConnectionEvent.RecordingTimeUpdated -> {
runOnUiThread {
recordingTimeText.text = event.duration
}
}
is ConnectionManager.ConnectionEvent.StreamingStarted -> {
runOnUiThread {
Toast.makeText(this@StreamingMonitorActivity, "Трансляция началась", Toast.LENGTH_SHORT).show()
}
}
is ConnectionManager.ConnectionEvent.StreamingStopped -> {
runOnUiThread {
Toast.makeText(this@StreamingMonitorActivity, "Трансляция остановлена", Toast.LENGTH_SHORT).show()
}
}
null -> {}
}
}
}
// Наблюдение за статусом трансляции
lifecycleScope.launch {
connectionManager.streamingStatus.collect { status ->
runOnUiThread {
updateUIForStatus(status)
}
}
}
// Наблюдение за длительностью записи
lifecycleScope.launch {
connectionManager.recordingDuration.collect { duration ->
runOnUiThread {
recordingTimeText.text = duration
}
}
}
// Наблюдение за состоянием записи
lifecycleScope.launch {
connectionManager.isRecording.collect { isRecording ->
runOnUiThread {
updateRecordingIndicator(isRecording)
}
}
}
}
/**
* Обновление UI в зависимости от статуса трансляции
*/
private fun updateUIForStatus(status: ConnectionManager.StreamingStatus) {
when (status) {
ConnectionManager.StreamingStatus.IDLE -> {
streamingStatusIndicator.setBackgroundColor(ContextCompat.getColor(this, R.color.status_idle))
startButton.isEnabled = true
stopButton.isEnabled = false
switchCameraButton.isEnabled = false
statusText.text = "Готов к трансляции"
}
ConnectionManager.StreamingStatus.CONNECTING -> {
streamingStatusIndicator.setBackgroundColor(ContextCompat.getColor(this, R.color.status_connecting))
startButton.isEnabled = false
stopButton.isEnabled = false
switchCameraButton.isEnabled = false
statusText.text = "Подключение..."
}
ConnectionManager.StreamingStatus.STREAMING -> {
streamingStatusIndicator.setBackgroundColor(ContextCompat.getColor(this, R.color.status_streaming))
startButton.isEnabled = false
stopButton.isEnabled = true
switchCameraButton.isEnabled = true
statusText.text = "Трансляция активна"
}
ConnectionManager.StreamingStatus.ERROR -> {
streamingStatusIndicator.setBackgroundColor(ContextCompat.getColor(this, R.color.status_error))
startButton.isEnabled = true
stopButton.isEnabled = false
switchCameraButton.isEnabled = false
statusText.text = "Ошибка трансляции"
}
ConnectionManager.StreamingStatus.STOPPING -> {
streamingStatusIndicator.setBackgroundColor(ContextCompat.getColor(this, R.color.status_stopping))
startButton.isEnabled = false
stopButton.isEnabled = false
switchCameraButton.isEnabled = false
statusText.text = "Остановка трансляции..."
}
}
}
/**
* Обновление индикатора записи
*/
private fun updateRecordingIndicator(isRecording: Boolean) {
if (isRecording) {
recordingIndicator.setImageResource(R.drawable.ic_recording_active)
recordingIndicator.visibility = View.VISIBLE
// Добавляем анимацию мигания
recordingIndicator.animate()
.alpha(0f)
.setDuration(500)
.withEndAction {
recordingIndicator.animate()
.alpha(1f)
.setDuration(500)
.withEndAction {
if (connectionManager.isCurrentlyRecording()) {
updateRecordingIndicator(true) // Повторяем анимацию
}
}
}
} else {
recordingIndicator.visibility = View.GONE
recordingIndicator.clearAnimation()
}
}
/**
* Начало трансляции
*/
private fun startStreaming() {
try {
Logger.step("MONITOR_START_STREAMING", "Starting streaming from monitor")
// Здесь можно получить sessionId и operatorId из Intent или диалога
val sessionId = intent.getStringExtra("sessionId") ?: generateSessionId()
val operatorId = intent.getStringExtra("operatorId") ?: "operator_1"
val cameraType = intent.getStringExtra("cameraType") ?: "back"
connectionManager.startVideoStream(sessionId, operatorId, cameraType)
} catch (e: Exception) {
Logger.error("MONITOR_START_ERROR", "Failed to start streaming", e)
Toast.makeText(this, "Ошибка запуска трансляции: ${e.message}", Toast.LENGTH_LONG).show()
}
}
/**
* Остановка трансляции
*/
private fun stopStreaming() {
try {
Logger.step("MONITOR_STOP_STREAMING", "Stopping streaming from monitor")
connectionManager.stopAllStreaming()
} catch (e: Exception) {
Logger.error("MONITOR_STOP_ERROR", "Failed to stop streaming", e)
Toast.makeText(this, "Ошибка остановки трансляции: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
/**
* Переключение камеры
*/
private fun switchCamera() {
try {
Logger.step("MONITOR_SWITCH_CAMERA", "Switching camera from monitor")
connectionManager.switchCamera()
} catch (e: Exception) {
Logger.error("MONITOR_SWITCH_ERROR", "Failed to switch camera", e)
Toast.makeText(this, "Ошибка переключения камеры: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
/**
* Отключение от сервера
*/
private fun disconnect() {
try {
Logger.step("MONITOR_DISCONNECT", "Disconnecting from monitor")
connectionManager.disconnect()
} catch (e: Exception) {
Logger.error("MONITOR_DISCONNECT_ERROR", "Failed to disconnect", e)
}
}
/**
* Генерация ID сессии
*/
private fun generateSessionId(): String {
return "session_${System.currentTimeMillis()}"
}
override fun onDestroy() {
super.onDestroy()
try {
connectionManager.dispose()
} catch (e: Exception) {
Logger.error("MONITOR_DISPOSE_ERROR", "Failed to dispose connection manager", e)
}
}
override fun onPause() {
super.onPause()
// Не останавливаем трансляцию при уходе активности в фон
Logger.step("MONITOR_PAUSE", "Monitor activity paused, streaming continues")
}
override fun onResume() {
super.onResume()
Logger.step("MONITOR_RESUME", "Monitor activity resumed")
// Обновляем UI с текущим состоянием
runOnUiThread {
updateUIForStatus(connectionManager.getCurrentStreamingStatus())
recordingTimeText.text = connectionManager.getCurrentRecordingDuration()
updateRecordingIndicator(connectionManager.isCurrentlyRecording())
}
}
}

View File

@@ -0,0 +1,267 @@
package com.example.godeye.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.godeye.utils.Logger
/**
* Компонент для отображения детальной информации о WebRTC соединениях
*/
@Composable
fun NetworkTrafficMonitor(
sessionId: String?,
modifier: Modifier = Modifier
) {
// Состояние для отслеживания логов
var networkLogs by remember { mutableStateOf(listOf<NetworkLogEntry>()) }
var isExpanded by remember { mutableStateOf(false) }
// Подписываемся на логи (в реальном приложении это бы было через Flow)
LaunchedEffect(sessionId) {
// Здесь мы бы подписались на логи, но для демонстрации создадим тестовые данные
networkLogs = listOf(
NetworkLogEntry("WEBRTC_ICE_SERVERS", "ICE servers: stun:stun.l.google.com:19302", System.currentTimeMillis()),
NetworkLogEntry("WEBRTC_SDP_CANDIDATE", "UDP candidate: 192.168.1.100:54321", System.currentTimeMillis()),
NetworkLogEntry("WEBRTC_CONNECTION_STATE", "ICE connection state: CONNECTED", System.currentTimeMillis()),
NetworkLogEntry("WEBRTC_VIDEO_STATS", "Sending video to remote peer", System.currentTimeMillis())
)
}
Card(
modifier = modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(
containerColor = Color(0xFF1A2B3D).copy(alpha = 0.9f)
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
// Заголовок с кнопкой раскрытия
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Default.NetworkCheck,
contentDescription = null,
tint = Color(0xFF00BFFF),
modifier = Modifier.size(24.dp)
)
Text(
text = "📡 Мониторинг трафика",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF00BFFF)
)
}
IconButton(
onClick = { isExpanded = !isExpanded }
) {
Icon(
imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (isExpanded) "Скрыть" else "Показать",
tint = Color(0xFF00BFFF)
)
}
}
if (isExpanded) {
Spacer(modifier = Modifier.height(12.dp))
// Информация о текущем соединении
if (sessionId != null) {
ConnectionSummary(sessionId = sessionId)
Spacer(modifier = Modifier.height(12.dp))
}
// Логи сетевой активности
Text(
text = "Сетевые логи:",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Color(0xFFE0F4FF)
)
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(
modifier = Modifier.heightIn(max = 200.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(networkLogs.reversed()) { log ->
NetworkLogItem(log = log)
}
}
} else {
// Краткая информация когда свернуто
Spacer(modifier = Modifier.height(8.dp))
if (sessionId != null) {
Text(
text = "Активная сессия: $sessionId",
fontSize = 14.sp,
color = Color(0xFF40E0D0)
)
Text(
text = "Статус: Передача видео активна",
fontSize = 12.sp,
color = Color(0xFFB0C4DE)
)
} else {
Text(
text = "Нет активных соединений",
fontSize = 14.sp,
color = Color(0xFFB0C4DE)
)
}
}
}
}
}
@Composable
private fun ConnectionSummary(sessionId: String) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = Color(0xFF2D4A73).copy(alpha = 0.7f)
)
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Сессия:",
fontSize = 12.sp,
color = Color(0xFFB0C4DE)
)
Text(
text = sessionId,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = Color(0xFFE0F4FF)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Протокол:",
fontSize = 12.sp,
color = Color(0xFFB0C4DE)
)
Text(
text = "WebRTC (UDP/STUN)",
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = Color(0xFFE0F4FF)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Назначение:",
fontSize = 12.sp,
color = Color(0xFFB0C4DE)
)
Text(
text = "Удаленный оператор",
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF40E0D0)
)
}
}
}
}
@Composable
private fun NetworkLogItem(log: NetworkLogEntry) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(
Color(0xFF0A1828).copy(alpha = 0.5f),
RoundedCornerShape(4.dp)
)
.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Индикатор типа лога
val (icon, color) = when {
log.type.contains("CANDIDATE") -> Icons.Default.LocationOn to Color(0xFF4CAF50)
log.type.contains("CONNECTION") -> Icons.Default.Link to Color(0xFF2196F3)
log.type.contains("STATS") -> Icons.Default.Analytics to Color(0xFFFF9800)
log.type.contains("SDP") -> Icons.Default.Code to Color(0xFF9C27B0)
else -> Icons.Default.Info to Color(0xFF607D8B)
}
Icon(
imageVector = icon,
contentDescription = null,
tint = color,
modifier = Modifier.size(16.dp)
)
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = log.type,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
color = color
)
Text(
text = log.message,
fontSize = 11.sp,
color = Color(0xFFE0F4FF),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
// Временная метка
Text(
text = java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault()).format(java.util.Date(log.timestamp)),
fontSize = 10.sp,
color = Color(0xFFB0C4DE)
)
}
}
data class NetworkLogEntry(
val type: String,
val message: String,
val timestamp: Long
)

View File

@@ -0,0 +1,251 @@
package com.example.godeye.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.example.godeye.models.IncomingCall
import com.example.godeye.models.SessionState
import com.example.godeye.models.SignalingState
@Composable
fun SignalingStatusCard(
signalingState: SignalingState,
sessionState: SessionState,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = when (signalingState) {
SignalingState.CONNECTED -> MaterialTheme.colorScheme.primaryContainer
SignalingState.CONNECTING, SignalingState.RECONNECTING -> MaterialTheme.colorScheme.secondaryContainer
SignalingState.ERROR -> MaterialTheme.colorScheme.errorContainer
SignalingState.DISCONNECTED -> MaterialTheme.colorScheme.surfaceVariant
}
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = when (signalingState) {
SignalingState.CONNECTED -> Icons.Default.CheckCircle
SignalingState.CONNECTING, SignalingState.RECONNECTING -> Icons.Default.Refresh
SignalingState.ERROR -> Icons.Default.Warning
SignalingState.DISCONNECTED -> Icons.Default.Close
},
contentDescription = null,
tint = when (signalingState) {
SignalingState.CONNECTED -> MaterialTheme.colorScheme.primary
SignalingState.CONNECTING, SignalingState.RECONNECTING -> MaterialTheme.colorScheme.secondary
SignalingState.ERROR -> MaterialTheme.colorScheme.error
SignalingState.DISCONNECTED -> MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(32.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = when (signalingState) {
SignalingState.CONNECTED -> "Подключено к серверу"
SignalingState.CONNECTING -> "Подключение..."
SignalingState.RECONNECTING -> "Переподключение..."
SignalingState.ERROR -> "Ошибка подключения"
SignalingState.DISCONNECTED -> "Отключено"
},
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center
)
Text(
text = when (sessionState) {
SessionState.WAITING -> "Ожидание звонка от оператора"
SessionState.INCOMING -> "Входящий звонок"
SessionState.ACTIVE -> "Активная сессия"
SessionState.ENDED -> "Сессия завершена"
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}
@Composable
fun IncomingCallDialog(
incomingCall: IncomingCall,
onAccept: () -> Unit,
onReject: () -> Unit,
modifier: Modifier = Modifier
) {
Dialog(
onDismissRequest = { /* Не позволяем закрыть диалог без выбора */ }
) {
Card(
modifier = modifier,
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Иконка входящего звонка
Icon(
imageVector = Icons.Default.Phone,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Входящий звонок",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "От: ${incomingCall.operatorName}",
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center
)
Text(
text = "ID: ${incomingCall.operatorId}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Кнопка отклонения
OutlinedButton(
onClick = onReject,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Отклонить")
}
// Кнопка принятия
Button(
onClick = onAccept,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Icon(
imageVector = Icons.Default.Call,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Принять")
}
}
}
}
}
}
@Composable
fun ActiveCallCard(
incomingCall: IncomingCall,
onEndCall: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Активный звонок",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Text(
text = "С оператором: ${incomingCall.operatorName}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Button(
onClick = onEndCall,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Завершить")
}
}
}
}
}

View File

@@ -0,0 +1,572 @@
package com.example.godeye.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import com.example.godeye.models.VideoStatistics
import org.webrtc.SurfaceViewRenderer
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StreamingVideoView(
isStreaming: Boolean,
statistics: VideoStatistics?,
onStartStreaming: () -> Unit,
onStopStreaming: () -> Unit,
webRTCManager: com.example.godeye.managers.WebRTCManager? = null,
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
val context = LocalContext.current
// Состояния для сворачивания секций
var cameraExpanded by remember { mutableStateOf(true) }
var statsExpanded by remember { mutableStateOf(true) }
var operatorExpanded by remember { mutableStateOf(true) }
var trafficExpanded by remember { mutableStateOf(true) }
var debugExpanded by remember { mutableStateOf(false) }
// Состояние для SurfaceViewRenderer
var surfaceRenderer by remember { mutableStateOf<SurfaceViewRenderer?>(null) }
// ИСПРАВЛЕНИЕ: Улучшенная инициализация камеры для предпросмотра
LaunchedEffect(webRTCManager) {
if (webRTCManager != null && surfaceRenderer == null) {
try {
android.util.Log.d("StreamingVideoView", "🎥 Initializing camera preview renderer...")
// Создаем SurfaceViewRenderer
val renderer = SurfaceViewRenderer(context).apply {
setScalingType(org.webrtc.RendererCommon.ScalingType.SCALE_ASPECT_FIT)
setEnableHardwareScaler(true)
setMirror(false) // Отключаем зеркалирование для корректного отображения
}
// ИСПРАВЛЕНИЕ: Инициализируем с EGL контекстом от WebRTCManager
webRTCManager.getEglBaseContext()?.let { eglContext ->
renderer.init(eglContext, null)
surfaceRenderer = renderer
// КРИТИЧНО: Подключаем к WebRTCManager ПОСЛЕ инициализации
webRTCManager.attachLocalVideoRenderer(renderer)
// НОВОЕ: Запускаем предпросмотр камеры немедленно
webRTCManager.startCameraPreview()
android.util.Log.d("StreamingVideoView", "✅ Camera preview renderer initialized and started successfully")
} ?: run {
android.util.Log.e("StreamingVideoView", "❌ EGL context is null - cannot initialize camera preview")
}
} catch (e: Exception) {
android.util.Log.e("StreamingVideoView", "❌ Failed to initialize camera preview renderer", e)
}
}
}
// ИСПРАВЛЕНИЕ: Правильная очистка рендерера при уничтожении
DisposableEffect(surfaceRenderer, webRTCManager) {
onDispose {
try {
if (surfaceRenderer != null && webRTCManager != null) {
android.util.Log.d("StreamingVideoView", "🧹 Cleaning up camera preview renderer...")
// Останавливаем предпросмотр
webRTCManager.stopCameraPreview()
// Отключаем рендерер
webRTCManager.detachLocalVideoRenderer()
// Освобождаем ресурсы
surfaceRenderer?.release()
surfaceRenderer = null
android.util.Log.d("StreamingVideoView", "✅ Camera preview renderer cleaned up successfully")
}
} catch (e: Exception) {
android.util.Log.e("StreamingVideoView", "❌ Failed to cleanup camera preview renderer", e)
}
}
}
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// 1. Плашка предпросмотра камеры
CollapsibleCard(
title = "📹 Предпросмотр камеры",
expanded = cameraExpanded,
onToggle = { cameraExpanded = !cameraExpanded }
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(240.dp)
.background(
Color.Black,
RoundedCornerShape(8.dp)
)
) {
// WebRTC SurfaceView для предпросмотра
AndroidView(
factory = { context ->
SurfaceViewRenderer(context).apply {
setScalingType(org.webrtc.RendererCommon.ScalingType.SCALE_ASPECT_FIT)
setEnableHardwareScaler(true)
// Инициализируем с EGL контекстом от WebRTCManager
webRTCManager?.getEglBaseContext()?.let { eglContext ->
try {
init(eglContext, null)
surfaceRenderer = this
// ИСПРАВЛЕНИЕ: Подключаем рендерер к WebRTCManager сразу
webRTCManager.attachLocalVideoRenderer(this)
android.util.Log.d("StreamingVideoView", "✅ Camera renderer attached in factory")
} catch (e: Exception) {
android.util.Log.e("StreamingVideoView", "❌ Failed to init SurfaceViewRenderer", e)
}
}
}
},
update = { view ->
// Убираем дублирование инициализации
android.util.Log.d("StreamingVideoView", "AndroidView update called")
},
modifier = Modifier.fillMaxSize()
)
// Мигающий индикатор REC
if (isStreaming) {
RecordingIndicator(
modifier = Modifier
.align(Alignment.TopStart)
.padding(12.dp)
)
}
// Кнопки управления
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = if (isStreaming) onStopStreaming else onStartStreaming,
colors = ButtonDefaults.buttonColors(
containerColor = if (isStreaming) Color.Red else MaterialTheme.colorScheme.primary
)
) {
Icon(
if (isStreaming) Icons.Default.Stop else Icons.Default.PlayArrow,
contentDescription = null
)
Spacer(modifier = Modifier.width(4.dp))
Text(if (isStreaming) "Стоп" else "Старт")
}
IconButton(
onClick = {
webRTCManager?.switchCamera()
},
modifier = Modifier.background(
Color.Black.copy(alpha = 0.5f),
RoundedCornerShape(50)
)
) {
Icon(
Icons.Default.FlipCameraAndroid,
contentDescription = "Переключить камеру",
tint = Color.White
)
}
}
}
}
// 2. Характеристики видеопотока
CollapsibleCard(
title = "📊 Характеристики видеопотока",
expanded = statsExpanded,
onToggle = { statsExpanded = !statsExpanded }
) {
statistics?.let { stats ->
VideoStatisticsContent(statistics = stats)
} ?: run {
Text(
"Статистика недоступна",
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
// 3. Подключенный оператор
CollapsibleCard(
title = "👤 Подключенный оператор",
expanded = operatorExpanded,
onToggle = { operatorExpanded = !operatorExpanded }
) {
OperatorInfoContent(isConnected = isStreaming)
}
// 4. Переданный трафик
CollapsibleCard(
title = "📡 Переданный трафик",
expanded = trafficExpanded,
onToggle = { trafficExpanded = !trafficExpanded }
) {
TrafficInfoContent(statistics = statistics)
}
// 5. Отладка и диагностика
CollapsibleCard(
title = "🔧 Отладка и диагностика",
expanded = debugExpanded,
onToggle = { debugExpanded = !debugExpanded }
) {
DebugInfoContent()
}
// Мониторинг сетевого трафика (всегда внизу)
NetworkTrafficMonitor(
sessionId = if (isStreaming) "active_session" else null,
modifier = Modifier.fillMaxWidth()
)
}
}
@Composable
private fun RecordingIndicator(
modifier: Modifier = Modifier
) {
var isVisible by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
while (true) {
kotlinx.coroutines.delay(1000)
isVisible = !isVisible
}
}
if (isVisible) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(containerColor = Color.Red),
shape = RoundedCornerShape(16.dp)
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
Icons.Default.FiberManualRecord,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(8.dp)
)
Text(
"REC",
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CollapsibleCard(
title: String,
expanded: Boolean,
onToggle: () -> Unit,
content: @Composable () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column {
// Заголовок (всегда видим)
Surface(
onClick = onToggle,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
Icon(
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (expanded) "Свернуть" else "Развернуть",
tint = MaterialTheme.colorScheme.primary
)
}
}
// Содержимое (показывается только при развернутом состоянии)
if (expanded) {
Column(
modifier = Modifier.padding(16.dp)
) {
content()
}
}
}
}
}
@Composable
private fun VideoStatisticsContent(statistics: VideoStatistics) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
StatisticRow("FPS", "${statistics.fps}")
StatisticRow("Разрешение", "${statistics.width}x${statistics.height}")
StatisticRow("Битрейт", formatBitrate(statistics.bitrate))
StatisticRow("Переданные кадры", "${statistics.framerate}")
StatisticRow("Потерянные пакеты", "${statistics.packetsLost}")
StatisticRow("Задержка (RTT)", "${statistics.rtt}ms")
StatisticRow("Джиттер", "${String.format("%.2f", statistics.jitter)}ms")
}
}
@Composable
private fun OperatorInfoContent(isConnected: Boolean) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Circle,
contentDescription = null,
tint = if (isConnected) Color.Green else Color.Gray,
modifier = Modifier.size(12.dp)
)
Text(
if (isConnected) "Подключен" else "Не подключен",
fontWeight = FontWeight.Medium,
color = if (isConnected) Color.Green else Color.Gray
)
}
if (isConnected) {
StatisticRow("ID оператора", "OP_12345")
StatisticRow("Время сессии", "05:23")
StatisticRow("Качество связи", "Отлично")
StatisticRow("Тип камеры", "Основная")
}
}
}
@Composable
private fun TrafficInfoContent(statistics: VideoStatistics?) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
statistics?.let { stats ->
StatisticRow("Всего отправлено", formatBytes(stats.bytesSent))
StatisticRow("Скорость", formatBitrate(stats.bitrate))
StatisticRow("Сжатие", "H.264")
StatisticRow("Использовано трафика", formatBytes(stats.bytesSent))
} ?: run {
Text(
"Нет данных о трафике",
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
}
@Composable
private fun DebugInfoContent() {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
// Информация о соединении
Text(
"Информация о соединении",
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp
)
StatisticRow("WebRTC состояние", "CONNECTED")
StatisticRow("ICE состояние", "COMPLETED")
StatisticRow("Signaling", "STABLE")
HorizontalDivider()
// Сетевая информация
Text(
"Сетевая информация",
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp
)
StatisticRow("Локальный IP", "192.168.1.105")
StatisticRow("Внешний IP", "203.45.67.89")
StatisticRow("STUN сервер", "stun.l.google.com:19302")
StatisticRow("Порт", "51234")
HorizontalDivider()
// Кнопки действий
Text(
"Действия отладки",
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = { /* Тест соединения */ },
modifier = Modifier.weight(1f)
) {
Text("Тест связи", fontSize = 12.sp)
}
OutlinedButton(
onClick = { /* Перезапуск */ },
modifier = Modifier.weight(1f)
) {
Text("Перезапуск", fontSize = 12.sp)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = { /* Логи */ },
modifier = Modifier.weight(1f)
) {
Text("Экспорт логов", fontSize = 12.sp)
}
OutlinedButton(
onClick = { /* Диагностика */ },
modifier = Modifier.weight(1f)
) {
Text("Диагностика", fontSize = 12.sp)
}
}
// Переключатели отладки
HorizontalDivider()
Text(
"Настройки отладки",
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp
)
var verboseLogging by remember { mutableStateOf(false) }
var showStatistics by remember { mutableStateOf(true) }
var autoReconnect by remember { mutableStateOf(true) }
DebugSwitchRow(
"Детальное логирование",
verboseLogging
) { verboseLogging = it }
DebugSwitchRow(
"Показывать статистику",
showStatistics
) { showStatistics = it }
DebugSwitchRow(
"Автоперезподключение",
autoReconnect
) { autoReconnect = it }
}
}
@Composable
private fun DebugSwitchRow(
label: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
label,
fontSize = 14.sp,
modifier = Modifier.weight(1f)
)
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
modifier = Modifier.height(24.dp)
)
}
}
@Composable
private fun StatisticRow(
label: String,
value: String
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.weight(1f)
)
Text(
text = value,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
private fun formatBytes(bytes: Long): String {
return when {
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
bytes < 1024 * 1024 * 1024 -> "${bytes / (1024 * 1024)} MB"
else -> "${bytes / (1024 * 1024 * 1024)} GB"
}
}
private fun formatBitrate(bitrate: Long): String {
return when {
bitrate < 1000 -> "${bitrate} bps"
bitrate < 1000000 -> "${bitrate / 1000} Kbps"
else -> "${bitrate / 1000000} Mbps"
}
}

View File

@@ -0,0 +1,850 @@
package com.example.godeye.ui.screens
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.godeye.managers.AutoApprovalManager
import com.example.godeye.managers.ConnectionManager
import com.example.godeye.utils.PreferenceManager
import com.example.godeye.utils.PermissionHelper
import com.example.godeye.ui.components.StreamingVideoView
import com.example.godeye.models.StreamingState
import com.example.godeye.managers.WebRTCManager
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
connectionManager: ConnectionManager,
autoApprovalManager: AutoApprovalManager,
preferenceManager: PreferenceManager,
permissionHelper: PermissionHelper,
onOpenSettings: () -> Unit
) {
val connectionState by connectionManager.connectionState.collectAsState(initial = ConnectionManager.ConnectionState.DISCONNECTED)
val deviceInfo by connectionManager.deviceInfo.collectAsState(initial = null)
val activeSessions by autoApprovalManager.activeSessions.collectAsState(initial = emptyList())
val pendingRequest by autoApprovalManager.pendingRequest.collectAsState(initial = null)
val serverUrl by preferenceManager.serverUrl.collectAsState(initial = "")
val deviceName by preferenceManager.deviceName.collectAsState(initial = "")
val autoConnect by preferenceManager.autoConnect.collectAsState(initial = false)
val autoApprove by preferenceManager.autoApprove.collectAsState(initial = false)
// НОВОЕ: Состояние трансляции
val streamingState by connectionManager.webRTCManager.streamingState.collectAsState()
val isStreamingMode = streamingState.isStreaming
Box(
modifier = Modifier
.fillMaxSize()
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color(0xFF0A1828),
Color(0xFF1E3A5F),
Color(0xFF2D4A73)
)
)
)
) {
if (isStreamingMode) {
// НОВОЕ: Режим трансляции с видео и статистикой
StreamingModeContent(
streamingState = streamingState,
onSwitchCamera = { connectionManager.webRTCManager.switchCamera() },
onStopStreaming = {
streamingState.sessionId?.let { sessionId ->
connectionManager.webRTCManager.stopStreaming(sessionId)
}
},
webRTCManager = connectionManager.webRTCManager // Передаем WebRTCManager
)
} else {
// Обычный режим с плашками настроек
NormalModeContent(
connectionState = connectionState,
deviceInfo = deviceInfo,
activeSessions = activeSessions,
pendingRequest = pendingRequest,
serverUrl = serverUrl,
deviceName = deviceName,
autoConnect = autoConnect,
autoApprove = autoApprove,
connectionManager = connectionManager,
autoApprovalManager = autoApprovalManager,
preferenceManager = preferenceManager,
permissionHelper = permissionHelper,
onOpenSettings = onOpenSettings
)
}
}
}
@Composable
private fun StreamingModeContent(
streamingState: StreamingState,
onSwitchCamera: () -> Unit,
onStopStreaming: () -> Unit,
webRTCManager: com.example.godeye.managers.WebRTCManager // Добавлен параметр webRTCManager
) {
StreamingVideoView(
isStreaming = streamingState.isStreaming,
statistics = streamingState.statistics,
onStartStreaming = { /* Здесь можно добавить логику запуска трансляции */ },
onStopStreaming = onStopStreaming,
webRTCManager = webRTCManager,
modifier = Modifier.fillMaxSize()
)
}
@Composable
private fun NormalModeContent(
connectionState: ConnectionManager.ConnectionState,
deviceInfo: Any?,
activeSessions: List<Any>,
pendingRequest: Any?,
serverUrl: String,
deviceName: String,
autoConnect: Boolean,
autoApprove: Boolean,
connectionManager: ConnectionManager,
autoApprovalManager: AutoApprovalManager,
preferenceManager: PreferenceManager,
permissionHelper: PermissionHelper,
onOpenSettings: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Заголовок с настройками
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "GodEye",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
color = Color(0xFF00BFFF)
)
Text(
text = "Signal Center",
style = MaterialTheme.typography.titleMedium,
color = Color(0xFF40E0D0)
)
}
IconButton(
onClick = onOpenSettings,
modifier = Modifier
.background(
Color(0xFF1A2B3D),
CircleShape
)
) {
Icon(
Icons.Default.Settings,
contentDescription = "Настройки",
tint = Color(0xFF00BFFF)
)
}
}
// Карточка статуса подключения
ConnectionStatusCard(
connectionState = connectionState,
serverUrl = serverUrl,
deviceName = deviceName,
deviceInfo = deviceInfo as? com.example.godeye.models.DeviceInfo,
onConnect = { connectionManager.connect() },
onDisconnect = { connectionManager.disconnect() }
)
// Карточка настроек
SettingsOverviewCard(
autoConnect = autoConnect,
autoApprove = autoApprove,
hasAllPermissions = permissionHelper.hasAllPermissions(),
onOpenSettings = onOpenSettings
)
// Тестовая кнопка для проверки автоподтверждения
if (connectionState == ConnectionManager.ConnectionState.CONNECTED) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = Color(0xFF2D4A22).copy(alpha = 0.8f)
),
border = BorderStroke(1.dp, Color(0xFF4CAF50))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "Тестирование автоподтверждения",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF4CAF50)
)
Text(
text = "Имитирует запрос камеры от оператора",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFFB3B3B3)
)
}
Button(
onClick = { connectionManager.testAutoApproval() },
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF4CAF50)
)
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Тест"
)
Spacer(modifier = Modifier.width(8.dp))
Text("Тест")
}
}
}
}
// Pending запрос (если есть)
pendingRequest?.let { request ->
if (request is org.json.JSONObject) {
PendingRequestCard(
request = request,
onApprove = { sessionId, operatorId, cameraType ->
autoApprovalManager.approveRequest(sessionId, operatorId, cameraType)
},
onDeny = { sessionId ->
autoApprovalManager.denyRequest(sessionId)
}
)
}
}
// Активные сессии
if (activeSessions.isNotEmpty()) {
Text(
text = "Активные сессии (${activeSessions.size})",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF00BFFF)
)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(activeSessions) { session ->
if (session is AutoApprovalManager.CameraSession) {
ActiveSessionCard(session = session)
}
}
}
} else {
// Пустое состояние
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = Color(0xFF1A2B3D).copy(alpha = 0.8f)
),
border = BorderStroke(1.dp, Color(0xFF40E0D0).copy(alpha = 0.3f))
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = Color(0xFF40E0D0)
)
Text(
text = "Нет активных сессий",
style = MaterialTheme.typography.titleMedium,
color = Color(0xFFE0F4FF)
)
Text(
text = "Ожидание запросов от операторов",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFFB0C4DE)
)
}
}
}
}
}
@Composable
private fun ConnectionStatusCard(
connectionState: ConnectionManager.ConnectionState,
serverUrl: String,
deviceName: String,
deviceInfo: com.example.godeye.models.DeviceInfo?,
onConnect: () -> Unit,
onDisconnect: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = when (connectionState) {
ConnectionManager.ConnectionState.CONNECTED -> MaterialTheme.colorScheme.primaryContainer
ConnectionManager.ConnectionState.CONNECTING -> MaterialTheme.colorScheme.tertiaryContainer
ConnectionManager.ConnectionState.ERROR -> MaterialTheme.colorScheme.errorContainer
ConnectionManager.ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.surfaceVariant
ConnectionManager.ConnectionState.RECONNECTING -> MaterialTheme.colorScheme.tertiaryContainer
}
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Подключение к серверу",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
when (connectionState) {
ConnectionManager.ConnectionState.CONNECTED -> {
Button(
onClick = onDisconnect,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Text("Отключиться")
}
}
ConnectionManager.ConnectionState.CONNECTING -> {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
else -> {
Button(onClick = onConnect) {
Text("Подключиться")
}
}
}
}
// Статус
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
when (connectionState) {
ConnectionManager.ConnectionState.CONNECTED -> Icons.Default.CheckCircle
ConnectionManager.ConnectionState.CONNECTING -> Icons.Default.Refresh
ConnectionManager.ConnectionState.ERROR -> Icons.Default.Warning
ConnectionManager.ConnectionState.DISCONNECTED -> Icons.Default.Close
ConnectionManager.ConnectionState.RECONNECTING -> Icons.Default.Refresh
},
contentDescription = null,
tint = when (connectionState) {
ConnectionManager.ConnectionState.CONNECTED -> MaterialTheme.colorScheme.onPrimaryContainer
ConnectionManager.ConnectionState.CONNECTING -> MaterialTheme.colorScheme.onTertiaryContainer
ConnectionManager.ConnectionState.ERROR -> MaterialTheme.colorScheme.onErrorContainer
ConnectionManager.ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.onSurfaceVariant
ConnectionManager.ConnectionState.RECONNECTING -> MaterialTheme.colorScheme.onTertiaryContainer
}
)
Text(
text = when (connectionState) {
ConnectionManager.ConnectionState.CONNECTED -> "Подключено"
ConnectionManager.ConnectionState.CONNECTING -> "Подключение..."
ConnectionManager.ConnectionState.ERROR -> "Ошибка соединения"
ConnectionManager.ConnectionState.DISCONNECTED -> "Отключено"
ConnectionManager.ConnectionState.RECONNECTING -> "Переподключение..."
},
style = MaterialTheme.typography.bodyMedium
)
}
// Информация о сервере
Text(
text = "Сервер: $serverUrl",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Устройство: $deviceName",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (deviceInfo is com.example.godeye.models.DeviceInfo) {
Text(
text = "Модель: ${deviceInfo.manufacturer} ${deviceInfo.model} (Android ${deviceInfo.androidVersion})",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
private fun SettingsOverviewCard(
autoConnect: Boolean,
autoApprove: Boolean,
hasAllPermissions: Boolean,
onOpenSettings: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Быстрые настройки",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
TextButton(onClick = onOpenSettings) {
Text("Все настройки")
Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null)
}
}
// Статусы настроек
SettingStatusRow("Автоподключение", autoConnect)
SettingStatusRow("Автоподтверждение", autoApprove, isWarning = autoApprove)
SettingStatusRow("Разрешения", hasAllPermissions, isError = !hasAllPermissions)
}
}
}
@Composable
private fun SettingStatusRow(
title: String,
enabled: Boolean,
isWarning: Boolean = false,
isError: Boolean = false
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
if (enabled) Icons.Default.Check else Icons.Default.Close,
contentDescription = null,
tint = when {
isError -> MaterialTheme.colorScheme.error
isWarning -> MaterialTheme.colorScheme.tertiary
enabled -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
)
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
color = when {
isError -> MaterialTheme.colorScheme.error
isWarning -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
}
@Composable
private fun PendingRequestCard(
request: org.json.JSONObject,
onApprove: (String, String, String) -> Unit,
onDeny: (String) -> Unit
) {
val sessionId = request.optString("sessionId")
val operatorId = request.optString("operatorId")
val cameraType = request.optString("cameraType", "back")
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = Color(0xFF2D4A22).copy(alpha = 0.8f)
),
border = BorderStroke(1.dp, Color(0xFFFF9800))
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.NotificationImportant,
contentDescription = null,
tint = Color(0xFFFF9800),
modifier = Modifier.size(24.dp)
)
Text(
text = "Входящий запрос камеры",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = Color(0xFFFF9800)
)
}
HorizontalDivider(
color = Color(0xFFFF9800).copy(alpha = 0.3f),
thickness = 1.dp
)
// UUID оператора
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "UUID оператора:",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFB0C4DE)
)
Text(
text = operatorId,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = Color(0xFFE0F4FF),
modifier = Modifier
.background(
Color(0xFF2D4A73).copy(alpha = 0.5f),
shape = MaterialTheme.shapes.small
)
.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
// Информация о запросе
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(
text = "Запрашиваемая камера:",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFB0C4DE)
)
Text(
text = getCameraDisplayName(cameraType),
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFFE0F4FF)
)
}
Column(
horizontalAlignment = Alignment.End
) {
Text(
text = "ID сессии:",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFB0C4DE)
)
Text(
text = sessionId.take(8) + "...",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFE0F4FF)
)
}
}
}
// Предупреждение
Card(
colors = CardDefaults.cardColors(
containerColor = Color(0xFFFF9800).copy(alpha = 0.1f)
),
border = BorderStroke(1.dp, Color(0xFFFF9800).copy(alpha = 0.3f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = Color(0xFFFF9800),
modifier = Modifier.size(16.dp)
)
Text(
text = "Убедитесь, что вы доверяете этому оператору",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFFF9800)
)
}
}
// Кнопки действий
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = { onDeny(sessionId) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = Color(0xFFF44336)
),
border = BorderStroke(1.dp, Color(0xFFF44336))
) {
Icon(
Icons.Default.Close,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Отклонить")
}
Button(
onClick = { onApprove(sessionId, operatorId, cameraType) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF4CAF50)
)
) {
Icon(
Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Разрешить")
}
}
}
}
}
@Composable
private fun ActiveSessionCard(
session: AutoApprovalManager.CameraSession
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = Color(0xFF1A2B3D).copy(alpha = 0.9f)
),
border = BorderStroke(1.dp, Color(0xFF00BFFF).copy(alpha = 0.5f))
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Заголовок с состоянием
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Подключенный оператор",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = Color(0xFF00BFFF)
)
// Индикатор состояния
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Box(
modifier = Modifier
.size(8.dp)
.background(
color = when (session.streamingState) {
AutoApprovalManager.StreamingState.STREAMING -> Color(0xFF4CAF50)
AutoApprovalManager.StreamingState.CONNECTING -> Color(0xFFFF9800)
AutoApprovalManager.StreamingState.ERROR -> Color(0xFFF44336)
else -> Color(0xFF9E9E9E)
},
shape = CircleShape
)
)
Text(
text = when (session.streamingState) {
AutoApprovalManager.StreamingState.STREAMING -> "Активна"
AutoApprovalManager.StreamingState.CONNECTING -> "Подключение"
AutoApprovalManager.StreamingState.ERROR -> "Ошибка"
else -> "Ожидание"
},
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFB0C4DE)
)
}
}
HorizontalDivider(
color = Color(0xFF40E0D0).copy(alpha = 0.3f),
thickness = 1.dp
)
// UUID оператора
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "UUID оператора:",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFB0C4DE)
)
Text(
text = session.operatorId,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = Color(0xFFE0F4FF),
// Делаем текст выделяемым для копирования
modifier = Modifier
.background(
Color(0xFF2D4A73).copy(alpha = 0.5f),
shape = MaterialTheme.shapes.small
)
.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
// Информация о камере и времени
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = "Камера:",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFB0C4DE)
)
Text(
text = getCameraDisplayName(session.cameraType),
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFE0F4FF)
)
}
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
horizontalAlignment = Alignment.End
) {
Text(
text = "Длительность:",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFB0C4DE)
)
Text(
text = formatDuration(System.currentTimeMillis() - session.startTime),
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFE0F4FF)
)
}
}
// Дополнительная информация
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (session.isAutoApproved) "Автоматически одобрено" else "Одобрено вручную",
style = MaterialTheme.typography.bodySmall,
color = if (session.isAutoApproved) Color(0xFF4CAF50) else Color(0xFF2196F3)
)
Icon(
imageVector = Icons.Default.Videocam,
contentDescription = "Камера активна",
tint = Color(0xFF00BFFF),
modifier = Modifier.size(20.dp)
)
}
}
}
}
// Вспомогательная функция для форматирования времени
private fun formatDuration(durationMs: Long): String {
val seconds = (durationMs / 1000) % 60
val minutes = (durationMs / (1000 * 60)) % 60
val hours = (durationMs / (1000 * 60 * 60)) % 24
return when {
hours > 0 -> String.format("%02d:%02d:%02d", hours, minutes, seconds)
else -> String.format("%02d:%02d", minutes, seconds)
}
}
// Вспомогательная функция для отображения типа камеры
private fun getCameraDisplayName(cameraType: String): String {
return when (cameraType) {
"back" -> "Основная камера"
"front" -> "Фронтальная камера"
"wide" -> "Широкоугольная камера"
"telephoto" -> "Телеобъектив"
else -> "Камера"
}
}

View File

@@ -0,0 +1,350 @@
package com.example.godeye.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.godeye.utils.PreferenceManager
import com.example.godeye.utils.PermissionHelper
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
preferenceManager: PreferenceManager,
permissionHelper: PermissionHelper,
onNavigateBack: () -> Unit
) {
val serverUrl by preferenceManager.serverUrl.collectAsStateWithLifecycle()
val deviceName by preferenceManager.deviceName.collectAsStateWithLifecycle()
val autoConnect by preferenceManager.autoConnect.collectAsStateWithLifecycle()
val autoApprove by preferenceManager.autoApprove.collectAsStateWithLifecycle()
var tempServerUrl by remember(serverUrl) { mutableStateOf(serverUrl) }
var tempDeviceName by remember(deviceName) { mutableStateOf(deviceName) }
var showPermissionDialog by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Заголовок
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Настройки",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.Close, contentDescription = "Закрыть")
}
}
HorizontalDivider()
// Секция "Подключение к серверу"
SettingsSection(title = "Подключение к серверу") {
// URL сервера
OutlinedTextField(
value = tempServerUrl,
onValueChange = { tempServerUrl = it },
label = { Text("URL сервера") },
placeholder = { Text("http://192.168.219.108:3001") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
// Кнопка сохранения URL
Button(
onClick = {
if (tempServerUrl.isNotBlank()) {
preferenceManager.setServerUrl(tempServerUrl.trim())
}
},
modifier = Modifier.align(Alignment.End),
enabled = tempServerUrl.trim() != serverUrl
) {
Icon(Icons.Default.Done, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Сохранить URL")
}
// Автоматическое подключение
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Автоматическое подключение",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Text(
text = "Подключаться к серверу при запуске приложения",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = autoConnect,
onCheckedChange = { preferenceManager.setAutoConnect(it) }
)
}
}
}
// Секция "Устройство"
SettingsSection(title = "Устройство") {
// Имя устройства
OutlinedTextField(
value = tempDeviceName,
onValueChange = { tempDeviceName = it },
label = { Text("Имя устройства") },
placeholder = { Text("Android Device") },
leadingIcon = { Icon(Icons.Default.Phone, contentDescription = null) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
// Кнопка сохранения имени
Button(
onClick = {
if (tempDeviceName.isNotBlank()) {
preferenceManager.setDeviceName(tempDeviceName.trim())
}
},
modifier = Modifier.align(Alignment.End),
enabled = tempDeviceName.trim() != deviceName
) {
Icon(Icons.Default.Done, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Сохранить имя")
}
// ID устройства (только для чтения)
OutlinedTextField(
value = preferenceManager.getDeviceId(),
onValueChange = { },
label = { Text("ID устройства") },
leadingIcon = { Icon(Icons.Default.Info, contentDescription = null) },
modifier = Modifier.fillMaxWidth(),
enabled = false,
singleLine = true
)
}
// Секция "Безопасность"
SettingsSection(title = "Безопасность") {
// Автоматическое подтверждение
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (autoApprove)
MaterialTheme.colorScheme.errorContainer
else
MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Автоматическое подтверждение",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = if (autoApprove)
MaterialTheme.colorScheme.onErrorContainer
else
MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = if (autoApprove) "⚠️ Операторы получат доступ без подтверждения"
else "Запрашивать подтверждение для каждого запроса",
style = MaterialTheme.typography.bodyMedium,
color = if (autoApprove)
MaterialTheme.colorScheme.onErrorContainer
else
MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = autoApprove,
onCheckedChange = { preferenceManager.setAutoApprove(it) }
)
}
}
}
// Секция "Разрешения"
SettingsSection(title = "Разрешения") {
val hasAllPermissions = permissionHelper.hasAllPermissions()
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (hasAllPermissions)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.errorContainer
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
if (hasAllPermissions) Icons.Default.CheckCircle else Icons.Default.Warning,
contentDescription = null,
tint = if (hasAllPermissions)
MaterialTheme.colorScheme.onPrimaryContainer
else
MaterialTheme.colorScheme.onErrorContainer
)
Text(
text = if (hasAllPermissions) "Все разрешения предоставлены" else "Требуются разрешения",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = if (hasAllPermissions)
MaterialTheme.colorScheme.onPrimaryContainer
else
MaterialTheme.colorScheme.onErrorContainer
)
}
// Список разрешений
PermissionHelper.REQUIRED_PERMISSIONS.forEach { permission ->
val hasPermission = permissionHelper.hasPermission(permission)
val permissionName = getPermissionDisplayName(permission)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
if (hasPermission) Icons.Default.Check else Icons.Default.Close,
contentDescription = null,
tint = if (hasPermission)
MaterialTheme.colorScheme.onPrimaryContainer
else
MaterialTheme.colorScheme.onErrorContainer
)
Text(
text = permissionName,
style = MaterialTheme.typography.bodyMedium,
color = if (hasAllPermissions)
MaterialTheme.colorScheme.onPrimaryContainer
else
MaterialTheme.colorScheme.onErrorContainer
)
}
}
if (!hasAllPermissions) {
Button(
onClick = { showPermissionDialog = true },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Lock, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Запросить разрешения")
}
}
}
}
}
}
// Диалог запроса разрешений
if (showPermissionDialog) {
AlertDialog(
onDismissRequest = { showPermissionDialog = false },
title = { Text("Разрешения приложения") },
text = {
Text("Для корректной работы приложению необходимы разрешения на доступ к камере, микрофону и интернету.")
},
confirmButton = {
TextButton(
onClick = {
showPermissionDialog = false
permissionHelper.requestPermissions { granted ->
// Результат обработается автоматически через recomposition
}
}
) {
Text("Предоставить")
}
},
dismissButton = {
TextButton(onClick = { showPermissionDialog = false }) {
Text("Отмена")
}
}
)
}
}
@Composable
private fun SettingsSection(
title: String,
content: @Composable ColumnScope.() -> Unit
) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
content()
}
}
private fun getPermissionDisplayName(permission: String): String {
return when (permission) {
android.Manifest.permission.CAMERA -> "Камера"
android.Manifest.permission.RECORD_AUDIO -> "Микрофон"
android.Manifest.permission.INTERNET -> "Интернет"
android.Manifest.permission.ACCESS_NETWORK_STATE -> "Состояние сети"
android.Manifest.permission.WAKE_LOCK -> "Предотвращение блокировки"
android.Manifest.permission.POST_NOTIFICATIONS -> "Уведомления"
else -> permission.split(".").lastOrNull() ?: permission
}
}

View File

@@ -5,93 +5,54 @@ import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/**
* Цветовая палитра GodEye согласно ТЗ
*/
// Цветовая схема GodEye
object GodEyeColors {
// Основные цвета приложения
val BlackPure = Color(0xFF000000)
val BlackSoft = Color(0xFF1A1A1A)
val BlackMedium = Color(0xFF2D2D2D)
val Primary = Color(0xFF1976D2)
val PrimaryVariant = Color(0xFF1565C0)
val Secondary = Color(0xFF03DAC6)
val Background = Color(0xFF121212)
val Surface = Color(0xFF1E1E1E)
val Error = Color(0xFFCF6679)
val OnPrimary = Color.White
val OnSecondary = Color.Black
val OnBackground = Color.White
val OnSurface = Color.White
val OnError = Color.Black
val IvoryPure = Color(0xFFFFFFF0)
val IvorySoft = Color(0xFFF5F5DC)
val IvoryMedium = Color(0xFFE6E6D4)
val NavyDark = Color(0xFF0F1419)
val NavyMedium = Color(0xFF1E2328)
val NavyLight = Color(0xFF2D3748)
// Функциональные цвета
val RecordRed = Color(0xFFFF3B30)
val WarningAmber = Color(0xFFFF9500)
val SuccessGreen = Color(0xFF30D158)
val InfoBlue = Color(0xFF007AFF)
// Градиенты
val PrimaryGradientStart = NavyDark
val PrimaryGradientEnd = BlackSoft
val AccentGradientStart = NavyLight
val AccentGradientEnd = NavyMedium
// Статусные цвета
val Connected = Color(0xFF4CAF50)
val Disconnected = Color(0xFFF44336)
val Waiting = Color(0xFFFF9800)
}
private val DarkColorScheme = darkColorScheme(
primary = GodEyeColors.NavyLight,
onPrimary = GodEyeColors.IvoryPure,
primaryContainer = GodEyeColors.NavyMedium,
onPrimaryContainer = GodEyeColors.IvorySoft,
secondary = GodEyeColors.IvoryMedium,
onSecondary = GodEyeColors.BlackPure,
secondaryContainer = GodEyeColors.BlackMedium,
onSecondaryContainer = GodEyeColors.IvoryPure,
tertiary = GodEyeColors.WarningAmber,
onTertiary = GodEyeColors.BlackPure,
error = GodEyeColors.RecordRed,
onError = GodEyeColors.IvoryPure,
background = GodEyeColors.BlackPure,
onBackground = GodEyeColors.IvoryPure,
surface = GodEyeColors.BlackSoft,
onSurface = GodEyeColors.IvoryPure,
surfaceVariant = GodEyeColors.BlackMedium,
onSurfaceVariant = GodEyeColors.IvorySoft,
outline = GodEyeColors.NavyMedium,
outlineVariant = GodEyeColors.NavyLight
primary = GodEyeColors.Primary,
secondary = GodEyeColors.Secondary,
tertiary = GodEyeColors.PrimaryVariant,
background = GodEyeColors.Background,
surface = GodEyeColors.Surface,
error = GodEyeColors.Error,
onPrimary = GodEyeColors.OnPrimary,
onSecondary = GodEyeColors.OnSecondary,
onTertiary = GodEyeColors.OnPrimary,
onBackground = GodEyeColors.OnBackground,
onSurface = GodEyeColors.OnSurface,
onError = GodEyeColors.OnError,
)
private val LightColorScheme = lightColorScheme(
primary = GodEyeColors.NavyMedium,
onPrimary = GodEyeColors.IvoryPure,
primaryContainer = GodEyeColors.NavyLight,
onPrimaryContainer = GodEyeColors.BlackPure,
secondary = GodEyeColors.BlackMedium,
onSecondary = GodEyeColors.IvoryPure,
secondaryContainer = GodEyeColors.IvoryMedium,
onSecondaryContainer = GodEyeColors.BlackPure,
tertiary = GodEyeColors.WarningAmber,
onTertiary = GodEyeColors.IvoryPure,
error = GodEyeColors.RecordRed,
onError = GodEyeColors.IvoryPure,
background = GodEyeColors.IvoryPure,
onBackground = GodEyeColors.BlackPure,
surface = GodEyeColors.IvorySoft,
onSurface = GodEyeColors.BlackPure,
surfaceVariant = GodEyeColors.IvoryMedium,
onSurfaceVariant = GodEyeColors.BlackMedium,
outline = GodEyeColors.NavyLight,
outlineVariant = GodEyeColors.NavyMedium
primary = GodEyeColors.Primary,
secondary = GodEyeColors.Secondary,
tertiary = GodEyeColors.PrimaryVariant,
background = Color.White,
surface = Color(0xFFF5F5F5),
error = Color(0xFFD32F2F),
onPrimary = Color.White,
onSecondary = Color.Black,
onTertiary = Color.White,
onBackground = Color.Black,
onSurface = Color.Black,
onError = Color.White,
)
@Composable
@@ -99,9 +60,10 @@ fun GodEyeTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = when {
darkTheme -> DarkColorScheme
else -> LightColorScheme
val colorScheme = if (darkTheme) {
DarkColorScheme
} else {
LightColorScheme
}
MaterialTheme(

View File

@@ -1,5 +1,7 @@
package com.example.godeye.utils
import android.content.Context
/**
* Constants - константы приложения согласно ТЗ
*/
@@ -8,7 +10,9 @@ object Constants {
// Настройки сервера согласно ТЗ
const val DEFAULT_SERVER_URL = "http://192.168.219.108:3001"
const val LOCALHOST_SERVER_URL = "http://localhost:3001"
const val LOCAL_NETWORK_SERVER_URL = "http://192.168.1.100:3001"
const val LOCAL_NETWORK_SERVER_URL = "http://192.168.219.108:3001"
const val WEBSOCKET_SERVER_URL = "ws://192.168.219.108:3000"
const val SIGNALING_SERVER_URL = "ws://192.168.219.108:8765"
// Настройки Socket.IO
const val SOCKET_TIMEOUT = 10000L
@@ -53,6 +57,11 @@ object Constants {
const val WEBRTC_OFFER_RECEIVED = "webrtc:offer"
const val WEBRTC_ANSWER_RECEIVED = "webrtc:answer"
const val WEBRTC_ICE_RECEIVED = "webrtc:ice-candidate"
const val CONNECT = "connect"
const val DISCONNECT = "disconnect"
const val DEVICE_REGISTER = "device-register"
const val KEEP_ALIVE = "keep-alive"
}
// SharedPreferences ключи согласно ТЗ
@@ -88,4 +97,37 @@ object Constants {
const val FPS_30 = 30
const val FPS_60 = 60
}
// НОВОЕ: Добавляем автоматическое определение IP сервера
fun getServerUrlForCurrentNetwork(context: Context): String {
val localIP = context.getLocalIpAddress()
// Проверяем, находимся ли мы в подсети 192.168.219.x
return if (localIP.startsWith("192.168.219.")) {
"http://192.168.219.108:3001" // Ваш сервер
} else {
// Fallback для других сетей
val serverIP = localIP.substringBeforeLast(".") + ".108"
"http://$serverIP:3001"
}
}
fun getWebSocketUrlForCurrentNetwork(context: Context): String {
val localIP = context.getLocalIpAddress()
return if (localIP.startsWith("192.168.219.")) {
"ws://192.168.219.108:3000" // Ваш сервер
} else {
val serverIP = localIP.substringBeforeLast(".") + ".108"
"ws://$serverIP:3000"
}
}
fun getSignalingUrlForCurrentNetwork(context: Context): String {
val localIP = context.getLocalIpAddress()
return if (localIP.startsWith("192.168.219.")) {
"ws://192.168.219.108:8765" // Ваш сервер
} else {
val serverIP = localIP.substringBeforeLast(".") + ".108"
"ws://$serverIP:8765"
}
}
}

View File

@@ -1,146 +1,14 @@
package com.example.godeye.utils
import android.content.Context
import android.widget.Toast
import androidx.compose.material3.SnackbarHostState
import com.example.godeye.models.AppError
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* ErrorHandler - обработка ошибок согласно ТЗ
* Централизованная обработка всех типов ошибок приложения
*/
class ErrorHandler {
/**
* Обработка ошибок приложения согласно ТЗ
*/
fun handleError(error: AppError, context: Context, scope: CoroutineScope? = null, snackbarHost: SnackbarHostState? = null) {
Logger.error("APP_ERROR", "Handling application error: ${error::class.simpleName}", null)
when (error) {
is AppError.NetworkError -> {
showNetworkError(context, scope, snackbarHost)
}
is AppError.CameraPermissionDenied -> {
showPermissionError(context, scope, snackbarHost)
}
is AppError.CameraNotAvailable -> {
showCameraError(context, scope, snackbarHost)
}
is AppError.WebRTCConnectionFailed -> {
showWebRTCError(context, scope, snackbarHost)
}
is AppError.SocketError -> {
showSocketError(context, error.message, scope, snackbarHost)
}
is AppError.UnknownError -> {
showUnknownError(context, error.throwable, scope, snackbarHost)
}
}
fun handle(error: Throwable) {
// Простая обработка ошибок: логирование
println("Error: ${error.message}")
}
/**
* Специальная обработка исключений для предотвращения крашей
*/
fun handleUncaughtException(thread: Thread, throwable: Throwable) {
Logger.error("UNCAUGHT_EXCEPTION", "Uncaught exception in thread: ${thread.name}", throwable)
// Специальная обработка известных Compose ошибок
when {
throwable.message?.contains("ACTION_HOVER_EXIT event was not cleared") == true -> {
Logger.d("Ignoring Compose hover event bug")
// Игнорируем эту ошибку, так как это известный баг Compose
return
}
throwable.message?.contains("Thread starting during runtime shutdown") == true -> {
Logger.d("Ignoring shutdown thread creation error")
// Игнорируем ошибки создания потоков при завершении
return
}
else -> {
// Для остальных ошибок делаем стандартную обработку
Logger.error("CRITICAL_ERROR", "Critical error occurred", throwable)
}
}
}
private fun showNetworkError(context: Context, scope: CoroutineScope?, snackbarHost: SnackbarHostState?) {
val message = "Ошибка сети. Проверьте подключение к интернету."
Logger.step("ERROR_NETWORK", message)
if (scope != null && snackbarHost != null) {
scope.launch {
snackbarHost.showSnackbar(message)
}
} else {
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
}
private fun showPermissionError(context: Context, scope: CoroutineScope?, snackbarHost: SnackbarHostState?) {
val message = "Необходимы разрешения для работы с камерой и микрофоном."
Logger.step("ERROR_PERMISSION", message)
if (scope != null && snackbarHost != null) {
scope.launch {
snackbarHost.showSnackbar(message)
}
} else {
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
}
private fun showCameraError(context: Context, scope: CoroutineScope?, snackbarHost: SnackbarHostState?) {
val message = "Камера недоступна. Проверьте, что другие приложения не используют камеру."
Logger.step("ERROR_CAMERA", message)
if (scope != null && snackbarHost != null) {
scope.launch {
snackbarHost.showSnackbar(message)
}
} else {
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
}
private fun showWebRTCError(context: Context, scope: CoroutineScope?, snackbarHost: SnackbarHostState?) {
val message = "Ошибка WebRTC соединения. Попробуйте переподключиться."
Logger.step("ERROR_WEBRTC", message)
if (scope != null && snackbarHost != null) {
scope.launch {
snackbarHost.showSnackbar(message)
}
} else {
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
}
private fun showSocketError(context: Context, errorMessage: String, scope: CoroutineScope?, snackbarHost: SnackbarHostState?) {
val message = "Ошибка подключения к серверу: $errorMessage"
Logger.step("ERROR_SOCKET", message)
if (scope != null && snackbarHost != null) {
scope.launch {
snackbarHost.showSnackbar(message)
}
} else {
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
}
private fun showUnknownError(context: Context, throwable: Throwable, scope: CoroutineScope?, snackbarHost: SnackbarHostState?) {
val message = "Неизвестная ошибка: ${throwable.message ?: "Unknown"}"
Logger.error("ERROR_UNKNOWN", message, throwable)
if (scope != null && snackbarHost != null) {
scope.launch {
snackbarHost.showSnackbar(message)
}
} else {
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
fun handleUncaughtException(thread: Thread, exception: Throwable) {
// Обработка неперехваченных исключений
println("Uncaught exception in thread ${thread.name}: ${exception.message}")
exception.printStackTrace()
}
}

View File

@@ -2,7 +2,13 @@ package com.example.godeye.utils
import android.content.Context
import android.content.SharedPreferences
import android.net.wifi.WifiManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import java.net.NetworkInterface
import java.net.InetAddress
import java.util.*
import java.text.SimpleDateFormat
fun Context.getPreferences(): SharedPreferences {
return getSharedPreferences("godeye_prefs", Context.MODE_PRIVATE)
@@ -11,3 +17,142 @@ fun Context.getPreferences(): SharedPreferences {
fun generateDeviceId(): String {
return "android_${UUID.randomUUID().toString().take(8)}"
}
/**
* НОВОЕ: Получение реального IP-адреса устройства с обновленными API
*/
fun Context.getLocalIpAddress(): String {
try {
// Метод 1: Через WiFi Manager (для WiFi соединений) - обновленный API
val wifiManager = applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
wifiManager?.let { wifi ->
if (wifi.isWifiEnabled) {
// ИСПРАВЛЕНИЕ: Используем современный API для получения WiFi информации
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
// Для Android 10+ используем ConnectivityManager
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
connectivityManager?.let { cm ->
val activeNetwork = cm.activeNetwork
val linkProperties = cm.getLinkProperties(activeNetwork)
linkProperties?.linkAddresses?.forEach { linkAddress ->
val address = linkAddress.address
if (address is java.net.Inet4Address && !address.isLoopbackAddress) {
val ip = address.hostAddress
if (ip != null && !ip.startsWith("127.")) {
Logger.step("IP_DETECTION", "Modern API IP detected: $ip")
return ip
}
}
}
}
} else {
// Для старых версий Android используем deprecated API с обработкой
@Suppress("DEPRECATION")
val wifiInfo = wifi.connectionInfo
@Suppress("DEPRECATION")
val ipInt = wifiInfo.ipAddress
if (ipInt != 0) {
val ip = String.format(
"%d.%d.%d.%d",
ipInt and 0xff,
ipInt shr 8 and 0xff,
ipInt shr 16 and 0xff,
ipInt shr 24 and 0xff
)
Logger.step("IP_DETECTION", "Legacy WiFi IP detected: $ip")
return ip
}
}
}
}
// Метод 2: Через NetworkInterface (универсальный метод)
val interfaces = NetworkInterface.getNetworkInterfaces()
for (networkInterface in interfaces) {
// Пропускаем loopback и неактивные интерфейсы
if (networkInterface.isLoopback || !networkInterface.isUp) continue
val addresses = networkInterface.inetAddresses
for (address in addresses) {
// Пропускаем IPv6 и loopback адреса
if (!address.isLoopbackAddress && address is java.net.Inet4Address) {
val ip = address.hostAddress
if (ip != null && !ip.startsWith("127.")) {
Logger.step("IP_DETECTION", "Network interface IP detected: $ip on ${networkInterface.name}")
return ip
}
}
}
}
// Метод 3: Через ConnectivityManager (Android 6+)
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
connectivityManager?.let { cm ->
val activeNetwork = cm.activeNetwork
val networkCapabilities = cm.getNetworkCapabilities(activeNetwork)
if (networkCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true) {
Logger.step("IP_DETECTION", "Active network detected but couldn't extract IP")
}
}
} catch (e: Exception) {
Logger.error("IP_DETECTION_ERROR", "Failed to detect local IP", e)
}
// Fallback: возвращаем IP по умолчанию для вашей подсети
val fallbackIP = "192.168.219.108" // ИСПРАВЛЕНИЕ: Изменено на ваш сервер
Logger.step("IP_DETECTION_FALLBACK", "Using fallback IP: $fallbackIP")
return fallbackIP
}
/**
* НОВОЕ: Получение IP сервера из URL
*/
fun extractServerIP(serverUrl: String): String {
return try {
val regex = """://([^:/]+)""".toRegex()
val match = regex.find(serverUrl)
match?.groupValues?.get(1) ?: "192.168.219.1" // Fallback на ваш роутер
} catch (e: Exception) {
Logger.error("SERVER_IP_EXTRACTION_ERROR", "Failed to extract server IP from URL: $serverUrl", e)
"192.168.219.1"
}
}
/**
* НОВОЕ: Автоматическое определение подсети
*/
fun Context.detectNetworkSubnet(): String {
val localIP = getLocalIpAddress()
return try {
val parts = localIP.split(".")
if (parts.size >= 3) {
"${parts[0]}.${parts[1]}.${parts[2]}.0/24"
} else {
"192.168.219.0/24" // Fallback на вашу подсеть
}
} catch (e: Exception) {
Logger.error("SUBNET_DETECTION_ERROR", "Failed to detect subnet", e)
"192.168.219.0/24"
}
}
/**
* НОВОЕ: Проверка, находится ли IP в локальной сети
*/
fun isLocalNetworkIP(ip: String): Boolean {
return try {
val parts = ip.split(".").map { it.toInt() }
when {
// 192.168.x.x
parts[0] == 192 && parts[1] == 168 -> true
// 10.x.x.x
parts[0] == 10 -> true
// 172.16.x.x - 172.31.x.x
parts[0] == 172 && parts[1] in 16..31 -> true
else -> false
}
} catch (e: Exception) {
false
}
}

View File

@@ -26,6 +26,23 @@ object Logger {
throwable?.printStackTrace()
}
// Методы для совместимости с WebRTCManager
fun debug(tag: String, message: String) {
Log.d(tag, "🔍 $message")
println("🔍 [DEBUG] [$tag] $message")
}
fun info(tag: String, message: String) {
Log.i(tag, " $message")
println(" [INFO] [$tag] $message")
}
fun error(tag: String, message: String, throwable: Throwable? = null) {
Log.e(tag, "$message", throwable)
println("❌ [ERROR] [$tag] $message")
throwable?.printStackTrace()
}
fun step(stepName: String, message: String) {
Log.d(TAG, "📋 STEP [$stepName]: $message")
println("📋 STEP [$stepName]: $message")
@@ -55,10 +72,4 @@ object Logger {
Log.d(TAG, "🌍 NETWORK: $message")
println("🌍 NETWORK: $message")
}
fun error(step: String, message: String, throwable: Throwable? = null) {
Log.e(TAG, "💥 ERROR in [$step]: $message", throwable)
println("💥 ERROR in [$step]: $message")
throwable?.printStackTrace()
}
}

View File

@@ -0,0 +1,58 @@
package com.example.godeye.utils
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
/**
* Утилиты для управления разрешениями
*/
class PermissionHelper(private val activity: ComponentActivity) {
companion object {
val REQUIRED_PERMISSIONS = arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO,
Manifest.permission.INTERNET,
Manifest.permission.ACCESS_NETWORK_STATE,
Manifest.permission.WAKE_LOCK,
Manifest.permission.POST_NOTIFICATIONS
)
}
private val permissionLauncher = activity.registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val allGranted = permissions.values.all { it }
onPermissionResult?.invoke(allGranted)
}
private var onPermissionResult: ((Boolean) -> Unit)? = null
/**
* Проверка всех необходимых разрешений
*/
fun hasAllPermissions(): Boolean {
return REQUIRED_PERMISSIONS.all { permission ->
ContextCompat.checkSelfPermission(activity, permission) == PackageManager.PERMISSION_GRANTED
}
}
/**
* Запрос разрешений
*/
fun requestPermissions(onResult: (Boolean) -> Unit) {
onPermissionResult = onResult
permissionLauncher.launch(REQUIRED_PERMISSIONS)
}
/**
* Проверка конкретного разрешения
*/
fun hasPermission(permission: String): Boolean {
return ContextCompat.checkSelfPermission(activity, permission) == PackageManager.PERMISSION_GRANTED
}
}

View File

@@ -0,0 +1,185 @@
package com.example.godeye.utils
import android.content.Context
import android.content.SharedPreferences
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Менеджер настроек приложения
*/
class PreferenceManager(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences("godeye_settings", Context.MODE_PRIVATE)
companion object {
private const val TAG = "PreferenceManager"
private const val PREFS_NAME = "godeye_preferences"
private const val KEY_DEVICE_ID = "device_id"
private const val KEY_SERVER_URL = "server_url"
private const val KEY_AUTO_APPROVAL = "auto_approval"
private const val KEY_OPERATOR_MODE = "operator_mode"
private const val KEY_LAST_CONNECTION = "last_connection"
private const val KEY_STATISTICS_ENABLED = "statistics_enabled"
private const val KEY_DEVICE_NAME = "device_name"
private const val KEY_UUID_MIGRATED = "uuid_migrated"
private const val KEY_AUTO_CONNECT = "auto_connect"
private const val KEY_AUTO_APPROVE = "auto_approve"
private const val KEY_NOTIFICATIONS_ENABLED = "notifications_enabled"
private const val KEY_CAMERA_QUALITY = "camera_quality"
// ИСПРАВЛЕНИЕ: Обновляем IP по умолчанию на ваш сервер
private const val DEFAULT_SERVER_URL = "http://192.168.219.108:3001"
private const val DEFAULT_DEVICE_NAME = "Android Device"
}
// StateFlow для реактивного наблюдения за изменениями настроек
private val _serverUrl = MutableStateFlow(getServerUrl())
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
private val _deviceName = MutableStateFlow(getDeviceName())
val deviceName: StateFlow<String> = _deviceName.asStateFlow()
private val _autoConnect = MutableStateFlow(getAutoConnect())
val autoConnect: StateFlow<Boolean> = _autoConnect.asStateFlow()
private val _autoApprove = MutableStateFlow(getAutoApprove())
val autoApprove: StateFlow<Boolean> = _autoApprove.asStateFlow()
// URL сервера
fun getServerUrl(): String = prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL
fun setServerUrl(url: String) {
prefs.edit().putString(KEY_SERVER_URL, url).apply()
_serverUrl.value = url
}
// Имя устройства
fun getDeviceName(): String = prefs.getString(KEY_DEVICE_NAME, DEFAULT_DEVICE_NAME) ?: DEFAULT_DEVICE_NAME
fun setDeviceName(name: String) {
prefs.edit().putString(KEY_DEVICE_NAME, name).apply()
_deviceName.value = name
}
// Автоматическое подключение
fun getAutoConnect(): Boolean = prefs.getBoolean(KEY_AUTO_CONNECT, false)
fun setAutoConnect(enabled: Boolean) {
prefs.edit().putBoolean(KEY_AUTO_CONNECT, enabled).apply()
_autoConnect.value = enabled
}
// Автоматическое подтверждение
fun getAutoApprove(): Boolean = prefs.getBoolean(KEY_AUTO_APPROVE, false)
fun setAutoApprove(enabled: Boolean) {
prefs.edit().putBoolean(KEY_AUTO_APPROVE, enabled).apply()
_autoApprove.value = enabled
}
// ID устройства (генерируется один раз) - теперь использует UUID
fun getDeviceId(): String {
var deviceId = prefs.getString(KEY_DEVICE_ID, null)
if (deviceId == null) {
// Генерируем настоящий UUID вместо timestamp
deviceId = java.util.UUID.randomUUID().toString()
prefs.edit().putString(KEY_DEVICE_ID, deviceId).apply()
}
return deviceId
}
/**
* Принудительная перегенерация Device ID с новым UUID
* Используется для обновления старых устройств на новый формат
*/
fun regenerateDeviceId(): String {
val newDeviceId = java.util.UUID.randomUUID().toString()
prefs.edit().putString(KEY_DEVICE_ID, newDeviceId).apply()
return newDeviceId
}
/**
* Проверка, является ли Device ID старым форматом (android_timestamp)
*/
fun isLegacyDeviceId(): Boolean {
val deviceId = prefs.getString(KEY_DEVICE_ID, null)
return deviceId != null && deviceId.startsWith("android_")
}
/**
* Автоматическое обновление старого Device ID на UUID
*/
fun migrateToUUID(): String {
return if (isLegacyDeviceId()) {
val oldDeviceId = prefs.getString(KEY_DEVICE_ID, null)
val newDeviceId = regenerateDeviceId()
android.util.Log.d("PreferenceManager", "Migrated Device ID from $oldDeviceId to $newDeviceId")
newDeviceId
} else {
getDeviceId()
}
}
// Настройки уведомлений
fun getNotificationsEnabled(): Boolean = prefs.getBoolean(KEY_NOTIFICATIONS_ENABLED, true)
fun setNotificationsEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_NOTIFICATIONS_ENABLED, enabled).apply()
}
// Качество камеры
fun getCameraQuality(): String = prefs.getString(KEY_CAMERA_QUALITY, "HIGH") ?: "HIGH"
fun setCameraQuality(quality: String) {
prefs.edit().putString(KEY_CAMERA_QUALITY, quality).apply()
}
/**
* Получить все настройки для отладки
*/
fun getAllSettings(): Map<String, Any> {
return mapOf(
"serverUrl" to getServerUrl(),
"deviceName" to getDeviceName(),
"autoConnect" to getAutoConnect(),
"autoApprove" to getAutoApprove(),
"deviceId" to getDeviceId(),
"notificationsEnabled" to getNotificationsEnabled(),
"cameraQuality" to getCameraQuality()
)
}
/**
* Очистка всех настроек (для отладки)
*/
fun clearAllSettings() {
prefs.edit().clear().apply()
android.util.Log.d("PreferenceManager", "All settings cleared")
}
/**
* Проверка времени последней миграции Device ID
*/
private fun shouldMigrate(): Boolean {
val lastMigration = prefs.getLong("last_migration_time", 0)
val currentTime = System.currentTimeMillis()
val migrationInterval = 24 * 60 * 60 * 1000L // 24 часа
return (currentTime - lastMigration) > migrationInterval
}
/**
* Автоматическая миграция с проверкой времени
*/
fun autoMigrateDeviceId(): String {
return if (isLegacyDeviceId() && shouldMigrate()) {
val newDeviceId = regenerateDeviceId()
prefs.edit().putLong("last_migration_time", System.currentTimeMillis()).apply()
newDeviceId
} else {
getDeviceId()
}
}
}

View File

@@ -1,646 +0,0 @@
package com.example.godeye.webrtc
import android.content.Context
import com.example.godeye.utils.Logger
import org.webrtc.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONObject
/**
* WebRTCManager - обработка WebRTC соединений согласно ТЗ
* Архитектура: Socket.IO для сигнализации, WebRTC для P2P медиа-потоков
*/
class WebRTCManager(
private val context: Context,
private val onSignalingMessage: (message: JSONObject) -> Unit
) {
private var peerConnectionFactory: PeerConnectionFactory? = null
private val activePeerConnections = mutableMapOf<String, PeerConnection>()
private var localVideoTrack: VideoTrack? = null
private var localAudioTrack: AudioTrack? = null
private var videoCapturer: CameraVideoCapturer? = null
// Состояния соединения
private val _connectionState = MutableStateFlow<Map<String, PeerConnection.PeerConnectionState>>(emptyMap())
val connectionState: StateFlow<Map<String, PeerConnection.PeerConnectionState>> = _connectionState.asStateFlow()
// ICE серверы согласно ТЗ
private val iceServers = listOf(
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer(),
PeerConnection.IceServer.builder("stun:stun1.l.google.com:19302").createIceServer()
)
// Конфигурация RTCConfiguration согласно ТЗ
private val rtcConfig = PeerConnection.RTCConfiguration(iceServers).apply {
tcpCandidatePolicy = PeerConnection.TcpCandidatePolicy.DISABLED
bundlePolicy = PeerConnection.BundlePolicy.MAXBUNDLE
rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.REQUIRE
continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY
}
init {
Logger.step("WEBRTC_INIT", "Initializing WebRTC Manager according to ТЗ")
initializePeerConnectionFactory()
}
/**
* Инициализация PeerConnectionFactory согласно ТЗ
*/
private fun initializePeerConnectionFactory() {
Logger.step("WEBRTC_FACTORY_INIT", "Initializing PeerConnectionFactory")
try {
val initializationOptions = PeerConnectionFactory.InitializationOptions.builder(context)
.setEnableInternalTracer(false)
.createInitializationOptions()
PeerConnectionFactory.initialize(initializationOptions)
val options = PeerConnectionFactory.Options()
peerConnectionFactory = PeerConnectionFactory.builder()
.setOptions(options)
.createPeerConnectionFactory()
Logger.step("WEBRTC_FACTORY_READY", "PeerConnectionFactory initialized successfully")
} catch (e: Exception) {
Logger.error("WEBRTC_FACTORY_ERROR", "Failed to initialize PeerConnectionFactory", e)
}
}
/**
* Начало стриминга для сессии - создание offer согласно ТЗ
*/
fun startStreaming(sessionId: String, cameraType: String) {
Logger.step("WEBRTC_START_STREAMING", "Starting WebRTC streaming for session: $sessionId, camera: $cameraType")
try {
val peerConnection = createPeerConnection(sessionId)
if (peerConnection == null) {
Logger.error("WEBRTC_START_ERROR", "Failed to create peer connection", null)
return
}
// Добавление локальных медиа-потоков
addLocalStreams(peerConnection, cameraType)
// Создание offer согласно ТЗ
createOffer(sessionId, peerConnection)
} catch (e: Exception) {
Logger.error("WEBRTC_START_ERROR", "Failed to start WebRTC streaming", e)
}
}
/**
* Создание PeerConnection для сессии
*/
private fun createPeerConnection(sessionId: String): PeerConnection? {
val factory = peerConnectionFactory ?: return null
val observer = object : PeerConnection.Observer {
override fun onIceCandidate(candidate: IceCandidate) {
Logger.step("WEBRTC_ICE_CANDIDATE", "ICE candidate for session: $sessionId")
// Отправка ICE candidate через SocketService согласно ТЗ
val message = JSONObject().apply {
put("type", "ice-candidate")
put("sessionId", sessionId)
put("candidate", candidate.sdp)
put("sdpMid", candidate.sdpMid)
put("sdpMLineIndex", candidate.sdpMLineIndex)
}
onSignalingMessage(message)
}
override fun onConnectionChange(newState: PeerConnection.PeerConnectionState) {
Logger.step("WEBRTC_CONNECTION_CHANGE", "Session $sessionId state: $newState")
val currentStates = _connectionState.value.toMutableMap()
currentStates[sessionId] = newState
_connectionState.value = currentStates
}
override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState) {
Logger.step("WEBRTC_ICE_CONNECTION", "Session $sessionId ICE state: $newState")
}
override fun onIceConnectionReceivingChange(receiving: Boolean) {
Logger.step("WEBRTC_ICE_RECEIVING", "Session $sessionId ICE receiving: $receiving")
}
override fun onAddStream(stream: MediaStream) {
Logger.step("WEBRTC_STREAM_ADDED", "Remote stream added for session: $sessionId")
}
override fun onRemoveStream(stream: MediaStream) {
Logger.step("WEBRTC_STREAM_REMOVED", "Remote stream removed for session: $sessionId")
}
override fun onDataChannel(dataChannel: DataChannel) {
Logger.step("WEBRTC_DATA_CHANNEL", "Data channel opened for session: $sessionId")
}
override fun onRenegotiationNeeded() {
Logger.step("WEBRTC_RENEGOTIATION", "Renegotiation needed for session: $sessionId")
}
override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState) {
Logger.step("WEBRTC_ICE_GATHERING", "Session $sessionId ICE gathering: $newState")
}
override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>) {
Logger.step("WEBRTC_ICE_REMOVED", "ICE candidates removed for session: $sessionId")
}
override fun onSignalingChange(newState: PeerConnection.SignalingState) {
Logger.step("WEBRTC_SIGNALING", "Session $sessionId signaling: $newState")
}
}
val peerConnection = factory.createPeerConnection(rtcConfig, observer)
if (peerConnection != null) {
activePeerConnections[sessionId] = peerConnection
}
return peerConnection
}
/**
* Добавление локальных медиа-потоков (видео + аудио)
*/
private fun addLocalStreams(peerConnection: PeerConnection, cameraType: String) {
try {
// Создание локального видео-потока
if (localVideoTrack == null) {
localVideoTrack = createVideoTrack(cameraType)
}
// Создание локального аудио-потока
if (localAudioTrack == null) {
localAudioTrack = createAudioTrack()
}
// Добавление потоков в PeerConnection
localVideoTrack?.let { peerConnection.addTrack(it, listOf("stream")) }
localAudioTrack?.let { peerConnection.addTrack(it, listOf("stream")) }
Logger.step("WEBRTC_STREAMS_ADDED", "Local media streams added to peer connection")
} catch (e: Exception) {
Logger.error("WEBRTC_STREAMS_ERROR", "Failed to add local streams", e)
}
}
/**
* Создание видео-трека с указанной камерой
*/
private fun createVideoTrack(cameraType: String): VideoTrack? {
Logger.step("WEBRTC_VIDEO_TRACK", "Creating video track for camera: $cameraType")
try {
val factory = peerConnectionFactory ?: return null
// Создание видео источника
val videoSource = factory.createVideoSource(false)
// Создание захватчика камеры
videoCapturer = createCameraCapturer(cameraType)
if (videoCapturer == null) {
Logger.error("WEBRTC_VIDEO_ERROR", "Failed to create camera capturer", null)
return null
}
// Инициализация захвата видео
val surfaceTextureHelper = SurfaceTextureHelper.create("CaptureThread", null)
videoCapturer?.initialize(surfaceTextureHelper, context, videoSource.capturerObserver)
// Запуск захвата видео с разрешением 1280x720 и 30 FPS
videoCapturer?.startCapture(1280, 720, 30)
// Создание видео-трека
val videoTrack = factory.createVideoTrack("video_track", videoSource)
Logger.step("WEBRTC_VIDEO_TRACK_CREATED", "Video track created successfully for camera: $cameraType")
return videoTrack
} catch (e: Exception) {
Logger.error("WEBRTC_VIDEO_TRACK_ERROR", "Failed to create video track", e)
return null
}
}
/**
* Создание захватчика камеры для указанного типа
*/
private fun createCameraCapturer(cameraType: String): CameraVideoCapturer? {
try {
val camera1Enumerator = Camera1Enumerator(false)
val camera2Enumerator = Camera2Enumerator(context)
val enumerator = if (Camera2Enumerator.isSupported(context)) camera2Enumerator else camera1Enumerator
// Поиск камеры по типу
val deviceNames = enumerator.deviceNames
for (deviceName in deviceNames) {
val isFrontFacing = enumerator.isFrontFacing(deviceName)
val isBackFacing = enumerator.isBackFacing(deviceName)
val matches = when (cameraType) {
"front" -> isFrontFacing
"back", "ultra_wide", "telephoto" -> isBackFacing
else -> isBackFacing
}
if (matches) {
Logger.d("Using camera device: $deviceName for type: $cameraType")
return enumerator.createCapturer(deviceName, null)
}
}
// Fallback к первой доступной камере
if (deviceNames.isNotEmpty()) {
Logger.d("Using fallback camera: ${deviceNames[0]}")
return enumerator.createCapturer(deviceNames[0], null)
}
Logger.error("CAMERA_CAPTURER_ERROR", "No camera devices found", null)
return null
} catch (e: Exception) {
Logger.error("CAMERA_CAPTURER_ERROR", "Failed to create camera capturer", e)
return null
}
}
/**
* Создание аудио-трека
*/
private fun createAudioTrack(): AudioTrack? {
try {
val factory = peerConnectionFactory ?: return null
// Создание аудио источника
val audioConstraints = MediaConstraints().apply {
mandatory.add(MediaConstraints.KeyValuePair("googEchoCancellation", "true"))
mandatory.add(MediaConstraints.KeyValuePair("googNoiseSuppression", "true"))
mandatory.add(MediaConstraints.KeyValuePair("googAutoGainControl", "true"))
mandatory.add(MediaConstraints.KeyValuePair("googHighpassFilter", "true"))
}
val audioSource = factory.createAudioSource(audioConstraints)
val audioTrack = factory.createAudioTrack("audio_track", audioSource)
Logger.step("WEBRTC_AUDIO_TRACK_CREATED", "Audio track created successfully")
return audioTrack
} catch (e: Exception) {
Logger.error("WEBRTC_AUDIO_TRACK_ERROR", "Failed to create audio track", e)
return null
}
}
/**
* Создание offer для сессии
*/
private fun createOffer(sessionId: String, peerConnection: PeerConnection) {
try {
val constraints = MediaConstraints().apply {
// Исправляем настройки для корректной работы WebRTC
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "false"))
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "false"))
// Добавляем дополнительные constraints для стабильности
mandatory.add(MediaConstraints.KeyValuePair("VoiceActivityDetection", "true"))
}
peerConnection.createOffer(object : SdpObserver {
override fun onCreateSuccess(sessionDescription: SessionDescription) {
Logger.step("WEBRTC_OFFER_CREATED", "Offer created for session: $sessionId")
// Изменяем SDP для исправления проблемы с m-section
val modifiedSdp = modifySdpForCompatibility(sessionDescription.description)
val modifiedSessionDescription = SessionDescription(sessionDescription.type, modifiedSdp)
peerConnection.setLocalDescription(object : SdpObserver {
override fun onSetSuccess() {
Logger.step("WEBRTC_LOCAL_DESC_SET", "Local description set successfully for session: $sessionId")
// Отправка offer через SocketService согласно ТЗ
val message = JSONObject().apply {
put("type", "offer")
put("sessionId", sessionId)
put("sdp", modifiedSdp)
}
onSignalingMessage(message)
}
override fun onSetFailure(error: String) {
Logger.error("WEBRTC_SET_LOCAL_ERROR", "Failed to set local description: $error", null)
// Не крашим приложение, а пытаемся создать новое соединение
handleWebRTCError(sessionId, "Local description error: $error")
}
override fun onCreateSuccess(p0: SessionDescription?) {}
override fun onCreateFailure(p0: String?) {}
}, modifiedSessionDescription)
}
override fun onCreateFailure(error: String) {
Logger.error("WEBRTC_OFFER_ERROR", "Failed to create offer: $error", null)
handleWebRTCError(sessionId, "Offer creation error: $error")
}
override fun onSetSuccess() {}
override fun onSetFailure(error: String?) {}
}, constraints)
} catch (e: Exception) {
Logger.error("WEBRTC_OFFER_EXCEPTION", "Exception creating offer", e)
handleWebRTCError(sessionId, "Offer exception: ${e.message}")
}
}
/**
* Модификация SDP для совместимости
*/
private fun modifySdpForCompatibility(originalSdp: String): String {
try {
Logger.d("Original SDP length: ${originalSdp.length}")
// Для send-only режима создаем минимальный корректный SDP
val lines = originalSdp.split("\r\n").toMutableList()
val modifiedLines = mutableListOf<String>()
var inVideoSection = false
var inAudioSection = false
var currentSection = ""
for (line in lines) {
when {
line.startsWith("v=") -> {
modifiedLines.add(line)
}
line.startsWith("o=") -> {
modifiedLines.add(line)
}
line.startsWith("s=") -> {
modifiedLines.add(line)
}
line.startsWith("t=") -> {
modifiedLines.add(line)
}
line.startsWith("a=group:BUNDLE") -> {
modifiedLines.add("a=group:BUNDLE 0 1")
}
line.startsWith("a=msid-semantic") -> {
modifiedLines.add(line)
}
line.startsWith("m=video") -> {
inVideoSection = true
inAudioSection = false
currentSection = "video"
modifiedLines.add(line)
}
line.startsWith("m=audio") -> {
inVideoSection = false
inAudioSection = true
currentSection = "audio"
modifiedLines.add(line)
}
line.startsWith("c=") -> {
modifiedLines.add(line)
}
line.startsWith("a=mid:") -> {
when (currentSection) {
"video" -> modifiedLines.add("a=mid:0")
"audio" -> modifiedLines.add("a=mid:1")
else -> modifiedLines.add(line)
}
}
line.startsWith("a=sendonly") || line.startsWith("a=sendrecv") || line.startsWith("a=recvonly") -> {
modifiedLines.add("a=sendonly")
}
line.startsWith("a=rtcp-mux") && !line.contains("only") -> {
modifiedLines.add(line)
}
line.startsWith("a=rtpmap:") ||
line.startsWith("a=fmtp:") ||
line.startsWith("a=ssrc:") ||
line.startsWith("a=msid:") ||
line.startsWith("a=cname:") ||
line.startsWith("a=ice-ufrag:") ||
line.startsWith("a=ice-pwd:") ||
line.startsWith("a=fingerprint:") ||
line.startsWith("a=setup:") ||
line.startsWith("a=candidate:") -> {
modifiedLines.add(line)
}
// Пропускаем проблемные RTCP feedback атрибуты для send-only
line.startsWith("a=rtcp-fb:") ||
line.startsWith("a=rtcp-mux-only") ||
line.startsWith("a=rtcp-rsize") -> {
// Пропускаем эти строки
}
line.trim().isEmpty() -> {
// Пропускаем пустые строки
}
else -> {
// Добавляем остальные атрибуты
modifiedLines.add(line)
}
}
}
val modifiedSdp = modifiedLines.joinToString("\r\n")
Logger.d("Modified SDP length: ${modifiedSdp.length}")
Logger.d("SDP modifications applied successfully")
return modifiedSdp
} catch (e: Exception) {
Logger.error("SDP_MODIFY_ERROR", "Failed to modify SDP", e)
return originalSdp
}
}
/**
* Обработка WebRTC ошибок без краша приложения
*/
private fun handleWebRTCError(sessionId: String, error: String) {
Logger.error("WEBRTC_ERROR_HANDLED", "WebRTC error for session $sessionId: $error", null)
// Уведомляем о проблеме через сигналинг
val errorMessage = JSONObject().apply {
put("type", "error")
put("sessionId", sessionId)
put("error", error)
}
try {
onSignalingMessage(errorMessage)
} catch (e: Exception) {
Logger.error("WEBRTC_ERROR_SIGNALING", "Failed to send error message", e)
}
}
/**
* Обработка answer от оператора
*/
fun handleAnswer(sessionId: String, answerSdp: String) {
Logger.step("WEBRTC_ANSWER", "Processing WebRTC answer for session: $sessionId")
val peerConnection = activePeerConnections[sessionId]
if (peerConnection == null) {
Logger.error("WEBRTC_ANSWER_ERROR", "No peer connection found for session: $sessionId", null)
return
}
try {
val answer = SessionDescription(SessionDescription.Type.ANSWER, answerSdp)
peerConnection.setRemoteDescription(object : SdpObserver {
override fun onSetSuccess() {
Logger.step("WEBRTC_ANSWER_SET", "Answer set successfully for session: $sessionId")
}
override fun onSetFailure(error: String) {
Logger.error("WEBRTC_ANSWER_SET_ERROR", "Failed to set answer: $error", null)
}
override fun onCreateSuccess(p0: SessionDescription?) {}
override fun onCreateFailure(p0: String?) {}
}, answer)
} catch (e: Exception) {
Logger.error("WEBRTC_ANSWER_EXCEPTION", "Exception handling answer", e)
}
}
/**
* Обработка offer от оператора (не используется в текущей архитектуре)
*/
@Suppress("UNUSED_PARAMETER")
fun handleOffer(sessionId: String, offerSdp: String) {
Logger.step("WEBRTC_OFFER", "Processing WebRTC offer for session: $sessionId")
// Пока не реализовано - Android устройство только отправляет offer
}
/**
* Обработка ICE кандидата от оператора
*/
fun handleIceCandidate(sessionId: String, candidateSdp: String, sdpMid: String, sdpMLineIndex: Int) {
Logger.step("WEBRTC_ICE", "Processing ICE candidate for session: $sessionId")
val peerConnection = activePeerConnections[sessionId]
if (peerConnection == null) {
Logger.error("WEBRTC_ICE_ERROR", "No peer connection found for session: $sessionId", null)
return
}
try {
val iceCandidate = IceCandidate(sdpMid, sdpMLineIndex, candidateSdp)
peerConnection.addIceCandidate(iceCandidate)
Logger.step("WEBRTC_ICE_ADDED", "ICE candidate added for session: $sessionId")
} catch (e: Exception) {
Logger.error("WEBRTC_ICE_EXCEPTION", "Exception handling ICE candidate", e)
}
}
/**
* Переключение камеры
*/
fun switchCamera(cameraType: String) {
Logger.step("WEBRTC_SWITCH_CAMERA", "Switching camera to: $cameraType")
try {
// Остановка текущего захвата
videoCapturer?.stopCapture()
// Создание нового видео-трека
localVideoTrack?.dispose()
localVideoTrack = createVideoTrack(cameraType)
// Обновление треков во всех активных соединениях
activePeerConnections.forEach { (_, peerConnection) ->
localVideoTrack?.let { videoTrack ->
// Удаление старого трека и добавление нового
val senders = peerConnection.senders
senders.forEach { sender ->
if (sender.track()?.kind() == "video") {
sender.setTrack(videoTrack, false)
}
}
}
}
Logger.step("WEBRTC_CAMERA_SWITCHED", "Camera switched to: $cameraType")
} catch (e: Exception) {
Logger.error("WEBRTC_SWITCH_ERROR", "Failed to switch camera", e)
}
}
/**
* Завершение сессии
*/
fun endSession(sessionId: String) {
Logger.step("WEBRTC_END_SESSION", "Ending WebRTC session: $sessionId")
activePeerConnections[sessionId]?.let { peerConnection ->
peerConnection.close()
activePeerConnections.remove(sessionId)
}
val currentStates = _connectionState.value.toMutableMap()
currentStates.remove(sessionId)
_connectionState.value = currentStates
}
/**
* Остановка всех стримов
*/
fun stopAllStreaming() {
Logger.step("WEBRTC_STOP_ALL", "Stopping all WebRTC streaming")
activePeerConnections.forEach { (_, peerConnection) ->
peerConnection.close()
}
activePeerConnections.clear()
videoCapturer?.stopCapture()
videoCapturer?.dispose()
videoCapturer = null
localVideoTrack?.dispose()
localVideoTrack = null
localAudioTrack?.dispose()
localAudioTrack = null
_connectionState.value = emptyMap()
}
/**
* Освобождение ресурсов
*/
fun dispose() {
Logger.step("WEBRTC_DISPOSE", "Disposing WebRTC Manager")
stopAllStreaming()
peerConnectionFactory?.dispose()
peerConnectionFactory = null
}
}
/**
* SdpObserver по умолчанию для упрощения кода
*/
open class SimpleSdpObserver : SdpObserver {
override fun onCreateSuccess(sessionDescription: SessionDescription) {}
override fun onSetSuccess() {}
override fun onCreateFailure(error: String) {}
override fun onSetFailure(error: String) {}
}

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Основной градиентный фон в стиле GodEye -->
<item>
<shape>
<gradient
android:startColor="#0a1828"
android:centerColor="#1e3a5f"
android:endColor="#2d5a87"
android:angle="135" />
</shape>
</item>
<!-- Дополнительный слой с точечным паттерном -->
<item>
<shape>
<gradient
android:startColor="#00000000"
android:centerColor="#1a000000"
android:endColor="#33000000"
android:angle="45" />
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="@color/button_danger_pressed" />
<corners android:radius="8dp" />
</shape>
</item>
<item android:state_enabled="false">
<shape android:shape="rectangle">
<solid android:color="@color/button_secondary" />
<corners android:radius="8dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@color/button_danger" />
<corners android:radius="8dp" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="@color/button_primary_pressed" />
<corners android:radius="8dp" />
</shape>
</item>
<item android:state_enabled="false">
<shape android:shape="rectangle">
<solid android:color="@color/button_secondary" />
<corners android:radius="8dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@color/button_primary" />
<corners android:radius="8dp" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="@color/button_secondary_pressed" />
<corners android:radius="8dp" />
<stroke android:width="1dp" android:color="@color/panel_border" />
</shape>
</item>
<item android:state_enabled="false">
<shape android:shape="rectangle">
<solid android:color="#404040" />
<corners android:radius="8dp" />
<stroke android:width="1dp" android:color="@color/panel_border" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@color/button_secondary" />
<corners android:radius="8dp" />
<stroke android:width="1dp" android:color="@color/panel_border" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/status_idle" />
<size
android:width="20dp"
android:height="20dp" />
</shape>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Основная иконка камеры -->
<path
android:fillColor="@color/success_green"
android:pathData="M17,10.5V7A1,1 0,0 0,16 6H4A1,1 0,0 0,3 7V17A1,1 0,0 0,4 18H16A1,1 0,0 0,17 17V13.5L21,17.5V6.5L17,10.5Z"/>
<!-- Красный индикатор записи -->
<path
android:fillColor="@color/error_red"
android:pathData="M19,2A3,3 0,1 1,19 8A3,3 0,1 1,19 2Z"/>
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/accent_blue"
android:pathData="M9,12l2,2l4,-4m5.618,-4.016A11.955,11.955 0,0 1,12 2.944A11.955,11.955 0,0 1,2.382 5.984l1.158,1.158a10,10 0,0 0,17.8 0L20.618,5.984zM21,10v8a2,2 0,0 1,-2 2H5a2,2 0,0 1,-2,-2V10l9,-5 9,5z"/>
</vector>

View File

@@ -1,170 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Основной градиентный фон -->
<path android:pathData="M0,0h108v108h-108z">
<aapt:attr name="android:fillColor">
<gradient
android:startY="0"
android:startX="0"
android:endY="108"
android:endX="108"
android:type="linear">
<item android:offset="0" android:color="#0A1828"/>
<item android:offset="0.3" android:color="#1E3A5F"/>
<item android:offset="0.7" android:color="#2D5A87"/>
<item android:offset="1" android:color="#3A6EA5"/>
</gradient>
</aapt:attr>
</path>
<!-- Дополнительные элементы фона -->
<!-- Круговые линии для технологического эффекта (заменены на path) -->
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
android:pathData="M54,14A40,40 0,1 1,54,94A40,40 0,1 1,54,14Z"
android:fillColor="@android:color/transparent"
android:strokeColor="#1A40E0D0"
android:strokeWidth="1"/>
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
android:pathData="M54,4A50,50 0,1 1,54,104A50,50 0,1 1,54,4Z"
android:fillColor="@android:color/transparent"
android:strokeColor="#1A00BFFF"
android:strokeWidth="0.5"/>
<!-- Точки на фоне (заменены на path) -->
<path android:pathData="M20,19a1,1 0,1 1,-2,0a1,1 0,1 1,2 0z" android:fillColor="#3340E0D0"/>
<path android:pathData="M88,19a1,1 0,1 1,-2,0a1,1 0,1 1,2 0z" android:fillColor="#3340E0D0"/>
<path android:pathData="M20,87a1,1 0,1 1,-2,0a1,1 0,1 1,2 0z" android:fillColor="#3340E0D0"/>
<path android:pathData="M88,87a1,1 0,1 1,-2,0a1,1 0,1 1,2 0z" android:fillColor="#3340E0D0"/>
</vector>

View File

@@ -4,27 +4,80 @@
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
<!-- Фоновый градиент -->
<path
android:pathData="M0,0h108v108h-108z">
<aapt:attr name="android:fillColor">
<gradient
android:startY="0"
android:startX="0"
android:endY="108"
android:endX="108"
android:type="linear">
<item android:offset="0" android:color="#0A1828"/>
<item android:offset="0.5" android:color="#1E3A5F"/>
<item android:offset="1" android:color="#2D5A87"/>
</gradient>
</aapt:attr>
</path>
<!-- Основной контур глаза -->
<path
android:fillColor="#00BFFF"
android:pathData="M54,30C42,30 32,40 32,52C32,64 42,74 54,74C66,74 76,64 76,52C76,40 66,30 54,30Z"/>
<!-- Внутренний контур глаза -->
<path
android:fillColor="#E0F4FF"
android:pathData="M54,35C45,35 37,43 37,52C37,61 45,69 54,69C63,69 71,61 71,52C71,43 63,35 54,35Z"/>
<!-- Зрачок -->
<path
android:fillColor="#0A1828"
android:pathData="M54,42C50,42 47,45 47,49C47,53 50,56 54,56C58,56 61,53 61,49C61,45 58,42 54,42Z"/>
<!-- Отражение в глазу -->
<path
android:fillColor="#FFFFFF"
android:pathData="M52,44C51,44 50,45 50,46C50,47 51,48 52,48C53,48 54,47 54,46C54,45 53,44 52,44Z"/>
<!-- Технологические линии вокруг глаза -->
<!-- Верхние линии -->
<path
android:fillColor="#40E0D0"
android:strokeWidth="2"
android:strokeColor="#40E0D0"
android:pathData="M25,45 L30,45 M78,45 L83,45"/>
<path
android:fillColor="#40E0D0"
android:strokeWidth="2"
android:strokeColor="#40E0D0"
android:pathData="M25,52 L30,52 M78,52 L83,52"/>
<path
android:fillColor="#40E0D0"
android:strokeWidth="2"
android:strokeColor="#40E0D0"
android:pathData="M25,59 L30,59 M78,59 L83,59"/>
<!-- Точки на концах линий (заменены на path элементы) -->
<path
android:fillColor="#00FFFF"
android:pathData="M23,43a2,2 0,1 1,-4,0a2,2 0,1 1,4 0z"/>
<path
android:fillColor="#00FFFF"
android:pathData="M23,50a2,2 0,1 1,-4,0a2,2 0,1 1,4 0z"/>
<path
android:fillColor="#00FFFF"
android:pathData="M23,57a2,2 0,1 1,-4,0a2,2 0,1 1,4 0z"/>
<path
android:fillColor="#00FFFF"
android:pathData="M85,43a2,2 0,1 1,-4,0a2,2 0,1 1,4 0z"/>
<path
android:fillColor="#00FFFF"
android:pathData="M85,50a2,2 0,1 1,-4,0a2,2 0,1 1,4 0z"/>
<path
android:fillColor="#00FFFF"
android:pathData="M85,57a2,2 0,1 1,-4,0a2,2 0,1 1,4 0z"/>
</vector>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/error_red"
android:pathData="M12,12m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0" />
<path
android:fillColor="@color/white"
android:pathData="M12,12m-3,0a3,3 0,1 1,6 0a3,3 0,1 1,-6 0" />
</vector>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/panel_background" />
<corners android:radius="12dp" />
<stroke android:width="1dp" android:color="@color/panel_border" />
</shape>

View File

@@ -0,0 +1,256 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp"
android:background="@color/background_dark">
<!-- Заголовок -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Мониторинг трансляции"
android:textSize="24sp"
android:textStyle="bold"
android:textColor="@color/text_primary"
android:gravity="center"
android:layout_marginBottom="24dp" />
<!-- Статус соединения -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="16dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Соединение:"
android:textSize="16sp"
android:textColor="@color/text_secondary" />
<TextView
android:id="@+id/connectionStatusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Не подключено"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="@color/error_red" />
</LinearLayout>
<!-- Индикатор статуса трансляции -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="16dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Статус трансляции:"
android:textSize="16sp"
android:textColor="@color/text_secondary" />
<View
android:id="@+id/streamingStatusIndicator"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="8dp"
android:background="@drawable/circle_indicator"
android:backgroundTint="@color/status_idle" />
<TextView
android:id="@+id/statusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Готов к трансляции"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="@color/text_primary" />
</LinearLayout>
<!-- Время записи -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="32dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Время записи:"
android:textSize="16sp"
android:textColor="@color/text_secondary" />
<ImageView
android:id="@+id/recordingIndicator"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginEnd="8dp"
android:src="@drawable/ic_recording_active"
android:visibility="gone" />
<TextView
android:id="@+id/recordingTimeText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="00:00:00"
android:textSize="24sp"
android:textStyle="bold"
android:fontFamily="monospace"
android:textColor="@color/accent_blue" />
</LinearLayout>
<!-- Разделитель -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/divider"
android:layout_marginBottom="24dp" />
<!-- Кнопки управления -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center">
<!-- Первая строка кнопок -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="12dp">
<Button
android:id="@+id/startButton"
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_weight="1"
android:layout_marginEnd="8dp"
android:text="Начать трансляцию"
android:textSize="14sp"
android:textStyle="bold"
android:background="@drawable/button_primary"
android:textColor="@color/white"
android:elevation="4dp" />
<Button
android:id="@+id/stopButton"
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:text="Остановить"
android:textSize="14sp"
android:textStyle="bold"
android:background="@drawable/button_danger"
android:textColor="@color/white"
android:elevation="4dp"
android:enabled="false" />
</LinearLayout>
<!-- Вторая строка кнопок -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="24dp">
<Button
android:id="@+id/switchCameraButton"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:layout_marginEnd="8dp"
android:text="Переключить камеру"
android:textSize="12sp"
android:background="@drawable/button_secondary"
android:textColor="@color/text_primary"
android:elevation="2dp"
android:enabled="false" />
<Button
android:id="@+id/disconnectButton"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:text="Отключиться"
android:textSize="12sp"
android:background="@drawable/button_secondary"
android:textColor="@color/text_primary"
android:elevation="2dp" />
</LinearLayout>
</LinearLayout>
<!-- Информация о сессии -->
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:background="@drawable/info_panel_background"
android:padding="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Информация о сессии"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="@color/text_primary"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/sessionInfoText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Сессия не активна"
android:textSize="14sp"
android:textColor="@color/text_secondary"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Возможности:"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="@color/text_primary"
android:layout_marginBottom="4dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="• Видеопоток без предварительного просмотра\n• Счетчик времени записи\n• Переключение камер\n• Статус трансляции в реальном времени"
android:textSize="12sp"
android:textColor="@color/text_secondary"
android:lineSpacingExtra="2dp" />
</LinearLayout>
</ScrollView>
</LinearLayout>

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Оригинальные цвета Material Design -->
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
@@ -7,4 +8,67 @@
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<!-- Цветовая схема GodEye Signal Center -->
<!-- Основные цвета из предоставленных изображений -->
<color name="godeye_primary">#00BFFF</color> <!-- Яркий голубой/cyan -->
<color name="godeye_primary_variant">#0080FF</color> <!-- Темно-голубой -->
<color name="godeye_secondary">#40E0D0</color> <!-- Бирюзовый -->
<color name="godeye_accent">#00FFFF</color> <!-- Cyan accent -->
<!-- Фоновые цвета -->
<color name="godeye_background_dark">#0A1828</color> <!-- Темно-синий фон -->
<color name="godeye_background_medium">#1E3A5F</color> <!-- Средний синий -->
<color name="godeye_background_light">#2D5A87</color> <!-- Светлый синий -->
<!-- Поверхности и карточки -->
<color name="godeye_surface">#1A2B3D</color> <!-- Поверхность карточек -->
<color name="godeye_surface_variant">#243447</color> <!-- Вариант поверхности -->
<color name="godeye_on_surface">#E0F4FF</color> <!-- Текст на поверхности -->
<color name="godeye_on_surface_variant">#B0C4DE</color> <!-- Вторичный текст -->
<!-- Состояния -->
<color name="godeye_success">#00FF80</color> <!-- Успешное подключение -->
<color name="godeye_warning">#FFB000</color> <!-- Предупреждения -->
<color name="godeye_error">#FF4444</color> <!-- Ошибки -->
<!-- Прозрачности -->
<color name="godeye_overlay_light">#33FFFFFF</color> <!-- Светлая overlay -->
<color name="godeye_overlay_dark">#66000000</color> <!-- Темная overlay -->
<!-- Специальные эффекты -->
<color name="godeye_glow">#80BFFF</color> <!-- Свечение элементов -->
<color name="godeye_connection_active">#00FF7F</color> <!-- Активное соединение -->
<color name="godeye_connection_inactive">#778899</color> <!-- Неактивное соединение -->
<!-- Основные цвета интерфейса мониторинга трансляции -->
<color name="background_dark">#1E1E1E</color>
<color name="text_primary">#FFFFFF</color>
<color name="text_secondary">#B3B3B3</color>
<color name="divider">#333333</color>
<!-- Цвета статусов трансляции -->
<color name="status_idle">#6C757D</color>
<color name="status_connecting">#FFC107</color>
<color name="status_streaming">#28A745</color>
<color name="status_error">#DC3545</color>
<color name="status_stopping">#FF8C00</color>
<!-- Цвета состояний -->
<color name="success_green">#28A745</color>
<color name="error_red">#DC3545</color>
<color name="warning_yellow">#FFC107</color>
<color name="accent_blue">#007BFF</color>
<!-- Цвета кнопок -->
<color name="button_primary">#007BFF</color>
<color name="button_primary_pressed">#0056B3</color>
<color name="button_danger">#DC3545</color>
<color name="button_danger_pressed">#C82333</color>
<color name="button_secondary">#6C757D</color>
<color name="button_secondary_pressed">#5A6268</color>
<!-- Цвета панелей -->
<color name="panel_background">#2D2D2D</color>
<color name="panel_border">#404040</color>
</resources>

View File

@@ -1,7 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.GodEye" parent="Theme.AppCompat.DayNight.NoActionBar">
<style name="Theme.GodEye" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Основные цвета приложения -->
<item name="colorPrimary">@color/godeye_primary</item>
<item name="colorPrimaryVariant">@color/godeye_primary_variant</item>
<item name="colorSecondary">@color/godeye_secondary</item>
<item name="colorOnPrimary">@color/white</item>
<item name="colorOnSecondary">@color/white</item>
<!-- Фон приложения -->
<item name="android:windowBackground">@drawable/app_background</item>
<item name="colorSurface">@color/godeye_surface</item>
<item name="colorOnSurface">@color/godeye_on_surface</item>
<!-- Статус бар -->
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<!-- Навигационная панель -->
<item name="android:navigationBarColor">@android:color/transparent</item>
<!-- Дополнительные настройки -->
<item name="android:windowContentTransitions">true</item>
<item name="android:windowAllowEnterTransitionOverlap">true</item>
<item name="android:windowAllowReturnTransitionOverlap">true</item>
</style>
<!-- Тема для экрана заставки -->
<style name="Theme.GodEye.Splash" parent="Theme.GodEye">
<item name="android:windowFullscreen">true</item>
<item name="android:windowNoTitle">true</item>
</style>
</resources>

View File

@@ -0,0 +1,112 @@
package com.example.godeye.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/**
* Цветовая палитра GodEye согласно ТЗ
*/
object GodEyeColors {
// Основные цвета приложения
val BlackPure = Color(0xFF000000)
val BlackSoft = Color(0xFF1A1A1A)
val BlackMedium = Color(0xFF2D2D2D)
val IvoryPure = Color(0xFFFFFFF0)
val IvorySoft = Color(0xFFF5F5DC)
val IvoryMedium = Color(0xFFE6E6D4)
val NavyDark = Color(0xFF0F1419)
val NavyMedium = Color(0xFF1E2328)
val NavyLight = Color(0xFF2D3748)
// Функциональные цвета
val RecordRed = Color(0xFFFF3B30)
val WarningAmber = Color(0xFFFF9500)
val SuccessGreen = Color(0xFF30D158)
val InfoBlue = Color(0xFF007AFF)
// Градиенты
val PrimaryGradientStart = NavyDark
val PrimaryGradientEnd = BlackSoft
val AccentGradientStart = NavyLight
val AccentGradientEnd = NavyMedium
}
private val DarkColorScheme = darkColorScheme(
primary = GodEyeColors.NavyLight,
onPrimary = GodEyeColors.IvoryPure,
primaryContainer = GodEyeColors.NavyMedium,
onPrimaryContainer = GodEyeColors.IvorySoft,
secondary = GodEyeColors.IvoryMedium,
onSecondary = GodEyeColors.BlackPure,
secondaryContainer = GodEyeColors.BlackMedium,
onSecondaryContainer = GodEyeColors.IvoryPure,
tertiary = GodEyeColors.WarningAmber,
onTertiary = GodEyeColors.BlackPure,
error = GodEyeColors.RecordRed,
onError = GodEyeColors.IvoryPure,
background = GodEyeColors.BlackPure,
onBackground = GodEyeColors.IvoryPure,
surface = GodEyeColors.BlackSoft,
onSurface = GodEyeColors.IvoryPure,
surfaceVariant = GodEyeColors.BlackMedium,
onSurfaceVariant = GodEyeColors.IvorySoft,
outline = GodEyeColors.NavyMedium,
outlineVariant = GodEyeColors.NavyLight
)
private val LightColorScheme = lightColorScheme(
primary = GodEyeColors.NavyMedium,
onPrimary = GodEyeColors.IvoryPure,
primaryContainer = GodEyeColors.NavyLight,
onPrimaryContainer = GodEyeColors.BlackPure,
secondary = GodEyeColors.BlackMedium,
onSecondary = GodEyeColors.IvoryPure,
secondaryContainer = GodEyeColors.IvoryMedium,
onSecondaryContainer = GodEyeColors.BlackPure,
tertiary = GodEyeColors.WarningAmber,
onTertiary = GodEyeColors.IvoryPure,
error = GodEyeColors.RecordRed,
onError = GodEyeColors.IvoryPure,
background = GodEyeColors.IvoryPure,
onBackground = GodEyeColors.BlackPure,
surface = GodEyeColors.IvorySoft,
onSurface = GodEyeColors.BlackPure,
surfaceVariant = GodEyeColors.IvoryMedium,
onSurfaceVariant = GodEyeColors.BlackMedium,
outline = GodEyeColors.NavyLight,
outlineVariant = GodEyeColors.NavyMedium
)
@Composable
fun GodEyeTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = when {
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography(),
content = content
)
}