init commit
@@ -0,0 +1,24 @@
|
||||
package com.example.godeye
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.example.godeye", appContext.packageName)
|
||||
}
|
||||
}
|
||||
57
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,57 @@
|
||||
<?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" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- Требования к оборудованию -->
|
||||
<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:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
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">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.GodEye"
|
||||
android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Socket Service для WebSocket соединения -->
|
||||
<service
|
||||
android:name=".services.SocketService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="camera" />
|
||||
|
||||
<!-- Camera Service для работы с камерой -->
|
||||
<service
|
||||
android:name=".services.CameraService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="camera" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
118
app/src/main/java/com/example/godeye/MainActivity.kt
Normal file
@@ -0,0 +1,118 @@
|
||||
package com.example.godeye
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.example.godeye.managers.PermissionManager
|
||||
import com.example.godeye.ui.screens.MainScreen
|
||||
import com.example.godeye.ui.theme.GodEyeTheme
|
||||
import com.example.godeye.ui.viewmodels.MainViewModel
|
||||
import com.example.godeye.utils.Logger
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val viewModel: MainViewModel by viewModels()
|
||||
private lateinit var permissionManager: PermissionManager
|
||||
|
||||
// Launcher для запроса разрешений
|
||||
private val permissionsLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { permissions ->
|
||||
val allGranted = permissions.values.all { it }
|
||||
if (allGranted) {
|
||||
Logger.d("All permissions granted")
|
||||
viewModel.startServices() // Запуск сервисов после получения разрешений
|
||||
} else {
|
||||
Logger.w("Some permissions were denied")
|
||||
val deniedPermissions = permissions.filterValues { !it }.keys
|
||||
Logger.w("Denied permissions: ${deniedPermissions.joinToString(", ")}")
|
||||
}
|
||||
|
||||
// Логируем статус разрешений
|
||||
permissionManager.logPermissionsStatus()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
Logger.d("MainActivity created")
|
||||
permissionManager = PermissionManager(this)
|
||||
|
||||
// Проверяем разрешения при запуске
|
||||
checkAndRequestPermissions()
|
||||
if (permissionManager.hasAllRequiredPermissions()) {
|
||||
viewModel.startServices() // Запуск сервисов если разрешения уже есть
|
||||
}
|
||||
|
||||
setContent {
|
||||
GodEyeTheme {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { paddingValues ->
|
||||
MainScreen(
|
||||
viewModel = viewModel,
|
||||
onRequestPermissions = {
|
||||
requestMissingPermissions()
|
||||
},
|
||||
onShowError = { message ->
|
||||
coroutineScope.launch {
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить и запросить недостающие разрешения
|
||||
*/
|
||||
private fun checkAndRequestPermissions() {
|
||||
if (!permissionManager.hasAllRequiredPermissions()) {
|
||||
Logger.d("Some permissions are missing, requesting...")
|
||||
requestMissingPermissions()
|
||||
} else {
|
||||
Logger.d("All required permissions are granted")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запросить недостающие разрешения
|
||||
*/
|
||||
private fun requestMissingPermissions() {
|
||||
val missingPermissions = permissionManager.getMissingPermissions()
|
||||
if (missingPermissions.isNotEmpty()) {
|
||||
Logger.d("Requesting permissions: ${missingPermissions.joinToString(", ")}")
|
||||
permissionsLauncher.launch(missingPermissions.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
Logger.d("MainActivity destroyed")
|
||||
}
|
||||
}
|
||||
245
app/src/main/java/com/example/godeye/managers/CameraManager.kt
Normal file
@@ -0,0 +1,245 @@
|
||||
package com.example.godeye.managers
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.hardware.camera2.*
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Size
|
||||
import android.view.Surface
|
||||
import com.example.godeye.models.AppError
|
||||
import com.example.godeye.utils.Constants
|
||||
import com.example.godeye.utils.Logger
|
||||
import com.example.godeye.utils.getCameraIdForType
|
||||
import com.example.godeye.utils.getAvailableCameraTypes
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Менеджер для управления камерами устройства
|
||||
*/
|
||||
class CameraManager(private val context: Context) {
|
||||
|
||||
private val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as android.hardware.camera2.CameraManager
|
||||
private var currentCameraId: String? = null
|
||||
private var captureSession: CameraCaptureSession? = null
|
||||
private var cameraDevice: CameraDevice? = null
|
||||
private var backgroundThread: HandlerThread? = null
|
||||
private var backgroundHandler: Handler? = null
|
||||
|
||||
private val _isRecording = MutableStateFlow(false)
|
||||
val isRecording: StateFlow<Boolean> = _isRecording.asStateFlow()
|
||||
|
||||
private val _currentCameraType = MutableStateFlow<String?>(null)
|
||||
val currentCameraType: StateFlow<String?> = _currentCameraType.asStateFlow()
|
||||
|
||||
private val _error = MutableStateFlow<AppError?>(null)
|
||||
val error: StateFlow<AppError?> = _error.asStateFlow()
|
||||
|
||||
/**
|
||||
* Инициализация фонового потока для камеры
|
||||
*/
|
||||
private fun startBackgroundThread() {
|
||||
backgroundThread = HandlerThread("CameraBackground").also { it.start() }
|
||||
backgroundHandler = Handler(backgroundThread?.looper!!)
|
||||
}
|
||||
|
||||
/**
|
||||
* Остановка фонового потока
|
||||
*/
|
||||
private fun stopBackgroundThread() {
|
||||
backgroundThread?.quitSafely()
|
||||
try {
|
||||
backgroundThread?.join()
|
||||
backgroundThread = null
|
||||
backgroundHandler = null
|
||||
} catch (e: InterruptedException) {
|
||||
Logger.e("Error stopping background thread", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить список доступных типов камер
|
||||
*/
|
||||
fun getAvailableCameraTypes(): List<String> {
|
||||
return cameraManager.getAvailableCameraTypes()
|
||||
}
|
||||
|
||||
/**
|
||||
* Открыть камеру указанного типа
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
fun openCamera(cameraType: String, surface: Surface, onSuccess: () -> Unit = {}, onError: (AppError) -> Unit = {}) {
|
||||
try {
|
||||
val cameraId = cameraManager.getCameraIdForType(cameraType)
|
||||
if (cameraId == null) {
|
||||
val error = AppError.CameraError("Camera type $cameraType not available")
|
||||
_error.value = error
|
||||
onError(error)
|
||||
return
|
||||
}
|
||||
|
||||
startBackgroundThread()
|
||||
|
||||
cameraManager.openCamera(cameraId, object : CameraDevice.StateCallback() {
|
||||
override fun onOpened(camera: CameraDevice) {
|
||||
Logger.d("Camera opened: $cameraId")
|
||||
cameraDevice = camera
|
||||
currentCameraId = cameraId
|
||||
_currentCameraType.value = cameraType
|
||||
createCameraPreviewSession(surface, onSuccess, onError)
|
||||
}
|
||||
|
||||
override fun onDisconnected(camera: CameraDevice) {
|
||||
Logger.d("Camera disconnected: $cameraId")
|
||||
camera.close()
|
||||
cameraDevice = null
|
||||
currentCameraId = null
|
||||
_currentCameraType.value = null
|
||||
}
|
||||
|
||||
override fun onError(camera: CameraDevice, error: Int) {
|
||||
Logger.e("Camera error: $error")
|
||||
camera.close()
|
||||
cameraDevice = null
|
||||
currentCameraId = null
|
||||
_currentCameraType.value = null
|
||||
val appError = AppError.CameraError("Camera error: $error")
|
||||
_error.value = appError
|
||||
onError(appError)
|
||||
}
|
||||
}, backgroundHandler)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error opening camera", e)
|
||||
val error = AppError.CameraError("Failed to open camera: ${e.message}")
|
||||
_error.value = error
|
||||
onError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать сессию предварительного просмотра камеры
|
||||
*/
|
||||
private fun createCameraPreviewSession(surface: Surface, onSuccess: () -> Unit, onError: (AppError) -> Unit) {
|
||||
try {
|
||||
val cameraDevice = this.cameraDevice ?: run {
|
||||
val error = AppError.CameraError("Camera device is null")
|
||||
_error.value = error
|
||||
onError(error)
|
||||
return
|
||||
}
|
||||
|
||||
val captureRequestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
|
||||
captureRequestBuilder.addTarget(surface)
|
||||
|
||||
// Используем совместимый подход для всех версий Android
|
||||
@Suppress("DEPRECATION")
|
||||
cameraDevice.createCaptureSession(
|
||||
listOf(surface),
|
||||
object : CameraCaptureSession.StateCallback() {
|
||||
override fun onConfigured(session: CameraCaptureSession) {
|
||||
captureSession = session
|
||||
try {
|
||||
captureRequestBuilder.set(
|
||||
CaptureRequest.CONTROL_AF_MODE,
|
||||
CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE
|
||||
)
|
||||
|
||||
val captureRequest = captureRequestBuilder.build()
|
||||
session.setRepeatingRequest(captureRequest, null, backgroundHandler)
|
||||
_isRecording.value = true
|
||||
Logger.d("Camera preview session created successfully")
|
||||
onSuccess()
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error starting camera preview", e)
|
||||
val error = AppError.CameraError("Failed to start preview: ${e.message}")
|
||||
_error.value = error
|
||||
onError(error)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConfigureFailed(session: CameraCaptureSession) {
|
||||
Logger.e("Camera capture session configuration failed")
|
||||
val error = AppError.CameraError("Failed to configure capture session")
|
||||
_error.value = error
|
||||
onError(error)
|
||||
}
|
||||
},
|
||||
backgroundHandler
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error creating camera preview session", e)
|
||||
val error = AppError.CameraError("Failed to create preview session: ${e.message}")
|
||||
_error.value = error
|
||||
onError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключить на другой тип камеры
|
||||
*/
|
||||
fun switchCamera(newCameraType: String, surface: Surface, onSuccess: () -> Unit = {}, onError: (AppError) -> Unit = {}) {
|
||||
Logger.d("Switching camera from ${_currentCameraType.value} to $newCameraType")
|
||||
closeCamera()
|
||||
openCamera(newCameraType, surface, onSuccess, onError)
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить оптимальный размер для предварительного просмотра
|
||||
*/
|
||||
fun getOptimalPreviewSize(cameraType: String): Size? {
|
||||
return try {
|
||||
val cameraId = cameraManager.getCameraIdForType(cameraType) ?: return null
|
||||
val characteristics = cameraManager.getCameraCharacteristics(cameraId)
|
||||
val map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
|
||||
val outputSizes = map?.getOutputSizes(SurfaceTexture::class.java)
|
||||
|
||||
// Выбираем размер близкий к 1080p, но не превышающий его
|
||||
outputSizes?.find { it.width <= 1920 && it.height <= 1080 }
|
||||
?: outputSizes?.minByOrNull { it.width * it.height }
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error getting optimal preview size", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Закрыть текущую камеру
|
||||
*/
|
||||
fun closeCamera() {
|
||||
try {
|
||||
captureSession?.close()
|
||||
captureSession = null
|
||||
|
||||
cameraDevice?.close()
|
||||
cameraDevice = null
|
||||
|
||||
currentCameraId = null
|
||||
_currentCameraType.value = null
|
||||
_isRecording.value = false
|
||||
|
||||
stopBackgroundThread()
|
||||
Logger.d("Camera closed successfully")
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error closing camera", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить, открыта ли камера
|
||||
*/
|
||||
fun isCameraOpen(): Boolean {
|
||||
return cameraDevice != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистить ошибку
|
||||
*/
|
||||
fun clearError() {
|
||||
_error.value = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.example.godeye.managers
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.example.godeye.utils.Logger
|
||||
|
||||
/**
|
||||
* Менеджер для управления разрешениями приложения
|
||||
*/
|
||||
class PermissionManager(private val context: Context) {
|
||||
|
||||
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.FOREGROUND_SERVICE,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
)
|
||||
|
||||
val CAMERA_PERMISSIONS = arrayOf(
|
||||
Manifest.permission.CAMERA,
|
||||
Manifest.permission.RECORD_AUDIO
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить, есть ли все необходимые разрешения
|
||||
*/
|
||||
fun hasAllRequiredPermissions(): Boolean {
|
||||
return REQUIRED_PERMISSIONS.all { permission ->
|
||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить разрешения для камеры
|
||||
*/
|
||||
fun hasCameraPermissions(): Boolean {
|
||||
return CAMERA_PERMISSIONS.all { permission ->
|
||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить конкретное разрешение
|
||||
*/
|
||||
fun hasPermission(permission: String): Boolean {
|
||||
return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить список отсутствующих разрешений
|
||||
*/
|
||||
fun getMissingPermissions(): List<String> {
|
||||
return REQUIRED_PERMISSIONS.filter { permission ->
|
||||
ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить список отсутствующих разрешений для камеры
|
||||
*/
|
||||
fun getMissingCameraPermissions(): List<String> {
|
||||
return CAMERA_PERMISSIONS.filter { permission ->
|
||||
ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить критические разрешения для основной функциональности
|
||||
*/
|
||||
fun hasCriticalPermissions(): Boolean {
|
||||
val criticalPermissions = arrayOf(
|
||||
Manifest.permission.CAMERA,
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
Manifest.permission.INTERNET
|
||||
)
|
||||
|
||||
return criticalPermissions.all { permission ->
|
||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Логирование состояния разрешений
|
||||
*/
|
||||
fun logPermissionsStatus() {
|
||||
Logger.d("=== Permission Status ===")
|
||||
REQUIRED_PERMISSIONS.forEach { permission ->
|
||||
val granted = hasPermission(permission)
|
||||
Logger.d("$permission: ${if (granted) "GRANTED" else "DENIED"}")
|
||||
}
|
||||
Logger.d("All required permissions: ${hasAllRequiredPermissions()}")
|
||||
Logger.d("Camera permissions: ${hasCameraPermissions()}")
|
||||
Logger.d("Critical permissions: ${hasCriticalPermissions()}")
|
||||
}
|
||||
}
|
||||
152
app/src/main/java/com/example/godeye/managers/SessionManager.kt
Normal file
@@ -0,0 +1,152 @@
|
||||
package com.example.godeye.managers
|
||||
|
||||
import com.example.godeye.models.CameraSession
|
||||
import com.example.godeye.utils.Logger
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Менеджер для управления активными сессиями с операторами
|
||||
*/
|
||||
class SessionManager {
|
||||
|
||||
private val _activeSessions = MutableStateFlow<List<CameraSession>>(emptyList())
|
||||
val activeSessions: StateFlow<List<CameraSession>> = _activeSessions.asStateFlow()
|
||||
|
||||
/**
|
||||
* Добавить новую сессию
|
||||
*/
|
||||
fun addSession(sessionId: String, operatorId: String, cameraType: String) {
|
||||
val newSession = CameraSession(
|
||||
sessionId = sessionId,
|
||||
operatorId = operatorId,
|
||||
cameraType = cameraType,
|
||||
startTime = System.currentTimeMillis(),
|
||||
isActive = true,
|
||||
webRTCConnected = false
|
||||
)
|
||||
|
||||
val currentSessions = _activeSessions.value.toMutableList()
|
||||
// Удаляем существующую сессию с тем же ID, если есть
|
||||
currentSessions.removeAll { it.sessionId == sessionId }
|
||||
currentSessions.add(newSession)
|
||||
_activeSessions.value = currentSessions
|
||||
|
||||
Logger.d("Session added: $sessionId, operator: $operatorId, camera: $cameraType")
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить статус WebRTC соединения для сессии
|
||||
*/
|
||||
fun updateWebRTCStatus(sessionId: String, connected: Boolean) {
|
||||
val currentSessions = _activeSessions.value.toMutableList()
|
||||
val sessionIndex = currentSessions.indexOfFirst { it.sessionId == sessionId }
|
||||
|
||||
if (sessionIndex != -1) {
|
||||
currentSessions[sessionIndex] = currentSessions[sessionIndex].copy(
|
||||
webRTCConnected = connected
|
||||
)
|
||||
_activeSessions.value = currentSessions
|
||||
Logger.d("WebRTC status updated for session $sessionId: $connected")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключить камеру для сессии
|
||||
*/
|
||||
fun switchCameraForSession(sessionId: String, newCameraType: String) {
|
||||
val currentSessions = _activeSessions.value.toMutableList()
|
||||
val sessionIndex = currentSessions.indexOfFirst { it.sessionId == sessionId }
|
||||
|
||||
if (sessionIndex != -1) {
|
||||
currentSessions[sessionIndex] = currentSessions[sessionIndex].copy(
|
||||
cameraType = newCameraType
|
||||
)
|
||||
_activeSessions.value = currentSessions
|
||||
Logger.d("Camera switched for session $sessionId to $newCameraType")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Завершить сессию
|
||||
*/
|
||||
fun endSession(sessionId: String) {
|
||||
val currentSessions = _activeSessions.value.toMutableList()
|
||||
val removed = currentSessions.removeAll { it.sessionId == sessionId }
|
||||
|
||||
if (removed) {
|
||||
_activeSessions.value = currentSessions
|
||||
Logger.d("Session ended: $sessionId")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить сессию по ID
|
||||
*/
|
||||
fun getSession(sessionId: String): CameraSession? {
|
||||
return _activeSessions.value.find { it.sessionId == sessionId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить, есть ли активные сессии
|
||||
*/
|
||||
fun hasActiveSessions(): Boolean {
|
||||
return _activeSessions.value.isNotEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить количество активных сессий
|
||||
*/
|
||||
fun getActiveSessionCount(): Int {
|
||||
return _activeSessions.value.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Завершить все сессии
|
||||
*/
|
||||
fun endAllSessions() {
|
||||
val sessionIds = _activeSessions.value.map { it.sessionId }
|
||||
_activeSessions.value = emptyList()
|
||||
Logger.d("All sessions ended: ${sessionIds.joinToString(", ")}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить текущий тип камеры для активной сессии
|
||||
*/
|
||||
fun getCurrentCameraType(): String? {
|
||||
return _activeSessions.value.firstOrNull()?.cameraType
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить, подключен ли WebRTC для сессии
|
||||
*/
|
||||
fun isWebRTCConnected(sessionId: String): Boolean {
|
||||
return getSession(sessionId)?.webRTCConnected ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить статистику сессий
|
||||
*/
|
||||
fun getSessionStats(): SessionStats {
|
||||
val sessions = _activeSessions.value
|
||||
return SessionStats(
|
||||
totalSessions = sessions.size,
|
||||
connectedSessions = sessions.count { it.webRTCConnected },
|
||||
activeSessions = sessions.count { it.isActive },
|
||||
oldestSessionTime = sessions.minOfOrNull { it.startTime },
|
||||
newestSessionTime = sessions.maxOfOrNull { it.startTime }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Статистика сессий
|
||||
*/
|
||||
data class SessionStats(
|
||||
val totalSessions: Int,
|
||||
val connectedSessions: Int,
|
||||
val activeSessions: Int,
|
||||
val oldestSessionTime: Long?,
|
||||
val newestSessionTime: Long?
|
||||
)
|
||||
145
app/src/main/java/com/example/godeye/managers/WebRTCManager.kt
Normal file
@@ -0,0 +1,145 @@
|
||||
package com.example.godeye.managers
|
||||
|
||||
import android.content.Context
|
||||
import com.example.godeye.models.AppError
|
||||
import com.example.godeye.models.WebRTCConnectionState
|
||||
import com.example.godeye.utils.Constants
|
||||
import com.example.godeye.utils.Logger
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Менеджер для управления WebRTC соединениями (заглушка)
|
||||
* В реальном проекте здесь будет полная реализация с WebRTC библиотекой
|
||||
*/
|
||||
class WebRTCManager(private val context: Context) {
|
||||
|
||||
private val _connectionState = MutableStateFlow(WebRTCConnectionState.NEW)
|
||||
val connectionState: StateFlow<WebRTCConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
private val _error = MutableStateFlow<AppError?>(null)
|
||||
val error: StateFlow<AppError?> = _error.asStateFlow()
|
||||
|
||||
// Callback для передачи событий WebRTC
|
||||
private var onOfferCreated: ((String) -> Unit)? = null
|
||||
private var onAnswerCreated: ((String) -> Unit)? = null
|
||||
private var onIceCandidateCreated: ((String, String, Int) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Инициализация WebRTC (заглушка)
|
||||
*/
|
||||
fun initialize() {
|
||||
try {
|
||||
Logger.d("WebRTC initialized (stub implementation)")
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error initializing WebRTC", e)
|
||||
_error.value = AppError.WebRTCConnectionFailed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать PeerConnection (заглушка)
|
||||
*/
|
||||
fun createPeerConnection(
|
||||
onOfferCreated: (String) -> Unit,
|
||||
onAnswerCreated: (String) -> Unit,
|
||||
onIceCandidateCreated: (String, String, Int) -> Unit
|
||||
) {
|
||||
try {
|
||||
this.onOfferCreated = onOfferCreated
|
||||
this.onAnswerCreated = onAnswerCreated
|
||||
this.onIceCandidateCreated = onIceCandidateCreated
|
||||
|
||||
Logger.d("PeerConnection created (stub implementation)")
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error creating PeerConnection", e)
|
||||
_error.value = AppError.WebRTCConnectionFailed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать локальные медиа треки (заглушка)
|
||||
*/
|
||||
fun createLocalMediaTracks(cameraType: String) {
|
||||
try {
|
||||
Logger.d("Local media tracks created for $cameraType (stub implementation)")
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error creating local media tracks", e)
|
||||
_error.value = AppError.WebRTCConnectionFailed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать Offer (заглушка)
|
||||
*/
|
||||
fun createOffer() {
|
||||
try {
|
||||
// Симулируем создание offer
|
||||
val mockOffer = "v=0\r\no=- 123456 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\nc=IN IP4 127.0.0.1\r\na=rtcp:9 IN IP4 127.0.0.1"
|
||||
onOfferCreated?.invoke(mockOffer)
|
||||
Logger.d("Offer created (stub implementation)")
|
||||
|
||||
// Симулируем успешное соединение через некоторое время
|
||||
_connectionState.value = WebRTCConnectionState.CONNECTED
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error creating offer", e)
|
||||
_error.value = AppError.WebRTCConnectionFailed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработать Answer (заглушка)
|
||||
*/
|
||||
fun handleAnswer(answerSdp: String) {
|
||||
try {
|
||||
Logger.d("Answer processed (stub implementation): ${answerSdp.take(50)}...")
|
||||
_connectionState.value = WebRTCConnectionState.CONNECTED
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error handling answer", e)
|
||||
_error.value = AppError.WebRTCConnectionFailed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавить ICE candidate (заглушка)
|
||||
*/
|
||||
fun addIceCandidate(candidateSdp: String, sdpMid: String, sdpMLineIndex: Int) {
|
||||
try {
|
||||
Logger.d("ICE candidate added (stub implementation): $candidateSdp")
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error adding ICE candidate", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключить камеру (заглушка)
|
||||
*/
|
||||
fun switchCamera(newCameraType: String) {
|
||||
try {
|
||||
Logger.d("Camera switched to: $newCameraType (stub implementation)")
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error switching camera", e)
|
||||
_error.value = AppError.CameraError("Failed to switch camera: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Закрыть WebRTC соединение (заглушка)
|
||||
*/
|
||||
fun close() {
|
||||
try {
|
||||
_connectionState.value = WebRTCConnectionState.CLOSED
|
||||
Logger.d("WebRTC connection closed (stub implementation)")
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error closing WebRTC connection", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистить ошибку
|
||||
*/
|
||||
fun clearError() {
|
||||
_error.value = null
|
||||
}
|
||||
}
|
||||
154
app/src/main/java/com/example/godeye/models/Models.kt
Normal file
@@ -0,0 +1,154 @@
|
||||
package com.example.godeye.models
|
||||
|
||||
import android.os.Build
|
||||
|
||||
/**
|
||||
* Информация об устройстве для регистрации на сервере
|
||||
*/
|
||||
data class DeviceInfo(
|
||||
val model: String = Build.MODEL,
|
||||
val androidVersion: String = Build.VERSION.RELEASE,
|
||||
val appVersion: String = "1.0.0", // Заменяем BuildConfig на хардкод для упрощения
|
||||
val availableCameras: List<String>
|
||||
)
|
||||
|
||||
/**
|
||||
* Активная сессия с оператором
|
||||
*/
|
||||
data class CameraSession(
|
||||
val sessionId: String,
|
||||
val operatorId: String,
|
||||
val cameraType: String,
|
||||
val startTime: Long,
|
||||
var isActive: Boolean = true,
|
||||
var webRTCConnected: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* Запрос доступа к камере от оператора
|
||||
*/
|
||||
data class CameraRequest(
|
||||
val sessionId: String,
|
||||
val operatorId: String,
|
||||
val cameraType: String,
|
||||
val timestamp: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
/**
|
||||
* Ответ на запрос доступа к камере
|
||||
*/
|
||||
data class CameraResponse(
|
||||
val sessionId: String,
|
||||
val accepted: Boolean,
|
||||
val reason: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* WebRTC Offer/Answer данные
|
||||
*/
|
||||
data class WebRTCMessage(
|
||||
val sessionId: String,
|
||||
val type: String, // "offer", "answer", "ice-candidate"
|
||||
val sdp: String? = null,
|
||||
val candidate: String? = null,
|
||||
val sdpMid: String? = null,
|
||||
val sdpMLineIndex: Int? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* События Socket.IO
|
||||
*/
|
||||
sealed class SocketEvent {
|
||||
data class RegisterAndroid(
|
||||
val deviceId: String,
|
||||
val deviceInfo: DeviceInfo
|
||||
) : SocketEvent()
|
||||
|
||||
data class CameraRequest(
|
||||
val sessionId: String,
|
||||
val operatorId: String,
|
||||
val cameraType: String
|
||||
) : SocketEvent()
|
||||
|
||||
data class CameraResponse(
|
||||
val sessionId: String,
|
||||
val accepted: Boolean,
|
||||
val reason: String? = null
|
||||
) : SocketEvent()
|
||||
|
||||
data class CameraDisconnect(
|
||||
val sessionId: String
|
||||
) : SocketEvent()
|
||||
|
||||
data class CameraSwitch(
|
||||
val sessionId: String,
|
||||
val newCameraType: String
|
||||
) : SocketEvent()
|
||||
|
||||
data class WebRTCOffer(
|
||||
val sessionId: String,
|
||||
val offer: String
|
||||
) : SocketEvent()
|
||||
|
||||
data class WebRTCAnswer(
|
||||
val sessionId: String,
|
||||
val answer: String
|
||||
) : SocketEvent()
|
||||
|
||||
data class WebRTCIceCandidate(
|
||||
val sessionId: String,
|
||||
val candidate: String,
|
||||
val sdpMid: String,
|
||||
val sdpMLineIndex: Int
|
||||
) : SocketEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Состояния подключения
|
||||
*/
|
||||
enum class ConnectionState {
|
||||
DISCONNECTED,
|
||||
CONNECTING,
|
||||
CONNECTED,
|
||||
ERROR,
|
||||
RECONNECTING
|
||||
}
|
||||
|
||||
/**
|
||||
* Состояния WebRTC соединения
|
||||
*/
|
||||
enum class WebRTCConnectionState {
|
||||
NEW,
|
||||
CONNECTING,
|
||||
CONNECTED,
|
||||
DISCONNECTED,
|
||||
FAILED,
|
||||
CLOSED
|
||||
}
|
||||
|
||||
/**
|
||||
* Типы ошибок приложения
|
||||
*/
|
||||
sealed class AppError {
|
||||
object NetworkError : AppError()
|
||||
object CameraPermissionDenied : AppError()
|
||||
object AudioPermissionDenied : AppError()
|
||||
object CameraNotAvailable : AppError()
|
||||
object WebRTCConnectionFailed : AppError()
|
||||
data class SocketError(val message: String) : AppError()
|
||||
data class CameraError(val message: String) : AppError()
|
||||
data class UnknownError(val throwable: Throwable) : AppError()
|
||||
}
|
||||
|
||||
/**
|
||||
* UI состояние главного экрана
|
||||
*/
|
||||
data class MainScreenState(
|
||||
val deviceId: String = "",
|
||||
val serverUrl: String = "",
|
||||
val connectionState: ConnectionState = ConnectionState.DISCONNECTED,
|
||||
val activeSessions: List<CameraSession> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: AppError? = null,
|
||||
val showCameraRequest: CameraRequest? = null
|
||||
)
|
||||
425
app/src/main/java/com/example/godeye/services/CameraService.kt
Normal file
@@ -0,0 +1,425 @@
|
||||
package com.example.godeye.services
|
||||
|
||||
import android.app.*
|
||||
import android.content.Intent
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import android.view.Surface
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.example.godeye.MainActivity
|
||||
import com.example.godeye.R
|
||||
import com.example.godeye.managers.CameraManager
|
||||
import com.example.godeye.managers.SessionManager
|
||||
import com.example.godeye.managers.WebRTCManager
|
||||
import com.example.godeye.models.AppError
|
||||
import com.example.godeye.models.WebRTCConnectionState
|
||||
import com.example.godeye.utils.Constants
|
||||
import com.example.godeye.utils.Logger
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
/**
|
||||
* Сервис для управления камерой и WebRTC соединениями
|
||||
*/
|
||||
class CameraService : Service() {
|
||||
|
||||
private val binder = LocalBinder()
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
private lateinit var cameraManager: CameraManager
|
||||
private lateinit var webRTCManager: WebRTCManager
|
||||
private lateinit var sessionManager: SessionManager
|
||||
|
||||
// Surface для WebRTC видео
|
||||
private var webRTCSurface: Surface? = null
|
||||
private var surfaceTexture: SurfaceTexture? = null
|
||||
|
||||
// StateFlows для отслеживания состояния
|
||||
private val _isActive = MutableStateFlow(false)
|
||||
val isActive: StateFlow<Boolean> = _isActive.asStateFlow()
|
||||
|
||||
private val _error = MutableStateFlow<AppError?>(null)
|
||||
val error: StateFlow<AppError?> = _error.asStateFlow()
|
||||
|
||||
// Callbacks для передачи WebRTC событий
|
||||
private var onWebRTCOfferCreated: ((String, String) -> Unit)? = null // sessionId, offer
|
||||
private var onWebRTCIceCandidateCreated: ((String, String, String, Int) -> Unit)? = null // sessionId, candidate, sdpMid, sdpMLineIndex
|
||||
|
||||
inner class LocalBinder : Binder() {
|
||||
fun getService(): CameraService = this@CameraService
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Logger.d("CameraService created")
|
||||
|
||||
cameraManager = CameraManager(this)
|
||||
webRTCManager = WebRTCManager(this)
|
||||
sessionManager = SessionManager()
|
||||
|
||||
// Инициализация WebRTC
|
||||
webRTCManager.initialize()
|
||||
|
||||
createNotificationChannel()
|
||||
observeManagerStates()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder = binder
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startForeground(Constants.FOREGROUND_SERVICE_ID + 1, createNotification())
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
/**
|
||||
* Установить callbacks для WebRTC событий
|
||||
*/
|
||||
fun setWebRTCCallbacks(
|
||||
onOfferCreated: (String, String) -> Unit,
|
||||
onIceCandidateCreated: (String, String, String, Int) -> Unit
|
||||
) {
|
||||
this.onWebRTCOfferCreated = onOfferCreated
|
||||
this.onWebRTCIceCandidateCreated = onIceCandidateCreated
|
||||
}
|
||||
|
||||
/**
|
||||
* Начать камера сессию
|
||||
*/
|
||||
fun startCameraSession(sessionId: String, operatorId: String, cameraType: String) {
|
||||
serviceScope.launch {
|
||||
try {
|
||||
Logger.d("Starting camera session: $sessionId, camera: $cameraType")
|
||||
|
||||
// Добавляем сессию в менеджер
|
||||
sessionManager.addSession(sessionId, operatorId, cameraType)
|
||||
|
||||
// Создаем Surface для WebRTC
|
||||
setupWebRTCSurface()
|
||||
|
||||
// Создаем WebRTC соединение
|
||||
webRTCManager.createPeerConnection(
|
||||
onOfferCreated = { offer ->
|
||||
onWebRTCOfferCreated?.invoke(sessionId, offer)
|
||||
},
|
||||
onAnswerCreated = { answer ->
|
||||
// Ответ не используется, так как мы создаем offer
|
||||
},
|
||||
onIceCandidateCreated = { candidate, sdpMid, sdpMLineIndex ->
|
||||
onWebRTCIceCandidateCreated?.invoke(sessionId, candidate, sdpMid, sdpMLineIndex)
|
||||
}
|
||||
)
|
||||
|
||||
// Создаем локальные медиа треки
|
||||
webRTCManager.createLocalMediaTracks(cameraType)
|
||||
|
||||
// Открываем камеру
|
||||
webRTCSurface?.let { surface ->
|
||||
cameraManager.openCamera(
|
||||
cameraType = cameraType,
|
||||
surface = surface,
|
||||
onSuccess = {
|
||||
Logger.d("Camera opened successfully for session: $sessionId")
|
||||
_isActive.value = true
|
||||
|
||||
// Создаем WebRTC offer
|
||||
webRTCManager.createOffer()
|
||||
},
|
||||
onError = { error ->
|
||||
Logger.e("Failed to open camera for session: $sessionId")
|
||||
_error.value = error
|
||||
sessionManager.endSession(sessionId)
|
||||
}
|
||||
)
|
||||
} ?: run {
|
||||
val error = AppError.CameraError("WebRTC surface not available")
|
||||
_error.value = error
|
||||
sessionManager.endSession(sessionId)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error starting camera session", e)
|
||||
_error.value = AppError.UnknownError(e)
|
||||
sessionManager.endSession(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработать WebRTC answer
|
||||
*/
|
||||
fun handleWebRTCAnswer(sessionId: String, answer: String) {
|
||||
serviceScope.launch {
|
||||
try {
|
||||
Logger.d("Handling WebRTC answer for session: $sessionId")
|
||||
webRTCManager.handleAnswer(answer)
|
||||
sessionManager.updateWebRTCStatus(sessionId, true)
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error handling WebRTC answer", e)
|
||||
_error.value = AppError.WebRTCConnectionFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавить ICE candidate
|
||||
*/
|
||||
fun addIceCandidate(sessionId: String, candidate: String, sdpMid: String, sdpMLineIndex: Int) {
|
||||
serviceScope.launch {
|
||||
try {
|
||||
Logger.d("Adding ICE candidate for session: $sessionId")
|
||||
webRTCManager.addIceCandidate(candidate, sdpMid, sdpMLineIndex)
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error adding ICE candidate", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключить камеру
|
||||
*/
|
||||
fun switchCamera(sessionId: String, newCameraType: String) {
|
||||
serviceScope.launch {
|
||||
try {
|
||||
Logger.d("Switching camera for session $sessionId to $newCameraType")
|
||||
|
||||
// Обновляем тип камеры в сессии
|
||||
sessionManager.switchCameraForSession(sessionId, newCameraType)
|
||||
|
||||
// Переключаем камеру в WebRTC
|
||||
webRTCManager.switchCamera(newCameraType)
|
||||
|
||||
// Переключаем физическую камеру
|
||||
webRTCSurface?.let { surface ->
|
||||
cameraManager.switchCamera(
|
||||
newCameraType = newCameraType,
|
||||
surface = surface,
|
||||
onSuccess = {
|
||||
Logger.d("Camera switched successfully to: $newCameraType")
|
||||
},
|
||||
onError = { error ->
|
||||
Logger.e("Failed to switch camera to: $newCameraType")
|
||||
_error.value = error
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error switching camera", e)
|
||||
_error.value = AppError.CameraError("Failed to switch camera: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Завершить сессию
|
||||
*/
|
||||
fun endSession(sessionId: String) {
|
||||
serviceScope.launch {
|
||||
try {
|
||||
Logger.d("Ending session: $sessionId")
|
||||
|
||||
// Закрываем камеру
|
||||
cameraManager.closeCamera()
|
||||
|
||||
// Закрываем WebRTC соединение
|
||||
webRTCManager.close()
|
||||
|
||||
// Удаляем сессию
|
||||
sessionManager.endSession(sessionId)
|
||||
|
||||
// Очищаем Surface
|
||||
cleanupWebRTCSurface()
|
||||
|
||||
_isActive.value = false
|
||||
Logger.d("Session ended successfully: $sessionId")
|
||||
|
||||
// Если нет активных сессий, останавливаем сервис
|
||||
if (!sessionManager.hasActiveSessions()) {
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error ending session", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Завершить все сессии
|
||||
*/
|
||||
fun endAllSessions() {
|
||||
serviceScope.launch {
|
||||
try {
|
||||
Logger.d("Ending all sessions")
|
||||
|
||||
// Закрываем камеру
|
||||
cameraManager.closeCamera()
|
||||
|
||||
// Закрываем WebRTC соединение
|
||||
webRTCManager.close()
|
||||
|
||||
// Удаляем все сессии
|
||||
sessionManager.endAllSessions()
|
||||
|
||||
// Очищаем Surface
|
||||
cleanupWebRTCSurface()
|
||||
|
||||
_isActive.value = false
|
||||
Logger.d("All sessions ended successfully")
|
||||
|
||||
stopSelf()
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error ending all sessions", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Настроить Surface для WebRTC
|
||||
*/
|
||||
private fun setupWebRTCSurface() {
|
||||
try {
|
||||
// Создаем SurfaceTexture для WebRTC
|
||||
surfaceTexture = SurfaceTexture(0).apply {
|
||||
setDefaultBufferSize(1280, 720)
|
||||
}
|
||||
webRTCSurface = Surface(surfaceTexture)
|
||||
Logger.d("WebRTC surface created successfully")
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error creating WebRTC surface", e)
|
||||
_error.value = AppError.CameraError("Failed to create WebRTC surface: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистить WebRTC Surface
|
||||
*/
|
||||
private fun cleanupWebRTCSurface() {
|
||||
try {
|
||||
webRTCSurface?.release()
|
||||
webRTCSurface = null
|
||||
|
||||
surfaceTexture?.release()
|
||||
surfaceTexture = null
|
||||
|
||||
Logger.d("WebRTC surface cleaned up")
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error cleaning up WebRTC surface", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Наблюдать за состояниями менеджеров
|
||||
*/
|
||||
private fun observeManagerStates() {
|
||||
serviceScope.launch {
|
||||
// Наблюдаем за ошибками камеры
|
||||
cameraManager.error.collect { error ->
|
||||
error?.let {
|
||||
_error.value = it
|
||||
Logger.e("Camera manager error: $it")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
serviceScope.launch {
|
||||
// Наблюдаем за ошибками WebRTC
|
||||
webRTCManager.error.collect { error ->
|
||||
error?.let {
|
||||
_error.value = it
|
||||
Logger.e("WebRTC manager error: $it")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
serviceScope.launch {
|
||||
// Наблюдаем за состоянием WebRTC соединения
|
||||
webRTCManager.connectionState.collect { state ->
|
||||
Logger.d("WebRTC connection state: $state")
|
||||
when (state) {
|
||||
WebRTCConnectionState.CONNECTED -> {
|
||||
// Обновляем статус всех активных сессий
|
||||
sessionManager.activeSessions.value.forEach { session ->
|
||||
sessionManager.updateWebRTCStatus(session.sessionId, true)
|
||||
}
|
||||
}
|
||||
WebRTCConnectionState.FAILED,
|
||||
WebRTCConnectionState.DISCONNECTED -> {
|
||||
// Обновляем статус всех активных сессий
|
||||
sessionManager.activeSessions.value.forEach { session ->
|
||||
sessionManager.updateWebRTCStatus(session.sessionId, false)
|
||||
}
|
||||
}
|
||||
else -> { /* Игнорируем другие состояния */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить менеджер сессий
|
||||
*/
|
||||
fun getSessionManager(): SessionManager = sessionManager
|
||||
|
||||
/**
|
||||
* Создать канал уведомлений
|
||||
*/
|
||||
private fun createNotificationChannel() {
|
||||
val channel = NotificationChannel(
|
||||
"${Constants.NOTIFICATION_CHANNEL_ID}_camera",
|
||||
"GodEye Camera Service",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Уведомления о работе камеры GodEye"
|
||||
setShowBadge(false)
|
||||
}
|
||||
|
||||
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать уведомление для foreground service
|
||||
*/
|
||||
private fun createNotification(): Notification {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val activeSessionsCount = sessionManager.getActiveSessionCount()
|
||||
val statusText = if (activeSessionsCount > 0) {
|
||||
"Активных сессий: $activeSessionsCount"
|
||||
} else {
|
||||
"Камера готова к работе"
|
||||
}
|
||||
|
||||
return NotificationCompat.Builder(this, "${Constants.NOTIFICATION_CHANNEL_ID}_camera")
|
||||
.setContentTitle("GodEye Camera")
|
||||
.setContentText(statusText)
|
||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистить ошибку
|
||||
*/
|
||||
fun clearError() {
|
||||
_error.value = null
|
||||
cameraManager.clearError()
|
||||
webRTCManager.clearError()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
serviceScope.launch {
|
||||
endAllSessions()
|
||||
}
|
||||
Logger.d("CameraService destroyed")
|
||||
}
|
||||
}
|
||||
430
app/src/main/java/com/example/godeye/services/SocketService.kt
Normal file
@@ -0,0 +1,430 @@
|
||||
package com.example.godeye.services
|
||||
|
||||
import android.app.*
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.example.godeye.MainActivity
|
||||
import com.example.godeye.R
|
||||
import com.example.godeye.managers.PermissionManager
|
||||
import com.example.godeye.models.*
|
||||
import com.example.godeye.utils.Constants
|
||||
import com.example.godeye.utils.Logger
|
||||
import com.example.godeye.utils.generateDeviceId
|
||||
import com.example.godeye.utils.getAvailableCameraTypes
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonObject
|
||||
import io.socket.client.IO
|
||||
import io.socket.client.Socket
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* Сервис для управления WebSocket соединением с backend сервером
|
||||
*/
|
||||
class SocketService : Service() {
|
||||
|
||||
private val binder = LocalBinder()
|
||||
private var socket: Socket? = null
|
||||
private val gson = Gson()
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
private lateinit var permissionManager: PermissionManager
|
||||
|
||||
// StateFlows для отслеживания состояния
|
||||
private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
private val _deviceId = MutableStateFlow("")
|
||||
val deviceId: StateFlow<String> = _deviceId.asStateFlow()
|
||||
|
||||
private val _error = MutableStateFlow<AppError?>(null)
|
||||
val error: StateFlow<AppError?> = _error.asStateFlow()
|
||||
|
||||
// События для UI
|
||||
private val _cameraRequest = MutableStateFlow<CameraRequest?>(null)
|
||||
val cameraRequest: StateFlow<CameraRequest?> = _cameraRequest.asStateFlow()
|
||||
|
||||
private val _webrtcOffer = MutableStateFlow<WebRTCMessage?>(null)
|
||||
val webrtcOffer: StateFlow<WebRTCMessage?> = _webrtcOffer.asStateFlow()
|
||||
|
||||
private val _webrtcAnswer = MutableStateFlow<WebRTCMessage?>(null)
|
||||
val webrtcAnswer: StateFlow<WebRTCMessage?> = _webrtcAnswer.asStateFlow()
|
||||
|
||||
private val _webrtcIceCandidate = MutableStateFlow<WebRTCMessage?>(null)
|
||||
val webrtcIceCandidate: StateFlow<WebRTCMessage?> = _webrtcIceCandidate.asStateFlow()
|
||||
|
||||
private val _cameraSwitchRequest = MutableStateFlow<Pair<String, String>?>(null) // sessionId, newCameraType
|
||||
val cameraSwitchRequest: StateFlow<Pair<String, String>?> = _cameraSwitchRequest.asStateFlow()
|
||||
|
||||
private val _sessionDisconnect = MutableStateFlow<String?>(null) // sessionId
|
||||
val sessionDisconnect: StateFlow<String?> = _sessionDisconnect.asStateFlow()
|
||||
|
||||
inner class LocalBinder : Binder() {
|
||||
fun getService(): SocketService = this@SocketService
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Logger.d("SocketService created")
|
||||
permissionManager = PermissionManager(this)
|
||||
_deviceId.value = generateDeviceId()
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder = binder
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startForeground(Constants.FOREGROUND_SERVICE_ID, createNotification())
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
/**
|
||||
* Подключиться к серверу
|
||||
*/
|
||||
fun connect(serverUrl: String) {
|
||||
serviceScope.launch {
|
||||
try {
|
||||
_connectionState.value = ConnectionState.CONNECTING
|
||||
Logger.d("Connecting to server: $serverUrl")
|
||||
|
||||
// Дополнительная проверка URL
|
||||
if (serverUrl.isBlank()) {
|
||||
Logger.e("Server URL is empty")
|
||||
_connectionState.value = ConnectionState.ERROR
|
||||
_error.value = AppError.NetworkError
|
||||
return@launch
|
||||
}
|
||||
|
||||
Logger.d("Creating URI from: $serverUrl")
|
||||
val uri = URI.create(serverUrl)
|
||||
Logger.d("URI created successfully: $uri")
|
||||
|
||||
Logger.d("Creating Socket.IO client")
|
||||
val options = IO.Options().apply {
|
||||
timeout = 10000 // Увеличиваем таймаут до 10 секунд
|
||||
reconnection = true
|
||||
reconnectionDelay = 2000 // Увеличиваем задержку между попытками
|
||||
reconnectionAttempts = 3 // Уменьшаем количество попыток
|
||||
forceNew = true // Принудительно создаваем новое соединение
|
||||
}
|
||||
|
||||
socket = IO.socket(uri, options).apply {
|
||||
Logger.d("Socket.IO client created, setting up listeners")
|
||||
setupEventListeners()
|
||||
Logger.d("Listeners set up, initiating connection")
|
||||
connect()
|
||||
Logger.d("Connection initiated")
|
||||
}
|
||||
|
||||
// Добавляем таймаут для проверки подключения
|
||||
launch {
|
||||
kotlinx.coroutines.delay(15000) // Ждем 15 секунд
|
||||
if (_connectionState.value == ConnectionState.CONNECTING) {
|
||||
Logger.w("Connection timeout after 15 seconds")
|
||||
_connectionState.value = ConnectionState.ERROR
|
||||
_error.value = AppError.SocketError("Connection timeout - server may be unreachable")
|
||||
socket?.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error connecting to server: ${e.message}", e)
|
||||
_connectionState.value = ConnectionState.ERROR
|
||||
_error.value = AppError.SocketError("Connection failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отключиться от сервера
|
||||
*/
|
||||
fun disconnect() {
|
||||
serviceScope.launch {
|
||||
try {
|
||||
socket?.disconnect()
|
||||
socket?.close()
|
||||
socket = null
|
||||
_connectionState.value = ConnectionState.DISCONNECTED
|
||||
Logger.d("Disconnected from server")
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error disconnecting from server", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Настроить обработчики событий Socket.IO
|
||||
*/
|
||||
private fun setupEventListeners() {
|
||||
socket?.apply {
|
||||
Logger.d("Setting up Socket.IO event listeners")
|
||||
|
||||
on(Socket.EVENT_CONNECT) {
|
||||
Logger.d("✅ Socket connected successfully")
|
||||
_connectionState.value = ConnectionState.CONNECTED
|
||||
registerDevice()
|
||||
}
|
||||
|
||||
on(Socket.EVENT_DISCONNECT) { args ->
|
||||
val reason = args.firstOrNull()?.toString() ?: "unknown"
|
||||
Logger.d("❌ Socket disconnected: $reason")
|
||||
_connectionState.value = ConnectionState.DISCONNECTED
|
||||
}
|
||||
|
||||
on(Socket.EVENT_CONNECT_ERROR) { args ->
|
||||
val error = args.firstOrNull()?.toString() ?: "Unknown connection error"
|
||||
Logger.e("🔥 Socket connection error: $error")
|
||||
_connectionState.value = ConnectionState.ERROR
|
||||
_error.value = AppError.SocketError(error)
|
||||
}
|
||||
|
||||
on(Constants.SocketEvents.REGISTER_SUCCESS) { args ->
|
||||
Logger.d("Device registered successfully")
|
||||
val data = args.firstOrNull()?.toString()
|
||||
Logger.d("Registration response: $data")
|
||||
}
|
||||
|
||||
on(Constants.SocketEvents.REGISTER_ERROR) { args ->
|
||||
val error = args.firstOrNull()?.toString() ?: "Registration failed"
|
||||
Logger.e("Device registration error: $error")
|
||||
_error.value = AppError.SocketError(error)
|
||||
}
|
||||
|
||||
on(Constants.SocketEvents.CAMERA_REQUEST) { args ->
|
||||
try {
|
||||
val data = JSONObject(args[0].toString())
|
||||
val request = CameraRequest(
|
||||
sessionId = data.getString("sessionId"),
|
||||
operatorId = data.getString("operatorId"),
|
||||
cameraType = data.getString("cameraType")
|
||||
)
|
||||
Logger.d("Camera request received: $request")
|
||||
_cameraRequest.value = request
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error parsing camera request", e)
|
||||
}
|
||||
}
|
||||
|
||||
on(Constants.SocketEvents.CAMERA_DISCONNECT) { args ->
|
||||
try {
|
||||
val data = JSONObject(args[0].toString())
|
||||
val sessionId = data.getString("sessionId")
|
||||
Logger.d("Camera disconnect received for session: $sessionId")
|
||||
_sessionDisconnect.value = sessionId
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error parsing camera disconnect", e)
|
||||
}
|
||||
}
|
||||
|
||||
on(Constants.SocketEvents.CAMERA_SWITCH) { args ->
|
||||
try {
|
||||
val data = JSONObject(args[0].toString())
|
||||
val sessionId = data.getString("sessionId")
|
||||
val newCameraType = data.getString("newCameraType")
|
||||
Logger.d("Camera switch request: $sessionId -> $newCameraType")
|
||||
_cameraSwitchRequest.value = Pair(sessionId, newCameraType)
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error parsing camera switch", e)
|
||||
}
|
||||
}
|
||||
|
||||
on(Constants.SocketEvents.WEBRTC_OFFER) { args ->
|
||||
try {
|
||||
val data = JSONObject(args[0].toString())
|
||||
val message = WebRTCMessage(
|
||||
sessionId = data.getString("sessionId"),
|
||||
type = "offer",
|
||||
sdp = data.getString("offer")
|
||||
)
|
||||
Logger.d("WebRTC offer received for session: ${message.sessionId}")
|
||||
_webrtcOffer.value = message
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error parsing WebRTC offer", e)
|
||||
}
|
||||
}
|
||||
|
||||
on(Constants.SocketEvents.WEBRTC_ICE_CANDIDATE) { args ->
|
||||
try {
|
||||
val data = JSONObject(args[0].toString())
|
||||
val message = WebRTCMessage(
|
||||
sessionId = data.getString("sessionId"),
|
||||
type = "ice-candidate",
|
||||
candidate = data.getString("candidate"),
|
||||
sdpMid = data.getString("sdpMid"),
|
||||
sdpMLineIndex = data.getInt("sdpMLineIndex")
|
||||
)
|
||||
Logger.d("WebRTC ICE candidate received for session: ${message.sessionId}")
|
||||
_webrtcIceCandidate.value = message
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error parsing WebRTC ICE candidate", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Зарегистрировать устройство на сервере
|
||||
*/
|
||||
private fun registerDevice() {
|
||||
try {
|
||||
val cameraManager = getSystemService(Context.CAMERA_SERVICE) as android.hardware.camera2.CameraManager
|
||||
val deviceInfo = DeviceInfo(
|
||||
availableCameras = cameraManager.getAvailableCameraTypes()
|
||||
)
|
||||
|
||||
val registrationData = JsonObject().apply {
|
||||
addProperty("deviceId", _deviceId.value)
|
||||
add("deviceInfo", gson.toJsonTree(deviceInfo))
|
||||
}
|
||||
|
||||
socket?.emit(Constants.SocketEvents.REGISTER_ANDROID, registrationData)
|
||||
Logger.d("Device registration sent: $registrationData")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error registering device", e)
|
||||
_error.value = AppError.SocketError("Failed to register device: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправить ответ на запрос камеры
|
||||
*/
|
||||
fun sendCameraResponse(sessionId: String, accepted: Boolean, reason: String? = null) {
|
||||
try {
|
||||
val response = JsonObject().apply {
|
||||
addProperty("sessionId", sessionId)
|
||||
addProperty("accepted", accepted)
|
||||
reason?.let { addProperty("reason", it) }
|
||||
}
|
||||
|
||||
socket?.emit(Constants.SocketEvents.CAMERA_RESPONSE, response)
|
||||
Logger.d("Camera response sent: $response")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error sending camera response", e)
|
||||
_error.value = AppError.SocketError("Failed to send camera response: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправить WebRTC answer
|
||||
*/
|
||||
fun sendWebRTCAnswer(sessionId: String, answer: String) {
|
||||
try {
|
||||
val data = JsonObject().apply {
|
||||
addProperty("sessionId", sessionId)
|
||||
addProperty("answer", answer)
|
||||
}
|
||||
|
||||
socket?.emit(Constants.SocketEvents.WEBRTC_ANSWER, data)
|
||||
Logger.d("WebRTC answer sent for session: $sessionId")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error sending WebRTC answer", e)
|
||||
_error.value = AppError.SocketError("Failed to send WebRTC answer: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправить ICE candidate
|
||||
*/
|
||||
fun sendIceCandidate(sessionId: String, candidate: String, sdpMid: String, sdpMLineIndex: Int) {
|
||||
try {
|
||||
val data = JsonObject().apply {
|
||||
addProperty("sessionId", sessionId)
|
||||
addProperty("candidate", candidate)
|
||||
addProperty("sdpMid", sdpMid)
|
||||
addProperty("sdpMLineIndex", sdpMLineIndex)
|
||||
}
|
||||
|
||||
socket?.emit(Constants.SocketEvents.WEBRTC_ICE_CANDIDATE, data)
|
||||
Logger.d("ICE candidate sent for session: $sessionId")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error sending ICE candidate", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать канал уведомлений
|
||||
*/
|
||||
private fun createNotificationChannel() {
|
||||
val channel = NotificationChannel(
|
||||
Constants.NOTIFICATION_CHANNEL_ID,
|
||||
"GodEye Service",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Уведомления о состоянии подключения GodEye"
|
||||
setShowBadge(false)
|
||||
}
|
||||
|
||||
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать уведомление для foreground service
|
||||
*/
|
||||
private fun createNotification(): Notification {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val statusText = when (_connectionState.value) {
|
||||
ConnectionState.CONNECTED -> "Подключено"
|
||||
ConnectionState.CONNECTING -> "Подключение..."
|
||||
ConnectionState.RECONNECTING -> "Переподключение..."
|
||||
ConnectionState.DISCONNECTED -> "Отключено"
|
||||
ConnectionState.ERROR -> "Ошибка подключения"
|
||||
}
|
||||
|
||||
return NotificationCompat.Builder(this, Constants.NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle("GodEye Signal Center")
|
||||
.setContentText("Статус: $statusText")
|
||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить уведомление
|
||||
*/
|
||||
private fun updateNotification() {
|
||||
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||
notificationManager.notify(Constants.FOREGROUND_SERVICE_ID, createNotification())
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистить события
|
||||
*/
|
||||
fun clearCameraRequest() {
|
||||
_cameraRequest.value = null
|
||||
}
|
||||
|
||||
fun clearWebRTCOffer() {
|
||||
_webrtcOffer.value = null
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_error.value = null
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
disconnect()
|
||||
Logger.d("SocketService destroyed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.example.godeye.ui.components
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.R
|
||||
import com.example.godeye.models.CameraRequest
|
||||
import com.example.godeye.utils.Constants
|
||||
|
||||
/**
|
||||
* Диалог запроса доступа к камере от оператора
|
||||
*/
|
||||
@Composable
|
||||
fun CameraRequestDialog(
|
||||
request: CameraRequest,
|
||||
onAccept: () -> Unit,
|
||||
onDeny: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var rememberChoice by remember { mutableStateOf(false) }
|
||||
|
||||
// Лог открытия диалога
|
||||
LaunchedEffect(Unit) {
|
||||
Log.d("GodEye", "CameraRequestDialog открыт: sessionId=${request.sessionId}, operatorId=${request.operatorId}, cameraType=${request.cameraType}")
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = {
|
||||
Log.d("GodEye", "Диалог закрыт пользователем")
|
||||
onDismiss()
|
||||
}) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Иконка камеры
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person, // Заменено с PhotoCamera на Person
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Заголовок
|
||||
Text(
|
||||
text = stringResource(R.string.camera_request_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Основное сообщение
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.camera_request_message,
|
||||
request.operatorId,
|
||||
getCameraTypeName(request.cameraType)
|
||||
),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// ID сессии
|
||||
Text(
|
||||
text = stringResource(R.string.session_id_label, request.sessionId),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Чекбокс "Запомнить выбор"
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = rememberChoice,
|
||||
onCheckedChange = { rememberChoice = it }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.remember_choice),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Кнопки
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Кнопка "Отклонить"
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
Log.d("GodEye", "Пользователь отклонил запрос камеры")
|
||||
onDeny()
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.deny_button))
|
||||
}
|
||||
|
||||
// Кнопка "Разрешить"
|
||||
Button(
|
||||
onClick = {
|
||||
Log.d("GodEye", "Пользователь разрешил доступ к камере")
|
||||
onAccept()
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.allow_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить локализованное название типа камеры
|
||||
*/
|
||||
@Composable
|
||||
private fun getCameraTypeName(cameraType: String): String {
|
||||
return when (cameraType) {
|
||||
Constants.CameraTypes.BACK -> stringResource(R.string.camera_type_back)
|
||||
Constants.CameraTypes.FRONT -> stringResource(R.string.camera_type_front)
|
||||
Constants.CameraTypes.WIDE -> stringResource(R.string.camera_type_wide)
|
||||
Constants.CameraTypes.TELEPHOTO -> stringResource(R.string.camera_type_telephoto)
|
||||
else -> cameraType
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.example.godeye.ui.components
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
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.draw.rotate
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.godeye.R
|
||||
import com.example.godeye.models.ConnectionState
|
||||
|
||||
/**
|
||||
* Компонент для отображения статуса подключения к серверу
|
||||
*/
|
||||
@Composable
|
||||
fun ConnectionStatusCard(
|
||||
connectionState: ConnectionState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val (icon, color, statusText) = getConnectionStateInfo(connectionState)
|
||||
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = color.copy(alpha = 0.1f)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Иконка с анимацией для состояний загрузки
|
||||
ConnectionIcon(
|
||||
icon = icon,
|
||||
color = color,
|
||||
isAnimated = connectionState == ConnectionState.CONNECTING ||
|
||||
connectionState == ConnectionState.RECONNECTING
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// Текст статуса
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.connection_status_label),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = color
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Анимированная иконка подключения
|
||||
*/
|
||||
@Composable
|
||||
private fun ConnectionIcon(
|
||||
icon: ImageVector,
|
||||
color: androidx.compose.ui.graphics.Color,
|
||||
isAnimated: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (isAnimated) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "connection_animation")
|
||||
val rotation by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 360f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1000, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart
|
||||
),
|
||||
label = "rotation"
|
||||
)
|
||||
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = modifier
|
||||
.size(24.dp)
|
||||
.rotate(rotation),
|
||||
tint = color
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = modifier.size(24.dp),
|
||||
tint = color
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить информацию о состоянии подключения
|
||||
*/
|
||||
@Composable
|
||||
private fun getConnectionStateInfo(
|
||||
connectionState: ConnectionState
|
||||
): Triple<ImageVector, androidx.compose.ui.graphics.Color, String> {
|
||||
return when (connectionState) {
|
||||
ConnectionState.DISCONNECTED -> Triple(
|
||||
Icons.Default.Close,
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
stringResource(R.string.status_disconnected)
|
||||
)
|
||||
ConnectionState.CONNECTING -> Triple(
|
||||
Icons.Default.Refresh,
|
||||
MaterialTheme.colorScheme.primary,
|
||||
stringResource(R.string.status_connecting)
|
||||
)
|
||||
ConnectionState.CONNECTED -> Triple(
|
||||
Icons.Default.CheckCircle,
|
||||
MaterialTheme.colorScheme.primary,
|
||||
stringResource(R.string.status_connected)
|
||||
)
|
||||
ConnectionState.ERROR -> Triple(
|
||||
Icons.Default.Warning,
|
||||
MaterialTheme.colorScheme.error,
|
||||
stringResource(R.string.status_error)
|
||||
)
|
||||
ConnectionState.RECONNECTING -> Triple(
|
||||
Icons.Default.Refresh,
|
||||
MaterialTheme.colorScheme.secondary,
|
||||
stringResource(R.string.status_reconnecting)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
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.Person
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.godeye.R
|
||||
import com.example.godeye.models.CameraSession
|
||||
import com.example.godeye.utils.Constants
|
||||
import kotlinx.coroutines.delay
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Компонент для отображения списка активных сессий
|
||||
*/
|
||||
@Composable
|
||||
fun SessionsList(
|
||||
sessions: List<CameraSession>,
|
||||
onEndSession: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (sessions.isEmpty()) {
|
||||
// Пустое состояние
|
||||
Box(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_active_sessions),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(vertical = 8.dp)
|
||||
) {
|
||||
items(sessions, key = { it.sessionId }) { session ->
|
||||
SessionItem(
|
||||
session = session,
|
||||
onEndSession = { onEndSession(session.sessionId) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Элемент списка сессий
|
||||
*/
|
||||
@Composable
|
||||
fun SessionItem(
|
||||
session: CameraSession,
|
||||
onEndSession: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Заголовок с оператором
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "${stringResource(R.string.session_operator_label)} ${session.operatorId}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
// Статус WebRTC
|
||||
WebRTCStatusChip(isConnected = session.webRTCConnected)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Информация о камере
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person, // Заменено с Camera на Person
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "${stringResource(R.string.session_camera_label)} ${getCameraTypeName(session.cameraType)}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Длительность сессии
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
SessionDuration(startTime = session.startTime)
|
||||
|
||||
// Кнопка завершения сессии
|
||||
FilledTonalButton(
|
||||
onClick = onEndSession,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(stringResource(R.string.end_session_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Компонент для отображения статуса WebRTC соединения
|
||||
*/
|
||||
@Composable
|
||||
fun WebRTCStatusChip(
|
||||
isConnected: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val backgroundColor = if (isConnected) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.errorContainer
|
||||
}
|
||||
|
||||
val contentColor = if (isConnected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onErrorContainer
|
||||
}
|
||||
|
||||
val statusText = if (isConnected) {
|
||||
stringResource(R.string.webrtc_connected)
|
||||
} else {
|
||||
stringResource(R.string.webrtc_disconnected)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = backgroundColor
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.session_webrtc_status, statusText),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Компонент для отображения длительности сессии
|
||||
*/
|
||||
@Composable
|
||||
fun SessionDuration(
|
||||
startTime: Long,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var currentTime by remember { mutableStateOf(System.currentTimeMillis()) }
|
||||
|
||||
// Обновляем время каждую секунду
|
||||
LaunchedEffect(startTime) {
|
||||
while (true) {
|
||||
currentTime = System.currentTimeMillis()
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
val duration = currentTime - startTime
|
||||
val hours = (duration / (1000 * 60 * 60)) % 24
|
||||
val minutes = (duration / (1000 * 60)) % 60
|
||||
val seconds = (duration / 1000) % 60
|
||||
|
||||
Text(
|
||||
text = "${stringResource(R.string.session_duration_label)} ${String.format("%02d:%02d:%02d", hours, minutes, seconds)}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить локализованное название типа камеры
|
||||
*/
|
||||
@Composable
|
||||
private fun getCameraTypeName(cameraType: String): String {
|
||||
return when (cameraType) {
|
||||
Constants.CameraTypes.BACK -> stringResource(R.string.camera_type_back)
|
||||
Constants.CameraTypes.FRONT -> stringResource(R.string.camera_type_front)
|
||||
Constants.CameraTypes.WIDE -> stringResource(R.string.camera_type_wide)
|
||||
Constants.CameraTypes.TELEPHOTO -> stringResource(R.string.camera_type_telephoto)
|
||||
else -> cameraType
|
||||
}
|
||||
}
|
||||
318
app/src/main/java/com/example/godeye/ui/screens/MainScreen.kt
Normal file
@@ -0,0 +1,318 @@
|
||||
package com.example.godeye.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.example.godeye.R
|
||||
import com.example.godeye.models.ConnectionState
|
||||
import com.example.godeye.models.MainScreenState
|
||||
import com.example.godeye.ui.components.CameraRequestDialog
|
||||
import com.example.godeye.ui.components.ConnectionStatusCard
|
||||
import com.example.godeye.ui.components.SessionsList
|
||||
import com.example.godeye.ui.viewmodels.MainViewModel
|
||||
import com.example.godeye.ui.viewmodels.UiEvent
|
||||
import com.example.godeye.utils.collectAsEffect
|
||||
|
||||
/**
|
||||
* Главный экран приложения
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
viewModel: MainViewModel = viewModel(),
|
||||
onRequestPermissions: () -> Unit,
|
||||
onShowError: (String) -> Unit
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
var serverUrl by remember { mutableStateOf("") }
|
||||
|
||||
// Синхронизируем локальное состояние с ViewModel
|
||||
LaunchedEffect(uiState.serverUrl) {
|
||||
serverUrl = uiState.serverUrl
|
||||
}
|
||||
|
||||
// Обработка UI событий
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.events.collect { event ->
|
||||
when (event) {
|
||||
is UiEvent.RequestPermissions -> onRequestPermissions()
|
||||
is UiEvent.ShowError -> {
|
||||
// Получаем текст ошибки внутри LaunchedEffect
|
||||
val errorMessage = when (event.error) {
|
||||
is com.example.godeye.models.AppError.NetworkError -> "Ошибка сети"
|
||||
is com.example.godeye.models.AppError.CameraPermissionDenied -> "Нет разрешения на камеру"
|
||||
is com.example.godeye.models.AppError.AudioPermissionDenied -> "Нет разрешения на микрофон"
|
||||
is com.example.godeye.models.AppError.CameraNotAvailable -> "Камера недоступна"
|
||||
is com.example.godeye.models.AppError.WebRTCConnectionFailed -> "Ошибка WebRTC соединения"
|
||||
is com.example.godeye.models.AppError.SocketError -> "Ошибка WebSocket: ${event.error.message}"
|
||||
is com.example.godeye.models.AppError.CameraError -> "Ошибка камеры: ${event.error.message}"
|
||||
is com.example.godeye.models.AppError.UnknownError -> "Неизвестная ошибка"
|
||||
}
|
||||
onShowError(errorMessage)
|
||||
}
|
||||
is UiEvent.ShowMessage -> onShowError(event.message)
|
||||
is UiEvent.ShowCameraRequestDialog -> {
|
||||
// Диалог будет показан через состояние showCameraRequest
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { /* TODO: Настройки */ }) {
|
||||
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
MainContent(
|
||||
uiState = uiState,
|
||||
serverUrl = serverUrl,
|
||||
onServerUrlChange = { serverUrl = it },
|
||||
onConnect = {
|
||||
viewModel.updateServerUrl(serverUrl)
|
||||
viewModel.connect()
|
||||
},
|
||||
onDisconnect = viewModel::disconnect,
|
||||
onEndSession = viewModel::endSession,
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
)
|
||||
|
||||
// Диалог запроса камеры
|
||||
uiState.showCameraRequest?.let { request ->
|
||||
CameraRequestDialog(
|
||||
request = request,
|
||||
onAccept = { viewModel.respondToCameraRequest(request.sessionId, true) },
|
||||
onDeny = { viewModel.respondToCameraRequest(request.sessionId, false) },
|
||||
onDismiss = { viewModel.respondToCameraRequest(request.sessionId, false) }
|
||||
)
|
||||
}
|
||||
|
||||
// Индикатор загрузки
|
||||
if (uiState.isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Card(
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(stringResource(R.string.loading))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Основное содержимое экрана
|
||||
*/
|
||||
@Composable
|
||||
private fun MainContent(
|
||||
uiState: MainScreenState,
|
||||
serverUrl: String,
|
||||
onServerUrlChange: (String) -> Unit,
|
||||
onConnect: () -> Unit,
|
||||
onDisconnect: () -> Unit,
|
||||
onEndSession: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Информация об устройстве
|
||||
DeviceInfoCard(deviceId = uiState.deviceId)
|
||||
|
||||
// Статус подключения
|
||||
ConnectionStatusCard(connectionState = uiState.connectionState)
|
||||
|
||||
// Настройки подключения
|
||||
ConnectionSettingsCard(
|
||||
serverUrl = serverUrl,
|
||||
onServerUrlChange = onServerUrlChange,
|
||||
connectionState = uiState.connectionState,
|
||||
onConnect = onConnect,
|
||||
onDisconnect = onDisconnect
|
||||
)
|
||||
|
||||
// Список активных сессий
|
||||
ActiveSessionsCard(
|
||||
sessions = uiState.activeSessions,
|
||||
onEndSession = onEndSession
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Карточка с информацией об устройстве
|
||||
*/
|
||||
@Composable
|
||||
private fun DeviceInfoCard(
|
||||
deviceId: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.device_id_label),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = deviceId.ifEmpty { "..." },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Карточка настроек подключения
|
||||
*/
|
||||
@Composable
|
||||
private fun ConnectionSettingsCard(
|
||||
serverUrl: String,
|
||||
onServerUrlChange: (String) -> Unit,
|
||||
connectionState: ConnectionState,
|
||||
onConnect: () -> Unit,
|
||||
onDisconnect: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Поле ввода URL сервера
|
||||
OutlinedTextField(
|
||||
value = serverUrl,
|
||||
onValueChange = onServerUrlChange,
|
||||
label = { Text(stringResource(R.string.server_url_label)) },
|
||||
placeholder = { Text(stringResource(R.string.server_url_hint)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
enabled = connectionState == ConnectionState.DISCONNECTED,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Кнопка подключения/отключения
|
||||
val isConnected = connectionState == ConnectionState.CONNECTED
|
||||
val isLoading = connectionState == ConnectionState.CONNECTING ||
|
||||
connectionState == ConnectionState.RECONNECTING
|
||||
|
||||
Button(
|
||||
onClick = if (isConnected) onDisconnect else onConnect,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isLoading && serverUrl.isNotBlank()
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text(
|
||||
if (isConnected) stringResource(R.string.disconnect_button)
|
||||
else stringResource(R.string.connect_button)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Карточка активных сессий
|
||||
*/
|
||||
@Composable
|
||||
private fun ActiveSessionsCard(
|
||||
sessions: List<com.example.godeye.models.CameraSession>,
|
||||
onEndSession: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.active_sessions_label),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
if (sessions.isNotEmpty()) {
|
||||
Badge {
|
||||
Text("${sessions.size}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
SessionsList(
|
||||
sessions = sessions,
|
||||
onEndSession = onEndSession
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
11
app/src/main/java/com/example/godeye/ui/theme/Color.kt
Normal file
@@ -0,0 +1,11 @@
|
||||
package com.example.godeye.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
58
app/src/main/java/com/example/godeye/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,58 @@
|
||||
package com.example.godeye.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
|
||||
/* Other default colors to override
|
||||
background = Color(0xFFFFFBFE),
|
||||
surface = Color(0xFFFFFBFE),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onTertiary = Color.White,
|
||||
onBackground = Color(0xFF1C1B1F),
|
||||
onSurface = Color(0xFF1C1B1F),
|
||||
*/
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun GodEyeTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
34
app/src/main/java/com/example/godeye/ui/theme/Type.kt
Normal file
@@ -0,0 +1,34 @@
|
||||
package com.example.godeye.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
@@ -0,0 +1,385 @@
|
||||
package com.example.godeye.ui.viewmodels
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.IBinder
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.godeye.managers.PermissionManager
|
||||
import com.example.godeye.models.*
|
||||
import com.example.godeye.services.CameraService
|
||||
import com.example.godeye.services.SocketService
|
||||
import com.example.godeye.utils.Constants
|
||||
import com.example.godeye.utils.Logger
|
||||
import com.example.godeye.utils.getPreferences
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* ViewModel для главного экрана приложения
|
||||
*/
|
||||
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val context = getApplication<Application>()
|
||||
private val permissionManager = PermissionManager(context)
|
||||
|
||||
// Сервисы
|
||||
private var socketService: SocketService? = null
|
||||
private var cameraService: CameraService? = null
|
||||
private var socketServiceBound = false
|
||||
private var cameraServiceBound = false
|
||||
|
||||
// UI State
|
||||
private val _uiState = MutableStateFlow(MainScreenState())
|
||||
val uiState: StateFlow<MainScreenState> = _uiState.asStateFlow()
|
||||
|
||||
// События для UI
|
||||
private val _events = MutableSharedFlow<UiEvent>()
|
||||
val events: SharedFlow<UiEvent> = _events.asSharedFlow()
|
||||
|
||||
init {
|
||||
loadSavedSettings()
|
||||
// startServices() убран отсюда
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузить сохраненные настройки
|
||||
*/
|
||||
private fun loadSavedSettings() {
|
||||
val prefs = context.getPreferences()
|
||||
val serverUrl = prefs.getString(Constants.PreferenceKeys.SERVER_URL, Constants.DEFAULT_SERVER_URL) ?: Constants.DEFAULT_SERVER_URL
|
||||
val deviceId = prefs.getString(Constants.PreferenceKeys.DEVICE_ID, "") ?: ""
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
serverUrl = serverUrl,
|
||||
deviceId = deviceId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Запустить сервисы (вызывать из MainActivity после проверки разрешений)
|
||||
*/
|
||||
fun startServices() {
|
||||
// Запуск SocketService
|
||||
val socketIntent = Intent(context, SocketService::class.java)
|
||||
context.startForegroundService(socketIntent)
|
||||
context.bindService(socketIntent, socketConnection, Context.BIND_AUTO_CREATE)
|
||||
|
||||
// Запуск CameraService
|
||||
val cameraIntent = Intent(context, CameraService::class.java)
|
||||
context.startForegroundService(cameraIntent)
|
||||
context.bindService(cameraIntent, cameraConnection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceConnection для SocketService
|
||||
*/
|
||||
private val socketConnection = object : ServiceConnection {
|
||||
override fun onServiceConnected(className: ComponentName, service: IBinder) {
|
||||
val binder = service as SocketService.LocalBinder
|
||||
socketService = binder.getService()
|
||||
socketServiceBound = true
|
||||
|
||||
Logger.d("SocketService connected")
|
||||
observeSocketService()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(arg0: ComponentName) {
|
||||
socketServiceBound = false
|
||||
socketService = null
|
||||
Logger.d("SocketService disconnected")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceConnection для CameraService
|
||||
*/
|
||||
private val cameraConnection = object : ServiceConnection {
|
||||
override fun onServiceConnected(className: ComponentName, service: IBinder) {
|
||||
val binder = service as CameraService.LocalBinder
|
||||
cameraService = binder.getService()
|
||||
cameraServiceBound = true
|
||||
|
||||
Logger.d("CameraService connected")
|
||||
observeCameraService()
|
||||
setupCameraServiceCallbacks()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(arg0: ComponentName) {
|
||||
cameraServiceBound = false
|
||||
cameraService = null
|
||||
Logger.d("CameraService disconnected")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Наблюдать за состоянием SocketService
|
||||
*/
|
||||
private fun observeSocketService() {
|
||||
val service = socketService ?: return
|
||||
|
||||
viewModelScope.launch {
|
||||
service.connectionState.collect { state ->
|
||||
_uiState.value = _uiState.value.copy(connectionState = state)
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
service.deviceId.collect { deviceId ->
|
||||
_uiState.value = _uiState.value.copy(deviceId = deviceId)
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
service.error.collect { error ->
|
||||
error?.let {
|
||||
_uiState.value = _uiState.value.copy(error = it)
|
||||
_events.emit(UiEvent.ShowError(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
service.cameraRequest.collect { request ->
|
||||
request?.let {
|
||||
_uiState.value = _uiState.value.copy(showCameraRequest = it)
|
||||
_events.emit(UiEvent.ShowCameraRequestDialog(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
service.webrtcOffer.collect { offer ->
|
||||
offer?.let {
|
||||
handleWebRTCOffer(it)
|
||||
service.clearWebRTCOffer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
service.webrtcIceCandidate.collect { candidate ->
|
||||
candidate?.let {
|
||||
handleWebRTCIceCandidate(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
service.cameraSwitchRequest.collect { request ->
|
||||
request?.let { (sessionId, cameraType) ->
|
||||
handleCameraSwitch(sessionId, cameraType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
service.sessionDisconnect.collect { sessionId ->
|
||||
sessionId?.let {
|
||||
handleSessionDisconnect(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Наблюдать за состоянием CameraService
|
||||
*/
|
||||
private fun observeCameraService() {
|
||||
val service = cameraService ?: return
|
||||
|
||||
viewModelScope.launch {
|
||||
service.getSessionManager().activeSessions.collect { sessions ->
|
||||
_uiState.value = _uiState.value.copy(activeSessions = sessions)
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
service.error.collect { error ->
|
||||
error?.let {
|
||||
_uiState.value = _uiState.value.copy(error = it)
|
||||
_events.emit(UiEvent.ShowError(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Настроить callbacks для CameraService
|
||||
*/
|
||||
private fun setupCameraServiceCallbacks() {
|
||||
cameraService?.setWebRTCCallbacks(
|
||||
onOfferCreated = { sessionId, offer ->
|
||||
// WebRTC offer создан, но в нашем случае мы получаем offer от оператора
|
||||
Logger.d("WebRTC offer created for session: $sessionId")
|
||||
},
|
||||
onIceCandidateCreated = { sessionId, candidate, sdpMid, sdpMLineIndex ->
|
||||
socketService?.sendIceCandidate(sessionId, candidate, sdpMid, sdpMLineIndex)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Подключиться к серверу
|
||||
*/
|
||||
fun connect() {
|
||||
if (!permissionManager.hasCriticalPermissions()) {
|
||||
_events.tryEmit(UiEvent.RequestPermissions)
|
||||
return
|
||||
}
|
||||
|
||||
val serverUrl = _uiState.value.serverUrl
|
||||
if (serverUrl.isBlank()) {
|
||||
_events.tryEmit(UiEvent.ShowError(AppError.SocketError("Введите URL сервера")))
|
||||
return
|
||||
}
|
||||
|
||||
// Сохраняем URL сервера
|
||||
context.getPreferences().edit()
|
||||
.putString(Constants.PreferenceKeys.SERVER_URL, serverUrl)
|
||||
.apply()
|
||||
|
||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||
socketService?.connect(serverUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Отключиться от сервера
|
||||
*/
|
||||
fun disconnect() {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||
socketService?.disconnect()
|
||||
cameraService?.endAllSessions()
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить URL сервера
|
||||
*/
|
||||
fun updateServerUrl(url: String) {
|
||||
_uiState.value = _uiState.value.copy(serverUrl = url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ответить на запрос камеры
|
||||
*/
|
||||
fun respondToCameraRequest(sessionId: String, accepted: Boolean, reason: String? = null) {
|
||||
socketService?.sendCameraResponse(sessionId, accepted, reason)
|
||||
|
||||
if (accepted) {
|
||||
// Получаем информацию о запросе
|
||||
val request = _uiState.value.showCameraRequest
|
||||
if (request != null && request.sessionId == sessionId) {
|
||||
// Начинаем камера сессию
|
||||
cameraService?.startCameraSession(sessionId, request.operatorId, request.cameraType)
|
||||
}
|
||||
}
|
||||
|
||||
// Очищаем запрос из UI
|
||||
_uiState.value = _uiState.value.copy(showCameraRequest = null)
|
||||
socketService?.clearCameraRequest()
|
||||
}
|
||||
|
||||
/**
|
||||
* Завершить сессию
|
||||
*/
|
||||
fun endSession(sessionId: String) {
|
||||
cameraService?.endSession(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработать WebRTC Offer
|
||||
*/
|
||||
private fun handleWebRTCOffer(offer: WebRTCMessage) {
|
||||
val sessionId = offer.sessionId
|
||||
val offerSdp = offer.sdp ?: return
|
||||
|
||||
Logger.d("Handling WebRTC offer for session: $sessionId")
|
||||
// В нашем случае мы не обрабатываем offer, так как создаем его сами
|
||||
// Но можно добавить логику для обработки offer от оператора
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработать WebRTC ICE Candidate
|
||||
*/
|
||||
private fun handleWebRTCIceCandidate(candidate: WebRTCMessage) {
|
||||
val sessionId = candidate.sessionId
|
||||
val candidateSdp = candidate.candidate ?: return
|
||||
val sdpMid = candidate.sdpMid ?: return
|
||||
val sdpMLineIndex = candidate.sdpMLineIndex ?: return
|
||||
|
||||
Logger.d("Handling ICE candidate for session: $sessionId")
|
||||
cameraService?.addIceCandidate(sessionId, candidateSdp, sdpMid, sdpMLineIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработать переключение камеры
|
||||
*/
|
||||
private fun handleCameraSwitch(sessionId: String, newCameraType: String) {
|
||||
Logger.d("Handling camera switch for session $sessionId to $newCameraType")
|
||||
cameraService?.switchCamera(sessionId, newCameraType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработать отключение сессии
|
||||
*/
|
||||
private fun handleSessionDisconnect(sessionId: String) {
|
||||
Logger.d("Handling session disconnect: $sessionId")
|
||||
cameraService?.endSession(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистить ошибку
|
||||
*/
|
||||
fun clearError() {
|
||||
_uiState.value = _uiState.value.copy(error = null)
|
||||
socketService?.clearError()
|
||||
cameraService?.clearError()
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить разрешения
|
||||
*/
|
||||
fun checkPermissions(): Boolean {
|
||||
return permissionManager.hasAllRequiredPermissions()
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить отсутствующие разрешения
|
||||
*/
|
||||
fun getMissingPermissions(): List<String> {
|
||||
return permissionManager.getMissingPermissions()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
|
||||
// Отвязка сервисов
|
||||
try {
|
||||
if (socketServiceBound) {
|
||||
context.unbindService(socketConnection)
|
||||
socketServiceBound = false
|
||||
}
|
||||
if (cameraServiceBound) {
|
||||
context.unbindService(cameraConnection)
|
||||
cameraServiceBound = false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Error unbinding services", e)
|
||||
}
|
||||
|
||||
Logger.d("MainViewModel cleared")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* События UI для обработки в Activity/Compose
|
||||
*/
|
||||
sealed class UiEvent {
|
||||
object RequestPermissions : UiEvent()
|
||||
data class ShowError(val error: AppError) : UiEvent()
|
||||
data class ShowCameraRequestDialog(val request: CameraRequest) : UiEvent()
|
||||
data class ShowMessage(val message: String) : UiEvent()
|
||||
}
|
||||
49
app/src/main/java/com/example/godeye/utils/Constants.kt
Normal file
@@ -0,0 +1,49 @@
|
||||
package com.example.godeye.utils
|
||||
|
||||
object Constants {
|
||||
// WebSocket события
|
||||
object SocketEvents {
|
||||
const val REGISTER_ANDROID = "register:android"
|
||||
const val REGISTER_SUCCESS = "register:success"
|
||||
const val REGISTER_ERROR = "register:error"
|
||||
const val CAMERA_REQUEST = "camera:request"
|
||||
const val CAMERA_RESPONSE = "camera:response"
|
||||
const val CAMERA_DISCONNECT = "camera:disconnect"
|
||||
const val CAMERA_SWITCH = "camera:switch"
|
||||
const val WEBRTC_OFFER = "webrtc:offer"
|
||||
const val WEBRTC_ANSWER = "webrtc:answer"
|
||||
const val WEBRTC_ICE_CANDIDATE = "webrtc:ice-candidate"
|
||||
}
|
||||
|
||||
// Типы камер
|
||||
object CameraTypes {
|
||||
const val BACK = "back"
|
||||
const val FRONT = "front"
|
||||
const val WIDE = "wide"
|
||||
const val TELEPHOTO = "telephoto"
|
||||
}
|
||||
|
||||
// SharedPreferences ключи
|
||||
object PreferenceKeys {
|
||||
const val SERVER_URL = "server_url"
|
||||
const val DEVICE_ID = "device_id"
|
||||
const val AUTO_ACCEPT_REQUESTS = "auto_accept_requests"
|
||||
const val CAMERA_QUALITY = "camera_quality"
|
||||
const val NOTIFICATION_ENABLED = "notification_enabled"
|
||||
}
|
||||
|
||||
// Настройки по умолчанию
|
||||
const val DEFAULT_SERVER_URL = "http://10.0.2.2:3001" // Специальный IP для Android эмулятора
|
||||
const val SOCKET_CONNECTION_TIMEOUT = 10000L
|
||||
const val WEBRTC_CONNECTION_TIMEOUT = 15000L
|
||||
|
||||
// Уведомления
|
||||
const val NOTIFICATION_CHANNEL_ID = "godeye_service_channel"
|
||||
const val FOREGROUND_SERVICE_ID = 1001
|
||||
|
||||
// WebRTC настройки
|
||||
val STUN_SERVERS = listOf(
|
||||
"stun:stun.l.google.com:19302",
|
||||
"stun:stun1.l.google.com:19302"
|
||||
)
|
||||
}
|
||||
140
app/src/main/java/com/example/godeye/utils/Extensions.kt
Normal file
@@ -0,0 +1,140 @@
|
||||
package com.example.godeye.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.hardware.camera2.CameraCharacteristics
|
||||
import android.hardware.camera2.CameraManager
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Расширения для Context
|
||||
*/
|
||||
fun Context.getPreferences(): SharedPreferences {
|
||||
return getSharedPreferences("godeye_prefs", Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
fun Context.generateDeviceId(): String {
|
||||
val prefs = getPreferences()
|
||||
var deviceId = prefs.getString(Constants.PreferenceKeys.DEVICE_ID, null)
|
||||
if (deviceId == null) {
|
||||
deviceId = "android_${UUID.randomUUID().toString().take(8)}"
|
||||
prefs.edit().putString(Constants.PreferenceKeys.DEVICE_ID, deviceId).apply()
|
||||
}
|
||||
return deviceId
|
||||
}
|
||||
|
||||
/**
|
||||
* Расширения для CameraManager
|
||||
*/
|
||||
fun CameraManager.getAvailableCameraTypes(): List<String> {
|
||||
val cameras = mutableListOf<String>()
|
||||
try {
|
||||
for (cameraId in cameraIdList) {
|
||||
val characteristics = getCameraCharacteristics(cameraId)
|
||||
val facing = characteristics.get(CameraCharacteristics.LENS_FACING)
|
||||
|
||||
when (facing) {
|
||||
CameraCharacteristics.LENS_FACING_BACK -> {
|
||||
// Проверяем на широкоугольный и телеобъектив
|
||||
val focalLengths = characteristics.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)
|
||||
if (focalLengths != null && focalLengths.isNotEmpty()) {
|
||||
val minFocalLength = focalLengths.minOrNull() ?: 0f
|
||||
val maxFocalLength = focalLengths.maxOrNull() ?: 0f
|
||||
|
||||
when {
|
||||
minFocalLength < 2.8f -> cameras.add(Constants.CameraTypes.WIDE)
|
||||
maxFocalLength > 5.5f -> cameras.add(Constants.CameraTypes.TELEPHOTO)
|
||||
else -> cameras.add(Constants.CameraTypes.BACK)
|
||||
}
|
||||
} else {
|
||||
cameras.add(Constants.CameraTypes.BACK)
|
||||
}
|
||||
}
|
||||
CameraCharacteristics.LENS_FACING_FRONT -> cameras.add(Constants.CameraTypes.FRONT)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("CameraExtensions", "Error getting cameras", e)
|
||||
// Добавляем базовые камеры как fallback
|
||||
cameras.add(Constants.CameraTypes.BACK)
|
||||
cameras.add(Constants.CameraTypes.FRONT)
|
||||
}
|
||||
return cameras.distinct()
|
||||
}
|
||||
|
||||
fun CameraManager.getCameraIdForType(cameraType: String): String? {
|
||||
return try {
|
||||
for (cameraId in cameraIdList) {
|
||||
val characteristics = getCameraCharacteristics(cameraId)
|
||||
val facing = characteristics.get(CameraCharacteristics.LENS_FACING)
|
||||
|
||||
when (cameraType) {
|
||||
Constants.CameraTypes.FRONT -> {
|
||||
if (facing == CameraCharacteristics.LENS_FACING_FRONT) {
|
||||
return cameraId
|
||||
}
|
||||
}
|
||||
Constants.CameraTypes.BACK,
|
||||
Constants.CameraTypes.WIDE,
|
||||
Constants.CameraTypes.TELEPHOTO -> {
|
||||
if (facing == CameraCharacteristics.LENS_FACING_BACK) {
|
||||
// Для простоты используем первую найденную заднюю камеру
|
||||
// В реальном проекте здесь была бы более сложная логика
|
||||
return cameraId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
Log.e("CameraExtensions", "Error finding camera for type $cameraType", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose расширения для Flow
|
||||
*/
|
||||
@Composable
|
||||
fun <T> Flow<T>.collectAsEffect(
|
||||
key: Any? = null,
|
||||
action: suspend (T) -> Unit
|
||||
) {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
LaunchedEffect(key) {
|
||||
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
collect(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Логирование
|
||||
*/
|
||||
object Logger {
|
||||
private const val TAG = "GodEye"
|
||||
|
||||
fun d(message: String, tag: String = TAG) {
|
||||
Log.d(tag, message)
|
||||
}
|
||||
|
||||
fun e(message: String, throwable: Throwable? = null, tag: String = TAG) {
|
||||
Log.e(tag, message, throwable)
|
||||
}
|
||||
|
||||
fun i(message: String, tag: String = TAG) {
|
||||
Log.i(tag, message)
|
||||
}
|
||||
|
||||
fun w(message: String, tag: String = TAG) {
|
||||
Log.w(tag, message)
|
||||
}
|
||||
}
|
||||
170
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<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" />
|
||||
</vector>
|
||||
30
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,30 @@
|
||||
<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="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" />
|
||||
</vector>
|
||||
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 982 B |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
10
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
80
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,80 @@
|
||||
<resources>
|
||||
<string name="app_name">GodEye Signal Center</string>
|
||||
|
||||
<!-- Главный экран -->
|
||||
<string name="device_id_label">ID устройства:</string>
|
||||
<string name="server_url_label">URL сервера:</string>
|
||||
<string name="server_url_hint">http://192.168.1.100:3001</string>
|
||||
<string name="connection_status_label">Статус подключения:</string>
|
||||
<string name="connect_button">Подключиться</string>
|
||||
<string name="disconnect_button">Отключиться</string>
|
||||
<string name="active_sessions_label">Активные сессии:</string>
|
||||
<string name="no_active_sessions">Нет активных сессий</string>
|
||||
|
||||
<!-- Статусы подключения -->
|
||||
<string name="status_disconnected">Отключено</string>
|
||||
<string name="status_connecting">Подключение...</string>
|
||||
<string name="status_connected">Подключено</string>
|
||||
<string name="status_error">Ошибка подключения</string>
|
||||
<string name="status_reconnecting">Переподключение...</string>
|
||||
|
||||
<!-- Диалог запроса камеры -->
|
||||
<string name="camera_request_title">Запрос доступа к камере</string>
|
||||
<string name="camera_request_message">Оператор %1$s запрашивает доступ к камере %2$s</string>
|
||||
<string name="session_id_label">ID сессии: %1$s</string>
|
||||
<string name="allow_button">Разрешить</string>
|
||||
<string name="deny_button">Отклонить</string>
|
||||
<string name="remember_choice">Запомнить для этого оператора</string>
|
||||
|
||||
<!-- Типы камер -->
|
||||
<string name="camera_type_back">Основная</string>
|
||||
<string name="camera_type_front">Фронтальная</string>
|
||||
<string name="camera_type_wide">Широкоугольная</string>
|
||||
<string name="camera_type_telephoto">Телеобъектив</string>
|
||||
|
||||
<!-- Сессии -->
|
||||
<string name="session_operator_label">Оператор:</string>
|
||||
<string name="session_camera_label">Камера:</string>
|
||||
<string name="session_duration_label">Длительность:</string>
|
||||
<string name="session_webrtc_status">WebRTC: %1$s</string>
|
||||
<string name="webrtc_connected">Подключено</string>
|
||||
<string name="webrtc_disconnected">Отключено</string>
|
||||
<string name="end_session_button">Завершить</string>
|
||||
|
||||
<!-- Ошибки -->
|
||||
<string name="error_network">Ошибка сети</string>
|
||||
<string name="error_camera_permission">Нет разрешения на камеру</string>
|
||||
<string name="error_audio_permission">Нет разрешения на микрофон</string>
|
||||
<string name="error_camera_not_available">Камера недоступна</string>
|
||||
<string name="error_webrtc_connection_failed">Ошибка WebRTC соединения</string>
|
||||
<string name="error_socket">Ошибка WebSocket: %1$s</string>
|
||||
<string name="error_camera">Ошибка камеры: %1$s</string>
|
||||
<string name="error_unknown">Неизвестная ошибка</string>
|
||||
|
||||
<!-- Разрешения -->
|
||||
<string name="permissions_required_title">Необходимы разрешения</string>
|
||||
<string name="permissions_required_message">Для работы приложения необходимы разрешения на камеру, микрофон и уведомления</string>
|
||||
<string name="grant_permissions_button">Предоставить разрешения</string>
|
||||
<string name="permissions_denied_message">Без разрешений приложение не может работать</string>
|
||||
|
||||
<!-- Уведомления -->
|
||||
<string name="notification_service_title">GodEye Signal Center</string>
|
||||
<string name="notification_service_connected">Подключено к серверу</string>
|
||||
<string name="notification_service_disconnected">Отключено от сервера</string>
|
||||
<string name="notification_camera_title">GodEye Camera</string>
|
||||
<string name="notification_camera_active">Активных сессий: %1$d</string>
|
||||
<string name="notification_camera_ready">Камера готова к работе</string>
|
||||
|
||||
<!-- Общие -->
|
||||
<string name="ok">OK</string>
|
||||
<string name="cancel">Отмена</string>
|
||||
<string name="settings">Настройки</string>
|
||||
<string name="loading">Загрузка...</string>
|
||||
<string name="retry">Повторить</string>
|
||||
|
||||
<!-- Время -->
|
||||
<string name="duration_format">%1$02d:%2$02d:%3$02d</string>
|
||||
<string name="time_seconds">сек</string>
|
||||
<string name="time_minutes">мин</string>
|
||||
<string name="time_hours">ч</string>
|
||||
</resources>
|
||||
5
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.GodEye" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
13
app/src/main/res/xml/backup_rules.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older than API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
19
app/src/main/res/xml/data_extraction_rules.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||
17
app/src/test/java/com/example/godeye/ExampleUnitTest.kt
Normal file
@@ -0,0 +1,17 @@
|
||||
package com.example.godeye
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||