main commit
This commit is contained in:
@@ -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>
|
||||
|
||||
68
app/src/main/AndroidManifest.xml.backup
Normal file
68
app/src/main/AndroidManifest.xml.backup
Normal 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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,7 @@ class Camera2Manager(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Используем стандартный метод createCaptureSession вместо устаревшего
|
||||
camera.createCaptureSession(listOf(surface), sessionCallback, null)
|
||||
|
||||
} catch (e: CameraAccessException) {
|
||||
|
||||
1237
app/src/main/java/com/example/godeye/managers/ConnectionManager.kt
Normal file
1237
app/src/main/java/com/example/godeye/managers/ConnectionManager.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 -> "Системное разрешение"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
308
app/src/main/java/com/example/godeye/managers/SocketManager.kt
Normal file
308
app/src/main/java/com/example/godeye/managers/SocketManager.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
1234
app/src/main/java/com/example/godeye/managers/WebRTCManager.kt
Normal file
1234
app/src/main/java/com/example/godeye/managers/WebRTCManager.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
15
app/src/main/java/com/example/godeye/models/DeviceInfo.kt
Normal file
15
app/src/main/java/com/example/godeye/models/DeviceInfo.kt
Normal 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
|
||||
)
|
||||
@@ -53,3 +53,5 @@ object SocketEvents {
|
||||
const val HEARTBEAT_ACK = "heartbeat:ack"
|
||||
const val ERROR = "error"
|
||||
}
|
||||
|
||||
// DeviceInfo перенесен в отдельный файл DeviceInfo.kt
|
||||
|
||||
@@ -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?
|
||||
)
|
||||
@@ -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 сессиями
|
||||
|
||||
@@ -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
|
||||
}
|
||||
310
app/src/main/java/com/example/godeye/network/SignalingClient.kt
Normal file
310
app/src/main/java/com/example/godeye/network/SignalingClient.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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("Завершить")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
850
app/src/main/java/com/example/godeye/ui/screens/MainScreen.kt
Normal file
850
app/src/main/java/com/example/godeye/ui/screens/MainScreen.kt
Normal 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 -> "Камера"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
185
app/src/main/java/com/example/godeye/utils/PreferenceManager.kt
Normal file
185
app/src/main/java/com/example/godeye/utils/PreferenceManager.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
24
app/src/main/res/drawable/app_background.xml
Normal file
24
app/src/main/res/drawable/app_background.xml
Normal 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>
|
||||
22
app/src/main/res/drawable/button_danger.xml
Normal file
22
app/src/main/res/drawable/button_danger.xml
Normal 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>
|
||||
|
||||
22
app/src/main/res/drawable/button_primary.xml
Normal file
22
app/src/main/res/drawable/button_primary.xml
Normal 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>
|
||||
|
||||
25
app/src/main/res/drawable/button_secondary.xml
Normal file
25
app/src/main/res/drawable/button_secondary.xml
Normal 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>
|
||||
|
||||
9
app/src/main/res/drawable/circle_indicator.xml
Normal file
9
app/src/main/res/drawable/circle_indicator.xml
Normal 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>
|
||||
|
||||
15
app/src/main/res/drawable/ic_camera_active.xml
Normal file
15
app/src/main/res/drawable/ic_camera_active.xml
Normal 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>
|
||||
11
app/src/main/res/drawable/ic_camera_request.xml
Normal file
11
app/src/main/res/drawable/ic_camera_request.xml
Normal 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
14
app/src/main/res/drawable/ic_recording_active.xml
Normal file
14
app/src/main/res/drawable/ic_recording_active.xml
Normal 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>
|
||||
|
||||
7
app/src/main/res/drawable/info_panel_background.xml
Normal file
7
app/src/main/res/drawable/info_panel_background.xml
Normal 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>
|
||||
256
app/src/main/res/layout/activity_streaming_monitor.xml
Normal file
256
app/src/main/res/layout/activity_streaming_monitor.xml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
112
app/src/ui.disabled.backup/theme/GodEyeTheme.kt
Normal file
112
app/src/ui.disabled.backup/theme/GodEyeTheme.kt
Normal 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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user