init
@@ -0,0 +1,24 @@
|
||||
package com.example.camcontrol
|
||||
|
||||
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.camcontrol", appContext.packageName)
|
||||
}
|
||||
}
|
||||
37
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Required permissions -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- Hardware features -->
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera.autofocus"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.CamControl">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.CamControl">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
110
app/src/main/java/com/example/camcontrol/CameraManager.kt
Normal file
@@ -0,0 +1,110 @@
|
||||
package com.example.camcontrol
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageCapture
|
||||
import androidx.camera.core.ImageCaptureException
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class CameraManager(private val context: Context) {
|
||||
|
||||
private var cameraProvider: ProcessCameraProvider? = null
|
||||
private var imageCapture: ImageCapture? = null
|
||||
private val cameraExecutor = Executors.newSingleThreadExecutor()
|
||||
|
||||
fun startCamera(
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
previewSurfaceProvider: (Preview.SurfaceProvider) -> Unit,
|
||||
onError: (String) -> Unit
|
||||
) {
|
||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
||||
|
||||
cameraProviderFuture.addListener(
|
||||
{
|
||||
try {
|
||||
cameraProvider = cameraProviderFuture.get()
|
||||
|
||||
// Create preview
|
||||
val preview = Preview.Builder()
|
||||
.build()
|
||||
.apply {
|
||||
setSurfaceProvider { surfaceProvider ->
|
||||
previewSurfaceProvider(surfaceProvider)
|
||||
}
|
||||
}
|
||||
|
||||
// Create image capture
|
||||
imageCapture = ImageCapture.Builder()
|
||||
.setTargetRotation(android.view.Surface.ROTATION_0)
|
||||
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
|
||||
.build()
|
||||
|
||||
// Select back camera
|
||||
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
|
||||
|
||||
// Unbind all use cases
|
||||
cameraProvider?.unbindAll()
|
||||
|
||||
// Bind use cases to camera
|
||||
cameraProvider?.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
cameraSelector,
|
||||
preview,
|
||||
imageCapture
|
||||
)
|
||||
|
||||
Log.d("CameraManager", "Camera started successfully")
|
||||
} catch (exc: Exception) {
|
||||
Log.e("CameraManager", "Use case binding failed", exc)
|
||||
onError("Failed to start camera: ${exc.message}")
|
||||
}
|
||||
},
|
||||
ContextCompat.getMainExecutor(context)
|
||||
)
|
||||
}
|
||||
|
||||
fun captureFrame(onFrameCaptured: (ByteArray) -> Unit, onError: (String) -> Unit) {
|
||||
val imageCapture = imageCapture ?: return
|
||||
|
||||
val outputOptions = ImageCapture.OutputFileOptions.Builder(
|
||||
context.contentResolver,
|
||||
android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||
android.content.ContentValues().apply {
|
||||
put(android.provider.MediaStore.MediaColumns.DISPLAY_NAME, System.currentTimeMillis())
|
||||
put(android.provider.MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
|
||||
}
|
||||
).build()
|
||||
|
||||
imageCapture.takePicture(
|
||||
outputOptions,
|
||||
cameraExecutor,
|
||||
object : ImageCapture.OnImageSavedCallback {
|
||||
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
|
||||
Log.d("CameraManager", "Image captured successfully")
|
||||
}
|
||||
|
||||
override fun onError(exception: ImageCaptureException) {
|
||||
Log.e("CameraManager", "Image capture failed: ${exception.message}")
|
||||
onError("Failed to capture image: ${exception.message}")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun stopCamera() {
|
||||
try {
|
||||
cameraProvider?.unbindAll()
|
||||
cameraExecutor.shutdown()
|
||||
Log.d("CameraManager", "Camera stopped")
|
||||
} catch (exc: Exception) {
|
||||
Log.e("CameraManager", "Error stopping camera", exc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
418
app/src/main/java/com/example/camcontrol/MainActivity.kt
Normal file
@@ -0,0 +1,418 @@
|
||||
package com.example.camcontrol
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.camera.view.PreviewView
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.CallEnd
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.example.camcontrol.ui.theme.CamControlTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
// Request camera permission
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.CAMERA
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
ActivityCompat.requestPermissions(
|
||||
this,
|
||||
arrayOf(Manifest.permission.CAMERA),
|
||||
CAMERA_PERMISSION_REQUEST_CODE
|
||||
)
|
||||
}
|
||||
|
||||
setContent {
|
||||
CamControlTheme {
|
||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||
StreamingApp(
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CAMERA_PERMISSION_REQUEST_CODE = 101
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StreamingApp(modifier: Modifier = Modifier) {
|
||||
val viewModel: StreamViewModel = viewModel()
|
||||
val connectionState by viewModel.connectionState.collectAsState()
|
||||
val statusMessage by viewModel.statusMessage.collectAsState()
|
||||
val isStreaming by viewModel.isStreaming.collectAsState()
|
||||
val isCameraRunning by viewModel.isCameraRunning.collectAsState()
|
||||
|
||||
var serverHost by remember { mutableStateOf("192.168.1.100") }
|
||||
var serverPort by remember { mutableStateOf("8000") }
|
||||
var roomId by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var showConnectionForm by remember { mutableStateOf(true) }
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
Surface(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
// Header
|
||||
Text(
|
||||
text = "🎥 CamControl - Video Streaming",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
if (showConnectionForm) {
|
||||
ConnectionForm(
|
||||
serverHost = serverHost,
|
||||
serverPort = serverPort,
|
||||
roomId = roomId,
|
||||
password = password,
|
||||
isConnecting = connectionState is ConnectionState.Connecting,
|
||||
onServerHostChange = { serverHost = it },
|
||||
onServerPortChange = { serverPort = it },
|
||||
onRoomIdChange = { roomId = it },
|
||||
onPasswordChange = { password = it },
|
||||
onConnect = {
|
||||
showConnectionForm = false
|
||||
viewModel.initializeConnection(
|
||||
serverHost = serverHost,
|
||||
serverPort = serverPort.toIntOrNull() ?: 8000,
|
||||
roomId = roomId,
|
||||
password = password
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
StreamingScreen(
|
||||
connectionState = connectionState,
|
||||
statusMessage = statusMessage,
|
||||
isStreaming = isStreaming,
|
||||
isCameraRunning = isCameraRunning,
|
||||
viewModel = viewModel,
|
||||
onDisconnect = {
|
||||
showConnectionForm = true
|
||||
viewModel.disconnect()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConnectionForm(
|
||||
serverHost: String,
|
||||
serverPort: String,
|
||||
roomId: String,
|
||||
password: String,
|
||||
isConnecting: Boolean,
|
||||
onServerHostChange: (String) -> Unit,
|
||||
onServerPortChange: (String) -> Unit,
|
||||
onRoomIdChange: (String) -> Unit,
|
||||
onPasswordChange: (String) -> Unit,
|
||||
onConnect: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Подключение к серверу",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
TextField(
|
||||
value = serverHost,
|
||||
onValueChange = onServerHostChange,
|
||||
label = { Text("IP адрес сервера") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isConnecting,
|
||||
placeholder = { Text("192.168.1.100") }
|
||||
)
|
||||
|
||||
TextField(
|
||||
value = serverPort,
|
||||
onValueChange = onServerPortChange,
|
||||
label = { Text("Порт сервера") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isConnecting,
|
||||
placeholder = { Text("8000") }
|
||||
)
|
||||
|
||||
TextField(
|
||||
value = roomId,
|
||||
onValueChange = onRoomIdChange,
|
||||
label = { Text("ID комнаты") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isConnecting,
|
||||
placeholder = { Text("Введите ID комнаты") }
|
||||
)
|
||||
|
||||
TextField(
|
||||
value = password,
|
||||
onValueChange = onPasswordChange,
|
||||
label = { Text("Пароль комнаты") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isConnecting,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
placeholder = { Text("Введите пароль") }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = onConnect,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
enabled = !isConnecting && serverHost.isNotEmpty() && serverPort.isNotEmpty() && roomId.isNotEmpty() && password.isNotEmpty(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
if (isConnecting) {
|
||||
CircularProgressIndicator(
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.padding(end = 8.dp)
|
||||
.height(24.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
Text("Подключиться")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Примечание: Убедитесь, что сервер запущен и доступен по указанному адресу",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StreamingScreen(
|
||||
connectionState: ConnectionState,
|
||||
statusMessage: String,
|
||||
isStreaming: Boolean,
|
||||
isCameraRunning: Boolean,
|
||||
viewModel: StreamViewModel,
|
||||
onDisconnect: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val cameraManager = remember { CameraManager(context) }
|
||||
|
||||
LaunchedEffect(isCameraRunning) {
|
||||
if (isCameraRunning && ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.CAMERA
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
// Start camera preview when connected
|
||||
// In a real app, would bind to lifecycle and PreviewView
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// Camera preview placeholder
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.background(Color.Black),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (isCameraRunning) {
|
||||
// Camera preview would be rendered here
|
||||
// Using AndroidView with PreviewView in a real implementation
|
||||
Text(
|
||||
text = "🎥 Camera Preview",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "Camera Inactive",
|
||||
color = Color.Gray,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Status and controls
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Connection status
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
when (connectionState) {
|
||||
is ConnectionState.Connected -> Color(0xFF10b981).copy(alpha = 0.2f)
|
||||
is ConnectionState.Error -> Color(0xFFef4444).copy(alpha = 0.2f)
|
||||
is ConnectionState.Connecting -> Color(0xFFf59e0b).copy(alpha = 0.2f)
|
||||
else -> Color.Gray.copy(alpha = 0.2f)
|
||||
}
|
||||
)
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = "Статус: ${getConnectionStatusText(connectionState)}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = when (connectionState) {
|
||||
is ConnectionState.Connected -> Color(0xFF10b981)
|
||||
is ConnectionState.Error -> Color(0xFFef4444)
|
||||
is ConnectionState.Connecting -> Color(0xFFf59e0b)
|
||||
else -> Color.Gray
|
||||
}
|
||||
)
|
||||
if (statusMessage.isNotEmpty()) {
|
||||
Text(
|
||||
text = statusMessage,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Video controls
|
||||
if (isStreaming) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = { viewModel.sendCommand(VideoCommands.rotate(90)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Rotate 90°")
|
||||
}
|
||||
Button(
|
||||
onClick = { viewModel.sendCommand(VideoCommands.flip(0)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Flip H")
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = { viewModel.sendCommand(VideoCommands.grayscale()) },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Grayscale")
|
||||
}
|
||||
Button(
|
||||
onClick = { viewModel.sendCommand(VideoCommands.reset()) },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Reset")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect button
|
||||
Button(
|
||||
onClick = onDisconnect,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
androidx.compose.material.icons.Icon(
|
||||
imageVector = Icons.Filled.CallEnd,
|
||||
contentDescription = "Disconnect",
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
Text("Отключиться")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConnectionStatusText(state: ConnectionState): String {
|
||||
return when (state) {
|
||||
ConnectionState.Idle -> "Ожидание"
|
||||
ConnectionState.Connecting -> "Подключение..."
|
||||
ConnectionState.Connected -> "Подключено ✓"
|
||||
ConnectionState.Disconnected -> "Отключено"
|
||||
is ConnectionState.Error -> "Ошибка"
|
||||
}
|
||||
}
|
||||
44
app/src/main/java/com/example/camcontrol/Models.kt
Normal file
@@ -0,0 +1,44 @@
|
||||
package com.example.camcontrol
|
||||
|
||||
import com.google.gson.Gson
|
||||
|
||||
data class ServerConnectionConfig(
|
||||
val serverHost: String,
|
||||
val serverPort: Int,
|
||||
val roomId: String,
|
||||
val password: String
|
||||
) {
|
||||
fun getWebSocketUrl(): String {
|
||||
return "ws://$serverHost:$serverPort/ws/client/$roomId/$password"
|
||||
}
|
||||
}
|
||||
|
||||
data class ConnectionResponse(
|
||||
val success: Boolean,
|
||||
val client_id: String? = null,
|
||||
val room_id: String? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
data class VideoCommand(
|
||||
val type: String,
|
||||
val angle: Int? = null,
|
||||
val direction: Int? = null,
|
||||
val value: Any? = null,
|
||||
val quality: Int? = null
|
||||
) {
|
||||
fun toJson(): String {
|
||||
return Gson().toJson(this)
|
||||
}
|
||||
}
|
||||
|
||||
object VideoCommands {
|
||||
fun rotate(angle: Int) = VideoCommand(type = "rotate", angle = angle)
|
||||
fun flip(direction: Int) = VideoCommand(type = "flip", direction = direction)
|
||||
fun brightness(value: Int) = VideoCommand(type = "brightness", value = value)
|
||||
fun contrast(value: Double) = VideoCommand(type = "contrast", value = value)
|
||||
fun grayscale() = VideoCommand(type = "grayscale")
|
||||
fun adjustQuality(quality: Int) = VideoCommand(type = "adjust_quality", quality = quality)
|
||||
fun reset() = VideoCommand(type = "reset")
|
||||
}
|
||||
|
||||
176
app/src/main/java/com/example/camcontrol/StreamViewModel.kt
Normal file
@@ -0,0 +1,176 @@
|
||||
package com.example.camcontrol
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import android.util.Log
|
||||
|
||||
class StreamViewModel : ViewModel() {
|
||||
|
||||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Idle)
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState
|
||||
|
||||
private val _statusMessage = MutableStateFlow("")
|
||||
val statusMessage: StateFlow<String> = _statusMessage
|
||||
|
||||
private val _isStreaming = MutableStateFlow(false)
|
||||
val isStreaming: StateFlow<Boolean> = _isStreaming
|
||||
|
||||
private val _isCameraRunning = MutableStateFlow(false)
|
||||
val isCameraRunning: StateFlow<Boolean> = _isCameraRunning
|
||||
|
||||
private val _fps = MutableStateFlow(0)
|
||||
val fps: StateFlow<Int> = _fps
|
||||
|
||||
private val _bytesTransferred = MutableStateFlow(0L)
|
||||
val bytesTransferred: StateFlow<Long> = _bytesTransferred
|
||||
|
||||
private var wsManager: WebSocketManager? = null
|
||||
private var config: ServerConnectionConfig? = null
|
||||
private var frameCount = 0
|
||||
private var lastFpsTime = System.currentTimeMillis()
|
||||
private var totalBytesTransferred = 0L
|
||||
|
||||
fun initializeConnection(
|
||||
serverHost: String,
|
||||
serverPort: Int,
|
||||
roomId: String,
|
||||
password: String
|
||||
) {
|
||||
config = ServerConnectionConfig(serverHost, serverPort, roomId, password)
|
||||
|
||||
wsManager = WebSocketManager(
|
||||
onConnected = { onConnected() },
|
||||
onDisconnected = { onDisconnected() },
|
||||
onError = { error -> onError(error) },
|
||||
onMessage = { message -> onMessage(message) }
|
||||
)
|
||||
|
||||
connect()
|
||||
}
|
||||
|
||||
private fun connect() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_connectionState.value = ConnectionState.Connecting
|
||||
updateStatus("Подключение к серверу...")
|
||||
|
||||
val config = config ?: return@launch
|
||||
wsManager?.connect(config.getWebSocketUrl())
|
||||
} catch (e: Exception) {
|
||||
Log.e("StreamViewModel", "Connection error: ${e.message}")
|
||||
_connectionState.value = ConnectionState.Error(e.message ?: "Unknown error")
|
||||
updateStatus("Ошибка подключения: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onConnected() {
|
||||
viewModelScope.launch {
|
||||
_connectionState.value = ConnectionState.Connected
|
||||
_isStreaming.value = true
|
||||
_isCameraRunning.value = true
|
||||
updateStatus("Подключено к серверу ✓")
|
||||
Log.d("StreamViewModel", "Connected to server")
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDisconnected() {
|
||||
viewModelScope.launch {
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
_isStreaming.value = false
|
||||
_isCameraRunning.value = false
|
||||
updateStatus("Отключено от сервера")
|
||||
Log.d("StreamViewModel", "Disconnected from server")
|
||||
}
|
||||
}
|
||||
|
||||
private fun onError(error: String) {
|
||||
viewModelScope.launch {
|
||||
_connectionState.value = ConnectionState.Error(error)
|
||||
updateStatus("Ошибка: $error")
|
||||
Log.e("StreamViewModel", "Error: $error")
|
||||
}
|
||||
}
|
||||
|
||||
private fun onMessage(message: String) {
|
||||
Log.d("StreamViewModel", "Message received: $message")
|
||||
viewModelScope.launch {
|
||||
if (!message.contains("ping")) {
|
||||
updateStatus("Получено: $message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendVideoFrame(frameData: ByteArray) {
|
||||
try {
|
||||
wsManager?.sendBinary(frameData)
|
||||
|
||||
// Update statistics
|
||||
frameCount++
|
||||
totalBytesTransferred += frameData.size
|
||||
_bytesTransferred.value = totalBytesTransferred
|
||||
|
||||
// Update FPS every second
|
||||
val currentTime = System.currentTimeMillis()
|
||||
if (currentTime - lastFpsTime >= 1000) {
|
||||
_fps.value = frameCount
|
||||
frameCount = 0
|
||||
lastFpsTime = currentTime
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("StreamViewModel", "Failed to send frame: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun sendCommand(command: VideoCommand) {
|
||||
try {
|
||||
wsManager?.sendMessage(command.toJson())
|
||||
updateStatus("Команда отправлена: ${command.type}")
|
||||
Log.d("StreamViewModel", "Command sent: ${command.type}")
|
||||
} catch (e: Exception) {
|
||||
Log.e("StreamViewModel", "Failed to send command: ${e.message}")
|
||||
updateStatus("Ошибка отправки команды: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
viewModelScope.launch {
|
||||
_isStreaming.value = false
|
||||
_isCameraRunning.value = false
|
||||
wsManager?.disconnect()
|
||||
_connectionState.value = ConnectionState.Idle
|
||||
updateStatus("Отключение...")
|
||||
resetStatistics()
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetStatistics() {
|
||||
frameCount = 0
|
||||
totalBytesTransferred = 0L
|
||||
_fps.value = 0
|
||||
_bytesTransferred.value = 0L
|
||||
lastFpsTime = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
private fun updateStatus(message: String) {
|
||||
_statusMessage.value = message
|
||||
Log.d("StreamViewModel", "Status: $message")
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
wsManager?.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
sealed class ConnectionState {
|
||||
object Idle : ConnectionState()
|
||||
object Connecting : ConnectionState()
|
||||
object Connected : ConnectionState()
|
||||
object Disconnected : ConnectionState()
|
||||
data class Error(val message: String) : ConnectionState()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.example.camcontrol
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class VideoStreamingManager(
|
||||
private val context: Context,
|
||||
private val onFrameAvailable: (ByteArray) -> Unit,
|
||||
private val onError: (String) -> Unit
|
||||
) {
|
||||
|
||||
private var cameraProvider: ProcessCameraProvider? = null
|
||||
private val analysisExecutor = Executors.newSingleThreadExecutor()
|
||||
private var frameCount = 0
|
||||
private var lastLogTime = System.currentTimeMillis()
|
||||
|
||||
fun startStreaming(
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
previewSurfaceProvider: (Preview.SurfaceProvider) -> Unit
|
||||
) {
|
||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
||||
|
||||
cameraProviderFuture.addListener(
|
||||
{
|
||||
try {
|
||||
cameraProvider = cameraProviderFuture.get()
|
||||
|
||||
// Create preview
|
||||
val preview = Preview.Builder()
|
||||
.build()
|
||||
.apply {
|
||||
setSurfaceProvider { surfaceProvider ->
|
||||
previewSurfaceProvider(surfaceProvider)
|
||||
}
|
||||
}
|
||||
|
||||
// Create image analysis for frame processing
|
||||
val imageAnalysis = ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888)
|
||||
.build()
|
||||
.apply {
|
||||
setAnalyzer(analysisExecutor) { imageProxy ->
|
||||
processFrame(imageProxy)
|
||||
}
|
||||
}
|
||||
|
||||
// Select back camera
|
||||
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
|
||||
|
||||
// Unbind all use cases
|
||||
cameraProvider?.unbindAll()
|
||||
|
||||
// Bind use cases to camera
|
||||
cameraProvider?.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
cameraSelector,
|
||||
preview,
|
||||
imageAnalysis
|
||||
)
|
||||
|
||||
Log.d("VideoStreamingManager", "Streaming started")
|
||||
} catch (exc: Exception) {
|
||||
Log.e("VideoStreamingManager", "Error starting stream", exc)
|
||||
onError("Failed to start streaming: ${exc.message}")
|
||||
}
|
||||
},
|
||||
ContextCompat.getMainExecutor(context)
|
||||
)
|
||||
}
|
||||
|
||||
private fun processFrame(imageProxy: ImageProxy) {
|
||||
try {
|
||||
frameCount++
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
// Log every 5 seconds
|
||||
if (currentTime - lastLogTime > 5000) {
|
||||
Log.d("VideoStreamingManager", "Processing $frameCount frames/5s")
|
||||
frameCount = 0
|
||||
lastLogTime = currentTime
|
||||
}
|
||||
|
||||
// Convert image to byte array
|
||||
val buffer = imageProxy.planes[0].buffer
|
||||
buffer.rewind()
|
||||
val frameData = ByteArray(buffer.remaining())
|
||||
buffer.get(frameData)
|
||||
|
||||
// Send frame to callback
|
||||
onFrameAvailable(frameData)
|
||||
|
||||
imageProxy.close()
|
||||
} catch (e: Exception) {
|
||||
Log.e("VideoStreamingManager", "Error processing frame: ${e.message}")
|
||||
imageProxy.close()
|
||||
}
|
||||
}
|
||||
|
||||
fun stopStreaming() {
|
||||
try {
|
||||
cameraProvider?.unbindAll()
|
||||
analysisExecutor.shutdown()
|
||||
Log.d("VideoStreamingManager", "Streaming stopped")
|
||||
} catch (e: Exception) {
|
||||
Log.e("VideoStreamingManager", "Error stopping stream", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
95
app/src/main/java/com/example/camcontrol/WebSocketManager.kt
Normal file
@@ -0,0 +1,95 @@
|
||||
package com.example.camcontrol
|
||||
|
||||
import android.util.Log
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.Response
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class WebSocketManager(
|
||||
private val onConnected: () -> Unit = {},
|
||||
private val onDisconnected: () -> Unit = {},
|
||||
private val onError: (String) -> Unit = {},
|
||||
private val onMessage: (String) -> Unit = {}
|
||||
) : WebSocketListener() {
|
||||
|
||||
private var webSocket: WebSocket? = null
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
fun connect(url: String) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
webSocket = client.newWebSocket(request, this)
|
||||
Log.d("WebSocket", "Connecting to: $url")
|
||||
} catch (e: Exception) {
|
||||
Log.e("WebSocket", "Connection error: ${e.message}")
|
||||
onError(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(message: String) {
|
||||
try {
|
||||
webSocket?.send(message)
|
||||
Log.d("WebSocket", "Message sent: $message")
|
||||
} catch (e: Exception) {
|
||||
Log.e("WebSocket", "Send error: ${e.message}")
|
||||
onError(e.message ?: "Failed to send message")
|
||||
}
|
||||
}
|
||||
|
||||
fun sendBinary(data: ByteArray) {
|
||||
try {
|
||||
val byteString = okhttp3.ByteString.of(*data)
|
||||
webSocket?.send(byteString)
|
||||
Log.d("WebSocket", "Binary data sent: ${data.size} bytes")
|
||||
} catch (e: Exception) {
|
||||
Log.e("WebSocket", "Binary send error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
try {
|
||||
webSocket?.close(1000, "Client disconnecting")
|
||||
webSocket = null
|
||||
Log.d("WebSocket", "Disconnected")
|
||||
} catch (e: Exception) {
|
||||
Log.e("WebSocket", "Disconnect error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
Log.d("WebSocket", "Connected!")
|
||||
onConnected()
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
Log.d("WebSocket", "Message received: $text")
|
||||
onMessage(text)
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
webSocket.close(1000, null)
|
||||
Log.d("WebSocket", "Closing: $code $reason")
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
Log.d("WebSocket", "Closed: $code $reason")
|
||||
onDisconnected()
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
Log.e("WebSocket", "Failure: ${t.message}")
|
||||
onError(t.message ?: "Connection failed")
|
||||
onDisconnected()
|
||||
}
|
||||
}
|
||||
|
||||
11
app/src/main/java/com/example/camcontrol/ui/theme/Color.kt
Normal file
@@ -0,0 +1,11 @@
|
||||
package com.example.camcontrol.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
58
app/src/main/java/com/example/camcontrol/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,58 @@
|
||||
package com.example.camcontrol.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 CamControlTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
34
app/src/main/java/com/example/camcontrol/ui/theme/Type.kt
Normal file
@@ -0,0 +1,34 @@
|
||||
package com.example.camcontrol.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
|
||||
)
|
||||
*/
|
||||
)
|
||||
170
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
30
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,30 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 982 B |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
10
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
3
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">camControl</string>
|
||||
</resources>
|
||||
5
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.CamControl" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
13
app/src/main/res/xml/backup_rules.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older than API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
19
app/src/main/res/xml/data_extraction_rules.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||
17
app/src/test/java/com/example/camcontrol/ExampleUnitTest.kt
Normal file
@@ -0,0 +1,17 @@
|
||||
package com.example.camcontrol
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||