init commit

This commit is contained in:
2025-09-29 22:04:14 +09:00
commit 9951d8367f
1287 changed files with 189637 additions and 0 deletions

1
app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

105
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,105 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.example.godeye"
compileSdk = 36
defaultConfig {
applicationId = "com.example.godeye"
minSdk = 24
targetSdk = 36
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
viewBinding = true
}
// Исправляем проблему с Java toolchain
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}
}
dependencies {
// Core Android
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
// ViewModel and LiveData
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("androidx.activity:activity-ktx:1.8.2")
// Socket.IO для WebSocket соединения
implementation("io.socket:socket.io-client:2.1.2")
// Пока уберем WebRTC зависимость - создадим заглушку для демонстрации
// В реальном проекте нужно будет настроить правильную WebRTC библиотеку
// Camera2 API
implementation("androidx.camera:camera-core:1.3.1")
implementation("androidx.camera:camera-camera2:1.3.1")
implementation("androidx.camera:camera-lifecycle:1.3.1")
implementation("androidx.camera:camera-view:1.3.1")
// JSON парсинг
implementation("com.google.code.gson:gson:2.10.1")
// Корутины
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// RecyclerView
implementation("androidx.recyclerview:recyclerview:1.3.2")
// Work Manager для фоновых задач
implementation("androidx.work:work-runtime-ktx:2.9.0")
// Permissions
implementation("androidx.activity:activity-compose:1.8.2")
// Navigation
implementation("androidx.navigation:navigation-compose:2.7.6")
// Testing
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}

21
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -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)
}
}

View 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>

View 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")
}
}

View 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
}
}

View File

@@ -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()}")
}
}

View 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?
)

View 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
}
}

View 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
)

View 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")
}
}

View 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")
}
}

View File

@@ -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
}
}

View File

@@ -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)
)
}
}

View File

@@ -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
}
}

View 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
)
}
}
}

View 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)

View 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
)
}

View 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
)
*/
)

View File

@@ -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()
}

View 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"
)
}

View 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)
}
}

View 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>

View 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>

View 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>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View 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>

View 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>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.GodEye" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View 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>

View 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>

View 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)
}
}