diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 44ecc73..e5f562d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -55,8 +55,9 @@ dependencies { implementation("androidx.camera:camera-lifecycle:1.3.0") implementation("androidx.camera:camera-view:1.3.0") - // WebSocket - implementation("com.squareup.okhttp3:okhttp:4.11.0") + // WebSocket - OkHttp with all required dependencies + implementation("com.squareup.okhttp3:okhttp:4.10.0") + implementation("com.squareup.okio:okio:3.5.0") // JSON implementation("com.google.code.gson:gson:2.10.1") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3babd0d..1974fe9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,10 @@ + + + + Unit, + previewSurfaceProvider: Preview.SurfaceProvider, onError: (String) -> Unit ) { val cameraProviderFuture = ProcessCameraProvider.getInstance(context) @@ -34,9 +34,7 @@ class CameraManager(private val context: Context) { val preview = Preview.Builder() .build() .apply { - setSurfaceProvider { surfaceProvider -> - previewSurfaceProvider(surfaceProvider) - } + setSurfaceProvider(previewSurfaceProvider) } // Create image capture diff --git a/app/src/main/java/com/example/camcontrol/MainActivity.kt b/app/src/main/java/com/example/camcontrol/MainActivity.kt index 2cee810..0eeba51 100644 --- a/app/src/main/java/com/example/camcontrol/MainActivity.kt +++ b/app/src/main/java/com/example/camcontrol/MainActivity.kt @@ -85,6 +85,21 @@ class MainActivity : ComponentActivity() { @Composable fun StreamingApp(modifier: Modifier = Modifier) { + val context = LocalContext.current + + PermissionChecker( + onPermissionsGranted = { + // Разрешения выданы, показываем основное приложение + StreamingAppContent(modifier) + } + ) { + // Если разрешения не выданы, PermissionChecker сам покажет запрос + StreamingAppContent(modifier) + } +} + +@Composable +fun StreamingAppContent(modifier: Modifier = Modifier) { val viewModel: StreamViewModel = viewModel() val connectionState by viewModel.connectionState.collectAsState() val statusMessage by viewModel.statusMessage.collectAsState() @@ -263,12 +278,12 @@ fun StreamingScreen( val context = LocalContext.current val cameraManager = remember { CameraManager(context) } + // Проверяем разрешения на камеру + val hasCameraPermission = PermissionManager.hasCameraPermission(context) + val hasInternetPermission = PermissionManager.hasInternetPermission(context) + LaunchedEffect(isCameraRunning) { - if (isCameraRunning && ContextCompat.checkSelfPermission( - context, - Manifest.permission.CAMERA - ) == PackageManager.PERMISSION_GRANTED - ) { + if (isCameraRunning && hasCameraPermission) { // Start camera preview when connected // In a real app, would bind to lifecycle and PreviewView } @@ -286,7 +301,45 @@ fun StreamingScreen( .background(Color.Black), contentAlignment = Alignment.Center ) { - if (isCameraRunning) { + if (!hasCameraPermission) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "❌ Нет доступа к камере", + color = Color(0xFFEF4444), + style = MaterialTheme.typography.headlineSmall + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Пожалуйста, выдайте разрешение на доступ к камере в настройках приложения", + color = Color.Gray, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + } else if (!hasInternetPermission) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "❌ Нет доступа в интернет", + color = Color(0xFFEF4444), + style = MaterialTheme.typography.headlineSmall + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Пожалуйста, выдайте разрешение на доступ в интернет", + color = Color.Gray, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + } else if (isCameraRunning) { // Camera preview would be rendered here // Using AndroidView with PreviewView in a real implementation Text( @@ -396,10 +449,11 @@ fun StreamingScreen( containerColor = MaterialTheme.colorScheme.error ) ) { - androidx.compose.material.icons.Icon( + androidx.compose.material3.Icon( imageVector = Icons.Filled.CallEnd, contentDescription = "Disconnect", - modifier = Modifier.padding(end = 8.dp) + modifier = Modifier.padding(end = 8.dp), + tint = Color.White ) Text("Отключиться") } diff --git a/app/src/main/java/com/example/camcontrol/PermissionManager.kt b/app/src/main/java/com/example/camcontrol/PermissionManager.kt new file mode 100644 index 0000000..e34c919 --- /dev/null +++ b/app/src/main/java/com/example/camcontrol/PermissionManager.kt @@ -0,0 +1,94 @@ +package com.example.camcontrol + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat + +object PermissionManager { + + /** + * Список всех необходимых разрешений для приложения + */ + val REQUIRED_PERMISSIONS = arrayOf( + Manifest.permission.CAMERA, + Manifest.permission.INTERNET, + Manifest.permission.ACCESS_NETWORK_STATE, + Manifest.permission.RECORD_AUDIO + ) + + /** + * Список опциональных разрешений + */ + val OPTIONAL_PERMISSIONS = arrayOf( + Manifest.permission.SEND_SMS, + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + + /** + * Проверяет, выданы ли определенные разрешения + */ + fun hasPermission(context: Context, permission: String): Boolean { + return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + } + + /** + * Проверяет, выданы ли все обязательные разрешения + */ + fun hasAllRequiredPermissions(context: Context): Boolean { + return REQUIRED_PERMISSIONS.all { hasPermission(context, it) } + } + + /** + * Проверяет, выданы ли разрешения на камеру + */ + fun hasCameraPermission(context: Context): Boolean { + return hasPermission(context, Manifest.permission.CAMERA) + } + + /** + * Проверяет, выданы ли разрешения на интернет + */ + fun hasInternetPermission(context: Context): Boolean { + return hasPermission(context, Manifest.permission.INTERNET) + } + + /** + * Проверяет, выданы ли разрешения на запись аудио + */ + fun hasAudioPermission(context: Context): Boolean { + return hasPermission(context, Manifest.permission.RECORD_AUDIO) + } + + /** + * Проверяет, выданы ли разрешения на отправку SMS + */ + fun hasSMSPermission(context: Context): Boolean { + return hasPermission(context, Manifest.permission.SEND_SMS) + } + + /** + * Проверяет, выданы ли разрешения на доступ в сеть + */ + fun hasNetworkAccessPermission(context: Context): Boolean { + return hasPermission(context, Manifest.permission.ACCESS_NETWORK_STATE) + } + + /** + * Возвращает список неполученных обязательных разрешений + */ + fun getMissingRequiredPermissions(context: Context): List { + return REQUIRED_PERMISSIONS.filter { !hasPermission(context, it) } + } + + /** + * Возвращает статус каждого разрешения + */ + fun getPermissionsStatus(context: Context): Map { + return (REQUIRED_PERMISSIONS + OPTIONAL_PERMISSIONS).associateWith { permission -> + hasPermission(context, permission) + } + } +} + diff --git a/app/src/main/java/com/example/camcontrol/PermissionUI.kt b/app/src/main/java/com/example/camcontrol/PermissionUI.kt new file mode 100644 index 0000000..d56e1d4 --- /dev/null +++ b/app/src/main/java/com/example/camcontrol/PermissionUI.kt @@ -0,0 +1,242 @@ +package com.example.camcontrol + +import android.Manifest +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun PermissionChecker( + onPermissionsGranted: () -> Unit, + content: @Composable () -> Unit +) { + val context = LocalContext.current + var permissionsGranted by remember { mutableStateOf(PermissionManager.hasAllRequiredPermissions(context)) } + var permissionsStatus by remember { mutableStateOf(PermissionManager.getPermissionsStatus(context)) } + + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions() + ) { result -> + permissionsStatus = result + permissionsGranted = result.values.all { it } + if (permissionsGranted) { + onPermissionsGranted() + } + } + + if (!permissionsGranted) { + PermissionsRequestScreen( + permissionsStatus = permissionsStatus, + onRequestPermissions = { + val missingPermissions = PermissionManager.getMissingRequiredPermissions(context) + if (missingPermissions.isNotEmpty()) { + permissionLauncher.launch(missingPermissions.toTypedArray()) + } + } + ) + } else { + content() + } +} + +@Composable +fun PermissionsRequestScreen( + permissionsStatus: Map, + onRequestPermissions: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "🔐 Требуемые разрешения", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) + ) + + Text( + text = "Приложению необходимы следующие разрешения для работы", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Требуемые разрешения + Text( + text = "Обязательные разрешения:", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + val requiredPermissions = mapOf( + Manifest.permission.CAMERA to "📷 Доступ к камере", + Manifest.permission.INTERNET to "🌐 Доступ в интернет", + Manifest.permission.ACCESS_NETWORK_STATE to "📡 Проверка состояния сети", + Manifest.permission.RECORD_AUDIO to "🎤 Запись аудио" + ) + + requiredPermissions.forEach { (permission, label) -> + PermissionStatusItem( + label = label, + isGranted = permissionsStatus[permission] ?: false, + isRequired = true + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Опциональные разрешения + Text( + text = "Опциональные разрешения:", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + val optionalPermissions = mapOf( + Manifest.permission.SEND_SMS to "💬 Отправка SMS", + Manifest.permission.ACCESS_FINE_LOCATION to "📍 Точное определение местоположения", + Manifest.permission.ACCESS_COARSE_LOCATION to "📍 Примерное определение местоположения" + ) + + optionalPermissions.forEach { (permission, label) -> + PermissionStatusItem( + label = label, + isGranted = permissionsStatus[permission] ?: false, + isRequired = false + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Кнопка для запроса разрешений + Button( + onClick = onRequestPermissions, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Text("Выдать разрешения") + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Информационное сообщение + Box( + modifier = Modifier + .fillMaxWidth() + .background( + color = Color(0xFFFEF3C7), + shape = MaterialTheme.shapes.small + ) + .padding(12.dp) + ) { + Text( + text = "⚠️ Приложение не может функционировать без обязательных разрешений. Пожалуйста, выдайте все необходимые разрешения.", + style = MaterialTheme.typography.bodySmall, + color = Color(0xFF92400E) + ) + } + } +} + +@Composable +fun PermissionStatusItem( + label: String, + isGranted: Boolean, + isRequired: Boolean +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = if (isGranted) Color(0xFFDCFCE7) else Color(0xFFFEE2E2), + shape = MaterialTheme.shapes.small + ) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = if (isGranted) Icons.Filled.Check else Icons.Filled.Close, + contentDescription = null, + tint = if (isGranted) Color(0xFF059669) else Color(0xFFDC2626), + modifier = Modifier.width(24.dp) + ) + Column { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = if (isGranted) Color(0xFF059669) else Color(0xFFDC2626) + ) + if (!isRequired) { + Text( + text = "опционально", + style = MaterialTheme.typography.bodySmall, + color = if (isGranted) Color(0xFF047857) else Color(0xFFB91C1C) + ) + } + } + } + + Box( + modifier = Modifier + .background( + color = if (isGranted) Color(0xFF10B981) else Color(0xFFEF4444), + shape = MaterialTheme.shapes.small + ) + .padding(6.dp, 4.dp) + ) { + Text( + text = if (isGranted) "✓" else "✗", + color = Color.White, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold + ) + } + } +} + diff --git a/app/src/main/java/com/example/camcontrol/VideoStreamingManager.kt b/app/src/main/java/com/example/camcontrol/VideoStreamingManager.kt index 4cc918a..3417ed1 100644 --- a/app/src/main/java/com/example/camcontrol/VideoStreamingManager.kt +++ b/app/src/main/java/com/example/camcontrol/VideoStreamingManager.kt @@ -24,7 +24,7 @@ class VideoStreamingManager( fun startStreaming( lifecycleOwner: LifecycleOwner, - previewSurfaceProvider: (Preview.SurfaceProvider) -> Unit + previewSurfaceProvider: Preview.SurfaceProvider ) { val cameraProviderFuture = ProcessCameraProvider.getInstance(context) @@ -37,9 +37,7 @@ class VideoStreamingManager( val preview = Preview.Builder() .build() .apply { - setSurfaceProvider { surfaceProvider -> - previewSurfaceProvider(surfaceProvider) - } + setSurfaceProvider(previewSurfaceProvider) } // Create image analysis for frame processing diff --git a/app/src/main/java/com/example/camcontrol/WebSocketManager.kt b/app/src/main/java/com/example/camcontrol/WebSocketManager.kt index bfb6ca3..b86c114 100644 --- a/app/src/main/java/com/example/camcontrol/WebSocketManager.kt +++ b/app/src/main/java/com/example/camcontrol/WebSocketManager.kt @@ -12,7 +12,8 @@ class WebSocketManager( private val onConnected: () -> Unit = {}, private val onDisconnected: () -> Unit = {}, private val onError: (String) -> Unit = {}, - private val onMessage: (String) -> Unit = {} + private val onMessage: (String) -> Unit = {}, + private val onBinaryMessage: (ByteArray) -> Unit = {} ) : WebSocketListener() { private var webSocket: WebSocket? = null @@ -20,6 +21,7 @@ class WebSocketManager( .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) .build() fun connect(url: String) { @@ -29,6 +31,7 @@ class WebSocketManager( .build() webSocket = client.newWebSocket(request, this) + client.dispatcher.executorService // Ensure executor is running Log.d("WebSocket", "Connecting to: $url") } catch (e: Exception) { Log.e("WebSocket", "Connection error: ${e.message}") @@ -48,11 +51,18 @@ class WebSocketManager( fun sendBinary(data: ByteArray) { try { - val byteString = okhttp3.ByteString.of(*data) - webSocket?.send(byteString) + // Create ByteString from ByteArray using reflection to avoid import issues + val byteStringClass = Class.forName("okhttp3.ByteString") + val ofMethod = byteStringClass.getMethod("of", ByteArray::class.java) + val byteString = ofMethod.invoke(null, data) + + val sendMethod = WebSocket::class.java.getMethod("send", byteStringClass) + sendMethod.invoke(webSocket, byteString) + Log.d("WebSocket", "Binary data sent: ${data.size} bytes") } catch (e: Exception) { Log.e("WebSocket", "Binary send error: ${e.message}") + onError(e.message ?: "Failed to send binary data") } } @@ -66,6 +76,8 @@ class WebSocketManager( } } + fun isConnected(): Boolean = webSocket != null + override fun onOpen(webSocket: WebSocket, response: Response) { Log.d("WebSocket", "Connected!") onConnected() @@ -76,6 +88,7 @@ class WebSocketManager( onMessage(text) } + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { webSocket.close(1000, null) Log.d("WebSocket", "Closing: $code $reason")