commit 5be04b02179b830760a73ea06715bd0c7095b86c Author: Miguel Grandez Date: Thu Sep 3 16:36:46 2026 -0500 Initial version M530 COMMAND - photo video audio backend integration diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93f626b --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +.gradle/ +build/ +**/build/ +local.properties +.idea/ +*.iml +*.apk +*.aab +*.jks +*.keystore +*.p12 +*.pem +*.log +Thumbs.db +Desktop.ini +*.bak +*.bak_* +fix_*.ps1 +add_mic.ps1 +powershell +true +{ diff --git a/BUILD_AND_INSTALL_M530.bat b/BUILD_AND_INSTALL_M530.bat new file mode 100644 index 0000000..3991d5b --- /dev/null +++ b/BUILD_AND_INSTALL_M530.bat @@ -0,0 +1,84 @@ +@echo off +setlocal EnableExtensions +cd /d "%~dp0" + +echo ===================================================== +echo M530 COMMAND MQTT - Field Edition 1.2.1 +echo Build + Install + Launch +echo ===================================================== + +set "SDK=%LOCALAPPDATA%\Android\Sdk" +set "ADB=%SDK%\platform-tools\adb.exe" + +if not exist "%SDK%" ( + echo ERROR: Android SDK no encontrado en "%SDK%" + pause + exit /b 1 +) +if not exist "%ADB%" ( + echo ERROR: adb.exe no encontrado en "%ADB%" + pause + exit /b 1 +) +if not exist "gradlew.bat" ( + echo ERROR: gradlew.bat no existe en %CD% + pause + exit /b 1 +) + +for /f "tokens=3" %%v in ('java -version 2^>^&1 ^| findstr /i "version"') do set "JVER=%%~v" +echo Java detectado: %JVER% + +echo sdk.dir=%SDK:\=\\%>local.properties + +echo. +echo [1/5] Limpiando y compilando APK... +call gradlew.bat :app:assembleDebug --no-configuration-cache +set "BUILD_RC=%ERRORLEVEL%" +if not "%BUILD_RC%"=="0" goto :error + +set "APK=app\build\outputs\apk\debug\app-debug.apk" +if not exist "%APK%" ( + echo ERROR: No se genero %APK% + goto :error +) + +echo. +echo [2/5] Verificando dispositivo M530... +"%ADB%" start-server >nul 2>&1 +"%ADB%" devices +"%ADB%" get-state 1>nul 2>nul +if errorlevel 1 ( + echo ERROR: No hay un dispositivo ADB autorizado. + echo Revisa USB Debugging y acepta la autorizacion en el M530. + goto :error +) + +echo. +echo [3/5] Instalando/actualizando M530 COMMAND MQTT... +"%ADB%" install -r -d "%APK%" +if errorlevel 1 goto :error + +echo. +echo [4/5] Reiniciando aplicacion... +"%ADB%" shell am force-stop com.bodycamera.twentyfoulabs +"%ADB%" shell monkey -p com.bodycamera.twentyfoulabs -c android.intent.category.LAUNCHER 1 >nul + +echo. +echo [5/5] Verificacion de paquete... +"%ADB%" shell dumpsys package com.bodycamera.twentyfoulabs ^| findstr /i "versionName versionCode" + +echo. +echo ===================================================== +echo INSTALACION COMPLETADA + echo Pruebas: F2 FOTO / F4 PTT / F5 VIDEO / F7 SOS + echo UI: Video / Foto / SOS / Telemetria / Evidencias / Eventos + echo ===================================================== +pause +exit /b 0 + +:error +echo. +echo BUILD/INSTALL FALLIDO. Copia las ultimas lineas mostradas. +pause +exit /b 1 diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..eae8382 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,113 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.serialization") + id("kotlin-kapt") + id("com.google.dagger.hilt.android") +} + +android { + namespace = "com.bodycamera.twentyfoulabs" + compileSdk = 34 + + defaultConfig { + applicationId = "com.bodycamera.twentyfoulabs" + minSdk = 26 + targetSdk = 34 + versionCode = 152 + versionName = "1.5.2-dedicated-camera-control" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + 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 + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.4" + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + // Core Android + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2") + implementation("androidx.activity:activity-compose:1.8.1") + + // Jetpack Compose + implementation(platform("androidx.compose:compose-bom:2023.10.01")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.compose.ui:ui-text-google-fonts") + + // Navigation Compose + implementation("androidx.navigation:navigation-compose:2.7.5") + + // Dependency Injection - Hilt + implementation("com.google.dagger:hilt-android:2.48") + kapt("com.google.dagger:hilt-android-compiler:2.48") + implementation("androidx.hilt:hilt-navigation-compose:1.1.0") + + // DataStore para persistencia + implementation("androidx.datastore:datastore-preferences:1.0.0") + + // JSON Serialization (Kotlinx Serialization) + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") + + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + + // ViewModel + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2") + + // WebRTC - stream-webrtc-android (includes org.webrtc package) + implementation("io.getstream:stream-webrtc-android:1.1.1") + + // CameraX + implementation("androidx.camera:camera-core:1.3.0") + implementation("androidx.camera:camera-camera2:1.3.0") + implementation("androidx.camera:camera-lifecycle:1.3.0") + implementation("androidx.camera:camera-view:1.3.0") + + // Testing + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01")) + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..2a29620 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,29 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts. +# +# 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 + +# Keep Hilt generated classes +-keep class dagger.hilt.** { *; } +-keep class javax.inject.** { *; } +-keep class * extends dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper { *; } + +# Keep DataStore generated classes +-keep class androidx.datastore.* { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7563e5a --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainActivity.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainActivity.kt new file mode 100644 index 0000000..66242c8 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainActivity.kt @@ -0,0 +1,376 @@ +package com.bodycamera.twentyfoulabs + +import android.Manifest +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.view.KeyEvent +import android.view.View +import android.view.WindowManager +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.core.content.ContextCompat +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.bodycamera.twentyfoulabs.data.device.M530DeviceController +import com.bodycamera.twentyfoulabs.data.auth.AuthSessionStore +import com.bodycamera.twentyfoulabs.navigation.LocalNavigationHandler +import com.bodycamera.twentyfoulabs.navigation.rememberNavigationHandler +import com.bodycamera.twentyfoulabs.ui.screens.ConnectionSettingsScreen +import com.bodycamera.twentyfoulabs.ui.screens.ConnectionSettingsViewModel +import com.bodycamera.twentyfoulabs.ui.screens.MainScreen +import com.bodycamera.twentyfoulabs.ui.screens.MainViewModel +import com.bodycamera.twentyfoulabs.ui.screens.AuthViewModel +import com.bodycamera.twentyfoulabs.ui.screens.AuthUiState +import com.bodycamera.twentyfoulabs.ui.screens.LoginScreen +import com.bodycamera.twentyfoulabs.ui.screens.VideoConfigScreen +import com.bodycamera.twentyfoulabs.ui.screens.VideoConfigViewModel +import com.bodycamera.twentyfoulabs.ui.theme.MainAppTheme +import dagger.hilt.android.AndroidEntryPoint +import androidx.hilt.navigation.compose.hiltViewModel +import javax.inject.Inject +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.collect + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + private val viewModel: MainViewModel by viewModels() + + @Inject lateinit var deviceController: M530DeviceController + @Inject lateinit var authSessionStore: AuthSessionStore + + @Volatile private var isAuthenticated = false + + companion object { + private const val TAG = "MainActivity" + private const val ACTION_VIDEO_DOWN = "android.intent.action.ACTION_VIDEO_DOWN" + private const val ACTION_VIDEO_UP = "android.intent.action.ACTION_VIDEO_UP" + } + + private var isReceiverRegistered = false + + private val hardwareReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + ACTION_VIDEO_DOWN -> handleVideoButton() + ACTION_VIDEO_UP -> Unit + } + } + } + + private val permissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + val denied = permissions.filterValues { !it }.keys + if (denied.isNotEmpty()) { + Toast.makeText(this, "Permisos pendientes: ${denied.joinToString()}", Toast.LENGTH_LONG).show() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Log.i(TAG, "M530 Controller onCreate") + + window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + window.setFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + requestPermissions() + + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() = Unit + }) + + lifecycleScope.launch { + authSessionStore.token.collect { token -> isAuthenticated = !token.isNullOrBlank() } + } + + registerHardwareReceiver() + deviceController.logTelemetry() + + setContent { + MainAppTheme { AppNavigation(viewModel = viewModel, onOpenPhotoCamera = { handlePhotoButton() }, onOpenVideoCamera = { handleVideoButton() }) } + } + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + if (event?.repeatCount ?: 0 > 0) return true + Log.i(TAG, "M530 KEY DOWN: $keyCode") + if (!isAuthenticated && keyCode in setOf(KeyEvent.KEYCODE_F1, KeyEvent.KEYCODE_F2, KeyEvent.KEYCODE_F3, KeyEvent.KEYCODE_F4, KeyEvent.KEYCODE_F5, KeyEvent.KEYCODE_F7)) { + toast("Inicia sesión para usar la bodycam") + return true + } + return when (keyCode) { + KeyEvent.KEYCODE_F1 -> { + + toast("Marcando evidencia como favorita...") + + deviceController.markEvidence( + "F1_STAR" + ) { result -> + + runOnUiThread { + + if (result.isSuccess) { + + val mediaId = + result.getOrNull() + + toast( + "Evidencia favorita registrada (ID $mediaId)" + ) + + } else { + + toast( + result.exceptionOrNull()?.message + ?: "No se pudo marcar favorito" + ) + } + } + } + + true + } + KeyEvent.KEYCODE_F2 -> { + handlePhotoButton() + true + } + KeyEvent.KEYCODE_F3 -> { + if (deviceController.isMicRecording()) { + val result = deviceController.stopMicRecording() + toast( + if (result.isSuccess) + "MIC: grabacion guardada" + else + "MIC error: ${result.exceptionOrNull()?.message}" + ) + } else { + val result = deviceController.startMicRecording() + toast( + if (result.isSuccess) + "MIC: grabando" + else + "MIC error: ${result.exceptionOrNull()?.message}" + ) + } + true + } + KeyEvent.KEYCODE_F4 -> { + val result = deviceController.startPtt() + toast(if (result.isSuccess) "PTT: hablando" else "PTT error: ${result.exceptionOrNull()?.message}") + true + } + KeyEvent.KEYCODE_F5 -> { + // Algunas versiones del firmware también emiten ACTION_VIDEO_DOWN. + // El ViewModel aplica debounce para evitar doble ejecución. + handleVideoButton() + true + } + KeyEvent.KEYCODE_F7 -> { + handleSosButton() + true + } + else -> super.onKeyDown(keyCode, event) + } + } + + override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { + Log.i(TAG, "M530 KEY UP: $keyCode") + if (!isAuthenticated && keyCode in setOf(KeyEvent.KEYCODE_F1, KeyEvent.KEYCODE_F2, KeyEvent.KEYCODE_F3, KeyEvent.KEYCODE_F4, KeyEvent.KEYCODE_F5, KeyEvent.KEYCODE_F7)) return true + return when (keyCode) { + KeyEvent.KEYCODE_F4 -> { + val result = deviceController.stopPtt() + toast(if (result.isSuccess) "PTT: turno liberado" else "PTT error") + true + } + KeyEvent.KEYCODE_F1, KeyEvent.KEYCODE_F2, KeyEvent.KEYCODE_F3, + KeyEvent.KEYCODE_F5, KeyEvent.KEYCODE_F7 -> true + else -> super.onKeyUp(keyCode, event) + } + } + + private fun handleVideoButton() { + if (!isAuthenticated) { + toast("Inicia sesión para usar VIDEO") + return + } + startActivity( + android.content.Intent( + this, + com.bodycamera.twentyfoulabs.camera.M530CameraActivity::class.java + ).putExtra( + com.bodycamera.twentyfoulabs.camera.M530CameraActivity.EXTRA_MODE, + com.bodycamera.twentyfoulabs.camera.M530CameraActivity.MODE_VIDEO + ).addFlags(android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP) + ) + } + + + private fun handlePhotoButton() { + startActivity( + android.content.Intent( + this, + com.bodycamera.twentyfoulabs.camera.M530CameraActivity::class.java + ).addFlags(android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP) + ) + } + + private fun handleSosButton() { + val result = deviceController.triggerSos() + val loc = result.location + val msg = if (loc != null) "SOS registrado (${loc.latitude}, ${loc.longitude})" else "SOS registrado (GPS sin posición reciente)" + Log.w(TAG, msg) + toast(msg) + } + + private fun registerHardwareReceiver() { + if (isReceiverRegistered) return + val filter = IntentFilter().apply { + addAction(ACTION_VIDEO_DOWN) + addAction(ACTION_VIDEO_UP) + } + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(hardwareReceiver, filter, RECEIVER_EXPORTED) + } else { + @Suppress("DEPRECATION") + registerReceiver(hardwareReceiver, filter) + } + isReceiverRegistered = true + } catch (t: Throwable) { + Log.e(TAG, "No se pudo registrar receiver", t) + } + } + + private fun unregisterHardwareReceiver() { + if (!isReceiverRegistered) return + try { unregisterReceiver(hardwareReceiver) } catch (_: Throwable) {} + isReceiverRegistered = false + } + + override fun onResume() { + super.onResume() + hideSystemUI() + registerHardwareReceiver() + } + + override fun onPause() { + super.onPause() + unregisterHardwareReceiver() + } + + override fun onDestroy() { + deviceController.release() + super.onDestroy() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) hideSystemUI() + } + + private fun hideSystemUI() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + window.setDecorFitsSystemWindows(false) + window.insetsController?.let { controller -> + controller.hide(android.view.WindowInsets.Type.statusBars()) + controller.hide(android.view.WindowInsets.Type.navigationBars()) + controller.systemBarsBehavior = android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } else { + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or + View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + } + } + + private fun requestPermissions() { + val requested = listOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.CAMERA, + Manifest.permission.RECORD_AUDIO + ).filter { ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED } + if (requested.isNotEmpty()) permissionLauncher.launch(requested.toTypedArray()) + } + + private fun toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_SHORT).show() +} + +@Composable +fun AppNavigation(viewModel: MainViewModel, onOpenPhotoCamera: () -> Unit, onOpenVideoCamera: () -> Unit) { + val navController = rememberNavController() + val navigationHandler = rememberNavigationHandler(navController) + val authViewModel: AuthViewModel = hiltViewModel() + val authState by authViewModel.state.collectAsState() + + LaunchedEffect(authState) { + if (authState is AuthUiState.Authenticated && navController.currentDestination?.route != "main") { + navController.navigate("main") { popUpTo("login") { inclusive = true } } + } + } + + CompositionLocalProvider(LocalNavigationHandler provides navigationHandler) { + NavHost( + navController = navController, + startDestination = "login" + ) { + composable("login") { + LoginScreen( + state = authState, + onLogin = authViewModel::login, + onRetrySession = authViewModel::restoreSession, + onClearError = authViewModel::clearError + ) + } + + composable("main") { + MainScreen( + viewModel = viewModel, + onNavigateToSettings = { navController.navigate("connection_settings") }, + onNavigateToVideoConfig = { navController.navigate("video_config") }, + onOpenPhotoCamera = onOpenPhotoCamera, + onOpenVideoCamera = onOpenVideoCamera, + onLogout = { + authViewModel.logout() + navController.navigate("login") { popUpTo("main") { inclusive = true } } + } + ) + } + + composable("connection_settings") { + val settingsViewModel: ConnectionSettingsViewModel = hiltViewModel() + ConnectionSettingsScreen(viewModel = settingsViewModel) + } + + composable("video_config") { + val videoConfigViewModel: VideoConfigViewModel = hiltViewModel() + VideoConfigScreen(viewModel = videoConfigViewModel) + } + } + } +} + + + + + + diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainApplication.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainApplication.kt new file mode 100644 index 0000000..54aa54e --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainApplication.kt @@ -0,0 +1,11 @@ +package com.bodycamera.twentyfoulabs + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class MainApplication : Application() { + override fun onCreate() { + super.onCreate() + } +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/camera/M530CameraActivity.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/camera/M530CameraActivity.kt new file mode 100644 index 0000000..a0059fa --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/camera/M530CameraActivity.kt @@ -0,0 +1,714 @@ +package com.bodycamera.twentyfoulabs.camera + +import android.Manifest +import android.content.ContentValues +import android.content.pm.PackageManager +import android.graphics.Color +import android.graphics.Typeface +import android.os.Build +import android.os.Bundle +import android.os.SystemClock +import android.provider.MediaStore +import android.util.Log +import android.view.Gravity +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.widget.Button +import android.widget.Chronometer +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts +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.camera.video.FallbackStrategy +import androidx.camera.video.MediaStoreOutputOptions +import androidx.camera.video.Quality +import androidx.camera.video.QualitySelector +import androidx.camera.video.Recorder +import androidx.camera.video.Recording +import androidx.camera.video.VideoCapture +import androidx.camera.video.VideoRecordEvent +import androidx.camera.view.PreviewView +import androidx.core.content.ContextCompat +import com.bodycamera.twentyfoulabs.data.backend.BackendIntegrationManager +import dagger.hilt.android.AndroidEntryPoint +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import javax.inject.Inject + +@AndroidEntryPoint +class M530CameraActivity : ComponentActivity() { + + @Inject + lateinit var backend: BackendIntegrationManager + + companion object { + const val EXTRA_MODE = "mode" + const val MODE_PHOTO = "photo" + const val MODE_VIDEO = "video" + } + + private lateinit var previewView: PreviewView + private lateinit var modeText: TextView + private lateinit var stateText: TextView + private lateinit var batteryText: TextView + private lateinit var physicalHintText: TextView + private lateinit var captureButton: Button + private lateinit var closeButton: Button + private lateinit var recordDot: TextView + private lateinit var chronometer: Chronometer + + private var mode: String = MODE_PHOTO + private var imageCapture: ImageCapture? = null + private var videoCapture: VideoCapture? = null + private var activeRecording: Recording? = null + private var cameraProvider: ProcessCameraProvider? = null + private var lastPhysicalActionMs = 0L + private var lastTouchActionMs = 0L + + private val cameraPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) startCamera() else stateText.text = "Permiso de cámara requerido" + } + + private val audioPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + mode = intent.getStringExtra(EXTRA_MODE) ?: MODE_PHOTO + buildProfessionalUi() + updateBatteryIndicator() + + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + ) { + startCamera() + } else { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + + if (mode == MODE_VIDEO && + ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != + PackageManager.PERMISSION_GRANTED + ) { + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + + private fun buildProfessionalUi() { + val root = FrameLayout(this).apply { + setBackgroundColor(Color.BLACK) + } + + previewView = PreviewView(this).apply { + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + scaleType = PreviewView.ScaleType.FILL_CENTER + isClickable = false + isFocusable = false + } + root.addView( + previewView, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + ) + + val topBar = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(14), dp(8), dp(10), dp(8)) + setBackgroundColor(0xA6000000.toInt()) + isClickable = false + } + + val brand = TextView(this).apply { + text = "M530 COMMAND" + setTextColor(Color.WHITE) + textSize = 15f + setTypeface(typeface, Typeface.BOLD) + } + + modeText = TextView(this).apply { + text = if (mode == MODE_VIDEO) "VIDEO" else "FOTO" + setTextColor(if (mode == MODE_VIDEO) 0xFFFF3B30.toInt() else 0xFFFFC400.toInt()) + textSize = 14f + setTypeface(typeface, Typeface.BOLD) + gravity = Gravity.CENTER + } + + batteryText = TextView(this).apply { + text = "BAT --%" + setTextColor(Color.WHITE) + textSize = 12f + gravity = Gravity.END or Gravity.CENTER_VERTICAL + } + + topBar.addView(brand, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)) + topBar.addView(modeText, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)) + topBar.addView(batteryText, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)) + + root.addView( + topBar, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + dp(48), + Gravity.TOP + ) + ) + + val recordingPanel = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER + setPadding(dp(10), dp(5), dp(10), dp(5)) + setBackgroundColor(0x7A000000) + isClickable = false + } + + recordDot = TextView(this).apply { + text = "●" + setTextColor(0xFFFF3B30.toInt()) + textSize = 18f + visibility = View.GONE + } + + chronometer = Chronometer(this).apply { + format = "%s" + setTextColor(Color.WHITE) + textSize = 16f + setTypeface(typeface, Typeface.BOLD) + visibility = View.GONE + } + + recordingPanel.addView(recordDot) + recordingPanel.addView(chronometer) + + root.addView( + recordingPanel, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + dp(38), + Gravity.TOP or Gravity.CENTER_HORIZONTAL + ).apply { topMargin = dp(54) } + ) + + closeButton = Button(this).apply { + text = "×" + gravity = Gravity.CENTER + setTextColor(Color.WHITE) + textSize = 25f + setTypeface(typeface, Typeface.BOLD) + minWidth = 0 + minHeight = 0 + setPadding(0, 0, 0, 0) + background = circleDrawable(0xB0000000.toInt(), Color.WHITE, dp(1)) + isClickable = true + isFocusable = true + elevation = dp(12).toFloat() + setOnClickListener { + stopRecordingIfNeeded() + finish() + } + } + root.addView( + closeButton, + FrameLayout.LayoutParams(dp(54), dp(54), Gravity.TOP or Gravity.END).apply { + topMargin = dp(62) + rightMargin = dp(12) + } + ) + + captureButton = Button(this).apply { + text = if (mode == MODE_VIDEO) "REC" else "FOTO" + setTextColor(Color.WHITE) + textSize = 15f + setTypeface(typeface, Typeface.BOLD) + gravity = Gravity.CENTER + minWidth = 0 + minHeight = 0 + setPadding(0, 0, 0, 0) + background = circleDrawable( + if (mode == MODE_VIDEO) 0xE6C91515.toInt() else 0xE61C1C1C.toInt(), + Color.WHITE, + dp(4) + ) + isClickable = true + isFocusable = true + elevation = dp(16).toFloat() + + setOnClickListener { + performTouchAction() + } + + // Refuerzo para firmwares M530 que no entregan onClick de forma consistente. + setOnTouchListener { _, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + alpha = 0.72f + true + } + MotionEvent.ACTION_UP -> { + alpha = 1f + performTouchAction() + true + } + MotionEvent.ACTION_CANCEL -> { + alpha = 1f + true + } + else -> true + } + } + } + + root.addView( + captureButton, + FrameLayout.LayoutParams( + dp(92), + dp(92), + Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL + ).apply { bottomMargin = dp(72) } + ) + + val bottomBar = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(14), dp(8), dp(14), dp(8)) + setBackgroundColor(0xB8000000.toInt()) + isClickable = false + } + + physicalHintText = TextView(this).apply { + text = if (mode == MODE_VIDEO) "F5 VIDEO" else "F2 FOTO" + setTextColor(if (mode == MODE_VIDEO) 0xFFFF3B30.toInt() else 0xFFFFC400.toInt()) + textSize = 15f + setTypeface(typeface, Typeface.BOLD) + } + + stateText = TextView(this).apply { + text = if (mode == MODE_VIDEO) "Preparando video..." else "Preparando cámara..." + setTextColor(Color.WHITE) + textSize = 13f + gravity = Gravity.END or Gravity.CENTER_VERTICAL + } + + bottomBar.addView( + physicalHintText, + LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + ) + bottomBar.addView( + stateText, + LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 2f) + ) + + root.addView( + bottomBar, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + dp(58), + Gravity.BOTTOM + ) + ) + + // Garantiza el orden Z por encima del PreviewView. + topBar.bringToFront() + recordingPanel.bringToFront() + closeButton.bringToFront() + captureButton.bringToFront() + bottomBar.bringToFront() + + setContentView(root) + } + + private fun performTouchAction() { + val now = SystemClock.elapsedRealtime() + if (now - lastTouchActionMs < 350) return + lastTouchActionMs = now + + if (mode == MODE_VIDEO) { + toggleVideoRecording() + } else { + takePhoto() + } + } + + private fun startCamera() { + val future = ProcessCameraProvider.getInstance(this) + future.addListener({ + try { + val provider = future.get() + cameraProvider = provider + + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + + provider.unbindAll() + + if (mode == MODE_VIDEO) { + val recorder = Recorder.Builder() + .setQualitySelector( + QualitySelector.from( + Quality.HD, + FallbackStrategy.lowerQualityOrHigherThan(Quality.SD) + ) + ) + .build() + + val video = VideoCapture.withOutput(recorder) + videoCapture = video + imageCapture = null + + provider.bindToLifecycle( + this, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + video + ) + + stateText.text = "Listo para grabar" + captureButton.isEnabled = true + } else { + val photo = ImageCapture.Builder() + .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) + .build() + + imageCapture = photo + videoCapture = null + + provider.bindToLifecycle( + this, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + photo + ) + + stateText.text = "Listo para capturar" + captureButton.isEnabled = true + } + } catch (t: Throwable) { + stateText.text = "Error de cámara" + captureButton.isEnabled = false + } + }, ContextCompat.getMainExecutor(this)) + } + + private fun takePhoto() { + val capture = imageCapture ?: run { + stateText.text = "Cámara no lista" + return + } + + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, "M530_" + timestamp()) + put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/M530") + } + } + + val output = ImageCapture.OutputFileOptions.Builder( + contentResolver, + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + values + ).build() + + stateText.text = "Capturando..." + + capture.takePicture( + output, + ContextCompat.getMainExecutor(this), + object : ImageCapture.OnImageSavedCallback { + override fun onImageSaved(result: ImageCapture.OutputFileResults) { + stateText.text = "Foto guardada" + flashCaptureFeedback() + + val savedUri = result.savedUri + + if (savedUri == null) { + Log.e( + "M530CameraActivity", + "PHOTO UPLOAD: savedUri es null" + ) + return + } + + try { + val tempDir = File( + cacheDir, + "photo_upload" + ).apply { + mkdirs() + } + + val tempFile = File( + tempDir, + "M530_" + timestamp() + ".jpg" + ) + + contentResolver.openInputStream(savedUri)?.use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } ?: throw IllegalStateException( + "No se pudo abrir la fotografia desde MediaStore" + ) + + Log.i( + "M530CameraActivity", + "PHOTO UPLOAD preparado: ${tempFile.name} bytes=${tempFile.length()}" + ) + + backend.uploadMedia( + tempFile, + "photo", + org.json.JSONObject() + .put( + "title", + "Fotografia M530 ${tempFile.name}" + ) + ) + + } catch (t: Throwable) { + Log.e( + "M530CameraActivity", + "PHOTO UPLOAD error", + t + ) + } +} + + override fun onError(exception: ImageCaptureException) { + stateText.text = "Error al guardar foto" + } + } + ) + } + + private fun toggleVideoRecording() { + if (activeRecording != null) { + activeRecording?.stop() + stateText.text = "Finalizando video..." + return + } + + val capture = videoCapture ?: run { + stateText.text = "Video no listo" + return + } + + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, "M530_" + timestamp()) + put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/M530") + } + } + + val output = MediaStoreOutputOptions.Builder( + contentResolver, + MediaStore.Video.Media.EXTERNAL_CONTENT_URI + ).setContentValues(values).build() + + var pending = capture.output.prepareRecording(this, output) + + if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + ) { + pending = pending.withAudioEnabled() + } + + activeRecording = pending.start(ContextCompat.getMainExecutor(this)) { event -> + when (event) { + is VideoRecordEvent.Start -> enterRecordingState() + is VideoRecordEvent.Finalize -> { + val hasError = event.hasError() + + exitRecordingState(hasError) + + if (!hasError) { + val videoUri = event.outputResults.outputUri + + if (videoUri == android.net.Uri.EMPTY) { + Log.e( + "M530CameraActivity", + "VIDEO UPLOAD: outputUri vacio" + ) + } else { + try { + val tempDir = File( + cacheDir, + "video_upload" + ).apply { + mkdirs() + } + + val tempFile = File( + tempDir, + "M530_" + timestamp() + ".mp4" + ) + + contentResolver.openInputStream(videoUri)?.use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } ?: throw IllegalStateException( + "No se pudo abrir el video desde MediaStore" + ) + + Log.i( + "M530CameraActivity", + "VIDEO UPLOAD preparado: ${tempFile.name} bytes=${tempFile.length()}" + ) + + backend.uploadMedia( + tempFile, + "video", + org.json.JSONObject() + .put( + "title", + "Video M530 ${tempFile.name}" + ) + ) + + } catch (t: Throwable) { + Log.e( + "M530CameraActivity", + "VIDEO UPLOAD error", + t + ) + } + } + } else { + Log.e( + "M530CameraActivity", + "VIDEO UPLOAD omitido: grabacion finalizada con error" + ) + } + } + } + } + } + + private fun enterRecordingState() { + stateText.text = "GRABANDO" + physicalHintText.text = "F5 DETENER" + recordDot.visibility = View.VISIBLE + + chronometer.base = SystemClock.elapsedRealtime() + chronometer.visibility = View.VISIBLE + chronometer.start() + + captureButton.text = "STOP" + captureButton.setTextColor(Color.WHITE) + captureButton.background = circleDrawable( + 0xE6C91515.toInt(), + Color.WHITE, + dp(4) + ) + } + + private fun exitRecordingState(error: Boolean) { + activeRecording = null + chronometer.stop() + chronometer.visibility = View.GONE + recordDot.visibility = View.GONE + + stateText.text = if (error) "Error de grabación" else "Video guardado" + physicalHintText.text = "F5 VIDEO" + + captureButton.text = "REC" + captureButton.setTextColor(Color.WHITE) + captureButton.background = circleDrawable( + 0xE6C91515.toInt(), + Color.WHITE, + dp(4) + ) + } + + private fun flashCaptureFeedback() { + val flash = View(this).apply { + setBackgroundColor(Color.WHITE) + alpha = 0.75f + isClickable = false + } + val root = findViewById(android.R.id.content) + root.addView( + flash, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + ) + flash.animate() + .alpha(0f) + .setDuration(180) + .withEndAction { root.removeView(flash) } + .start() + } + + private fun updateBatteryIndicator() { + try { + val manager = getSystemService(BATTERY_SERVICE) as android.os.BatteryManager + val level = manager.getIntProperty(android.os.BatteryManager.BATTERY_PROPERTY_CAPACITY) + batteryText.text = if (level in 0..100) "BAT ${level}%" else "BAT --%" + } catch (_: Throwable) { + batteryText.text = "BAT --%" + } + } + + private fun stopRecordingIfNeeded() { + activeRecording?.stop() + activeRecording = null + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + if ((event?.repeatCount ?: 0) > 0) return true + + val now = System.currentTimeMillis() + if (now - lastPhysicalActionMs < 450) return true + + if (mode == MODE_PHOTO && keyCode == KeyEvent.KEYCODE_F2) { + lastPhysicalActionMs = now + takePhoto() + return true + } + + if (mode == MODE_VIDEO && keyCode == KeyEvent.KEYCODE_F5) { + lastPhysicalActionMs = now + toggleVideoRecording() + return true + } + + return super.onKeyDown(keyCode, event) + } + + override fun onDestroy() { + stopRecordingIfNeeded() + cameraProvider?.unbindAll() + super.onDestroy() + } + + private fun circleDrawable(fill: Int, stroke: Int, strokeWidth: Int): android.graphics.drawable.GradientDrawable = + android.graphics.drawable.GradientDrawable().apply { + shape = android.graphics.drawable.GradientDrawable.OVAL + setColor(fill) + setStroke(strokeWidth, stroke) + } + + private fun dp(value: Int): Int = + (value * resources.displayMetrics.density).toInt() + + private fun timestamp(): String = + SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date()) +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthApiClient.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthApiClient.kt new file mode 100644 index 0000000..779b943 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthApiClient.kt @@ -0,0 +1,67 @@ +package com.bodycamera.twentyfoulabs.data.auth + +import com.bodycamera.twentyfoulabs.data.backend.BackendConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import java.net.HttpURLConnection +import java.net.URL +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AuthApiClient @Inject constructor() { + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + + suspend fun login(request: LoginRequest): LoginResponse = withContext(Dispatchers.IO) { + val body = json.encodeToString(request) + val response = request("POST", "/mobile/v1/auth/login", body = body) + json.decodeFromString(response) + } + + suspend fun me(token: String): MeResponse = withContext(Dispatchers.IO) { + val response = request("GET", "/mobile/v1/auth/me", token = token) + json.decodeFromString(response) + } + + suspend fun logout(token: String): LogoutResponse = withContext(Dispatchers.IO) { + val response = request("DELETE", "/mobile/v1/auth/logout", token = token) + json.decodeFromString(response) + } + + private fun request(method: String, path: String, token: String? = null, body: String? = null): String { + val url = URL(BackendConfig.API_BASE_URL.trimEnd('/') + path) + val connection = (url.openConnection() as HttpURLConnection).apply { + requestMethod = method + connectTimeout = 15_000 + readTimeout = 20_000 + setRequestProperty("Accept", "application/json") + setRequestProperty("Content-Type", "application/json") + setRequestProperty("ngrok-skip-browser-warning", "true") + setRequestProperty("X-Device-Serial", BackendConfig.DEVICE_SERIAL) + if (!token.isNullOrBlank()) setRequestProperty("Authorization", "Bearer $token") + if (body != null) doOutput = true + } + + try { + if (body != null) { + connection.outputStream.bufferedWriter(Charsets.UTF_8).use { it.write(body) } + } + val code = connection.responseCode + val stream = if (code in 200..299) connection.inputStream else connection.errorStream + val responseBody = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty() + if (code !in 200..299) { + val apiError = runCatching { json.decodeFromString(responseBody) }.getOrNull() + val message = apiError?.error ?: apiError?.message ?: "HTTP $code" + throw AuthApiException(code, message) + } + return responseBody + } finally { + connection.disconnect() + } + } +} + +class AuthApiException(val statusCode: Int, override val message: String) : Exception(message) diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthModels.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthModels.kt new file mode 100644 index 0000000..8f9c990 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthModels.kt @@ -0,0 +1,67 @@ +package com.bodycamera.twentyfoulabs.data.auth + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class LoginRequest( + val email: String, + val password: String, + @SerialName("device_serial") val deviceSerial: String? = null, + @SerialName("device_model") val deviceModel: String? = null +) + +@Serializable +data class AuthUser( + val id: Int? = null, + val email: String = "", + val name: String = "", + val role: String = "", + @SerialName("assigned_device") val assignedDevice: AuthDevice? = null +) + +@Serializable +data class AuthDevice( + val id: Int? = null, + @SerialName("serial_number") val serialNumber: String = "", + @SerialName("device_model") val deviceModel: String = "", + val status: String = "", + @SerialName("battery_percentage") val batteryPercentage: Int? = null, + @SerialName("signal_type") val signalType: String? = null, + @SerialName("signal_strength") val signalStrength: Int? = null, + @SerialName("storage_used_mb") val storageUsedMb: Long? = null, + @SerialName("storage_total_mb") val storageTotalMb: Long? = null, + @SerialName("storage_available_mb") val storageAvailableMb: Long? = null, + @SerialName("firmware_version") val firmwareVersion: String? = null, + @SerialName("last_telemetry_at") val lastTelemetryAt: String? = null +) + +@Serializable +data class LoginResponse( + val user: AuthUser, + val token: String, + val device: AuthDevice? = null, + @SerialName("server_time") val serverTime: String? = null +) + +@Serializable +data class MeResponse( + val user: AuthUser, + val device: AuthDevice? = null, + @SerialName("server_time") val serverTime: String? = null +) + +@Serializable +data class LogoutResponse( + val ok: Boolean = false, + val message: String? = null +) + +@Serializable +data class ApiError(val error: String? = null, val message: String? = null) + +data class AuthSession( + val token: String, + val user: AuthUser, + val device: AuthDevice? = null +) diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthRepository.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthRepository.kt new file mode 100644 index 0000000..0ca3874 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthRepository.kt @@ -0,0 +1,50 @@ +package com.bodycamera.twentyfoulabs.data.auth + +import com.bodycamera.twentyfoulabs.data.backend.BackendConfig +import kotlinx.coroutines.flow.first +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AuthRepository @Inject constructor( + private val api: AuthApiClient, + private val store: AuthSessionStore +) { + suspend fun login(email: String, password: String): AuthSession { + val response = api.login( + LoginRequest( + email = email.trim(), + password = password, + deviceSerial = BackendConfig.DEVICE_SERIAL, + deviceModel = BackendConfig.DEVICE_MODEL + ) + ) + val session = AuthSession( + token = response.token, + user = response.user, + device = response.device ?: response.user.assignedDevice + ) + store.save(session) + return session + } + + suspend fun restore(): AuthSession? { + val token = store.token.first()?.takeIf { it.isNotBlank() } ?: return null + return try { + val response = api.me(token) + AuthSession(token, response.user, response.device ?: response.user.assignedDevice) + } catch (e: AuthApiException) { + if (e.statusCode == 401) store.clear() + throw e + } + } + + suspend fun logout() { + val token = store.token.first() + try { + if (!token.isNullOrBlank()) api.logout(token) + } finally { + store.clear() + } + } +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthSessionStore.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthSessionStore.kt new file mode 100644 index 0000000..e78948e --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthSessionStore.kt @@ -0,0 +1,43 @@ +package com.bodycamera.twentyfoulabs.data.auth + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.authDataStore by preferencesDataStore(name = "auth_session") + +@Singleton +class AuthSessionStore @Inject constructor( + @ApplicationContext private val context: Context +) { + private object Keys { + val token = stringPreferencesKey("jwt_token") + val userName = stringPreferencesKey("user_name") + val userEmail = stringPreferencesKey("user_email") + val userRole = stringPreferencesKey("user_role") + val deviceSerial = stringPreferencesKey("device_serial") + } + + val token: Flow = context.authDataStore.data.map { it[Keys.token] } + + suspend fun save(session: AuthSession) { + context.authDataStore.edit { prefs -> + prefs[Keys.token] = session.token + prefs[Keys.userName] = session.user.name + prefs[Keys.userEmail] = session.user.email + prefs[Keys.userRole] = session.user.role + val serial = session.device?.serialNumber ?: session.user.assignedDevice?.serialNumber + if (!serial.isNullOrBlank()) prefs[Keys.deviceSerial] = serial + } + } + + suspend fun clear() { + context.authDataStore.edit { it.clear() } + } +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/BackendConfig.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/BackendConfig.kt new file mode 100644 index 0000000..cdd92b3 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/BackendConfig.kt @@ -0,0 +1,19 @@ +package com.bodycamera.twentyfoulabs.data.backend + +/** Centralized endpoints for the M530 PoC. */ +object BackendConfig { + // Local LAN services used by MQTT and MediaMTX during the current PoC. + const val HOST = "192.168.18.57" + const val MQTT_PORT = 1883 + const val HTTP_PORT = 8000 + const val MQTT_CLIENT_PREFIX = "m530" + const val STREAM_ID = "webrtc_camera_stream" + + // Rails mobile API exposed through ngrok HTTPS. + const val API_BASE_URL = "https://0eed-2001-1388-7829-61d2-9c55-8104-5005-d3be.ngrok-free.app" + const val DEVICE_SERIAL = "BC-M530-001" + const val DEVICE_MODEL = "Recoda M530" + + val httpBaseUrl: String get() = "http://$HOST:$HTTP_PORT" + val whipUrl: String get() = "http://$HOST:8889/$STREAM_ID/whip" +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/BackendIntegrationManager.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/BackendIntegrationManager.kt new file mode 100644 index 0000000..99fa55c --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/BackendIntegrationManager.kt @@ -0,0 +1,629 @@ +package com.bodycamera.twentyfoulabs.data.backend + +import android.content.Context +import android.os.Build +import android.util.Log +import com.bodycamera.twentyfoulabs.data.auth.AuthSessionStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.json.JSONObject +import java.io.BufferedReader +import java.io.File +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.URL +import java.util.UUID +import java.util.concurrent.Executors +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class BackendIntegrationManager @Inject constructor( + @ApplicationContext private val context: Context, + private val authSessionStore: AuthSessionStore +) { + companion object { private const val TAG = "BackendIntegration" } + + private val mqttExecutor = Executors.newSingleThreadExecutor() + private val httpExecutor = Executors.newSingleThreadExecutor() + private val mqtt = SimpleMqttPublisher(BackendConfig.HOST, BackendConfig.MQTT_PORT) + private val deviceId: String by lazy { sanitize(runCatching { Build.getSerial() }.getOrNull() ?: Build.MODEL ?: "M530") } + private val clientId: String get() = "${BackendConfig.MQTT_CLIENT_PREFIX}-$deviceId-${UUID.randomUUID().toString().take(6)}" + private val pendingFile by lazy { File(context.filesDir, "mqtt_pending.jsonl") } + + @Volatile + private var lastMediaItemId: Int? = null + private val mediaStatePrefs by lazy { + context.getSharedPreferences( + "m530_media_state", + Context.MODE_PRIVATE + ) + } + + private fun rememberMediaItemId(id: Int) { + lastMediaItemId = id + + mediaStatePrefs + .edit() + .putInt("last_media_item_id", id) + .apply() + + Log.i( + TAG, + "FAVORITE media_item persistido: id=$id" + ) + } + + private fun currentMediaItemId(): Int? { + + lastMediaItemId?.let { + return it + } + + val persistedId = + mediaStatePrefs.getInt( + "last_media_item_id", + -1 + ) + + return persistedId + .takeIf { it > 0 } + ?.also { + lastMediaItemId = it + + Log.i( + TAG, + "FAVORITE media_item recuperado: id=$it" + ) + } + } + + fun publish(type: String, payload: JSONObject = JSONObject()) { + val envelope = JSONObject() + .put("eventId", UUID.randomUUID().toString()) + .put("deviceId", deviceId) + .put("type", type) + .put("timestamp", System.currentTimeMillis()) + .put("payload", payload) + val topic = "m530/$deviceId/events/${topicSafe(type)}" + enqueuePublish(topic, envelope) + } + + fun publishTelemetry(payload: JSONObject) { + val envelope = JSONObject() + .put("deviceId", deviceId) + .put("timestamp", System.currentTimeMillis()) + .put("payload", payload) + enqueuePublish("m530/$deviceId/telemetry", envelope) + } + + fun uploadMedia(file: File, kind: String, metadata: JSONObject = JSONObject()) { + if (!file.exists() || !file.isFile) return + httpExecutor.execute { + try { + val response = multipartUpload(file, kind, metadata) + + val uploadedMediaId = + response + .optJSONObject("media_item") + ?.optInt("id", -1) + ?.takeIf { it > 0 } + + if (uploadedMediaId != null) { + rememberMediaItemId(uploadedMediaId) + Log.i( + TAG, + "FAVORITE ultimo media_item guardado: id=$uploadedMediaId" + ) + } + val mediaPayload = JSONObject() + .put("kind", kind) + .put("fileName", file.name) + .put("sizeBytes", file.length()) + .put("localPath", file.absolutePath) + .put("server", response) + val event = JSONObject() + .put("eventId", UUID.randomUUID().toString()) + .put("deviceId", deviceId) + .put("type", "MEDIA_UPLOADED") + .put("timestamp", System.currentTimeMillis()) + .put("payload", mediaPayload) + publishNow("m530/$deviceId/media/$kind", event) + } catch (t: Throwable) { + Log.e(TAG, "Upload failed: ${file.name}", t) + publishNow("m530/$deviceId/events/media_upload_failed", JSONObject() + .put("deviceId", deviceId).put("timestamp", System.currentTimeMillis()) + .put("fileName", file.name).put("kind", kind).put("error", t.message ?: "unknown")) + } + } + } + + fun bookmarkLastMedia(callback: (Result) -> Unit) { + + val mediaId = currentMediaItemId() + + if (mediaId == null) { + callback( + Result.failure( + IllegalStateException("No hay evidencia para marcar") + ) + ) + return + } + + httpExecutor.execute { + + var conn: HttpURLConnection? = null + + try { + + val token = runBlocking { + authSessionStore.token.first() + }?.takeIf { it.isNotBlank() } + ?: error("No hay sesion JWT activa") + + val url = URL( + BackendConfig.API_BASE_URL.trimEnd('/') + + "/mobile/v1/media_items/$mediaId/bookmark" + ) + + Log.i( + TAG, + "FAVORITE PATCH iniciando: media_item=$mediaId url=$url" + ) + + conn = + (url.openConnection() as HttpURLConnection).apply { + + requestMethod = "PATCH" + + connectTimeout = 15_000 + readTimeout = 30_000 + + doOutput = true + + setRequestProperty( + "Content-Type", + "application/json" + ) + + setRequestProperty( + "Accept", + "application/json" + ) + + setRequestProperty( + "Authorization", + "Bearer $token" + ) + + setRequestProperty( + "X-Device-Serial", + BackendConfig.DEVICE_SERIAL + ) + + setRequestProperty( + "ngrok-skip-browser-warning", + "true" + ) + } + + val body = + JSONObject() + .put("is_bookmarked", true) + .toString() + + conn.outputStream.use { output -> + output.write( + body.toByteArray(Charsets.UTF_8) + ) + } + + val code = conn.responseCode + + val stream = + if (code in 200..299) { + conn.inputStream + } else { + conn.errorStream + } + + val responseText = + stream + ?.bufferedReader(Charsets.UTF_8) + ?.use { it.readText() } + .orEmpty() + + Log.i( + TAG, + "FAVORITE HTTP $code media_item=$mediaId response=$responseText" + ) + + if (code !in 200..299) { + error( + "HTTP $code $responseText" + ) + } + + publishNow( + "m530/$deviceId/events/media_bookmarked", + JSONObject() + .put("deviceId", deviceId) + .put( + "timestamp", + System.currentTimeMillis() + ) + .put( + "mediaItemId", + mediaId + ) + .put( + "isBookmarked", + true + ) + ) + + callback( + Result.success(mediaId) + ) + + } catch (t: Throwable) { + + Log.e( + TAG, + "FAVORITE error media_item=$mediaId", + t + ) + + callback( + Result.failure(t) + ) + + } finally { + + conn?.disconnect() + } + } + } + + fun shutdown() { + mqttExecutor.shutdownNow() + httpExecutor.shutdownNow() + } + + private fun enqueuePublish(topic: String, payload: JSONObject) { + mqttExecutor.execute { + flushPending() + if (!publishNow(topic, payload)) { + pendingFile.appendText(JSONObject().put("topic", topic).put("payload", payload).toString() + "\n") + } + } + } + + private fun publishNow(topic: String, payload: JSONObject): Boolean { + val ok = mqtt.publish(clientId, topic, payload.toString()) + Log.i(TAG, "MQTT ${if (ok) "OK" else "FAIL"} $topic ${payload.optString("type")}") + return ok + } + + private fun flushPending() { + if (!pendingFile.exists()) return + val lines = runCatching { pendingFile.readLines() }.getOrDefault(emptyList()) + if (lines.isEmpty()) return + val remaining = mutableListOf() + for (line in lines.take(100)) { + val obj = runCatching { JSONObject(line) }.getOrNull() ?: continue + val topic = obj.optString("topic") + val payload = obj.optJSONObject("payload") ?: JSONObject() + if (!publishNow(topic, payload)) remaining += line + } + if (lines.size > 100) remaining += lines.drop(100) + pendingFile.writeText(if (remaining.isEmpty()) "" else remaining.joinToString("\n", postfix = "\n")) + } + + private fun multipartUpload( + file: File, + kind: String, + metadata: JSONObject + ): JSONObject { + + // ---------------------------------------------------- + // Obtener JWT de la sesion ya iniciada + // ---------------------------------------------------- + + val token = runBlocking { + authSessionStore.token.first() + }?.takeIf { it.isNotBlank() } + ?: error("No hay sesion JWT activa") + + // ---------------------------------------------------- + // Convertir tipo interno al contrato del backend + // ---------------------------------------------------- + + val mediaType = when (kind.lowercase()) { + "photo", "foto" -> "photo" + "video" -> "video" + "audio", "mic", "ptt" -> "audio" + + else -> error( + "Tipo multimedia no soportado: $kind" + ) + } + + // ---------------------------------------------------- + // Endpoint Rails + // ---------------------------------------------------- + + val boundary = + "----M530${System.currentTimeMillis()}" + + val url = URL( + BackendConfig.API_BASE_URL.trimEnd('/') + + "/mobile/v1/media_items" + ) + + Log.i( + TAG, + "MEDIA UPLOAD iniciando: ${file.name} type=$mediaType url=$url" + ) + + val conn = + (url.openConnection() as HttpURLConnection).apply { + + requestMethod = "POST" + + connectTimeout = 15_000 + readTimeout = 60_000 + + doOutput = true + + setRequestProperty( + "Content-Type", + "multipart/form-data; boundary=$boundary" + ) + + setRequestProperty( + "Accept", + "application/json" + ) + + setRequestProperty( + "Authorization", + "Bearer $token" + ) + + setRequestProperty( + "X-Device-Serial", + BackendConfig.DEVICE_SERIAL + ) + + setRequestProperty( + "ngrok-skip-browser-warning", + "true" + ) + } + + try { + + conn.outputStream.use { out -> + + // -------------------------------------------- + // Funcion para campos de texto multipart + // -------------------------------------------- + + fun text( + name: String, + value: String + ) { + + out.write( + "--$boundary\r\n".toByteArray() + ) + + out.write( + ( + "Content-Disposition: form-data; " + + "name=\"$name\"\r\n\r\n" + ).toByteArray() + ) + + out.write( + value.toByteArray(Charsets.UTF_8) + ) + + out.write( + "\r\n".toByteArray() + ) + } + + // -------------------------------------------- + // Campo obligatorio media_type + // -------------------------------------------- + + text( + "media_type", + mediaType + ) + + // -------------------------------------------- + // Campos opcionales + // -------------------------------------------- + + metadata + .optString("title") + .takeIf { it.isNotBlank() } + ?.let { + text("title", it) + } + + if (metadata.has("incident_id")) { + text( + "incident_id", + metadata.optString("incident_id") + ) + } + + if (metadata.has("latitude")) { + text( + "latitude", + metadata.optString("latitude") + ) + } + + if (metadata.has("longitude")) { + text( + "longitude", + metadata.optString("longitude") + ) + } + + if (metadata.has("duration_seconds")) { + text( + "duration_seconds", + metadata.optString( + "duration_seconds" + ) + ) + } + + if (metadata.has("is_bookmarked")) { + text( + "is_bookmarked", + metadata.optBoolean( + "is_bookmarked" + ).toString() + ) + } + + metadata + .optString("started_at") + .takeIf { it.isNotBlank() } + ?.let { + text("started_at", it) + } + + metadata + .optString("ended_at") + .takeIf { it.isNotBlank() } + ?.let { + text("ended_at", it) + } + + // -------------------------------------------- + // Determinar MIME type + // -------------------------------------------- + + val mimeType = + when (file.extension.lowercase()) { + + "jpg", + "jpeg" -> + "image/jpeg" + + "png" -> + "image/png" + + "mp4" -> + "video/mp4" + + "m4a" -> + "audio/mp4" + + "aac" -> + "audio/aac" + + else -> + "application/octet-stream" + } + + // -------------------------------------------- + // Campo obligatorio file + // -------------------------------------------- + + out.write( + "--$boundary\r\n".toByteArray() + ) + + out.write( + ( + "Content-Disposition: form-data; " + + "name=\"file\"; " + + "filename=\"${file.name}\"\r\n" + ).toByteArray() + ) + + out.write( + "Content-Type: $mimeType\r\n\r\n" + .toByteArray() + ) + + file.inputStream().use { input -> + input.copyTo(out) + } + + out.write( + "\r\n--$boundary--\r\n" + .toByteArray() + ) + } + + // ------------------------------------------------ + // Leer respuesta Rails + // ------------------------------------------------ + + val code = + conn.responseCode + + val stream = + if (code in 200..299) { + conn.inputStream + } else { + conn.errorStream + } + + val responseText = + stream + ?.bufferedReader(Charsets.UTF_8) + ?.use { + it.readText() + } + .orEmpty() + + Log.i( + TAG, + "MEDIA HTTP $code " + + "${file.name} " + + "type=$mediaType " + + "response=$responseText" + ) + + if (code !in 200..299) { + + error( + "HTTP $code $responseText" + ) + } + + return runCatching { + + JSONObject(responseText) + + }.getOrElse { + + JSONObject() + .put( + "raw", + responseText + ) + } + + } finally { + + conn.disconnect() + } + } + private fun sanitize(value: String) = value.replace(Regex("[^A-Za-z0-9_-]"), "_") + private fun topicSafe(value: String) = value.lowercase().replace(Regex("[^a-z0-9_-]"), "_") +} + diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/SimpleMqttPublisher.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/SimpleMqttPublisher.kt new file mode 100644 index 0000000..de22e23 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/SimpleMqttPublisher.kt @@ -0,0 +1,76 @@ +package com.bodycamera.twentyfoulabs.data.backend + +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.net.InetSocketAddress +import java.net.Socket +import java.nio.charset.StandardCharsets + +/** Minimal MQTT 3.1.1 publisher for the PoC. QoS 0, clean session. */ +class SimpleMqttPublisher( + private val host: String, + private val port: Int, + private val connectTimeoutMs: Int = 3500 +) { + fun publish(clientId: String, topic: String, payload: String): Boolean { + val socket = Socket() + return try { + socket.connect(InetSocketAddress(host, port), connectTimeoutMs) + socket.soTimeout = 3500 + val out = BufferedOutputStream(socket.getOutputStream()) + val input = BufferedInputStream(socket.getInputStream()) + + out.write(connectPacket(clientId)); out.flush() + val connack = ByteArray(4) + var read = 0 + while (read < 4) { + val n = input.read(connack, read, 4 - read) + if (n < 0) return false + read += n + } + if (connack[0].toInt() != 0x20 || connack[3].toInt() != 0x00) return false + + out.write(publishPacket(topic, payload)); out.flush() + out.write(byteArrayOf(0xE0.toByte(), 0x00)); out.flush() + true + } catch (_: Throwable) { + false + } finally { + runCatching { socket.close() } + } + } + + private fun connectPacket(clientId: String): ByteArray { + val variableHeader = mutableListOf() + variableHeader += utf8("MQTT").toList() + variableHeader += 0x04.toByte() // MQTT 3.1.1 + variableHeader += 0x02.toByte() // clean session + variableHeader += byteArrayOf(0x00, 0x3C).toList() // keepalive 60s + val payload = utf8(clientId) + val remaining = variableHeader.size + payload.size + return byteArrayOf(0x10) + encodeRemainingLength(remaining) + variableHeader.toByteArray() + payload + } + + private fun publishPacket(topic: String, payload: String): ByteArray { + val topicBytes = utf8(topic) + val body = topicBytes + payload.toByteArray(StandardCharsets.UTF_8) + return byteArrayOf(0x30) + encodeRemainingLength(body.size) + body + } + + private fun utf8(value: String): ByteArray { + val b = value.toByteArray(StandardCharsets.UTF_8) + return byteArrayOf(((b.size shr 8) and 0xFF).toByte(), (b.size and 0xFF).toByte()) + b + } + + private fun encodeRemainingLength(value: Int): ByteArray { + var x = value + val bytes = mutableListOf() + do { + var digit = x % 128 + x /= 128 + if (x > 0) digit = digit or 0x80 + bytes += digit.toByte() + } while (x > 0) + return bytes.toByteArray() + } +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/device/M530DeviceController.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/device/M530DeviceController.kt new file mode 100644 index 0000000..e8b07eb --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/device/M530DeviceController.kt @@ -0,0 +1,392 @@ +package com.bodycamera.twentyfoulabs.data.device + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationManager +import android.media.MediaRecorder +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.BatteryManager +import android.os.Build +import android.os.Environment +import android.util.Log +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +import dagger.hilt.android.qualifiers.ApplicationContext +import com.bodycamera.twentyfoulabs.data.backend.BackendIntegrationManager +import org.json.JSONObject +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.Executor +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class M530DeviceController @Inject constructor( + @ApplicationContext private val context: Context, + private val backend: BackendIntegrationManager +) { + companion object { private const val TAG = "M530DeviceController" } + + data class SosResult(val file: File, val location: Location?) + data class TelemetrySnapshot( + val batteryPercent: Int, + val charging: Boolean, + val network: String, + val connected: Boolean, + val freeStorageMb: Long, + val timestamp: Long = System.currentTimeMillis() + ) + data class DeviceInfo( + val model: String, + val manufacturer: String, + val androidVersion: String, + val sdk: Int, + val serial: String + ) + data class EvidenceSummary(val photos: Int, val pttClips: Int, val eventLines: Int) + data class EventEntry(val type: String, val recordedAt: Long, val detail: String) + + private var mediaRecorder: MediaRecorder? = null + private var pttFile: File? = null + private var micRecorder: MediaRecorder? = null + private var micFile: File? = null + private val ioExecutor: Executor get() = ContextCompat.getMainExecutor(context) + + fun deviceInfo(): DeviceInfo = DeviceInfo( + model = Build.MODEL ?: "M530", + manufacturer = Build.MANUFACTURER ?: "Android", + androidVersion = Build.VERSION.RELEASE ?: "12", + sdk = Build.VERSION.SDK_INT, + serial = runCatching { Build.getSerial() }.getOrDefault("M530") + ) + + fun capturePhoto(lifecycleOwner: LifecycleOwner, callback: (Result) -> Unit) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { + callback(Result.failure(SecurityException("Permiso CAMERA no concedido"))); return + } + val outputDir = File(context.getExternalFilesDir(Environment.DIRECTORY_PICTURES), "evidence").apply { mkdirs() } + val outputFile = File(outputDir, "IMG_${timestamp()}.jpg") + val future = ProcessCameraProvider.getInstance(context) + future.addListener({ + try { + val provider = future.get(); provider.unbindAll() + val capture = ImageCapture.Builder().setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY).build() + provider.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, capture) + capture.takePicture(ImageCapture.OutputFileOptions.Builder(outputFile).build(), ioExecutor, + object : ImageCapture.OnImageSavedCallback { + override fun onImageSaved(r: ImageCapture.OutputFileResults) { + logEvent("PHOTO_CAPTURED", JSONObject().put("path", outputFile.absolutePath)); backend.publish("PHOTO_CAPTURED", JSONObject().put("fileName", outputFile.name).put("sizeBytes", outputFile.length())); backend.uploadMedia(outputFile, "photo"); provider.unbindAll(); callback(Result.success(outputFile)) + } + override fun onError(e: ImageCaptureException) { + logEvent("PHOTO_ERROR", JSONObject().put("error", e.message ?: "unknown")); provider.unbindAll(); callback(Result.failure(e)) + } + }) + } catch (t: Throwable) { + logEvent("PHOTO_ERROR", JSONObject().put("error", t.message ?: t.javaClass.simpleName)); callback(Result.failure(t)) + } + }, ioExecutor) + } + + @Synchronized fun startPtt(): Result { + if (micRecorder != null) return Result.failure(IllegalStateException("MIC activo")) + if (mediaRecorder != null) return Result.success(pttFile ?: File("")) + if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) + return Result.failure(SecurityException("Permiso RECORD_AUDIO no concedido")) + return try { + val dir = File(context.getExternalFilesDir(Environment.DIRECTORY_MUSIC), "ptt").apply { mkdirs() } + val file = File(dir, "PTT_${timestamp()}.m4a") + @Suppress("DEPRECATION") val recorder = MediaRecorder() + recorder.setAudioSource(MediaRecorder.AudioSource.MIC); recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); recorder.setAudioEncodingBitRate(64000); recorder.setAudioSamplingRate(16000) + recorder.setOutputFile(file.absolutePath); recorder.prepare(); recorder.start() + mediaRecorder = recorder; pttFile = file + logEvent("PTT_DOWN", JSONObject().put("path", file.absolutePath)); backend.publish("PTT_DOWN", JSONObject().put("fileName", file.name)); Result.success(file) + } catch (t: Throwable) { + try { mediaRecorder?.release() } catch (_: Throwable) {}; mediaRecorder = null; pttFile = null + logEvent("PTT_ERROR", JSONObject().put("error", t.message ?: t.javaClass.simpleName)); Result.failure(t) + } + } + + @Synchronized fun stopPtt(): Result { + val recorder = mediaRecorder ?: return Result.success(pttFile) + val file = pttFile + return try { + recorder.stop(); recorder.release(); mediaRecorder = null; pttFile = null + logEvent("PTT_UP", JSONObject().put("path", file?.absolutePath ?: "")); backend.publish("PTT_UP", JSONObject().put("fileName", file?.name ?: "")); if (file != null && file.exists()) backend.uploadMedia(file, "ptt"); Result.success(file) + } catch (t: Throwable) { + try { recorder.release() } catch (_: Throwable) {}; mediaRecorder = null; pttFile = null + logEvent("PTT_ERROR", JSONObject().put("error", t.message ?: t.javaClass.simpleName)); Result.failure(t) + } + } + + @Synchronized + fun startMicRecording(): Result { + if (micRecorder != null) { + return Result.success(micFile ?: File("")) + } + + if (mediaRecorder != null) { + return Result.failure( + IllegalStateException("PTT activo") + ) + } + + if ( + ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO + ) != PackageManager.PERMISSION_GRANTED + ) { + return Result.failure( + SecurityException("Permiso RECORD_AUDIO no concedido") + ) + } + + var recorder: MediaRecorder? = null + + return try { + val dir = File( + context.getExternalFilesDir(Environment.DIRECTORY_MUSIC), + "audio" + ).apply { + mkdirs() + } + + val file = File( + dir, + "MIC_${timestamp()}.m4a" + ) + + @Suppress("DEPRECATION") + val newRecorder = MediaRecorder() + recorder = newRecorder + + newRecorder.setAudioSource(MediaRecorder.AudioSource.MIC) + newRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + newRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + newRecorder.setAudioEncodingBitRate(64000) + newRecorder.setAudioSamplingRate(16000) + newRecorder.setOutputFile(file.absolutePath) + + newRecorder.prepare() + newRecorder.start() + + micRecorder = newRecorder + micFile = file + + logEvent( + "MIC_START", + JSONObject().put("path", file.absolutePath) + ) + + backend.publish( + "MIC_START", + JSONObject().put("fileName", file.name) + ) + + Result.success(file) + + } catch (t: Throwable) { + try { + recorder?.release() + } catch (_: Throwable) { + } + + micRecorder = null + micFile = null + + logEvent( + "MIC_ERROR", + JSONObject().put( + "error", + t.message ?: t.javaClass.simpleName + ) + ) + + Result.failure(t) + } + } + + @Synchronized + fun stopMicRecording(): Result { + val recorder = micRecorder + ?: return Result.success(micFile) + + val file = micFile + + return try { + recorder.stop() + recorder.release() + + micRecorder = null + micFile = null + + logEvent( + "MIC_STOP", + JSONObject().put( + "path", + file?.absolutePath ?: "" + ) + ) + + backend.publish( + "MIC_STOP", + JSONObject().put( + "fileName", + file?.name ?: "" + ) + ) + + // Subir evidencia de audio al backend Rails. + // El archivo local se conserva aunque la subida falle. + if (file != null && file.exists()) { + backend.uploadMedia( + file, + "audio", + JSONObject() + .put("title", "Audio MIC ${file.name}") + ) + } +Result.success(file) + + } catch (t: Throwable) { + try { + recorder.release() + } catch (_: Throwable) { + } + + micRecorder = null + micFile = null + + logEvent( + "MIC_ERROR", + JSONObject().put( + "error", + t.message ?: t.javaClass.simpleName + ) + ) + + Result.failure(t) + } + } + + fun isMicRecording(): Boolean = + micRecorder != null + fun triggerSos(): SosResult { + val location = bestLastKnownLocation() + val payload = JSONObject().put("type", "SOS").put("timestamp", System.currentTimeMillis()) + .put("model", Build.MODEL).put("android", Build.VERSION.RELEASE) + .put("latitude", location?.latitude).put("longitude", location?.longitude).put("accuracy", location?.accuracy) + backend.publish("SOS", payload); return SosResult(logEvent("SOS", payload), location) + } + + fun markEvidence( + label: String, + callback: (Result) -> Unit + ) { + val payload = + JSONObject() + .put("label", label) + .put( + "timestamp", + System.currentTimeMillis() + ) + + logEvent( + "MARKER", + payload + ) + + backend.bookmarkLastMedia(callback) + } + + fun telemetry(): TelemetrySnapshot { + val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + val level = batteryIntent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1 + val scale = batteryIntent?.getIntExtra(BatteryManager.EXTRA_SCALE, 100) ?: 100 + val batteryPercent = if (level >= 0 && scale > 0) ((level * 100f) / scale).toInt() else -1 + val status = batteryIntent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1 + val charging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) } + val connected = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true + val networkName = when { + caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true -> "Wi-Fi" + caps?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true -> "4G/LTE" + caps?.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) == true -> "Ethernet" + else -> "Sin red" + } + val freeMb = context.filesDir.usableSpace / (1024L * 1024L) + return TelemetrySnapshot(batteryPercent, charging, networkName, connected, freeMb) + } + + fun evidenceSummary(): EvidenceSummary { + val photoDir = File(context.getExternalFilesDir(Environment.DIRECTORY_PICTURES), "evidence") + val pttDir = File(context.getExternalFilesDir(Environment.DIRECTORY_MUSIC), "ptt") + val eventFile = eventFile() + val lines = if (eventFile.exists()) runCatching { eventFile.useLines { it.count() } }.getOrDefault(0) else 0 + return EvidenceSummary(photoDir.listFiles()?.count { it.isFile } ?: 0, pttDir.listFiles()?.count { it.isFile } ?: 0, lines) + } + + fun recentEvents(limit: Int = 8): List { + val file = eventFile(); if (!file.exists()) return emptyList() + return runCatching { + file.readLines().takeLast(limit).reversed().mapNotNull { line -> + runCatching { + val o = JSONObject(line); val payload = o.optJSONObject("payload") + EventEntry(o.optString("event", "EVENT"), o.optLong("recordedAt", 0L), summarizePayload(payload)) + }.getOrNull() + } + }.getOrDefault(emptyList()) + } + + fun logTelemetry(): File { + val t = telemetry() + val p = JSONObject().put("batteryPercent", t.batteryPercent).put("charging", t.charging) + .put("network", t.network).put("connected", t.connected).put("freeStorageMb", t.freeStorageMb).put("timestamp", t.timestamp); backend.publishTelemetry(p); return logEvent("TELEMETRY", p) + } + + fun notifyVideoStarted(endpoint: String) { backend.publish("VIDEO_STREAM_STARTED", JSONObject().put("whipUrl", endpoint).put("streamId", com.bodycamera.twentyfoulabs.data.backend.BackendConfig.STREAM_ID)) } + fun notifyVideoStopped() { backend.publish("VIDEO_STREAM_STOPPED", JSONObject().put("streamId", com.bodycamera.twentyfoulabs.data.backend.BackendConfig.STREAM_ID)) } + fun notifyVideoError(error: String) { backend.publish("VIDEO_STREAM_ERROR", JSONObject().put("error", error).put("streamId", com.bodycamera.twentyfoulabs.data.backend.BackendConfig.STREAM_ID)) } + + fun release() { try { mediaRecorder?.stop() } catch (_: Throwable) {}; try { mediaRecorder?.release() } catch (_: Throwable) {}; mediaRecorder = null; pttFile = null; try { micRecorder?.stop() } catch (_: Throwable) {}; try { micRecorder?.release() } catch (_: Throwable) {}; micRecorder = null; micFile = null } + + private fun bestLastKnownLocation(): Location? { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) return null + val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + return runCatching { lm.getProviders(true).mapNotNull { p -> runCatching { lm.getLastKnownLocation(p) }.getOrNull() }.maxByOrNull { it.time } }.getOrNull() + } + + private fun summarizePayload(payload: JSONObject?): String { + if (payload == null) return "" + return when { + payload.has("label") -> payload.optString("label") + payload.has("path") -> File(payload.optString("path")).name + payload.has("error") -> payload.optString("error") + payload.has("network") -> "${payload.optString("network")} · ${payload.optInt("batteryPercent", -1)}%" + payload.has("latitude") -> "GPS ${payload.optDouble("latitude", 0.0)}, ${payload.optDouble("longitude", 0.0)}" + else -> "" + } + } + + private fun eventFile(): File = File(File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "m530_events").apply { mkdirs() }, "events.jsonl") + private fun logEvent(type: String, payload: JSONObject): File { + val file = eventFile(); file.appendText(JSONObject().put("event", type).put("recordedAt", System.currentTimeMillis()).put("payload", payload).toString() + "\n") + Log.i(TAG, "$type: $payload"); return file + } + private fun timestamp(): String = SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date()) +} + diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/AdminLockConfig.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/AdminLockConfig.kt new file mode 100644 index 0000000..2864275 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/AdminLockConfig.kt @@ -0,0 +1,25 @@ +package com.bodycamera.twentyfoulabs.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class AdminLockConfig( + val isLocked: Boolean = true, + val pinHash: String = "" +) { + companion object { + const val DEFAULT_PIN = "000000" + } +} + +fun hashPin(pin: String): String { + val salt = "bodycam_sec_salt_2024" + val bytes = (pin + salt).toByteArray() + val md = java.security.MessageDigest.getInstance("SHA-256") + val digest = md.digest(bytes) + return digest.joinToString("") { "%02x".format(it) } +} + +fun isValidPin(pin: String): Boolean { + return pin.matches(Regex("^\\d{6}$")) +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/DnsConfig.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/DnsConfig.kt new file mode 100644 index 0000000..1d0d5ff --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/DnsConfig.kt @@ -0,0 +1,10 @@ +package com.bodycamera.twentyfoulabs.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class DnsConfig( + val dns1: String = "8.8.8.8", + val dns2: String = "8.8.4.4", + val useCustomDns: Boolean = false +) diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/MobileDataConfig.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/MobileDataConfig.kt new file mode 100644 index 0000000..7c59254 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/MobileDataConfig.kt @@ -0,0 +1,11 @@ +package com.bodycamera.twentyfoulabs.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class MobileDataConfig( + val enabled: Boolean = true, + val apn: String? = null, + val userName: String? = null, + val password: String? = null +) diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/NetworkConfiguration.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/NetworkConfiguration.kt new file mode 100644 index 0000000..19ba4c4 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/NetworkConfiguration.kt @@ -0,0 +1,14 @@ +package com.bodycamera.twentyfoulabs.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class NetworkConfiguration( + val version: Int = 1, + val wifi: WifiConfig? = null, + val mobileData: MobileDataConfig = MobileDataConfig(), + val proxy: ProxyConfig = ProxyConfig(), + val dns: DnsConfig = DnsConfig(), + val networkPriority: NetworkPriority = NetworkPriority.WIFI_FIRST, + val lastUpdated: String = System.currentTimeMillis().toString() +) diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/NetworkPriority.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/NetworkPriority.kt new file mode 100644 index 0000000..3f15005 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/NetworkPriority.kt @@ -0,0 +1,11 @@ +package com.bodycamera.twentyfoulabs.data.model + +import kotlinx.serialization.Serializable + +@Serializable +enum class NetworkPriority { + WIFI_FIRST, // Prioriza WiFi, usa móvil como fallback + MOBILE_FIRST, // Prioriza datos móviles + WIFI_ONLY, // Solo WiFi + MOBILE_ONLY // Solo datos móviles +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/ProxyConfig.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/ProxyConfig.kt new file mode 100644 index 0000000..2c23781 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/ProxyConfig.kt @@ -0,0 +1,11 @@ +package com.bodycamera.twentyfoulabs.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class ProxyConfig( + val enabled: Boolean = false, + val host: String? = null, + val port: Int? = null, + val bypassHosts: List = emptyList() +) diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/VideoConfiguration.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/VideoConfiguration.kt new file mode 100644 index 0000000..9e81870 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/VideoConfiguration.kt @@ -0,0 +1,84 @@ +package com.bodycamera.twentyfoulabs.data.model + +import kotlinx.serialization.Serializable + +/** + * Configuración completa para streaming de video WebRTC + */ +@Serializable +data class VideoConfiguration( + val version: Int = 1, + val webrtcServer: WebRTCServerConfig = WebRTCServerConfig(), + val camera: CameraConfig = CameraConfig(), + val video: VideoQualityConfig = VideoQualityConfig(), + val lastUpdated: String = System.currentTimeMillis().toString() +) + +/** + * Configuración del servidor WebRTC (MediaMTX) + */ +@Serializable +data class WebRTCServerConfig( + val serverUrl: String = "192.168.18.57", + val serverPort: Int = 8889, + val streamName: String = "webrtc_camera_stream", + val username: String = "admin", + val password: String = "admin", + val iceServers: List = listOf( + IceServerConfig("stun:stun.l.google.com:19302"), + IceServerConfig("stun:stun1.l.google.com:19302") + ) +) + +/** + * Configuración de servidor ICE (STUN/TURN) + */ +@Serializable +data class IceServerConfig( + val url: String, + val username: String? = null, + val credential: String? = null +) + +/** + * Configuración de cámara + */ +@Serializable +data class CameraConfig( + val cameraFacing: CameraFacing = CameraFacing.BACK, + val cameraId: String? = null // Para selección específica de cámara +) + +/** + * Orientación de la cámara + */ +@Serializable +enum class CameraFacing { + FRONT, + BACK, + EXTERNAL +} + +/** + * Configuración de calidad de video + */ +@Serializable +data class VideoQualityConfig( + val resolution: VideoResolution = VideoResolution.RES_720P, + val frameRate: Int = 30, + val bitrate: Int = 2000000, // 2 Mbps default + val useHardwareAcceleration: Boolean = true, + val autoStopTimeSeconds: Int = 600 // 10 minutos default +) + +/** + * Resoluciones de video soportadas + */ +@Serializable +enum class VideoResolution(val width: Int, val height: Int, val label: String) { + RES_VGA(640, 480, "VGA (480p)"), + RES_720P(1280, 720, "HD (720p)"), + RES_1080P(1920, 1080, "Full HD (1080p)"), + RES_1440P(2560, 1440, "2K (1440p)"), + RES_2160P(3840, 2160, "4K (2160p)") +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/WifiConfig.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/WifiConfig.kt new file mode 100644 index 0000000..47544ed --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/WifiConfig.kt @@ -0,0 +1,24 @@ +package com.bodycamera.twentyfoulabs.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class WifiConfig( + val ssid: String = "", + val password: String? = null, + val securityType: SecurityType = SecurityType.WPA2, + val ipAddress: String? = null, // Para IP estática + val gateway: String? = null, + val subnetMask: String = "255.255.255.0", + val dns1: String? = null, + val dns2: String? = null +) + +@Serializable +enum class SecurityType { + NONE, + WEP, + WPA, + WPA2, + WPA3 +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/network/NetworkFallbackManager.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/network/NetworkFallbackManager.kt new file mode 100644 index 0000000..03fbf23 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/network/NetworkFallbackManager.kt @@ -0,0 +1,133 @@ +package com.bodycamera.twentyfoulabs.data.network + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.wifi.WifiManager +import android.telephony.TelephonyManager +import dagger.hilt.android.qualifiers.ApplicationContext +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 javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NetworkFallbackManager @Inject constructor( + @ApplicationContext private val context: Context, + private val networkMonitor: NetworkMonitor +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + private val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + private val telephonyManager = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager + + data class FallbackState( + val activeConnection: NetworkMonitor.NetworkType = NetworkMonitor.NetworkType.NONE, + val wifiHasInternet: Boolean = true, + val mobileDataEnabled: Boolean = false, + val isFallbackActive: Boolean = false + ) + + private val _fallbackState = MutableStateFlow(FallbackState()) + val fallbackState: StateFlow = _fallbackState.asStateFlow() + + private var lastWifiWithInternet = true + + init { + scope.launch { + networkMonitor.observeNetworkStatus().collect { status -> + handleNetworkStatus(status) + } + } + } + + private fun handleNetworkStatus(status: NetworkMonitor.NetworkStatus) { + val currentState = _fallbackState.value + + when (status.type) { + NetworkMonitor.NetworkType.WIFI -> { + // WiFi está conectado + if (!status.hasInternet && lastWifiWithInternet) { + // WiFi perdió internet - activar fallback a móvil + lastWifiWithInternet = false + _fallbackState.value = FallbackState( + activeConnection = NetworkMonitor.NetworkType.MOBILE, + wifiHasInternet = false, + mobileDataEnabled = isMobileDataEnabled(), + isFallbackActive = true + ) + } else if (status.hasInternet && !lastWifiWithInternet) { + // WiFi recuperó internet - desactivar fallback + lastWifiWithInternet = true + _fallbackState.value = FallbackState( + activeConnection = NetworkMonitor.NetworkType.WIFI, + wifiHasInternet = true, + mobileDataEnabled = isMobileDataEnabled(), + isFallbackActive = false + ) + } else { + // Estado normal WiFi + _fallbackState.value = currentState.copy( + activeConnection = NetworkMonitor.NetworkType.WIFI, + wifiHasInternet = status.hasInternet + ) + } + } + NetworkMonitor.NetworkType.MOBILE -> { + // Conectado por datos móviles + _fallbackState.value = FallbackState( + activeConnection = NetworkMonitor.NetworkType.MOBILE, + wifiHasInternet = false, + mobileDataEnabled = true, + isFallbackActive = !lastWifiWithInternet + ) + } + NetworkMonitor.NetworkType.NONE -> { + // Sin conexión + _fallbackState.value = FallbackState( + activeConnection = NetworkMonitor.NetworkType.NONE, + wifiHasInternet = false, + mobileDataEnabled = isMobileDataEnabled(), + isFallbackActive = false + ) + } + } + } + + fun isMobileDataEnabled(): Boolean { + return try { + connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) + ?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true + } catch (e: Exception) { + false + } + } + + fun getDataNetworkType(): String { + return when (telephonyManager.dataNetworkType) { + TelephonyManager.NETWORK_TYPE_1xRTT -> "2G" + TelephonyManager.NETWORK_TYPE_CDMA -> "2G" + TelephonyManager.NETWORK_TYPE_EDGE -> "2G" + TelephonyManager.NETWORK_TYPE_GPRS -> "2G" + TelephonyManager.NETWORK_TYPE_IDEN -> "2G" + TelephonyManager.NETWORK_TYPE_EVDO_0 -> "3G" + TelephonyManager.NETWORK_TYPE_EVDO_A -> "3G" + TelephonyManager.NETWORK_TYPE_EVDO_B -> "3G" + TelephonyManager.NETWORK_TYPE_EHRPD -> "3G" + TelephonyManager.NETWORK_TYPE_HSDPA -> "3G" + TelephonyManager.NETWORK_TYPE_HSPA -> "3G" + TelephonyManager.NETWORK_TYPE_HSPAP -> "3G" + TelephonyManager.NETWORK_TYPE_HSUPA -> "3G" + TelephonyManager.NETWORK_TYPE_UMTS -> "3G" + TelephonyManager.NETWORK_TYPE_LTE -> "4G" + TelephonyManager.NETWORK_TYPE_NR -> "5G" + else -> "Desconocido" + } + } +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/network/NetworkMonitor.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/network/NetworkMonitor.kt new file mode 100644 index 0000000..ae2bbc5 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/network/NetworkMonitor.kt @@ -0,0 +1,101 @@ +package com.bodycamera.twentyfoulabs.data.network + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NetworkMonitor @Inject constructor( + @ApplicationContext private val context: Context +) { + private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + enum class NetworkType { + NONE, + WIFI, + MOBILE + } + + data class NetworkStatus( + val type: NetworkType, + val isConnected: Boolean, + val hasInternet: Boolean + ) + + fun observeNetworkStatus(): Flow = callbackFlow { + val networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + val capabilities = connectivityManager.getNetworkCapabilities(network) + val status = getStatusFromCapabilities(capabilities) + trySend(status) + } + + override fun onLost(network: Network) { + val status = getCurrentStatus() + trySend(status) + } + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities + ) { + val status = getStatusFromCapabilities(networkCapabilities) + trySend(status) + } + } + + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + .build() + + connectivityManager.registerNetworkCallback(request, networkCallback) + + // Enviar estado inicial + trySend(getCurrentStatus()) + + awaitClose { + connectivityManager.unregisterNetworkCallback(networkCallback) + } + }.distinctUntilChanged() + + private fun getCurrentStatus(): NetworkStatus { + val activeNetwork = connectivityManager.activeNetwork + val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) + return getStatusFromCapabilities(capabilities) + } + + private fun getStatusFromCapabilities(capabilities: NetworkCapabilities?): NetworkStatus { + if (capabilities == null) { + return NetworkStatus(NetworkType.NONE, false, false) + } + + val type = when { + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> NetworkType.WIFI + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> NetworkType.MOBILE + else -> NetworkType.NONE + } + + val isConnected = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + val hasInternet = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + + return NetworkStatus(type, isConnected, hasInternet) + } + + fun getCurrentNetworkType(): NetworkType { + return getCurrentStatus().type + } + + fun hasInternet(): Boolean { + return getCurrentStatus().hasInternet + } +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/network/WifiConnector.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/network/WifiConnector.kt new file mode 100644 index 0000000..e49e5b8 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/network/WifiConnector.kt @@ -0,0 +1,195 @@ +package com.bodycamera.twentyfoulabs.data.network + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.net.wifi.WifiConfiguration +import android.net.wifi.WifiManager +import android.net.wifi.WifiNetworkSpecifier +import android.os.Build +import androidx.annotation.RequiresApi +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +@Singleton +class WifiConnector @Inject constructor( + @ApplicationContext private val context: Context +) { + private val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + sealed class WifiConnectionResult { + data object Success : WifiConnectionResult() + data class Error(val message: String) : WifiConnectionResult() + data object Timeout : WifiConnectionResult() + } + + suspend fun connectToWifi( + ssid: String, + password: String?, + securityType: com.bodycamera.twentyfoulabs.data.model.SecurityType + ): WifiConnectionResult { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + connectToWifiModern(ssid, password, securityType) + } else { + connectToWifiLegacy(ssid, password, securityType) + } + } catch (e: Exception) { + WifiConnectionResult.Error(e.message ?: "Error desconocido") + } + } + + @RequiresApi(Build.VERSION_CODES.Q) + private suspend fun connectToWifiModern( + ssid: String, + password: String?, + securityType: com.bodycamera.twentyfoulabs.data.model.SecurityType + ): WifiConnectionResult = suspendCancellableCoroutine { continuation -> + val specifierBuilder = WifiNetworkSpecifier.Builder() + .setSsid(ssid) + + when (securityType) { + com.bodycamera.twentyfoulabs.data.model.SecurityType.NONE -> { + // No password needed + } + com.bodycamera.twentyfoulabs.data.model.SecurityType.WEP -> { + // WEP no es soportado en API moderna + continuation.resume(WifiConnectionResult.Error("WEP no es soportado")) + return@suspendCancellableCoroutine + } + com.bodycamera.twentyfoulabs.data.model.SecurityType.WPA -> { + if (password != null) { + specifierBuilder.setWpa2Passphrase(password) + } + } + com.bodycamera.twentyfoulabs.data.model.SecurityType.WPA2 -> { + if (password != null) { + specifierBuilder.setWpa2Passphrase(password) + } + } + com.bodycamera.twentyfoulabs.data.model.SecurityType.WPA3 -> { + if (password != null) { + specifierBuilder.setWpa3Passphrase(password) + } + } + } + + val specifier = specifierBuilder.build() + val request = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .setNetworkSpecifier(specifier) + .build() + + val networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + super.onAvailable(network) + connectivityManager.unregisterNetworkCallback(this) + continuation.resume(WifiConnectionResult.Success) + } + + override fun onUnavailable() { + super.onUnavailable() + connectivityManager.unregisterNetworkCallback(this) + continuation.resume(WifiConnectionResult.Error("No disponible")) + } + + override fun onLost(network: Network) { + super.onLost(network) + connectivityManager.unregisterNetworkCallback(this) + continuation.resume(WifiConnectionResult.Error("Conexión perdida")) + } + } + + connectivityManager.requestNetwork(request, networkCallback, 30000) + + continuation.invokeOnCancellation { + connectivityManager.unregisterNetworkCallback(networkCallback) + } + } + + @Suppress("DEPRECATION") + private suspend fun connectToWifiLegacy( + ssid: String, + password: String?, + securityType: com.bodycamera.twentyfoulabs.data.model.SecurityType + ): WifiConnectionResult = suspendCancellableCoroutine { continuation -> + try { + val wifiConfig = WifiConfiguration().apply { + SSID = ssid.quote() + when (securityType) { + com.bodycamera.twentyfoulabs.data.model.SecurityType.NONE -> { + // Open network + } + com.bodycamera.twentyfoulabs.data.model.SecurityType.WEP -> { + if (password != null) { + wepKeys = arrayOf("\"\"".repeat(3)) + wepTxKeyIndex = 0 + } + } + com.bodycamera.twentyfoulabs.data.model.SecurityType.WPA, + com.bodycamera.twentyfoulabs.data.model.SecurityType.WPA2 -> { + if (password != null) { + preSharedKey = password.quote() + } + } + com.bodycamera.twentyfoulabs.data.model.SecurityType.WPA3 -> { + // WPA3 no es soportado en API antigua + continuation.resume(WifiConnectionResult.Error("WPA3 requiere Android 10+")) + return@suspendCancellableCoroutine + } + } + } + + val netId = wifiManager.addNetwork(wifiConfig) + if (netId == -1) { + continuation.resume(WifiConnectionResult.Error("No se pudo agregar la red")) + return@suspendCancellableCoroutine + } + + wifiManager.disconnect() + val enabled = wifiManager.enableNetwork(netId, true) + val reconnected = wifiManager.reconnect() + + if (enabled && reconnected) { + // Esperar un momento para que se conecte + Thread.sleep(3000) + continuation.resume(WifiConnectionResult.Success) + } else { + continuation.resume(WifiConnectionResult.Error("No se pudo conectar")) + } + } catch (e: Exception) { + continuation.resume(WifiConnectionResult.Error(e.message ?: "Error")) + } + } + + private fun String.quote() = "\"$this\"" + + fun getCurrentSsid(): String? { + return try { + val wifiInfo = wifiManager.connectionInfo + wifiInfo?.ssid?.removeSurrounding("\"") + } catch (e: Exception) { + null + } + } + + fun isConnectedToWifi(): Boolean { + return try { + val network = connectivityManager.activeNetwork + val capabilities = connectivityManager.getNetworkCapabilities(network) + capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true + } catch (e: Exception) { + false + } + } +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/repository/AdminLockRepository.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/repository/AdminLockRepository.kt new file mode 100644 index 0000000..f16390f --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/repository/AdminLockRepository.kt @@ -0,0 +1,132 @@ +package com.bodycamera.twentyfoulabs.data.repository + +import android.content.Context +import android.provider.Settings +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.bodycamera.twentyfoulabs.data.model.AdminLockConfig +import com.bodycamera.twentyfoulabs.data.model.hashPin +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.take +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.adminLockDataStore: DataStore by preferencesDataStore(name = "admin_lock") + +@Singleton +class AdminLockRepository @Inject constructor( + @ApplicationContext private val context: Context +) { + private val PIN_HASH_KEY = stringPreferencesKey("admin_pin_hash") + private val LOCKED_KEY = stringPreferencesKey("admin_locked") + + /** + * Obtiene el estado de lock como Flow reactivo + */ + fun getLockState(): Flow { + return context.adminLockDataStore.data.map { preferences -> + val isLocked = preferences[LOCKED_KEY]?.toBoolean() ?: true + val pinHash = preferences[PIN_HASH_KEY] ?: getDefaultPinHash() + AdminLockConfig(isLocked = isLocked, pinHash = pinHash) + } + } + + /** + * Establece el estado de bloqueo + */ + suspend fun setLockState(locked: Boolean) { + context.adminLockDataStore.edit { preferences -> + preferences[LOCKED_KEY] = locked.toString() + } + } + + /** + * Verifica si el PIN es correcto + */ + suspend fun verifyPin(pin: String): Boolean { + val storedHash = getPinHash() + return hashPin(pin) == storedHash + } + + /** + * Cambia el PIN (requiere el PIN actual) + */ + suspend fun changePin(oldPin: String, newPin: String): Boolean { + // Verificar que el PIN actual sea correcto + if (!verifyPin(oldPin)) { + return false + } + + // Validar el nuevo PIN + if (!com.bodycamera.twentyfoulabs.data.model.isValidPin(newPin)) { + return false + } + + // Guardar el nuevo hash + val newHash = hashPin(newPin) + savePinHash(newHash) + return true + } + + /** + * Obtiene el hash del PIN almacenado + * Primero busca en DataStore, si no existe busca en Settings.Secure (inyectado por ADB) + */ + private suspend fun getPinHash(): String { + // Primero intentar leer de DataStore + val stored = context.adminLockDataStore.data.take(1).firstOrNull() + ?.get(PIN_HASH_KEY) + + if (stored != null) { + return stored + } + + // Si no existe en DataStore, leer de Settings.Secure (inyectado por ADB) + val injected = Settings.Secure.getString(context.contentResolver, "admin_pin") + if (!injected.isNullOrEmpty()) { + val injectedHash = hashPin(injected) + // Guardar en DataStore para futuros usos + savePinHash(injectedHash) + return injectedHash + } + + // Por defecto, retornar hash de "000000" + return hashPin(AdminLockConfig.DEFAULT_PIN) + } + + /** + * Guarda el hash del PIN en DataStore + */ + private suspend fun savePinHash(hash: String) { + context.adminLockDataStore.edit { preferences -> + preferences[PIN_HASH_KEY] = hash + } + } + + /** + * Obtiene el hash del PIN por defecto + */ + private suspend fun getDefaultPinHash(): String { + // Intentar leer de Settings.Secure primero + val injected = Settings.Secure.getString(context.contentResolver, "admin_pin") + if (!injected.isNullOrEmpty()) { + return hashPin(injected) + } + return hashPin(AdminLockConfig.DEFAULT_PIN) + } + + /** + * Verifica si está bloqueado (síncrono, para uso rápido en UI) + */ + suspend fun isLocked(): Boolean { + val state = context.adminLockDataStore.data.take(1).firstOrNull() + return state?.get(LOCKED_KEY)?.toBoolean() ?: true + } +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/repository/NetworkConfigRepository.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/repository/NetworkConfigRepository.kt new file mode 100644 index 0000000..639ef5a --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/repository/NetworkConfigRepository.kt @@ -0,0 +1,65 @@ +package com.bodycamera.twentyfoulabs.data.repository + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.bodycamera.twentyfoulabs.data.model.NetworkConfiguration +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.dataStore: DataStore by preferencesDataStore(name = "network_config") + +@Singleton +class NetworkConfigRepository @Inject constructor( + @ApplicationContext private val context: Context +) { + private val json = Json { ignoreUnknownKeys = true } + private val CONFIG_KEY = stringPreferencesKey("network_config_json") + + suspend fun getConfig(): NetworkConfiguration? { + val configJson = context.dataStore.data.map { preferences -> + preferences[CONFIG_KEY] + }.firstOrNull() + + return configJson?.let { + try { + json.decodeFromString(it) + } catch (e: Exception) { + null + } + } + } + + suspend fun saveConfig(config: NetworkConfiguration) { + context.dataStore.edit { preferences -> + val configJson = json.encodeToString(config) + preferences[CONFIG_KEY] = configJson + } + } + + suspend fun clearConfig() { + context.dataStore.edit { it.clear() } + } + + fun getConfigFlow(): Flow { + return context.dataStore.data.map { preferences -> + val configJson = preferences[CONFIG_KEY] + configJson?.let { + try { + json.decodeFromString(it) + } catch (e: Exception) { + null + } + } + } + } +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/repository/VideoConfigRepository.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/repository/VideoConfigRepository.kt new file mode 100644 index 0000000..6f33087 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/repository/VideoConfigRepository.kt @@ -0,0 +1,77 @@ +package com.bodycamera.twentyfoulabs.data.repository + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.bodycamera.twentyfoulabs.data.model.VideoConfiguration +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.videoConfigDataStore: DataStore by preferencesDataStore(name = "video_config") + +@Singleton +class VideoConfigRepository @Inject constructor( + @ApplicationContext private val context: Context +) { + private val json = Json { ignoreUnknownKeys = true } + private val CONFIG_KEY = stringPreferencesKey("video_config_json") + + /** + * Obtiene la configuración de video almacenada + */ + suspend fun getConfig(): VideoConfiguration? { + val configJson = context.videoConfigDataStore.data.map { preferences -> + preferences[CONFIG_KEY] + }.firstOrNull() + + return configJson?.let { + try { + json.decodeFromString(it) + } catch (e: Exception) { + null + } + } + } + + /** + * Guarda la configuración de video + */ + suspend fun saveConfig(config: VideoConfiguration) { + context.videoConfigDataStore.edit { preferences -> + val configJson = json.encodeToString(config) + preferences[CONFIG_KEY] = configJson + } + } + + /** + * Limpia la configuración almacenada + */ + suspend fun clearConfig() { + context.videoConfigDataStore.edit { it.clear() } + } + + /** + * Flow reactivo de la configuración de video + */ + fun getConfigFlow(): Flow { + return context.videoConfigDataStore.data.map { preferences -> + val configJson = preferences[CONFIG_KEY] + configJson?.let { + try { + json.decodeFromString(it) + } catch (e: Exception) { + null + } + } + } + } +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/webrtc/CameraHelper.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/webrtc/CameraHelper.kt new file mode 100644 index 0000000..5212ded --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/webrtc/CameraHelper.kt @@ -0,0 +1,180 @@ +package com.bodycamera.twentyfoulabs.data.webrtc + +import android.content.Context +import android.util.Log +import androidx.camera.core.CameraSelector +import androidx.camera.core.CameraInfo +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +import com.bodycamera.twentyfoulabs.data.model.CameraFacing +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.suspendCancellableCoroutine +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +/** + * Helper para gestionar la cámara usando CameraX + */ +class CameraHelper( + private val context: Context, + private val lifecycleOwner: LifecycleOwner +) { + companion object { + private const val TAG = "CameraHelper" + } + + private var cameraProvider: ProcessCameraProvider? = null + private var preview: Preview? = null + private var camera: androidx.camera.core.Camera? = null + private val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor() + + interface CameraCallback { + fun onCameraStarted() + fun onCameraError(error: String) + } + + private var callback: CameraCallback? = null + + /** + * Información de una cámara disponible + */ + data class CameraInfo( + val id: String, + val lensFacing: CameraFacing, + val name: String + ) + + /** + * Obtiene la lista de cámaras disponibles + */ + suspend fun getAvailableCameras(): List = suspendCancellableCoroutine { continuation -> + val cameraProviderFuture = ProcessCameraProvider.getInstance(context) + + cameraProviderFuture.addListener({ + try { + val provider = cameraProviderFuture.get() + cameraProvider = provider + + val cameras = provider.availableCameraInfos.mapIndexed { index, cameraInfo -> + val lensFacing = when (cameraInfo.lensFacing) { + CameraSelector.LENS_FACING_FRONT -> CameraFacing.FRONT + CameraSelector.LENS_FACING_BACK -> CameraFacing.BACK + else -> CameraFacing.EXTERNAL + } + CameraInfo( + id = "camera_$index", + lensFacing = lensFacing, + name = when (lensFacing) { + CameraFacing.FRONT -> "Cámara Frontal" + CameraFacing.BACK -> "Cámara Trasera" + CameraFacing.EXTERNAL -> "Cámara Externa" + } + ) + } + + Log.d(TAG, "Found ${cameras.size} cameras") + continuation.resume(cameras, onCancellation = null) + } catch (e: Exception) { + Log.e(TAG, "Failed to get cameras", e) + continuation.resumeWith(Result.failure(e)) + } + }, ContextCompat.getMainExecutor(context)) + + continuation.invokeOnCancellation { + // Cancelar si es necesario + } + } + + /** + * Inicia la cámara con el preview + */ + fun startCamera( + previewView: PreviewView, + cameraFacing: CameraFacing = CameraFacing.BACK, + callback: CameraCallback + ) { + this.callback = callback + + val cameraProviderFuture = ProcessCameraProvider.getInstance(context) + + cameraProviderFuture.addListener({ + try { + cameraProvider = cameraProviderFuture.get() + + // Configurar preview + preview = Preview.Builder() + .build() + .also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + + // Seleccionar cámara según la configuración + val lensFacing = when (cameraFacing) { + CameraFacing.FRONT -> CameraSelector.LENS_FACING_FRONT + CameraFacing.BACK -> CameraSelector.LENS_FACING_BACK + CameraFacing.EXTERNAL -> CameraSelector.LENS_FACING_EXTERNAL + } + + val cameraSelector = CameraSelector.Builder() + .requireLensFacing(lensFacing) + .build() + + bindCameraUseCases(cameraSelector) + callback.onCameraStarted() + + Log.d(TAG, "Camera started with lensFacing: $lensFacing") + } catch (e: Exception) { + Log.e(TAG, "Camera initialization failed", e) + callback.onCameraError("Error al iniciar cámara: ${e.message}") + } + }, ContextCompat.getMainExecutor(context)) + } + + /** + * Vincula los use cases de la cámara + */ + private fun bindCameraUseCases(cameraSelector: CameraSelector) { + val cameraProvider = cameraProvider ?: throw IllegalStateException("Camera initialization failed") + + try { + // Desvincular use cases antes de volver a vincular + cameraProvider.unbindAll() + + // Vincular use cases a la cámara + camera = cameraProvider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + preview + ) + + Log.d(TAG, "Camera use cases bound") + } catch (e: Exception) { + Log.e(TAG, "Use case binding failed", e) + callback?.onCameraError("Error al vincular cámara: ${e.message}") + } + } + + /** + * Detiene la cámara + */ + fun stopCamera() { + try { + cameraProvider?.unbindAll() + Log.d(TAG, "Camera stopped") + } catch (e: Exception) { + Log.e(TAG, "Error stopping camera", e) + } + } + + /** + * Libera recursos + */ + fun release() { + stopCamera() + cameraExecutor.shutdown() + cameraProvider = null + } +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/webrtc/SignalingClient.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/webrtc/SignalingClient.kt new file mode 100644 index 0000000..ac7ec4b --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/webrtc/SignalingClient.kt @@ -0,0 +1,166 @@ +package com.bodycamera.twentyfoulabs.data.webrtc + +import android.util.Log +import kotlinx.coroutines.* +import org.webrtc.IceCandidate +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.OutputStreamWriter +import java.net.HttpURLConnection +import java.net.URL + +/** + * Cliente de signaling para MediaMTX usando protocolo WHIP + * WHIP endpoint: http://server:port/{streamName}/whip + */ +class SignalingClient( + private val serverUrl: String, + private val streamName: String, + private val username: String, + private val password: String +) { + companion object { + private const val TAG = "SignalingClient" + } + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var whipUrl: String? = null + + // Basic auth header + private val authHeader: String + get() { + val credentials = "$username:$password" + val encoded = java.util.Base64.getEncoder().encodeToString( + credentials.toByteArray(Charsets.UTF_8) + ) + return "Basic $encoded" + } + + /** + * Publica stream a MediaMTX usando protocolo WHIP + * POST http://server:port/{streamName}/whip + * Content-Type: application/sdp + * Authorization: Basic + * Body: SDP offer (raw text) + * + * @param localSdpOffer SDP offer local + * @param callback Callback para respuestas del servidor + */ + fun publish(localSdpOffer: String, callback: SignalingCallback) { + scope.launch { + try { + val url = URL("$serverUrl/$streamName/whip") + whipUrl = url.toString() + val connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "POST" + connection.doOutput = true + connection.setRequestProperty("Content-Type", "application/sdp") + connection.setRequestProperty("Authorization", authHeader) + + Log.d(TAG, "WHIP POST to: $url") + + // Enviar SDP offer como texto plano (no JSON) + OutputStreamWriter(connection.outputStream).use { + it.write(localSdpOffer) + it.flush() + } + + val responseCode = connection.responseCode + Log.d(TAG, "WHIP response code: $responseCode") + + if (responseCode == HttpURLConnection.HTTP_OK || + responseCode == HttpURLConnection.HTTP_CREATED) { + + // Leer SDP answer de la respuesta + val sdpAnswer = BufferedReader(InputStreamReader(connection.inputStream)) + .use { it.readText() } + + Log.d(TAG, "SDP Answer received") + callback.onOfferReceived(sdpAnswer) + } else { + val error = BufferedReader(InputStreamReader(connection.errorStream)) + .use { it.readText() } + Log.e(TAG, "WHIP error: $responseCode, body: $error") + callback.onError("WHIP error: $responseCode - $error") + } + } catch (e: Exception) { + Log.e(TAG, "WHIP publish error", e) + callback.onError("WHIP error: ${e.message}") + } + } + } + + /** + * Envía ICE candidate a MediaMTX usando protocolo WHIP + * PATCH http://server:port/{streamName}/whip + * Content-Type: application/trickle-ice-sdpfrag + * Authorization: Basic + * Body: ICE candidate fragment en formato SDP + */ + fun sendIceCandidate(candidate: IceCandidate) { + val url = whipUrl ?: return + + scope.launch { + try { + val connection = URL(url).openConnection() as HttpURLConnection + connection.requestMethod = "PATCH" + connection.doOutput = true + connection.setRequestProperty("Content-Type", "application/trickle-ice-sdpfrag") + connection.setRequestProperty("Authorization", authHeader) + + // Crear fragmento ICE en formato SDP + val iceFragment = buildIceCandidateFragment( + candidate.sdpMid, + candidate.sdpMLineIndex, + candidate.sdp + ) + + Log.d(TAG, "Sending ICE candidate") + + OutputStreamWriter(connection.outputStream).use { + it.write(iceFragment) + it.flush() + } + + val responseCode = connection.responseCode + if (responseCode == HttpURLConnection.HTTP_OK || + responseCode == HttpURLConnection.HTTP_NO_CONTENT) { + Log.d(TAG, "ICE candidate sent successfully") + } else { + Log.w(TAG, "Failed to send ICE candidate: $responseCode") + } + } catch (e: Exception) { + Log.e(TAG, "Send ICE candidate error", e) + } + } + } + + /** + * Construye fragmento ICE candidate en formato SDP + * Format: a=ice-candidate:... + */ + private fun buildIceCandidateFragment( + sdpMid: String?, + sdpMLineIndex: Int, + candidate: String + ): String { + return "a=ice-candidate:$candidate\r\n" + + "a=ice-candidate-sdpMid:${sdpMid ?: ""}\r\n" + + "a=ice-candidate-sdpMLineIndex:$sdpMLineIndex\r\n" + } + + /** + * Cierra el cliente y cancela coroutines + */ + fun close() { + scope.cancel() + } + + /** + * Callback para eventos de signaling + */ + interface SignalingCallback { + fun onOfferReceived(sdp: String) + fun onError(error: String) + } +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/webrtc/VideoDecoderFactory.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/webrtc/VideoDecoderFactory.kt new file mode 100644 index 0000000..606c9af --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/webrtc/VideoDecoderFactory.kt @@ -0,0 +1,51 @@ +package com.bodycamera.twentyfoulabs.data.webrtc + +import android.util.Log +import org.webrtc.EglBase +import org.webrtc.HardwareVideoDecoderFactory +import org.webrtc.SoftwareVideoDecoderFactory +import org.webrtc.VideoDecoder +import org.webrtc.VideoDecoderFactory + +/** + * Factory de decoder de video que soporta decodificación hardware y software + */ +class VideoDecoderFactory(eglContext: EglBase.Context) : VideoDecoderFactory { + + companion object { + private const val TAG = "VideoDecoderFactory" + } + + private val hardwareDecoderFactory = HardwareVideoDecoderFactory(eglContext) + private val softwareDecoderFactory = SoftwareVideoDecoderFactory() + + override fun createDecoder(videoCodecInfo: org.webrtc.VideoCodecInfo?): VideoDecoder? { + // Intentar decoder hardware primero + val hardwareDecoder = try { + hardwareDecoderFactory.createDecoder(videoCodecInfo) + } catch (e: Exception) { + null + } + + if (hardwareDecoder != null) { + Log.d(TAG, "Using hardware decoder") + return hardwareDecoder + } + + // Fallback a decoder software + val softwareDecoder = softwareDecoderFactory.createDecoder(videoCodecInfo) + if (softwareDecoder != null) { + Log.d(TAG, "Using software decoder") + return softwareDecoder + } + + Log.e(TAG, "No decoder available for codec: ${videoCodecInfo?.name}") + return null + } + + override fun getSupportedCodecs(): Array { + val hardwareCodecs = hardwareDecoderFactory.supportedCodecs + val softwareCodecs = softwareDecoderFactory.supportedCodecs + return (hardwareCodecs.toList() + softwareCodecs.toList()).distinctBy { it.name }.toTypedArray() + } +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/webrtc/VideoEncoderFactory.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/webrtc/VideoEncoderFactory.kt new file mode 100644 index 0000000..1e27594 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/webrtc/VideoEncoderFactory.kt @@ -0,0 +1,46 @@ +package com.bodycamera.twentyfoulabs.data.webrtc + +import android.util.Log +import org.webrtc.EglBase +import org.webrtc.HardwareVideoEncoderFactory +import org.webrtc.SoftwareVideoEncoderFactory +import org.webrtc.VideoEncoder +import org.webrtc.VideoEncoderFactory + +/** + * Factory de encoder de video que soporta codificación hardware y software + */ +class VideoEncoderFactory(eglContext: EglBase.Context) : VideoEncoderFactory { + + companion object { + private const val TAG = "VideoEncoderFactory" + } + + private val hardwareEncoderFactory = HardwareVideoEncoderFactory(eglContext, true, true) + private val softwareEncoderFactory = SoftwareVideoEncoderFactory() + + override fun createEncoder(videoCodecInfo: org.webrtc.VideoCodecInfo?): VideoEncoder? { + // Intentar encoder hardware primero + val hardwareEncoder = hardwareEncoderFactory.createEncoder(videoCodecInfo) + if (hardwareEncoder != null) { + Log.d(TAG, "Using hardware encoder") + return hardwareEncoder + } + + // Fallback a encoder software + val softwareEncoder = softwareEncoderFactory.createEncoder(videoCodecInfo) + if (softwareEncoder != null) { + Log.d(TAG, "Using software encoder") + return softwareEncoder + } + + Log.e(TAG, "No encoder available for codec: ${videoCodecInfo?.name}") + return null + } + + override fun getSupportedCodecs(): Array { + val hardwareCodecs = hardwareEncoderFactory.supportedCodecs + val softwareCodecs = softwareEncoderFactory.supportedCodecs + return (hardwareCodecs + softwareCodecs).distinctBy { it.name }.toTypedArray() + } +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/webrtc/WebRTCManager.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/webrtc/WebRTCManager.kt new file mode 100644 index 0000000..e3fb9ba --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/webrtc/WebRTCManager.kt @@ -0,0 +1,540 @@ +package com.bodycamera.twentyfoulabs.data.webrtc + +import android.content.Context +import android.util.Log +import com.bodycamera.twentyfoulabs.data.model.VideoConfiguration +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.webrtc.* +import java.net.HttpURLConnection +import java.net.URL +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Manager para WebRTC streaming a servidor MediaMTX + * Implementación completa con captura de video real + */ +@Singleton +class WebRTCManager @Inject constructor( + private val context: Context +) { + companion object { + private const val TAG = "WebRTCManager" + } + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private var peerConnectionFactory: PeerConnectionFactory? = null + private var peerConnection: PeerConnection? = null + private var videoSource: VideoSource? = null + private var cameraVideoCapturer: CameraVideoCapturer? = null + private var localVideoTrack: VideoTrack? = null + private var surfaceTextureHelper: SurfaceTextureHelper? = null + private var eglBase: EglBase? = null + private var signalingClient: SignalingClient? = null + private var autoStopJob: Job? = null + + private var callback: StreamingCallback? = null + + sealed class ConnectionResult { + data object Success : ConnectionResult() + data class Error(val message: String) : ConnectionResult() + } + + interface StreamingCallback { + fun onStreamStarted() + fun onStreamStopped() + fun onConnectionChange(isConnected: Boolean) + fun onError(error: String) + fun onTimeRemaining(seconds: Int) + } + + /** + * Inicializa el WebRTCManager + */ + fun initialize(callback: StreamingCallback) { + this.callback = callback + initPeerConnectionFactory() + Log.e(TAG, "WebRTCManager initialized") + } + + private fun initPeerConnectionFactory() { + eglBase = EglBase.create() + + val options = PeerConnectionFactory.InitializationOptions.builder(context) + .setEnableInternalTracer(false) + .createInitializationOptions() + + PeerConnectionFactory.initialize(options) + + peerConnectionFactory = PeerConnectionFactory.builder() + .setVideoDecoderFactory(VideoDecoderFactory(eglBase!!.eglBaseContext)) + .setVideoEncoderFactory(VideoEncoderFactory(eglBase!!.eglBaseContext)) + .setOptions(PeerConnectionFactory.Options()) + .createPeerConnectionFactory() + + Log.e(TAG, "PeerConnectionFactory initialized") + } + + /** + * Prueba la conectividad con el servidor WebRTC + */ + suspend fun testConnection(config: VideoConfiguration): ConnectionResult { + val server = config.webrtcServer + + if (server.serverUrl.isBlank()) { + Log.e(TAG, "Server URL is blank") + return ConnectionResult.Error("URL del servidor está vacía") + } + if (server.streamName.isBlank()) { + Log.e(TAG, "Stream name is blank") + return ConnectionResult.Error("Nombre del stream está vacío") + } + + return try { + val urlString = "http://${server.serverUrl}:${server.serverPort}/" + val url = URL(urlString) + + Log.e(TAG, "Testing connection to: $url") + Log.e(TAG, "Stream will be: ${server.streamName}") + + val connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "GET" + connection.connectTimeout = 10000 + connection.readTimeout = 10000 + + val auth = java.util.Base64.getEncoder() + .encodeToString("${server.username}:${server.password}".toByteArray()) + connection.setRequestProperty("Authorization", "Basic $auth") + + val responseCode = connection.responseCode + val responseMessage = connection.responseMessage + Log.e(TAG, "Response code: $responseCode, message: $responseMessage") + connection.disconnect() + + when { + responseCode in 200..299 -> ConnectionResult.Success + responseCode == 401 -> ConnectionResult.Error("Autenticación fallida (401) - Verifica usuario/contraseña") + responseCode == 404 -> ConnectionResult.Success // 404 en raíz es normal + responseCode == 500 -> ConnectionResult.Success // 500 también significa servidor alcanzable + else -> ConnectionResult.Error("Error del servidor: $responseCode $responseMessage") + } + } catch (e: java.net.UnknownHostException) { + Log.e(TAG, "Unknown host", e) + ConnectionResult.Error("Servidor no encontrado - Verifica la IP: ${server.serverUrl}") + } catch (e: java.net.ConnectException) { + Log.e(TAG, "Connection refused", e) + ConnectionResult.Error("Conexión rechazada - Verifica que MediaMTX esté corriendo en puerto ${server.serverPort}") + } catch (e: java.net.SocketTimeoutException) { + Log.e(TAG, "Timeout", e) + ConnectionResult.Error("Tiempo de espera agotado - Servidor no responde") + } catch (e: Exception) { + Log.e(TAG, "Connection test failed", e) + ConnectionResult.Error("No se puede conectar: ${e.javaClass.simpleName}") + } + } + + /** + * Inicia el streaming de video real + */ + fun startStreaming(config: VideoConfiguration) { + scope.launch { + try { + val server = config.webrtcServer + + if (server.serverUrl.isBlank()) { + callback?.onError("URL del servidor está vacía") + return@launch + } + + // Detener cualquier streaming activo antes de iniciar uno nuevo + if (cameraVideoCapturer != null || peerConnection != null) { + Log.e(TAG, "Stopping active stream before starting new one") + stopStreaming() + delay(500) // Esperar a que se limpien los recursos + } + + val serverUrl = "http://${server.serverUrl}:${server.serverPort}" + Log.e(TAG, "Starting streaming to: $serverUrl/${server.streamName}/whip") + + // Crear SignalingClient + signalingClient = SignalingClient( + serverUrl = serverUrl, + streamName = server.streamName, + username = server.username, + password = server.password + ) + + // Crear PeerConnection + createPeerConnection(config) + + // Iniciar captura de cámara + startCameraCapture(config) + + // Iniciar timer de auto-stop + if (config.video.autoStopTimeSeconds > 0) { + startAutoStopTimer(config.video.autoStopTimeSeconds) + } + + callback?.onStreamStarted() + } catch (e: Exception) { + Log.e(TAG, "Error starting streaming", e) + callback?.onError("Error al iniciar streaming: ${e.message}") + } + } + } + + /** + * Crea el PeerConnection con la configuración + */ + private fun createPeerConnection(config: VideoConfiguration) { + val iceServers = config.webrtcServer.iceServers.map { iceConfig -> + val builder = PeerConnection.IceServer.builder(iceConfig.url) + iceConfig.username?.let { builder.setUsername(it) } + iceConfig.credential?.let { builder.setPassword(it) } + builder.createIceServer() + } + + val rtcConfig = PeerConnection.RTCConfiguration(iceServers) + rtcConfig.iceTransportsType = PeerConnection.IceTransportsType.ALL + + val observer = object : PeerConnection.Observer { + override fun onSignalingChange(state: PeerConnection.SignalingState?) { + Log.e(TAG, "Signaling state: $state") + } + + override fun onIceConnectionChange(state: PeerConnection.IceConnectionState?) { + Log.e(TAG, "ICE connection state: $state") + val isConnected = state == PeerConnection.IceConnectionState.CONNECTED || + state == PeerConnection.IceConnectionState.COMPLETED + callback?.onConnectionChange(isConnected) + } + + override fun onIceConnectionReceivingChange(receiving: Boolean) { + Log.e(TAG, "ICE connection receiving: $receiving") + } + + override fun onIceGatheringChange(state: PeerConnection.IceGatheringState?) { + Log.e(TAG, "ICE gathering state: $state") + } + + override fun onIceCandidate(candidate: IceCandidate?) { + Log.e(TAG, "ICE candidate gathered") + candidate?.let { + signalingClient?.sendIceCandidate(it) + } + } + + override fun onIceCandidatesRemoved(candidates: Array?) { + Log.e(TAG, "ICE candidates removed") + } + + override fun onAddStream(stream: MediaStream?) { + Log.e(TAG, "Remote stream added: ${stream?.id}") + } + + override fun onRemoveStream(stream: MediaStream?) { + Log.e(TAG, "Remote stream removed: ${stream?.id}") + } + + override fun onDataChannel(channel: DataChannel?) { + Log.e(TAG, "Data channel created: ${channel?.label()}") + } + + override fun onRenegotiationNeeded() { + Log.e(TAG, "Renegotiation needed") + } + } + + peerConnection = peerConnectionFactory?.createPeerConnection(rtcConfig, observer) + Log.e(TAG, "PeerConnection created") + } + + /** + * Inicia la captura de cámara + */ + private fun startCameraCapture(config: VideoConfiguration) { + // Validar que peerConnectionFactory esté inicializado + if (peerConnectionFactory == null) { + Log.e(TAG, "PeerConnectionFactory is null, cannot start camera") + callback?.onError("Error: PeerConnectionFactory no inicializado") + return + } + + // Validar que eglBase esté inicializado + if (eglBase == null) { + Log.e(TAG, "EglBase is null, cannot start camera") + callback?.onError("Error: EglBase no inicializado") + return + } + + // Crear SurfaceTextureHelper + surfaceTextureHelper = SurfaceTextureHelper.create( + "CameraThread", + eglBase!!.eglBaseContext + ) + + // Enumerar cámaras disponibles + val cameraEnumerator: CameraEnumerator = Camera1Enumerator() + val cameraNames = cameraEnumerator.deviceNames + + Log.e(TAG, "Available cameras: ${cameraNames.joinToString()}") + + // Seleccionar cámara según la configuración + val cameraName = when (config.camera.cameraFacing) { + com.bodycamera.twentyfoulabs.data.model.CameraFacing.FRONT -> { + cameraNames.firstOrNull { cameraEnumerator.isFrontFacing(it) } + } + com.bodycamera.twentyfoulabs.data.model.CameraFacing.BACK -> { + cameraNames.firstOrNull { !cameraEnumerator.isFrontFacing(it) } + } + else -> cameraNames.firstOrNull() + } + + if (cameraName == null) { + callback?.onError("No se encontró cámara con facing: ${config.camera.cameraFacing}") + return + } + + Log.e(TAG, "Using camera: $cameraName") + + // Crear video source + videoSource = peerConnectionFactory!!.createVideoSource(false) + + // Validar que videoSource se haya creado + if (videoSource == null) { + Log.e(TAG, "Failed to create video source") + callback?.onError("Error: No se pudo crear el video source") + return + } + + // Crear camera capturer + cameraVideoCapturer = cameraEnumerator.createCapturer( + cameraName, + object : CameraVideoCapturer.CameraEventsHandler { + override fun onCameraError(error: String?) { + Log.e(TAG, "Camera error: $error") + callback?.onError("Error de cámara: $error") + } + + override fun onCameraDisconnected() { + Log.w(TAG, "Camera disconnected") + } + + override fun onCameraFreezed(error: String?) { + Log.e(TAG, "Camera frozen: $error") + callback?.onError("Cámara congelada: $error") + } + + override fun onCameraOpening(cameraName: String?) { + Log.e(TAG, "Camera opening: $cameraName") + } + + override fun onFirstFrameAvailable() { + Log.e(TAG, "First frame available - Creating SDP offer") + + // Después que la cámara está lista, crear la oferta SDP + createOffer() + } + + override fun onCameraClosed() { + Log.e(TAG, "Camera closed") + } + } + ) + + if (cameraVideoCapturer == null) { + callback?.onError("No se pudo crear el capturer de cámara") + return + } + + // Inicializar capturer con validación + val capturerObserver = videoSource!!.capturerObserver + if (capturerObserver == null) { + Log.e(TAG, "CapturerObserver is null") + callback?.onError("Error: CapturerObserver no disponible") + return + } + + cameraVideoCapturer?.initialize( + surfaceTextureHelper, + context, + capturerObserver + ) + + // Iniciar captura + cameraVideoCapturer?.startCapture( + config.video.resolution.width, + config.video.resolution.height, + config.video.frameRate + ) + + // Crear video track + localVideoTrack = peerConnectionFactory!!.createVideoTrack("video", videoSource) + + // Agregar al PeerConnection + peerConnection?.addTrack(localVideoTrack!!, listOf("stream")) + + Log.e(TAG, "Video track created: ${config.video.resolution.label} @ ${config.video.frameRate}fps") + } + + /** + * Crea y envía la oferta SDP al servidor WHIP + */ + private fun createOffer() { + val mediaConstraints = MediaConstraints().apply { + mandatory.addAll(listOf( + MediaConstraints.KeyValuePair("OfferToReceiveAudio", "false"), + MediaConstraints.KeyValuePair("OfferToReceiveVideo", "false") + )) + } + + val observer = object : SdpObserver { + override fun onCreateSuccess(sdp: SessionDescription?) { + sdp?.let { + Log.e(TAG, "SDP Offer created (first 200 chars): ${it.description.take(200)}") + + // Set local description + peerConnection?.setLocalDescription(object : SdpObserver { + override fun onCreateSuccess(p0: SessionDescription?) {} + override fun onSetSuccess() { + Log.e(TAG, "Local description set") + // Enviar oferta al servidor WHIP + signalingClient?.publish(it.description, object : SignalingClient.SignalingCallback { + override fun onOfferReceived(sdp: String) { + Log.e(TAG, "SDP Answer received from server") + // Set remote description + setRemoteDescription(sdp) + } + + override fun onError(error: String) { + Log.e(TAG, "WHIP error: $error") + callback?.onError("Error WHIP: $error") + } + }) + } + + override fun onCreateFailure(p0: String?) { + Log.e(TAG, "Failed to set local description: $p0") + callback?.onError("Error al setear descripción local: $p0") + } + + override fun onSetFailure(p0: String?) { + Log.e(TAG, "Failed to set local description: $p0") + callback?.onError("Error al setear descripción local: $p0") + } + }, it) + } + } + + override fun onSetSuccess() {} + override fun onCreateFailure(p0: String?) { + Log.e(TAG, "Failed to create offer: $p0") + callback?.onError("Error al crear oferta: $p0") + } + + override fun onSetFailure(p0: String?) { + Log.e(TAG, "Failed to set offer: $p0") + callback?.onError("Error al setear oferta: $p0") + } + } + + peerConnection?.createOffer(observer, mediaConstraints) + } + + /** + * Setea la descripción remota (answer del servidor) + */ + private fun setRemoteDescription(sdp: String) { + val sessionDescription = SessionDescription( + SessionDescription.Type.ANSWER, + sdp + ) + + peerConnection?.setRemoteDescription(object : SdpObserver { + override fun onCreateSuccess(p0: SessionDescription?) {} + override fun onSetSuccess() { + Log.e(TAG, "Remote description set successfully") + } + + override fun onCreateFailure(p0: String?) {} + override fun onSetFailure(p0: String?) { + Log.e(TAG, "Failed to set remote description: $p0") + callback?.onError("Error al setear descripción remota: $p0") + } + }, sessionDescription) + } + + /** + * Inicia el timer de auto-stop + */ + private fun startAutoStopTimer(seconds: Int) { + autoStopJob?.cancel() + autoStopJob = scope.launch { + for (i in seconds downTo 1) { + callback?.onTimeRemaining(i) + delay(1000) + } + Log.e(TAG, "Auto-stop timer reached, stopping stream") + stopStreaming() + } + } + + /** + * Detiene el streaming de video + */ + fun stopStreaming() { + scope.launch { + try { + Log.e(TAG, "Stopping streaming") + + autoStopJob?.cancel() + autoStopJob = null + + localVideoTrack?.dispose() + localVideoTrack = null + + cameraVideoCapturer?.stopCapture() + cameraVideoCapturer = null + + videoSource?.dispose() + videoSource = null + + surfaceTextureHelper?.dispose() + surfaceTextureHelper = null + + peerConnection?.close() + peerConnection = null + + signalingClient?.close() + signalingClient = null + + callback?.onStreamStopped() + } catch (e: Exception) { + Log.e(TAG, "Error stopping streaming", e) + callback?.onError("Error al detener streaming: ${e.message}") + } + } + } + + /** + * Libera recursos + */ + fun release() { + Log.e(TAG, "Releasing WebRTCManager") + stopStreaming() + + peerConnectionFactory?.dispose() + peerConnectionFactory = null + + eglBase?.release() + eglBase = null + + callback = null + } +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/di/WebRTCModule.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/di/WebRTCModule.kt new file mode 100644 index 0000000..b0a85a8 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/di/WebRTCModule.kt @@ -0,0 +1,51 @@ +package com.bodycamera.twentyfoulabs.di + +import android.content.Context +import com.bodycamera.twentyfoulabs.data.webrtc.WebRTCManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * Módulo de Hilt para dependencias de WebRTC + */ +@Module +@InstallIn(SingletonComponent::class) +object WebRTCModule { + + /** + * Provee el WebRTCManager como Singleton + * Note: WebRTCManager ya tiene @Inject constructor y @Singleton, + * pero este módulo documenta explícitamente la dependencia + */ + @Provides + @Singleton + fun provideWebRTCManager( + @ApplicationContext context: Context + ): WebRTCManager { + return WebRTCManager(context) + } +} + +/** + * Nota sobre CameraHelper: + * + * CameraHelper requiere un LifecycleOwner que no puede ser proveído + * a nivel de módulo ya que depende del ciclo de vida del componente + * (Activity, Fragment, o Composable). + * + * Debe ser instanciado en el sitio de uso con: + * ```kotlin + * val cameraHelper = CameraHelper(context, lifecycleOwner) + * ``` + * + * Ejemplo en Composable: + * ```kotlin + * val lifecycleOwner = LocalLifecycleOwner.current + * val context = LocalContext.current + * val cameraHelper = remember { CameraHelper(context, lifecycleOwner) } + * ``` + */ diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/navigation/BackButton.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/navigation/BackButton.kt new file mode 100644 index 0000000..6a6b407 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/navigation/BackButton.kt @@ -0,0 +1,35 @@ +package com.bodycamera.twentyfoulabs.navigation + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.res.stringResource +import com.bodycamera.twentyfoulabs.R + +/** + * Botón de retroceso reutilizable con debounce integrado. + * Úsalo en todas las pantallas que necesitan navegación hacia atrás. + */ +@Composable +fun BackButton(navigationHandler: NavigationHandler) { + var isNavigating by remember { mutableStateOf(false) } + + IconButton( + onClick = { + if (!isNavigating) { + isNavigating = true + navigationHandler.navigateBack() + // El NavigationHandler maneja el reset automático + } + }, + enabled = !isNavigating + ) { + Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back)) + } +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/navigation/LocalNavigationHandler.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/navigation/LocalNavigationHandler.kt new file mode 100644 index 0000000..bec4251 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/navigation/LocalNavigationHandler.kt @@ -0,0 +1,10 @@ +package com.bodycamera.twentyfoulabs.navigation + +import androidx.compose.runtime.compositionLocalOf + +/** + * CompositionLocal para proveer el NavigationHandler a toda la app. + */ +val LocalNavigationHandler = compositionLocalOf { + error("No NavigationHandler provided") +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/navigation/NavigationHandler.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/navigation/NavigationHandler.kt new file mode 100644 index 0000000..e15eb1b --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/navigation/NavigationHandler.kt @@ -0,0 +1,53 @@ +package com.bodycamera.twentyfoulabs.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.navigation.NavController +import kotlinx.coroutines.delay + +/** + * Handler centralizado para navegación con debounce. + * Previene múltiples navegaciones rápidas que causan pantallas en blanco. + */ +class NavigationHandler(private val navController: NavController) { + private var isNavigatingBack = false + private val debounceTime = 500L + + /** + * Navega hacia atrás con debounce. + * Solo permite una navegación cada 500ms. + */ + fun navigateBack() { + if (!isNavigatingBack) { + isNavigatingBack = true + navController.popBackStack() + } + } + + /** + * Reset del estado de navegación (debe llamarse después del debounce) + */ + suspend fun resetNavigationState() { + delay(debounceTime) + isNavigatingBack = false + } +} + +/** + * Remember helper para NavigationHandler + */ +@Composable +fun rememberNavigationHandler(navController: NavController): NavigationHandler { + val handler = remember { NavigationHandler(navController) } + + // Auto-reset periódicamente para evitar estado bloqueado + LaunchedEffect(handler) { + while (true) { + delay(500L) + handler.resetNavigationState() + } + } + + return handler +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/components/IconButtonWithLabel.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/components/IconButtonWithLabel.kt new file mode 100644 index 0000000..7d70a8e --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/components/IconButtonWithLabel.kt @@ -0,0 +1,79 @@ +package com.bodycamera.twentyfoulabs.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun IconButtonWithLabel( + icon: ImageVector, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true +) { + Card( + modifier = modifier + .fillMaxWidth() + .aspectRatio(1f) + .let { m -> + if (enabled) { + m.clickable(onClick = onClick) + } else { + m + } + }, + colors = CardDefaults.cardColors( + containerColor = if (enabled) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + } + ), + border = if (enabled) null else BorderStroke( + 1.dp, + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) + ) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = icon, + contentDescription = label, + modifier = Modifier.size(48.dp), + tint = if (enabled) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + } + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = if (enabled) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + }, + textAlign = TextAlign.Center + ) + } + } +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/components/RecordingStatusBar.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/components/RecordingStatusBar.kt new file mode 100644 index 0000000..fb00cbd --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/components/RecordingStatusBar.kt @@ -0,0 +1,105 @@ +package com.bodycamera.twentyfoulabs.ui.components + +import android.util.Log +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * Barra de estado de grabación que se muestra en la parte superior de la pantalla. + * + * @param secondsRemaining Segundos restantes de grabación + * @param modifier Modifier para personalizar el componente + * @param onDismiss Callback para cerrar la barra (no detiene la grabación) + */ +@Composable +fun RecordingStatusBar( + secondsRemaining: Int, + modifier: Modifier = Modifier, + onDismiss: () -> Unit = {} +) { + Log.e("RecordingStatusBar", "Rendering with $secondsRemaining seconds remaining") + val minutes = secondsRemaining / 60 + val secs = secondsRemaining % 60 + + // Animación para el punto de grabación (parpadeo) + val alpha = remember { Animatable(1f) } + LaunchedEffect(alpha) { + alpha.animateTo( + targetValue = 0.3f, + animationSpec = infiniteRepeatable( + animation = tween(500), + repeatMode = RepeatMode.Reverse + ) + ) + } + + Row( + modifier = modifier + .fillMaxWidth() + .background(Color.Red) + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + // Punto blanco parpadeando (indicador de grabación) + Box( + modifier = Modifier + .size(12.dp) + .alpha(alpha.value) + .background(Color.White, CircleShape) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + "GRABANDO", + style = MaterialTheme.typography.titleMedium, + color = Color.White + ) + } + + Text( + String.format("%02d:%02d", minutes, secs), + style = MaterialTheme.typography.titleMedium, + color = Color.White + ) + + IconButton( + onClick = onDismiss + ) { + Icon( + Icons.Default.Close, + contentDescription = "Cerrar", + tint = Color.White + ) + } + } +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/AdminLockScreen.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/AdminLockScreen.kt new file mode 100644 index 0000000..86ce643 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/AdminLockScreen.kt @@ -0,0 +1,363 @@ +package com.bodycamera.twentyfoulabs.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Backspace +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.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.unit.sp +import com.bodycamera.twentyfoulabs.R + +@Composable +fun AdminLockScreen( + viewModel: AdminLockViewModel, + isLocked: Boolean, + onDismiss: () -> Unit, + onUnlockSuccess: () -> Unit = {} +) { + val uiState by viewModel.uiState.collectAsState() + var pin by remember { mutableStateOf("") } + var showChangePin by remember { mutableStateOf(false) } + var oldPin by remember { mutableStateOf("") } + var newPin by remember { mutableStateOf("") } + var confirmPin by remember { mutableStateOf("") } + var changePinStep by remember { mutableStateOf(0) } // 0=none, 1=old, 2=new, 3=confirm + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + when { + showChangePin && changePinStep == 0 -> stringResource(R.string.change_pin) + showChangePin && changePinStep == 1 -> stringResource(R.string.change_pin_enter_current) + showChangePin && changePinStep == 2 -> stringResource(R.string.change_pin_enter_new) + showChangePin && changePinStep == 3 -> stringResource(R.string.change_pin_confirm) + else -> if (isLocked) stringResource(R.string.admin_unlock) else stringResource(R.string.locked) + } + ) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // 6 cajitas para los dígitos + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + val currentPin = when { + showChangePin && changePinStep == 1 -> oldPin + showChangePin && changePinStep == 2 -> newPin + showChangePin && changePinStep == 3 -> confirmPin + else -> pin + } + repeat(6) { i -> + PinDigitBox( + digit = currentPin.getOrNull(i)?.toString() ?: "", + focused = currentPin.length == i + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Teclado numérico + NumericKeypad( + onDigit = { digit -> + when { + showChangePin && changePinStep == 1 -> { + if (oldPin.length < 6) oldPin += digit + } + showChangePin && changePinStep == 2 -> { + if (newPin.length < 6) newPin += digit + } + showChangePin && changePinStep == 3 -> { + if (confirmPin.length < 6) confirmPin += digit + } + else -> { + if (pin.length < 6) pin += digit + } + } + }, + onDelete = { + when { + showChangePin && changePinStep == 1 -> { + oldPin = oldPin.dropLast(1) + } + showChangePin && changePinStep == 2 -> { + newPin = newPin.dropLast(1) + } + showChangePin && changePinStep == 3 -> { + confirmPin = confirmPin.dropLast(1) + } + else -> { + pin = pin.dropLast(1) + } + } + }, + onConfirm = { + when { + showChangePin && changePinStep == 0 -> { + changePinStep = 1 + } + showChangePin && changePinStep == 1 -> { + if (oldPin.length == 6) changePinStep = 2 + } + showChangePin && changePinStep == 2 -> { + if (newPin.length == 6) changePinStep = 3 + } + showChangePin && changePinStep == 3 -> { + if (confirmPin.length == 6) { + if (newPin == confirmPin) { + viewModel.onEvent( + AdminLockEvent.ChangePin(oldPin, newPin) + ) + } else { + viewModel.onEvent( + AdminLockEvent.ShowError("Los PIN no coinciden") + ) + } + } + } + else -> { + if (pin.length == 6) { + viewModel.onEvent(AdminLockEvent.VerifyPin(pin)) + } + } + } + } + ) + + // Mensaje de error o éxito + when (val state = uiState) { + is AdminLockUiState.Success -> { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = if (isLocked) "¡Desbloqueado!" else "¡Bloqueado!", + color = Color(0xFF4CAF50), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + } + is AdminLockUiState.Error -> { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = state.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + } + is AdminLockUiState.PinChanged -> { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.pin_changed), + color = Color(0xFF4CAF50), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + } + else -> {} + } + + // Botón para cambiar PIN (solo si no estamos en modo cambio) + if (!showChangePin && changePinStep == 0) { + Spacer(modifier = Modifier.height(16.dp)) + TextButton(onClick = { + showChangePin = true + changePinStep = 1 + }) { + Text(stringResource(R.string.change_pin)) + } + } + } + }, + confirmButton = { + when (uiState) { + is AdminLockUiState.PinChanged -> { + TextButton(onClick = { + showChangePin = false + changePinStep = 0 + oldPin = "" + newPin = "" + confirmPin = "" + viewModel.onEvent(AdminLockEvent.ResetState) + onDismiss() + }) { + Text(stringResource(R.string.ok)) + } + } + else -> { + TextButton(onClick = { + showChangePin = false + changePinStep = 0 + oldPin = "" + newPin = "" + confirmPin = "" + pin = "" + viewModel.onEvent(AdminLockEvent.ResetState) + onDismiss() + }) { + Text(stringResource(R.string.cancel)) + } + } + } + }, + dismissButton = null + ) + + // Manejar estado de éxito con LaunchedEffect + LaunchedEffect(uiState) { + when (uiState) { + is AdminLockUiState.Success -> { + kotlinx.coroutines.delay(800) + if (isLocked) { + viewModel.onEvent(AdminLockEvent.Unlock) + } else { + viewModel.onEvent(AdminLockEvent.Lock) + } + onUnlockSuccess() + onDismiss() + } + else -> {} + } + } +} + +@Composable +private fun PinDigitBox(digit: String, focused: Boolean) { + Box( + modifier = Modifier + .size(36.dp) + .background( + color = if (focused) MaterialTheme.colorScheme.primaryContainer else Color.Transparent, + shape = RoundedCornerShape(6.dp) + ) + .border( + width = 1.5.dp, + color = if (focused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(6.dp) + ), + contentAlignment = Alignment.Center + ) { + Text( + text = if (digit.isNotEmpty()) "*" else "", + fontSize = 20.sp, + fontWeight = if (digit.isNotEmpty()) FontWeight.Bold else FontWeight.Normal, + color = if (digit.isNotEmpty()) MaterialTheme.colorScheme.onSurface else Color.Transparent + ) + } +} + +@Composable +private fun NumericKeypad( + onDigit: (String) -> Unit, + onDelete: () -> Unit, + onConfirm: () -> Unit +) { + val buttons = listOf( + "1", "2", "3", + "4", "5", "6", + "7", "8", "9", + "✓", "0", "⌫" + ) + + LazyVerticalGrid( + columns = GridCells.Fixed(3), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth() + ) { + items(buttons.size) { index -> + val button = buttons[index] + if (button == "✓") { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = MaterialTheme.colorScheme.primaryContainer, + shape = RoundedCornerShape(6.dp) + ) + .clickable { onConfirm() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Aceptar", + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(22.dp) + ) + } + } else if (button == "⌫") { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = MaterialTheme.colorScheme.errorContainer, + shape = RoundedCornerShape(6.dp) + ) + .clickable { onDelete() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Backspace, + contentDescription = "Borrar", + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(20.dp) + ) + } + } else { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(6.dp) + ) + .clickable { onDigit(button) }, + contentAlignment = Alignment.Center + ) { + Text( + text = button, + fontSize = 20.sp, + fontWeight = FontWeight.Medium + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/AdminLockViewModel.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/AdminLockViewModel.kt new file mode 100644 index 0000000..7b28e71 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/AdminLockViewModel.kt @@ -0,0 +1,128 @@ +package com.bodycamera.twentyfoulabs.ui.screens + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.bodycamera.twentyfoulabs.data.repository.AdminLockRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AdminLockViewModel @Inject constructor( + private val repository: AdminLockRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(AdminLockUiState.Idle) + val uiState: StateFlow = _uiState.asStateFlow() + + val lockState = repository.getLockState() + + fun onEvent(event: AdminLockEvent) { + when (event) { + is AdminLockEvent.VerifyPin -> verifyPin(event.pin) + is AdminLockEvent.ChangePin -> changePin(event.oldPin, event.newPin) + is AdminLockEvent.ShowError -> showError(event.message) + AdminLockEvent.ToggleLock -> toggleLock() + is AdminLockEvent.Unlock -> unlock() + is AdminLockEvent.Lock -> lock() + AdminLockEvent.ResetState -> resetState() + } + } + + private fun showError(message: String) { + _uiState.value = AdminLockUiState.Error(message) + } + + private fun verifyPin(pin: String) { + viewModelScope.launch { + _uiState.value = AdminLockUiState.Loading + + // Validar formato del PIN + if (!com.bodycamera.twentyfoulabs.data.model.isValidPin(pin)) { + _uiState.value = AdminLockUiState.Error("PIN inválido. Debe ser 6 dígitos.") + return@launch + } + + // Verificar PIN + val isValid = repository.verifyPin(pin) + if (isValid) { + _uiState.value = AdminLockUiState.Success + } else { + _uiState.value = AdminLockUiState.Error("PIN incorrecto") + } + } + } + + private fun changePin(oldPin: String, newPin: String) { + viewModelScope.launch { + _uiState.value = AdminLockUiState.Loading + + // Validar formato del nuevo PIN + if (!com.bodycamera.twentyfoulabs.data.model.isValidPin(newPin)) { + _uiState.value = AdminLockUiState.Error("PIN nuevo inválido. Debe ser 6 dígitos.") + return@launch + } + + // Validar formato del PIN actual + if (!com.bodycamera.twentyfoulabs.data.model.isValidPin(oldPin)) { + _uiState.value = AdminLockUiState.Error("PIN actual inválido.") + return@launch + } + + // Cambiar PIN + val success = repository.changePin(oldPin, newPin) + if (success) { + _uiState.value = AdminLockUiState.PinChanged + } else { + _uiState.value = AdminLockUiState.Error("PIN actual incorrecto o los PIN no coinciden") + } + } + } + + private fun toggleLock() { + viewModelScope.launch { + val currentState = repository.isLocked() + repository.setLockState(!currentState) + _uiState.value = AdminLockUiState.Idle + } + } + + private fun unlock() { + viewModelScope.launch { + repository.setLockState(false) + _uiState.value = AdminLockUiState.Idle + } + } + + private fun lock() { + viewModelScope.launch { + repository.setLockState(true) + _uiState.value = AdminLockUiState.Idle + } + } + + private fun resetState() { + _uiState.value = AdminLockUiState.Idle + } +} + +sealed interface AdminLockUiState { + data object Idle : AdminLockUiState + data object Loading : AdminLockUiState + data object Success : AdminLockUiState + data object PinChanged : AdminLockUiState + data class Error(val message: String) : AdminLockUiState +} + +sealed interface AdminLockEvent { + data class VerifyPin(val pin: String) : AdminLockEvent + data class ChangePin(val oldPin: String, val newPin: String) : AdminLockEvent + data class ShowError(val message: String) : AdminLockEvent + data object ToggleLock : AdminLockEvent + data object Unlock : AdminLockEvent + data object Lock : AdminLockEvent + data object ResetState : AdminLockEvent +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/AuthViewModel.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/AuthViewModel.kt new file mode 100644 index 0000000..639ff16 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/AuthViewModel.kt @@ -0,0 +1,73 @@ +package com.bodycamera.twentyfoulabs.ui.screens + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.bodycamera.twentyfoulabs.data.auth.AuthApiException +import com.bodycamera.twentyfoulabs.data.auth.AuthRepository +import com.bodycamera.twentyfoulabs.data.auth.AuthSession +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +sealed interface AuthUiState { + data object Checking : AuthUiState + data object LoggedOut : AuthUiState + data object Loading : AuthUiState + data class Authenticated(val session: AuthSession) : AuthUiState + data class Error(val message: String) : AuthUiState +} + +@HiltViewModel +class AuthViewModel @Inject constructor( + private val repository: AuthRepository +) : ViewModel() { + private val _state = MutableStateFlow(AuthUiState.Checking) + val state: StateFlow = _state.asStateFlow() + + init { restoreSession() } + + fun restoreSession() { + viewModelScope.launch { + _state.value = AuthUiState.Checking + try { + val session = repository.restore() + _state.value = if (session != null) AuthUiState.Authenticated(session) else AuthUiState.LoggedOut + } catch (e: AuthApiException) { + _state.value = if (e.statusCode == 401) AuthUiState.LoggedOut else AuthUiState.Error("No se pudo validar la sesión: ${e.message}") + } catch (e: Exception) { + _state.value = AuthUiState.Error("Sin conexión con el servidor: ${e.message ?: "error de red"}") + } + } + } + + fun login(email: String, password: String) { + if (email.isBlank() || password.isBlank()) { + _state.value = AuthUiState.Error("Ingresa correo y contraseña") + return + } + viewModelScope.launch { + _state.value = AuthUiState.Loading + try { + _state.value = AuthUiState.Authenticated(repository.login(email, password)) + } catch (e: AuthApiException) { + _state.value = AuthUiState.Error(if (e.statusCode == 401) "Correo o contraseña incorrectos" else e.message) + } catch (e: Exception) { + _state.value = AuthUiState.Error("No se pudo conectar al backend: ${e.message ?: "error de red"}") + } + } + } + + fun logout() { + viewModelScope.launch { + runCatching { repository.logout() } + _state.value = AuthUiState.LoggedOut + } + } + + fun clearError() { + if (_state.value is AuthUiState.Error) _state.value = AuthUiState.LoggedOut + } +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/ConnectionSettingsScreen.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/ConnectionSettingsScreen.kt new file mode 100644 index 0000000..5a506e8 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/ConnectionSettingsScreen.kt @@ -0,0 +1,541 @@ +package com.bodycamera.twentyfoulabs.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.bodycamera.twentyfoulabs.R +import com.bodycamera.twentyfoulabs.data.model.NetworkPriority +import com.bodycamera.twentyfoulabs.data.model.SecurityType +import com.bodycamera.twentyfoulabs.navigation.BackButton +import com.bodycamera.twentyfoulabs.navigation.LocalNavigationHandler + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConnectionSettingsScreen( + viewModel: ConnectionSettingsViewModel +) { + val uiState by viewModel.uiState.collectAsState() + val navigationHandler = LocalNavigationHandler.current + var showWifiDialog by remember { mutableStateOf(false) } + var showDnsDialog by remember { mutableStateOf(false) } + var showProxyDialog by remember { mutableStateOf(false) } + + // Auto navegar hacia atrás cuando se guarda con éxito + LaunchedEffect(uiState) { + when (uiState) { + is ConnectionSettingsUiState.Saved, + is ConnectionSettingsUiState.SavedWithError -> { + kotlinx.coroutines.delay(1500) // Mostrar mensaje brevemente + navigationHandler.navigateBack() + // Resetear el estado para la próxima vez + viewModel.onEvent(ConnectionSettingsEvent.Cancel) + } + else -> {} + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.connection_settings_title)) }, + navigationIcon = { + BackButton(navigationHandler) + } + ) + } + ) { paddingValues -> + when (val state = uiState) { + is ConnectionSettingsUiState.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + is ConnectionSettingsUiState.Error -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Text("${stringResource(R.string.error)}: ${(state as ConnectionSettingsUiState.Error).message}") + } + } + is ConnectionSettingsUiState.Saved -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(stringResource(R.string.configuration_saved)) + Spacer(modifier = Modifier.height(16.dp)) + CircularProgressIndicator() + } + } + } + is ConnectionSettingsUiState.SavedWithError -> { + val errorState = state as ConnectionSettingsUiState.SavedWithError + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = errorState.message, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + CircularProgressIndicator() + } + } + } + is ConnectionSettingsUiState.Success -> { + val config = (state as ConnectionSettingsUiState.Success).config + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Sección WiFi + item { + Card { + ListItem( + headlineContent = { Text(stringResource(R.string.wifi_configuration)) }, + supportingContent = { + Text(config.wifi?.ssid ?: stringResource(R.string.not_configured)) + }, + trailingContent = { + TextButton(onClick = { showWifiDialog = true }) { + Text(stringResource(R.string.configure)) + } + } + ) + } + } + + // Sección DNS + item { + Card { + ListItem( + headlineContent = { Text(stringResource(R.string.dns_configuration)) }, + supportingContent = { + Text( + if (config.dns.useCustomDns) { + "${config.dns.dns1}, ${config.dns.dns2}" + } else { + stringResource(R.string.auto_dhcp) + } + ) + }, + trailingContent = { + TextButton(onClick = { showDnsDialog = true }) { + Text(stringResource(R.string.configure)) + } + } + ) + } + } + + // Sección Proxy + item { + Card { + ListItem( + headlineContent = { Text(stringResource(R.string.proxy_configuration)) }, + supportingContent = { + Text( + if (config.proxy.enabled) { + "${config.proxy.host}:${config.proxy.port}" + } else { + stringResource(R.string.disabled) + } + ) + }, + trailingContent = { + TextButton(onClick = { showProxyDialog = true }) { + Text(stringResource(R.string.configure)) + } + } + ) + } + } + + // Sección Prioridad de Red + item { + Card { + Column(modifier = Modifier.padding(16.dp)) { + Text( + stringResource(R.string.network_priority), + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + + NetworkPriority.values().forEach { priority -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + RadioButton( + selected = config.networkPriority == priority, + onClick = { + viewModel.onEvent( + ConnectionSettingsEvent.SetNetworkPriority(priority) + ) + } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + when (priority) { + NetworkPriority.WIFI_FIRST -> stringResource(R.string.wifi_first) + NetworkPriority.MOBILE_FIRST -> stringResource(R.string.mobile_first) + NetworkPriority.WIFI_ONLY -> stringResource(R.string.wifi_only) + NetworkPriority.MOBILE_ONLY -> stringResource(R.string.mobile_only) + } + ) + } + } + } + } + } + + // Botón Guardar + item { + Button( + onClick = { + viewModel.onEvent(ConnectionSettingsEvent.SaveConfig) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.save_configuration)) + } + } + } + } + } + + // Diálogos + if (showWifiDialog) { + WifiConfigDialog( + config = (uiState as? ConnectionSettingsUiState.Success)?.config?.wifi, + onDismiss = { showWifiDialog = false }, + onConfirm = { wifiConfig -> + viewModel.onEvent( + ConnectionSettingsEvent.UpdateWifiConfig(wifiConfig) + ) + showWifiDialog = false + } + ) + } + + if (showDnsDialog) { + DnsConfigDialog( + config = (uiState as? ConnectionSettingsUiState.Success)?.config?.dns + ?: com.bodycamera.twentyfoulabs.data.model.DnsConfig(), + onDismiss = { showDnsDialog = false }, + onConfirm = { dnsConfig -> + viewModel.onEvent( + ConnectionSettingsEvent.UpdateDnsConfig(dnsConfig) + ) + showDnsDialog = false + } + ) + } + + if (showProxyDialog) { + ProxyConfigDialog( + config = (uiState as? ConnectionSettingsUiState.Success)?.config?.proxy + ?: com.bodycamera.twentyfoulabs.data.model.ProxyConfig(), + onDismiss = { showProxyDialog = false }, + onConfirm = { proxyConfig -> + viewModel.onEvent( + ConnectionSettingsEvent.UpdateProxyConfig(proxyConfig) + ) + showProxyDialog = false + } + ) + } + } +} + +@Composable +private fun WifiConfigDialog( + config: com.bodycamera.twentyfoulabs.data.model.WifiConfig?, + onDismiss: () -> Unit, + onConfirm: (com.bodycamera.twentyfoulabs.data.model.WifiConfig) -> Unit +) { + var ssid by remember { mutableStateOf(config?.ssid ?: "") } + var password by remember { mutableStateOf(config?.password ?: "") } + var securityType by remember { mutableStateOf(config?.securityType ?: SecurityType.WPA2) } + var useStaticIp by remember { mutableStateOf(config?.ipAddress != null) } + var ipAddress by remember { mutableStateOf(config?.ipAddress ?: "") } + var gateway by remember { mutableStateOf(config?.gateway ?: "") } + var subnetMask by remember { mutableStateOf(config?.subnetMask ?: "255.255.255.0") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.wifi_configuration)) }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 450.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = ssid, + onValueChange = { ssid = it }, + label = { Text(stringResource(R.string.ssid)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(R.string.password)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Text(stringResource(R.string.security_type) + ":") + SecurityType.values().forEach { type -> + Row( + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = securityType == type, + onClick = { securityType = type } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + when (type) { + com.bodycamera.twentyfoulabs.data.model.SecurityType.NONE -> stringResource(R.string.security_none) + com.bodycamera.twentyfoulabs.data.model.SecurityType.WEP -> stringResource(R.string.security_wep) + com.bodycamera.twentyfoulabs.data.model.SecurityType.WPA -> stringResource(R.string.security_wpa) + com.bodycamera.twentyfoulabs.data.model.SecurityType.WPA2 -> stringResource(R.string.security_wpa2) + com.bodycamera.twentyfoulabs.data.model.SecurityType.WPA3 -> stringResource(R.string.security_wpa3) + } + ) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = useStaticIp, + onCheckedChange = { useStaticIp = it } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.use_static_ip)) + } + + if (useStaticIp) { + OutlinedTextField( + value = ipAddress, + onValueChange = { ipAddress = it }, + label = { Text(stringResource(R.string.ip_address)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = gateway, + onValueChange = { gateway = it }, + label = { Text(stringResource(R.string.gateway)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = subnetMask, + onValueChange = { subnetMask = it }, + label = { Text(stringResource(R.string.subnet_mask)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + } + }, + confirmButton = { + TextButton(onClick = { + onConfirm( + com.bodycamera.twentyfoulabs.data.model.WifiConfig( + ssid = ssid, + password = password.ifBlank { null }, + securityType = securityType, + ipAddress = ipAddress.ifBlank { null }, + gateway = gateway.ifBlank { null }, + subnetMask = subnetMask + ) + ) + }) { + Text(stringResource(R.string.save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + +@Composable +private fun DnsConfigDialog( + config: com.bodycamera.twentyfoulabs.data.model.DnsConfig, + onDismiss: () -> Unit, + onConfirm: (com.bodycamera.twentyfoulabs.data.model.DnsConfig) -> Unit +) { + var useCustomDns by remember { mutableStateOf(config.useCustomDns) } + var dns1 by remember { mutableStateOf(config.dns1) } + var dns2 by remember { mutableStateOf(config.dns2) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.dns_configuration)) }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = useCustomDns, + onCheckedChange = { useCustomDns = it } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.use_custom_dns)) + } + + if (useCustomDns) { + OutlinedTextField( + value = dns1, + onValueChange = { dns1 = it }, + label = { Text(stringResource(R.string.dns1)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = dns2, + onValueChange = { dns2 = it }, + label = { Text(stringResource(R.string.dns2)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + } + }, + confirmButton = { + TextButton(onClick = { + onConfirm(config.copy(useCustomDns = useCustomDns, dns1 = dns1, dns2 = dns2)) + }) { + Text(stringResource(R.string.save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + +@Composable +private fun ProxyConfigDialog( + config: com.bodycamera.twentyfoulabs.data.model.ProxyConfig, + onDismiss: () -> Unit, + onConfirm: (com.bodycamera.twentyfoulabs.data.model.ProxyConfig) -> Unit +) { + var enabled by remember { mutableStateOf(config.enabled) } + var host by remember { mutableStateOf(config.host ?: "") } + var port by remember { mutableStateOf(config.port?.toString() ?: "") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.proxy_configuration)) }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = enabled, + onCheckedChange = { enabled = it } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.enabled)) + } + + if (enabled) { + OutlinedTextField( + value = host, + onValueChange = { host = it }, + label = { Text(stringResource(R.string.proxy_host)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = port, + onValueChange = { port = it }, + label = { Text(stringResource(R.string.proxy_port)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + } + }, + confirmButton = { + TextButton(onClick = { + onConfirm( + config.copy( + enabled = enabled, + host = host.ifBlank { null }, + port = port.toIntOrNull() + ) + ) + }) { + Text(stringResource(R.string.save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/ConnectionSettingsViewModel.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/ConnectionSettingsViewModel.kt new file mode 100644 index 0000000..1bb161b --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/ConnectionSettingsViewModel.kt @@ -0,0 +1,143 @@ +package com.bodycamera.twentyfoulabs.ui.screens + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.bodycamera.twentyfoulabs.data.model.DnsConfig +import com.bodycamera.twentyfoulabs.data.model.NetworkConfiguration +import com.bodycamera.twentyfoulabs.data.model.NetworkPriority +import com.bodycamera.twentyfoulabs.data.model.ProxyConfig +import com.bodycamera.twentyfoulabs.data.model.SecurityType +import com.bodycamera.twentyfoulabs.data.model.WifiConfig +import com.bodycamera.twentyfoulabs.data.network.WifiConnector +import com.bodycamera.twentyfoulabs.data.repository.NetworkConfigRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ConnectionSettingsViewModel @Inject constructor( + private val repository: NetworkConfigRepository, + private val wifiConnector: WifiConnector +) : ViewModel() { + + private val _uiState = MutableStateFlow(ConnectionSettingsUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadConfig() + } + + private fun loadConfig() { + viewModelScope.launch { + _uiState.value = ConnectionSettingsUiState.Loading + try { + val config = repository.getConfig() ?: NetworkConfiguration() + _uiState.value = ConnectionSettingsUiState.Success(config) + } catch (e: Exception) { + _uiState.value = ConnectionSettingsUiState.Error(e.message ?: "Unknown error") + } + } + } + + fun onEvent(event: ConnectionSettingsEvent) { + when (event) { + is ConnectionSettingsEvent.UpdateWifiConfig -> { + val currentConfig = (_uiState.value as? ConnectionSettingsUiState.Success)?.config + ?: NetworkConfiguration() + _uiState.value = ConnectionSettingsUiState.Success( + currentConfig.copy(wifi = event.wifiConfig) + ) + } + is ConnectionSettingsEvent.UpdateDnsConfig -> { + val currentConfig = (_uiState.value as? ConnectionSettingsUiState.Success)?.config + ?: NetworkConfiguration() + _uiState.value = ConnectionSettingsUiState.Success( + currentConfig.copy(dns = event.dnsConfig) + ) + } + is ConnectionSettingsEvent.UpdateProxyConfig -> { + val currentConfig = (_uiState.value as? ConnectionSettingsUiState.Success)?.config + ?: NetworkConfiguration() + _uiState.value = ConnectionSettingsUiState.Success( + currentConfig.copy(proxy = event.proxyConfig) + ) + } + is ConnectionSettingsEvent.SetNetworkPriority -> { + val currentConfig = (_uiState.value as? ConnectionSettingsUiState.Success)?.config + ?: NetworkConfiguration() + _uiState.value = ConnectionSettingsUiState.Success( + currentConfig.copy(networkPriority = event.priority) + ) + } + ConnectionSettingsEvent.SaveConfig -> { + saveConfig() + } + ConnectionSettingsEvent.Cancel -> { + loadConfig() + } + } + } + + private fun saveConfig() { + viewModelScope.launch { + val config = (_uiState.value as? ConnectionSettingsUiState.Success)?.config + ?: return@launch + + try { + // Primero guardar la configuración + repository.saveConfig(config.copy(lastUpdated = System.currentTimeMillis().toString())) + + // Luego intentar conectar al WiFi si está configurado + config.wifi?.let { wifiConfig -> + if (wifiConfig.ssid.isNotEmpty()) { + val result = wifiConnector.connectToWifi( + ssid = wifiConfig.ssid, + password = wifiConfig.password, + securityType = wifiConfig.securityType + ) + when (result) { + is WifiConnector.WifiConnectionResult.Success -> { + _uiState.value = ConnectionSettingsUiState.Saved + } + is WifiConnector.WifiConnectionResult.Error -> { + _uiState.value = ConnectionSettingsUiState.SavedWithError( + "Configuración guardada. Error de conexión: ${result.message}" + ) + } + is WifiConnector.WifiConnectionResult.Timeout -> { + _uiState.value = ConnectionSettingsUiState.SavedWithError( + "Configuración guardada. Tiempo de espera de conexión agotado" + ) + } + } + return@launch + } + } + + _uiState.value = ConnectionSettingsUiState.Saved + } catch (e: Exception) { + _uiState.value = ConnectionSettingsUiState.Error(e.message ?: "Failed to save") + } + } + } +} + +sealed interface ConnectionSettingsUiState { + data object Loading : ConnectionSettingsUiState + data class Success(val config: NetworkConfiguration) : ConnectionSettingsUiState + data object Saved : ConnectionSettingsUiState + data class SavedWithError(val message: String) : ConnectionSettingsUiState + data class Error(val message: String) : ConnectionSettingsUiState +} + +sealed interface ConnectionSettingsEvent { + data class UpdateWifiConfig(val wifiConfig: WifiConfig) : ConnectionSettingsEvent + data class UpdateDnsConfig(val dnsConfig: DnsConfig) : ConnectionSettingsEvent + data class UpdateProxyConfig(val proxyConfig: ProxyConfig) : ConnectionSettingsEvent + data class SetNetworkPriority(val priority: NetworkPriority) : ConnectionSettingsEvent + data object SaveConfig : ConnectionSettingsEvent + data object Cancel : ConnectionSettingsEvent +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/LoginScreen.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/LoginScreen.kt new file mode 100644 index 0000000..5071b0a --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/LoginScreen.kt @@ -0,0 +1,103 @@ +package com.bodycamera.twentyfoulabs.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.bodycamera.twentyfoulabs.data.backend.BackendConfig + +private val LoginNavy = Color(0xFF0B1F33) +private val LoginSurface = Color(0xFF132A3E) +private val LoginAccent = Color(0xFF2F80ED) + +@Composable +fun LoginScreen( + state: AuthUiState, + onLogin: (String, String) -> Unit, + onRetrySession: () -> Unit, + onClearError: () -> Unit +) { + var email by remember { mutableStateOf("supervisor@bodycam.local") } + var password by remember { mutableStateOf("") } + + Box( + modifier = Modifier.fillMaxSize().background(LoginNavy).padding(18.dp), + contentAlignment = Alignment.Center + ) { + Card( + colors = CardDefaults.cardColors(containerColor = LoginSurface), + shape = RoundedCornerShape(18.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(18.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("M530 COMMAND", color = Color.White, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Text("Inicio de turno", color = Color(0xFF9FC3E5)) + Text("${BackendConfig.DEVICE_MODEL} • ${BackendConfig.DEVICE_SERIAL}", color = Color(0xFF8FAEC7), style = MaterialTheme.typography.labelSmall) + + OutlinedTextField( + value = email, + onValueChange = { email = it; onClearError() }, + label = { Text("Correo") }, + leadingIcon = { Icon(Icons.Default.Person, null) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = password, + onValueChange = { password = it; onClearError() }, + label = { Text("Contraseña") }, + leadingIcon = { Icon(Icons.Default.Lock, null) }, + visualTransformation = PasswordVisualTransformation(), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + if (state is AuthUiState.Error) { + Text(state.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + + Button( + onClick = { onLogin(email, password) }, + enabled = state !is AuthUiState.Loading && state !is AuthUiState.Checking, + colors = ButtonDefaults.buttonColors(containerColor = LoginAccent), + modifier = Modifier.fillMaxWidth().height(48.dp) + ) { + if (state is AuthUiState.Loading) { + CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.White) + Spacer(Modifier.width(8.dp)) + } + Text(if (state is AuthUiState.Loading) "VALIDANDO…" else "INICIAR SESIÓN") + } + + if (state is AuthUiState.Checking) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + Text("Validando sesión guardada…", color = Color(0xFFB7CEE1), style = MaterialTheme.typography.bodySmall) + } + } else if (state is AuthUiState.Error) { + TextButton(onClick = onRetrySession) { Text("Reintentar conexión") } + } + } + } + } +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/MainScreen.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/MainScreen.kt new file mode 100644 index 0000000..0408e30 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/MainScreen.kt @@ -0,0 +1,353 @@ +package com.bodycamera.twentyfoulabs.ui.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.LocalLifecycleOwner +import com.bodycamera.twentyfoulabs.data.device.M530DeviceController +import java.text.SimpleDateFormat +import java.util.* +import android.view.MotionEvent +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.input.pointer.pointerInteropFilter + +private val Navy = Color(0xFF0B1F33) +private val Navy2 = Color(0xFF123551) +private val Accent = Color(0xFF2F80ED) +private val Green = Color(0xFF2E7D32) +private val Amber = Color(0xFFF9A825) +private val Danger = Color(0xFFC62828) +private val SurfaceDark = Color(0xFF132A3E) + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun MainScreen( + viewModel: MainViewModel, + onNavigateToSettings: () -> Unit, + onNavigateToVideoConfig: () -> Unit, + onOpenPhotoCamera: () -> Unit, + onOpenVideoCamera: () -> Unit, + onLogout: () -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + val isLocked by viewModel.isLocked.collectAsState() + val streamingState by viewModel.streamingState.collectAsState() + val deviceStatus by viewModel.deviceStatus.collectAsState() + val lifecycleOwner = LocalLifecycleOwner.current + var showLockDialog by remember { mutableStateOf(false) } + var pttPressed by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { viewModel.refreshDeviceStatus() } + + Scaffold( + containerColor = Navy, + topBar = { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors(containerColor = Navy, titleContentColor = Color.White), + title = { + Column { + Text("M530 COMMAND", fontWeight = FontWeight.Bold) + Text("Backend Auth 1.4.0", style = MaterialTheme.typography.labelSmall, color = Color(0xFF9FC3E5)) + } + }, + actions = { + IconButton(onClick = { if (isLocked) showLockDialog = true else viewModel.onEvent(MainUiEvent.ToggleLock) }) { + Icon(if (isLocked) Icons.Default.Lock else Icons.Default.LockOpen, null, tint = if (isLocked) Amber else Green) + } + IconButton(onClick = onLogout) { + Icon(Icons.Default.Logout, "Cerrar sesión", tint = Color.White) + } + } + ) + } + ) { padding -> + when (val state = uiState) { + MainUiState.Loading -> FullScreenMessage(padding, "Inicializando unidad M530…", true) + is MainUiState.Error -> FullScreenMessage(padding, state.message, false) + is MainUiState.Success -> { + Column( + modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + OperationalHeader(deviceStatus, streamingState, isLocked) + QuickActions( + streamingState = streamingState, + locked = isLocked, + pttPressed = pttPressed, + onVideo = onOpenVideoCamera, + onPhoto = onOpenPhotoCamera, + onPttDown = { if (!pttPressed) { pttPressed = viewModel.startPttFromUi() } }, + onPttUp = { viewModel.stopPttFromUi(); pttPressed = false }, + onSos = { viewModel.triggerSosFromUi() } + ) + TelemetryPanel(deviceStatus) + EvidencePanel(deviceStatus.evidence) + RecentEventsPanel(deviceStatus.recentEvents) + SettingsPanel( + locked = isLocked, + onConnection = onNavigateToSettings, + onVideo = onNavigateToVideoConfig, + onRefresh = { viewModel.refreshDeviceStatus() } + ) + Spacer(Modifier.height(10.dp)) + Text( + "F2 FOTO • F4 PTT • F5 VIDEO • F7 SOS", + color = Color(0xFF8FAEC7), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.fillMaxWidth(), + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + } + } + } + } + + if (showLockDialog) { + val lockVm: AdminLockViewModel = androidx.hilt.navigation.compose.hiltViewModel() + AdminLockScreen(lockVm, isLocked, onDismiss = { showLockDialog = false }) + } +} + +@Composable +private fun OperationalHeader(status: DeviceStatus, streaming: StreamingState, locked: Boolean) { + Card(colors = CardDefaults.cardColors(containerColor = SurfaceDark), shape = RoundedCornerShape(16.dp)) { + Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("UNIDAD OPERATIVA", color = Color(0xFF8FAEC7), style = MaterialTheme.typography.labelMedium) + Text(status.info?.model ?: "M530", color = Color.White, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Text("Android ${status.info?.androidVersion ?: "-"} | API ${status.info?.sdk ?: 0}", color = Color(0xFFB7CEE1), style = MaterialTheme.typography.bodySmall) + } + StreamBadge(streaming) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + StatusPill(if (status.telemetry?.connected == true) status.telemetry.network else "OFFLINE", status.telemetry?.connected == true) + StatusPill("BAT ${status.telemetry?.batteryPercent ?: 0}%", (status.telemetry?.batteryPercent ?: 0) >= 20) + StatusPill(if (locked) "BLOQUEADO" else "OPERADOR", !locked) + } + } + } +} + +@Composable +private fun StreamBadge(state: StreamingState) { + val (text, color) = when (state) { + StreamingState.Stopped -> "LISTO" to Green + StreamingState.Connecting -> "CONECTANDO" to Amber + is StreamingState.Active -> "EN VIVO" to Danger + is StreamingState.Error -> "ERROR" to Danger + } + Surface(color = color, shape = RoundedCornerShape(50)) { + Text(text, color = Color.White, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)) + } +} + +@Composable +private fun StatusPill(text: String, ok: Boolean) { + Surface(color = if (ok) Color(0xFF173D32) else Color(0xFF4A2A2A), shape = RoundedCornerShape(50)) { + Text(text, color = if (ok) Color(0xFF8AD7A5) else Color(0xFFFFB0B0), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp), maxLines = 1) + } +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) +@Composable +private fun QuickActions( + streamingState: StreamingState, + locked: Boolean, + pttPressed: Boolean, + onVideo: () -> Unit, + onPhoto: () -> Unit, + onPttDown: () -> Unit, + onPttUp: () -> Unit, + onSos: () -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SectionTitle("ACCIONES RAPIDAS") + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ActionTile( + Modifier.weight(1f), if (streamingState is StreamingState.Active || streamingState is StreamingState.Connecting) "DETENER" else "VIDEO", + if (streamingState is StreamingState.Active) Icons.Default.StopCircle else Icons.Default.Videocam, + if (streamingState is StreamingState.Active) Danger else Accent, !locked, onVideo + ) + ActionTile(Modifier.weight(1f), "FOTO", Icons.Default.PhotoCamera, Navy2, !locked, onPhoto) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Card( + modifier = Modifier + .weight(1f) + .height(82.dp) + .pointerInteropFilter { event -> + if (locked) return@pointerInteropFilter false + + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + onPttDown() + true + } + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> { + onPttUp() + true + } + else -> true + } + }, + colors = CardDefaults.cardColors(containerColor = if (pttPressed) Green else Navy2), + shape = RoundedCornerShape(14.dp) + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(Icons.Default.Mic, contentDescription = "PTT", tint = Color.White, modifier = Modifier.size(28.dp)) + Text(if (pttPressed) "PTT ACTIVO" else "PTT", color = Color.White, fontWeight = FontWeight.Bold) + Text(if (pttPressed) "Suelta para liberar" else "Mantener para hablar", color = Color(0xFFB7CEE1), style = MaterialTheme.typography.labelSmall) + } + } + } + ActionTile(Modifier.weight(1f), "SOS", Icons.Default.Sos, Danger, !locked, onSos) + } +} +} + +@Composable +private fun ActionTile(modifier: Modifier, label: String, icon: androidx.compose.ui.graphics.vector.ImageVector, color: Color, enabled: Boolean, onClick: () -> Unit) { + Card( + modifier = modifier.height(82.dp).clickable(enabled = enabled, onClick = onClick), + colors = CardDefaults.cardColors(containerColor = if (enabled) color else Color(0xFF263747)), + shape = RoundedCornerShape(14.dp) + ) { + Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + Icon(icon, null, tint = if (enabled) Color.White else Color.Gray, modifier = Modifier.size(30.dp)) + Spacer(Modifier.height(4.dp)); Text(label, color = if (enabled) Color.White else Color.Gray, fontWeight = FontWeight.Bold) + } + } +} + +@Composable +private fun TelemetryPanel(status: DeviceStatus) { + val t = status.telemetry + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + SectionTitle("ESTADO DEL EQUIPO") + Card(colors = CardDefaults.cardColors(containerColor = SurfaceDark), shape = RoundedCornerShape(14.dp)) { + Row(Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.SpaceAround) { + Metric(Icons.Default.BatteryStd, "Batería", if ((t?.batteryPercent ?: -1) >= 0) "${t?.batteryPercent}%" else "—") + Metric(Icons.Default.SignalCellularAlt, "Red", t?.network ?: "—") + Metric(Icons.Default.Storage, "Libre", formatMb(t?.freeStorageMb ?: 0L)) + Metric(Icons.Default.CloudDone, "Estado", if (t?.connected == true) "Online" else "Offline") + } + } + } +} + +@Composable +private fun Metric(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, value: String) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(68.dp)) { + Icon(icon, null, tint = Color(0xFF8FC5F5), modifier = Modifier.size(22.dp)); Spacer(Modifier.height(3.dp)) + Text(value, color = Color.White, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(label, color = Color(0xFF8FAEC7), style = MaterialTheme.typography.labelSmall) + } +} + +@Composable +private fun EvidencePanel(e: M530DeviceController.EvidenceSummary?) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + SectionTitle("EVIDENCIAS LOCALES") + Card(colors = CardDefaults.cardColors(containerColor = SurfaceDark), shape = RoundedCornerShape(14.dp)) { + Row(Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.SpaceBetween) { + EvidenceCounter("Fotos", e?.photos ?: 0, Icons.Default.PhotoLibrary) + EvidenceCounter("PTT", e?.pttClips ?: 0, Icons.Default.GraphicEq) + EvidenceCounter("Eventos", e?.eventLines ?: 0, Icons.Default.FactCheck) + } + } + } +} + +@Composable +private fun EvidenceCounter(label: String, value: Int, icon: androidx.compose.ui.graphics.vector.ImageVector) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(icon, null, tint = Color(0xFF8FC5F5)); Spacer(Modifier.width(7.dp)) + Column { Text(value.toString(), color = Color.White, fontWeight = FontWeight.Bold); Text(label, color = Color(0xFF8FAEC7), style = MaterialTheme.typography.labelSmall) } + } +} + +@Composable +private fun RecentEventsPanel(events: List) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + SectionTitle("ACTIVIDAD RECIENTE") + Card(colors = CardDefaults.cardColors(containerColor = SurfaceDark), shape = RoundedCornerShape(14.dp)) { + Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp)) { + if (events.isEmpty()) Text("Sin eventos registrados", color = Color(0xFF8FAEC7), modifier = Modifier.padding(8.dp)) + events.take(5).forEachIndexed { index, event -> + EventRow(event) + if (index < events.take(5).lastIndex) Divider(color = Color(0xFF284A64)) + } + } + } + } +} + +@Composable +private fun EventRow(event: M530DeviceController.EventEntry) { + Row(Modifier.fillMaxWidth().padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { + val icon = when (event.type) { + "SOS" -> Icons.Default.Warning + "PHOTO_CAPTURED" -> Icons.Default.PhotoCamera + "PTT_DOWN", "PTT_UP" -> Icons.Default.Mic + "TELEMETRY" -> Icons.Default.MonitorHeart + else -> Icons.Default.Circle + } + Icon(icon, null, tint = if (event.type == "SOS") Danger else Color(0xFF8FC5F5), modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(9.dp)) + Column(Modifier.weight(1f)) { + Text(event.type.replace('_', ' '), color = Color.White, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + if (event.detail.isNotBlank()) Text(event.detail, color = Color(0xFF8FAEC7), style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Text(formatTime(event.recordedAt), color = Color(0xFF8FAEC7), style = MaterialTheme.typography.labelSmall) + } +} + +@Composable +private fun SettingsPanel(locked: Boolean, onConnection: () -> Unit, onVideo: () -> Unit, onRefresh: () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + SectionTitle("CONFIGURACIÓN") + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onConnection, enabled = !locked, modifier = Modifier.weight(1f)) { Icon(Icons.Default.SettingsEthernet, null); Spacer(Modifier.width(5.dp)); Text("Red") } + OutlinedButton(onClick = onVideo, enabled = !locked, modifier = Modifier.weight(1f)) { Icon(Icons.Default.Tune, null); Spacer(Modifier.width(5.dp)); Text("Video") } + IconButton(onClick = onRefresh) { Icon(Icons.Default.Refresh, "Actualizar", tint = Color.White) } + } + } +} + +@Composable private fun SectionTitle(text: String) = Text(text, color = Color(0xFF8FAEC7), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) + +@Composable +private fun FullScreenMessage(padding: PaddingValues, text: String, progress: Boolean) { + Box(Modifier.fillMaxSize().padding(padding).background(Navy), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + if (progress) { CircularProgressIndicator(color = Accent); Spacer(Modifier.height(14.dp)) } + Text(text, color = Color.White) + } + } +} + +private fun formatMb(mb: Long): String = if (mb >= 1024) String.format(Locale.US, "%.1f GB", mb / 1024.0) else "$mb MB" +private fun formatTime(ts: Long): String = if (ts <= 0) "—" else SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(ts)) + + + + + diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/MainViewModel.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/MainViewModel.kt new file mode 100644 index 0000000..b0844cf --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/MainViewModel.kt @@ -0,0 +1,317 @@ +package com.bodycamera.twentyfoulabs.ui.screens + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.bodycamera.twentyfoulabs.data.model.NetworkConfiguration +import com.bodycamera.twentyfoulabs.data.device.M530DeviceController +import com.bodycamera.twentyfoulabs.data.model.VideoConfiguration +import com.bodycamera.twentyfoulabs.data.network.NetworkFallbackManager +import com.bodycamera.twentyfoulabs.data.repository.AdminLockRepository +import com.bodycamera.twentyfoulabs.data.repository.NetworkConfigRepository +import com.bodycamera.twentyfoulabs.data.repository.VideoConfigRepository +import com.bodycamera.twentyfoulabs.data.webrtc.WebRTCManager +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.delay +import androidx.lifecycle.LifecycleOwner +import javax.inject.Inject + +@HiltViewModel +class MainViewModel @Inject constructor( + repository: NetworkConfigRepository, + networkFallbackManager: NetworkFallbackManager, + private val adminLockRepository: AdminLockRepository, + private val videoConfigRepository: VideoConfigRepository, + private val webrtcManager: WebRTCManager, + private val deviceController: M530DeviceController +) : ViewModel() { + + companion object { + private const val TAG = "MainViewModel" + private const val BUTTON_DEBOUNCE_MS = 1000L + } + + val uiState: StateFlow = combine( + repository.getConfigFlow(), + networkFallbackManager.fallbackState + ) { config, fallbackState -> + MainUiState.Success(config, fallbackState) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = MainUiState.Loading + ) + + val isLocked: StateFlow = adminLockRepository.getLockState() + .map { it.isLocked } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = true + ) + + // Estado del streaming WebRTC + private val _streamingState = MutableStateFlow(StreamingState.Stopped) + val streamingState: StateFlow = _streamingState.asStateFlow() + + private var streamingCallback: WebRTCManager.StreamingCallback? = null + + // Debounce para prevenir múltiples presiones rápidas del botón + private var lastButtonPressTime = 0L + + // Estado solicitado por el operador. Se mantiene separado del estado de red WebRTC + // para que una segunda pulsación de F5 SIEMPRE detenga cámara/stream aunque WHIP falle. + @Volatile + private var streamingRequested = false + + // Scope para actualizaciones de estado inmediatas en el main thread + private val mainImmediateScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + + private val _deviceStatus = MutableStateFlow(DeviceStatus()) + val deviceStatus: StateFlow = _deviceStatus.asStateFlow() + + init { + initializeStreamingCallback() + startDeviceStatusLoop() + } + + private fun startDeviceStatusLoop() { + viewModelScope.launch(Dispatchers.IO) { + var ticks = 0 + while (true) { + refreshDeviceStatus() + if (ticks % 3 == 0) deviceController.logTelemetry() + ticks++ + delay(5000) + } + } + } + + fun refreshDeviceStatus() { + val telemetry = deviceController.telemetry() + val info = deviceController.deviceInfo() + val evidence = deviceController.evidenceSummary() + val events = deviceController.recentEvents(8) + _deviceStatus.value = DeviceStatus(telemetry, info, evidence, events) + } + + fun triggerSosFromUi() { + deviceController.triggerSos() + refreshDeviceStatus() + } + + fun capturePhotoFromUi(owner: LifecycleOwner) { + deviceController.capturePhoto(owner) { refreshDeviceStatus() } + } + + fun startPttFromUi(): Boolean { + val ok = deviceController.startPtt().isSuccess + refreshDeviceStatus() + return ok + } + + fun stopPttFromUi() { + deviceController.stopPtt() + refreshDeviceStatus() + } + + private fun initializeStreamingCallback() { + streamingCallback = object : WebRTCManager.StreamingCallback { + override fun onStreamStarted() { + Log.e(TAG, "Stream started - updating state to Active") + deviceController.notifyVideoStarted(com.bodycamera.twentyfoulabs.data.backend.BackendConfig.whipUrl) + mainImmediateScope.launch { + _streamingState.value = StreamingState.Active(600) // Default time + } + } + + override fun onStreamStopped() { + Log.e(TAG, "Stream stopped - updating state to Stopped") + deviceController.notifyVideoStopped() + streamingRequested = false + mainImmediateScope.launch { + _streamingState.value = StreamingState.Stopped + } + } + + override fun onConnectionChange(isConnected: Boolean) { + Log.e(TAG, "Connection changed: $isConnected, current state: ${_streamingState.value}") + mainImmediateScope.launch { + _streamingState.value = when (_streamingState.value) { + is StreamingState.Connecting -> { + // Si estaba conectando y ahora está conectado, pasar a Active + if (isConnected) { + Log.e(TAG, "Connection established, changing to Active") + StreamingState.Active(600) + } else { + _streamingState.value + } + } + is StreamingState.Active -> { + if (!isConnected) { + Log.e(TAG, "Connection lost, changing to Connecting") + StreamingState.Connecting + } else { + _streamingState.value + } + } + else -> _streamingState.value + } + } + } + + override fun onError(error: String) { + Log.e(TAG, "Streaming error: $error - forcing media cleanup") + deviceController.notifyVideoError(error) + streamingRequested = false + // Si WHIP/signaling falla, no dejar la cámara capturando indefinidamente. + webrtcManager.stopStreaming() + mainImmediateScope.launch { + _streamingState.value = StreamingState.Error(error) + } + } + + override fun onTimeRemaining(seconds: Int) { + // No loguear cada segundo para no saturar + mainImmediateScope.launch { + when (_streamingState.value) { + is StreamingState.Active -> { + _streamingState.value = StreamingState.Active(seconds) + } + else -> { + // No actualizar si no estamos en estado Active + } + } + } + } + } + webrtcManager.initialize(streamingCallback!!) + } + + fun onEvent(event: MainUiEvent) { + when (event) { + MainUiEvent.RetryLoad -> { + // El flow se actualiza automáticamente + } + MainUiEvent.ImportConfigFromFile -> { + // TODO: Implement file picker + } + MainUiEvent.ToggleLock -> { + viewModelScope.launch { + val currentState = adminLockRepository.isLocked() + adminLockRepository.setLockState(!currentState) + } + } + MainUiEvent.ToggleStreaming -> { + toggleStreaming() + } + } + } + + /** + * Método público para ser llamado desde el botón físico de cámara (KEYCODE_F5). + * Verifica si la app está bloqueada antes de iniciar/detener streaming. + */ + fun triggerCameraButton() { + val currentTime = System.currentTimeMillis() + if (currentTime - lastButtonPressTime < BUTTON_DEBOUNCE_MS) { + Log.e(TAG, "Botón presionado muy rápido, ignorando (debounce)") + return + } + lastButtonPressTime = currentTime + + Log.e(TAG, "triggerCameraButton llamado") + viewModelScope.launch { + val isLocked = adminLockRepository.isLocked() + Log.e(TAG, "isLocked: $isLocked") + if (isLocked) { + Log.e(TAG, "App bloqueada - botón cámara ignorado") + return@launch + } + Log.e(TAG, "App desbloqueada - llamando a toggleStreaming") + toggleStreaming() + } + } + + private fun toggleStreaming() { + Log.e(TAG, "toggleStreaming llamado, requested=$streamingRequested, estado=${_streamingState.value}") + viewModelScope.launch { + if (streamingRequested) { + Log.e(TAG, "F5 STOP solicitado - liberando cámara/WebRTC") + streamingRequested = false + webrtcManager.stopStreaming() + _streamingState.value = StreamingState.Stopped + return@launch + } + + val config = videoConfigRepository.getConfig() + if (config == null) { + Log.e(TAG, "No hay configuración de video") + streamingRequested = false + _streamingState.value = StreamingState.Error("No hay configuración de video") + return@launch + } + + Log.e(TAG, "F5 START solicitado: ${config.webrtcServer.serverUrl}:${config.webrtcServer.serverPort}") + streamingRequested = true + _streamingState.value = StreamingState.Connecting + try { + webrtcManager.startStreaming(config) + } catch (e: Exception) { + Log.e(TAG, "Error iniciando streaming", e) + streamingRequested = false + webrtcManager.stopStreaming() + _streamingState.value = StreamingState.Error(e.message ?: "Error iniciando streaming") + } + } + } + + override fun onCleared() { + super.onCleared() + webrtcManager.release() + deviceController.release() + } +} + +sealed interface MainUiState { + data object Loading : MainUiState + data class Success( + val networkConfig: NetworkConfiguration?, + val fallbackState: NetworkFallbackManager.FallbackState = NetworkFallbackManager.FallbackState() + ) : MainUiState + data class Error(val message: String) : MainUiState +} + +sealed interface StreamingState { + data object Stopped : StreamingState + data object Connecting : StreamingState + data class Active(val secondsRemaining: Int) : StreamingState + data class Error(val message: String) : StreamingState +} + +sealed interface MainUiEvent { + data object RetryLoad : MainUiEvent + data object ImportConfigFromFile : MainUiEvent + data object ToggleLock : MainUiEvent + data object ToggleStreaming : MainUiEvent +} + + +data class DeviceStatus( + val telemetry: M530DeviceController.TelemetrySnapshot? = null, + val info: M530DeviceController.DeviceInfo? = null, + val evidence: M530DeviceController.EvidenceSummary? = null, + val recentEvents: List = emptyList() +) diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/VideoConfigScreen.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/VideoConfigScreen.kt new file mode 100644 index 0000000..47bf9b7 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/VideoConfigScreen.kt @@ -0,0 +1,794 @@ +package com.bodycamera.twentyfoulabs.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.bodycamera.twentyfoulabs.data.model.* +import com.bodycamera.twentyfoulabs.navigation.BackButton +import com.bodycamera.twentyfoulabs.navigation.LocalNavigationHandler + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VideoConfigScreen( + viewModel: VideoConfigViewModel +) { + val uiState by viewModel.uiState.collectAsState() + val testConnectionState by viewModel.testConnectionState.collectAsState() + val testStreamState by viewModel.testStreamState.collectAsState() + val navigationHandler = LocalNavigationHandler.current + + var showServerDialog by remember { mutableStateOf(false) } + var showCameraDialog by remember { mutableStateOf(false) } + var showQualityDialog by remember { mutableStateOf(false) } + + // Auto navegar hacia atrás cuando se guarda con éxito + LaunchedEffect(uiState) { + when (uiState) { + is VideoConfigUiState.Saved -> { + kotlinx.coroutines.delay(1000) + navigationHandler.navigateBack() + viewModel.onEvent(VideoConfigEvent.Cancel) + } + else -> {} + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Configuración de Video") }, + navigationIcon = { + BackButton(navigationHandler) + } + ) + } + ) { paddingValues -> + when (val state = uiState) { + is VideoConfigUiState.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + is VideoConfigUiState.Error -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Text( + "Error: ${state.message}", + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + } + is VideoConfigUiState.Saved -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text("Configuración guardada") + } + } + } + is VideoConfigUiState.Success -> { + val config = state.config + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Sección Servidor WebRTC + item { + Card { + Column(modifier = Modifier.padding(16.dp)) { + Text( + "Servidor WebRTC", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + "URL: ${config.webrtcServer.serverUrl}:${config.webrtcServer.serverPort}", + style = MaterialTheme.typography.bodyMedium + ) + Text( + "Stream: ${config.webrtcServer.streamName}", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + TextButton( + onClick = { showServerDialog = true }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Configurar Servidor") + } + } + } + } + + // Sección Cámara + item { + Card { + Column(modifier = Modifier.padding(16.dp)) { + Text( + "Selección de Cámara", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Cámara: ${ + when (config.camera.cameraFacing) { + CameraFacing.FRONT -> "Frontal" + CameraFacing.BACK -> "Trasera" + CameraFacing.EXTERNAL -> "Externa" + } + }", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + TextButton( + onClick = { showCameraDialog = true }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Seleccionar Cámara") + } + } + } + } + + // Sección Calidad de Video + item { + Card { + Column(modifier = Modifier.padding(16.dp)) { + Text( + "Calidad de Video", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Resolución: ${config.video.resolution.label}", + style = MaterialTheme.typography.bodyMedium + ) + Text( + "FPS: ${config.video.frameRate}", + style = MaterialTheme.typography.bodyMedium + ) + Text( + "Bitrate: ${config.video.bitrate / 1000000} Mbps", + style = MaterialTheme.typography.bodyMedium + ) + Text( + "Auto-detener: ${config.video.autoStopTimeSeconds}s (${config.video.autoStopTimeSeconds / 60} min)", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + TextButton( + onClick = { showQualityDialog = true }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Configurar Calidad") + } + } + } + } + + // Sección Diagnóstico + item { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "Diagnóstico de Configuración", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + Spacer(modifier = Modifier.height(8.dp)) + + // Mostrar URL completa + Text( + "URL WHIP:", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + Text( + "http://${config.webrtcServer.serverUrl}:${config.webrtcServer.serverPort}/${config.webrtcServer.streamName}/whip", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // Mostrar usuario + Text( + "Usuario: ${config.webrtcServer.username}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // Indicador de contraseña + Text( + if (config.webrtcServer.password.isNotBlank()) "Contraseña: ******** (configurada)" + else "Contraseña: NO CONFIGURADA", + style = MaterialTheme.typography.bodySmall, + color = if (config.webrtcServer.password.isNotBlank()) + MaterialTheme.colorScheme.onSecondaryContainer + else + MaterialTheme.colorScheme.error + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // Validaciones + if (config.webrtcServer.serverUrl.isBlank()) { + Text( + "⚠️ URL del servidor está vacía", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + if (config.webrtcServer.streamName.isBlank()) { + Text( + "⚠️ Nombre del stream está vacío", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + } + } + + // Sección Prueba de Conexión + item { + Card { + Column(modifier = Modifier.padding(16.dp)) { + Text( + "Prueba de Conexión", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + + when (val testState = testConnectionState) { + is TestConnectionState.Idle -> { + Button( + onClick = { + viewModel.onEvent(VideoConfigEvent.TestConnection) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Probar Conexión") + } + } + is TestConnectionState.Connecting -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Conectando…") + } + } + is TestConnectionState.Success -> { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + testState.message, + color = MaterialTheme.colorScheme.primary + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + viewModel.onEvent(VideoConfigEvent.TestConnection) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Probar de Nuevo") + } + } + is TestConnectionState.Error -> { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "X", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + testState.message, + color = MaterialTheme.colorScheme.error + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + viewModel.onEvent(VideoConfigEvent.TestConnection) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Reintentar") + } + } + } + } + } + } + + // Sección Stream de Prueba + item { + Card { + Column(modifier = Modifier.padding(16.dp)) { + Text( + "Stream de Prueba", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + + when (val streamState = testStreamState) { + is TestStreamState.Idle -> { + Button( + onClick = { + viewModel.onEvent(VideoConfigEvent.TestStream) + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Icon( + imageVector = Icons.Default.Wifi, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Iniciar Stream de Prueba") + } + } + is TestStreamState.Starting -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Iniciando stream WHIP…") + } + } + is TestStreamState.Active -> { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + streamState.message, + color = MaterialTheme.colorScheme.primary + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + viewModel.onEvent(VideoConfigEvent.TestStream) + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Detener Stream") + } + } + is TestStreamState.Stopped -> { + Text( + "Stream detenido", + color = MaterialTheme.colorScheme.secondary + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + viewModel.onEvent(VideoConfigEvent.TestStream) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Iniciar de Nuevo") + } + } + is TestStreamState.Error -> { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "X", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + streamState.message, + color = MaterialTheme.colorScheme.error + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + viewModel.onEvent(VideoConfigEvent.TestStream) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Reintentar") + } + } + } + } + } + } + + // Botón Guardar + item { + Button( + onClick = { + viewModel.onEvent(VideoConfigEvent.SaveConfig) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Guardar Configuración") + } + } + } + } + } + + // Diálogos + if (showServerDialog) { + ServerConfigDialog( + config = (uiState as? VideoConfigUiState.Success)?.config?.webrtcServer + ?: WebRTCServerConfig(), + onDismiss = { showServerDialog = false }, + onConfirm = { serverConfig -> + viewModel.onEvent(VideoConfigEvent.UpdateServerConfig(serverConfig)) + showServerDialog = false + } + ) + } + + if (showCameraDialog) { + CameraConfigDialog( + config = (uiState as? VideoConfigUiState.Success)?.config?.camera + ?: CameraConfig(), + onDismiss = { showCameraDialog = false }, + onConfirm = { cameraConfig -> + viewModel.onEvent(VideoConfigEvent.UpdateCameraConfig(cameraConfig)) + showCameraDialog = false + } + ) + } + + if (showQualityDialog) { + VideoQualityDialog( + config = (uiState as? VideoConfigUiState.Success)?.config?.video + ?: VideoQualityConfig(), + onDismiss = { showQualityDialog = false }, + onConfirm = { qualityConfig -> + viewModel.onEvent(VideoConfigEvent.UpdateVideoQuality(qualityConfig)) + showQualityDialog = false + } + ) + } + } +} + +@Composable +private fun ServerConfigDialog( + config: WebRTCServerConfig, + onDismiss: () -> Unit, + onConfirm: (WebRTCServerConfig) -> Unit +) { + var serverUrl by remember { mutableStateOf(config.serverUrl) } + var serverPort by remember { mutableStateOf(config.serverPort.toString()) } + var streamName by remember { mutableStateOf(config.streamName) } + var username by remember { mutableStateOf(config.username) } + var password by remember { mutableStateOf(config.password) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Configuración del Servidor WebRTC") }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 350.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = serverUrl, + onValueChange = { serverUrl = it }, + label = { Text("URL del Servidor") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = serverPort, + onValueChange = { serverPort = it }, + label = { Text("Puerto") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = streamName, + onValueChange = { streamName = it }, + label = { Text("Nombre del Stream") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text("Usuario") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Contraseña") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton(onClick = { + onConfirm( + config.copy( + serverUrl = serverUrl, + serverPort = serverPort.toIntOrNull() ?: config.serverPort, + streamName = streamName, + username = username, + password = password + ) + ) + }) { + Text("Guardar") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancelar") + } + } + ) +} + +@Composable +private fun CameraConfigDialog( + config: CameraConfig, + onDismiss: () -> Unit, + onConfirm: (CameraConfig) -> Unit +) { + var selectedFacing by remember { mutableStateOf(config.cameraFacing) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Selección de Cámara") }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + CameraFacing.values().forEach { facing -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + RadioButton( + selected = selectedFacing == facing, + onClick = { selectedFacing = facing } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + when (facing) { + CameraFacing.FRONT -> "Cámara Frontal" + CameraFacing.BACK -> "Cámara Trasera" + CameraFacing.EXTERNAL -> "Cámara Externa" + } + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = { + onConfirm(config.copy(cameraFacing = selectedFacing)) + }) { + Text("Guardar") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancelar") + } + } + ) +} + +@Composable +private fun VideoQualityDialog( + config: VideoQualityConfig, + onDismiss: () -> Unit, + onConfirm: (VideoQualityConfig) -> Unit +) { + var selectedResolution by remember { mutableStateOf(config.resolution) } + var selectedFrameRate by remember { mutableStateOf(config.frameRate) } + var selectedBitrate by remember { mutableStateOf(config.bitrate) } + var autoStopMinutes by remember { mutableStateOf((config.autoStopTimeSeconds / 60).toString()) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Calidad de Video") }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 400.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Resolución:", style = MaterialTheme.typography.titleSmall) + VideoResolution.values().forEach { resolution -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + RadioButton( + selected = selectedResolution == resolution, + onClick = { selectedResolution = resolution } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(resolution.label) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text("Frame Rate (FPS):", style = MaterialTheme.typography.titleSmall) + val frameRates = listOf(15, 24, 30, 60) + frameRates.forEach { fps -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + RadioButton( + selected = selectedFrameRate == fps, + onClick = { selectedFrameRate = fps } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("$fps FPS") + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = (selectedBitrate / 1000000).toString(), + onValueChange = { + selectedBitrate = (it.toDoubleOrNull()?.times(1000000))?.toInt() + ?: config.bitrate + }, + label = { Text("Bitrate (Mbps)") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = autoStopMinutes, + onValueChange = { autoStopMinutes = it }, + label = { Text("Auto-detener (minutos)") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + supportingText = { Text("0 = sin auto-detener") } + ) + } + }, + confirmButton = { + TextButton(onClick = { + val autoStopSeconds = autoStopMinutes.toIntOrNull()?.times(60) ?: config.autoStopTimeSeconds + onConfirm( + config.copy( + resolution = selectedResolution, + frameRate = selectedFrameRate, + bitrate = selectedBitrate, + autoStopTimeSeconds = autoStopSeconds + ) + ) + }) { + Text("Guardar") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancelar") + } + } + ) +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/VideoConfigViewModel.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/VideoConfigViewModel.kt new file mode 100644 index 0000000..49878a8 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/VideoConfigViewModel.kt @@ -0,0 +1,203 @@ +package com.bodycamera.twentyfoulabs.ui.screens + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.bodycamera.twentyfoulabs.data.model.CameraConfig +import com.bodycamera.twentyfoulabs.data.model.CameraFacing +import com.bodycamera.twentyfoulabs.data.model.VideoConfiguration +import com.bodycamera.twentyfoulabs.data.model.VideoQualityConfig +import com.bodycamera.twentyfoulabs.data.model.WebRTCServerConfig +import com.bodycamera.twentyfoulabs.data.repository.VideoConfigRepository +import com.bodycamera.twentyfoulabs.data.webrtc.WebRTCManager +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@HiltViewModel +class VideoConfigViewModel @Inject constructor( + private val repository: VideoConfigRepository, + private val webrtcManager: WebRTCManager +) : ViewModel() { + + private val _uiState = MutableStateFlow(VideoConfigUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _testConnectionState = MutableStateFlow(TestConnectionState.Idle) + val testConnectionState: StateFlow = _testConnectionState.asStateFlow() + + private val _testStreamState = MutableStateFlow(TestStreamState.Idle) + val testStreamState: StateFlow = _testStreamState.asStateFlow() + + init { + loadConfig() + } + + private fun loadConfig() { + viewModelScope.launch { + _uiState.value = VideoConfigUiState.Loading + try { + val config = repository.getConfig() ?: VideoConfiguration() + _uiState.value = VideoConfigUiState.Success(config) + } catch (e: Exception) { + _uiState.value = VideoConfigUiState.Error(e.message ?: "Error al cargar configuración") + } + } + } + + fun onEvent(event: VideoConfigEvent) { + when (event) { + is VideoConfigEvent.UpdateServerConfig -> { + val currentConfig = getCurrentConfig() + _uiState.value = VideoConfigUiState.Success( + currentConfig.copy(webrtcServer = event.config) + ) + } + is VideoConfigEvent.UpdateCameraConfig -> { + val currentConfig = getCurrentConfig() + _uiState.value = VideoConfigUiState.Success( + currentConfig.copy(camera = event.config) + ) + } + is VideoConfigEvent.UpdateVideoQuality -> { + val currentConfig = getCurrentConfig() + _uiState.value = VideoConfigUiState.Success( + currentConfig.copy(video = event.config) + ) + } + is VideoConfigEvent.TestConnection -> { + testConnection() + } + is VideoConfigEvent.TestStream -> { + testStream() + } + VideoConfigEvent.SaveConfig -> { + saveConfig() + } + VideoConfigEvent.Cancel -> { + loadConfig() + } + } + } + + private fun getCurrentConfig(): VideoConfiguration { + return (_uiState.value as? VideoConfigUiState.Success)?.config + ?: VideoConfiguration() + } + + private fun testConnection() { + viewModelScope.launch { + val config = getCurrentConfig() + _testConnectionState.value = TestConnectionState.Connecting + + // Ejecutar llamada de red en IO dispatcher + val result = withContext(Dispatchers.IO) { + webrtcManager.testConnection(config) + } + + when (result) { + is WebRTCManager.ConnectionResult.Success -> { + _testConnectionState.value = TestConnectionState.Success( + "Conexión exitosa al servidor WebRTC" + ) + } + is WebRTCManager.ConnectionResult.Error -> { + _testConnectionState.value = TestConnectionState.Error(result.message) + } + } + } + } + + private fun testStream() { + when (_testStreamState.value) { + is TestStreamState.Idle, is TestStreamState.Error, is TestStreamState.Stopped -> { + // Iniciar stream de prueba real + viewModelScope.launch { + val config = getCurrentConfig() + _testStreamState.value = TestStreamState.Starting + + try { + // Iniciar streaming real con WebRTC + withContext(Dispatchers.Main) { + webrtcManager.startStreaming(config) + _testStreamState.value = TestStreamState.Active("Streaming activo - WHIP conectado") + } + } catch (e: Exception) { + _testStreamState.value = TestStreamState.Error("Error: ${e.message}") + } + } + } + is TestStreamState.Starting -> { + // No hacer nada, está iniciando + } + is TestStreamState.Active -> { + // Detener stream de prueba + webrtcManager.stopStreaming() + _testStreamState.value = TestStreamState.Stopped + } + } + } + + private fun saveConfig() { + viewModelScope.launch { + val config = getCurrentConfig() + + try { + repository.saveConfig( + config.copy(lastUpdated = System.currentTimeMillis().toString()) + ) + _uiState.value = VideoConfigUiState.Saved + } catch (e: Exception) { + _uiState.value = VideoConfigUiState.Error(e.message ?: "Error al guardar configuración") + } + } + } +} + +/** + * Estados de la UI para VideoConfigScreen + */ +sealed interface VideoConfigUiState { + data object Loading : VideoConfigUiState + data class Success(val config: VideoConfiguration) : VideoConfigUiState + data object Saved : VideoConfigUiState + data class Error(val message: String) : VideoConfigUiState +} + +/** + * Eventos de la UI para VideoConfigScreen + */ +sealed interface VideoConfigEvent { + data class UpdateServerConfig(val config: WebRTCServerConfig) : VideoConfigEvent + data class UpdateCameraConfig(val config: CameraConfig) : VideoConfigEvent + data class UpdateVideoQuality(val config: VideoQualityConfig) : VideoConfigEvent + data object TestConnection : VideoConfigEvent + data object TestStream : VideoConfigEvent + data object SaveConfig : VideoConfigEvent + data object Cancel : VideoConfigEvent +} + +/** + * Estado de prueba de conexión + */ +sealed interface TestConnectionState { + data object Idle : TestConnectionState + data object Connecting : TestConnectionState + data class Success(val message: String) : TestConnectionState + data class Error(val message: String) : TestConnectionState +} + +/** + * Estado de prueba de streaming + */ +sealed interface TestStreamState { + data object Idle : TestStreamState + data object Starting : TestStreamState + data class Active(val message: String) : TestStreamState + data object Stopped : TestStreamState + data class Error(val message: String) : TestStreamState +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/theme/Color.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/theme/Color.kt new file mode 100644 index 0000000..fba08cb --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/theme/Color.kt @@ -0,0 +1,18 @@ +package com.bodycamera.twentyfoulabs.ui.theme + +import androidx.compose.ui.graphics.Color + +// Primary colors +val Primary = Color(0xFF1976D2) +val PrimaryDark = Color(0xFF0D47A1) +val PrimaryContainer = Color(0xFFB3E5FC) +val OnPrimaryContainer = Color(0xFF001D25) + +// Secondary colors +val Secondary = Color(0xFF03A9F4) +val SecondaryContainer = Color(0xFFB3E5FC) + +// Status colors +val Success = Color(0xFF4CAF50) +val Warning = Color(0xFFFF9800) +val Error = Color(0xFFF44336) diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/theme/Theme.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/theme/Theme.kt new file mode 100644 index 0000000..1553ff4 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/theme/Theme.kt @@ -0,0 +1,55 @@ +package com.bodycamera.twentyfoulabs.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.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = darkColorScheme( + primary = Primary, + secondary = Secondary, + tertiary = PrimaryContainer +) + +private val LightColorScheme = lightColorScheme( + primary = Primary, + secondary = Secondary, + tertiary = PrimaryContainer +) + +@Composable +fun MainAppTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = false, // Disabled for consistency + content: @Composable () -> Unit +) { + val colorScheme = when { + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/theme/Type.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/theme/Type.kt new file mode 100644 index 0000000..c632881 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/theme/Type.kt @@ -0,0 +1,115 @@ +package com.bodycamera.twentyfoulabs.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 + +val Typography = Typography( + displayLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp + ), + displayMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp + ), + displaySmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = 0.sp + ), + headlineLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp + ), + headlineMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp + ), + headlineSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + titleMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp + ), + titleSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + bodyMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp + ), + bodySmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp + ), + labelLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + labelMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) +) diff --git a/app/src/main/res/drawable/geosatelital_logo.jpg b/app/src/main/res/drawable/geosatelital_logo.jpg new file mode 100644 index 0000000..2ed6132 Binary files /dev/null and b/app/src/main/res/drawable/geosatelital_logo.jpg differ diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..b22eb31 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..ae77a4d --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_foreground.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_foreground.xml new file mode 100644 index 0000000..b22eb31 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_foreground.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..ae77a4d --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..e8c6632 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,20 @@ + + + #FF000000 + #FFFFFFFF + + + #FF1976D2 + #FF0D47A1 + #FFB3E5FC + #FF001D25 + + + #FF03A9F4 + #FFB3E5FC + + + #FF4CAF50 + #FFFF9800 + #FFF44336 + diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..ce431c5 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #1976D2 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..a06bbdd --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,125 @@ + + + M530 COMMAND + + + Configuración de Conexión + Redes WiFi + Cámara + SOS + Importar Configuración + + + Configuración de Conexión + Configuración WiFi + Configuración DNS + Configuración Proxy + Prioridad de Red + Guardar Configuración + Configurar + + + No configurado + Automático (DHCP) + Nombre de red (SSID) + Contraseña + Tipo de seguridad + Dirección IP + Puerta de enlace + Máscara de subred + Usar IP estática + + + DNS 1 + DNS 2 + Usar DNS personalizado + + + Habilitado + Deshabilitado + Servidor Proxy + Puerto Proxy + + + WiFi Primero + Datos Móviles Primero + Solo WiFi + Solo Datos Móviles + + + Cancelar + Guardar + Aceptar + + + Conectado + Desconectado + Conectando… + + + Cargando configuración… + Error + Reintentar + Configuración guardada + Volver + + + Estado de Red + WiFi: No configurado + Datos Móviles: Habilitado + Datos Móviles: Deshabilitado + Prioridad: WiFi Primero + + + Sin Internet + WiFi Activo + Datos Móviles Activos (%s) + Modo Fallback (WiFi sin internet) + + + Abierta (sin contraseña) + WEP (inseguro) + WPA + WPA2 (recomendado) + WPA3 (más seguro) + + + Bloqueado + Desbloqueado + Desbloquear + Ingrese PIN + Cambiar PIN + Cambiar PIN: Ingrese PIN actual + Cambiar PIN: Ingrese nuevo PIN + Cambiar PIN: Confirme nuevo PIN + PIN Actual + Nuevo PIN + Confirmar PIN + PIN cambiado exitosamente + PIN incorrecto + Los PIN no coinciden + PIN debe ser 6 dígitos + + + Configuración de Video + Servidor WebRTC + Selección de Cámara + Calidad de Video + Probar Conexión + Conexión Exitosa + Conexión Fallida + URL del Servidor + Puerto + Nombre del Stream + Usuario + Cámara Frontal + Cámara Trasera + Cámara Externa + Streaming + Resolución + Frame Rate (FPS) + Bitrate (Mbps) + Configurar Servidor + Seleccionar Cámara + Configurar Calidad + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..31f7ba1 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..cd84519 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..7e29385 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,21 @@ + + + + + + 192.168.0.25 + 192.168 + 10 + 172.16 + localhost + 127.0.0.1 + + + + + + + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..4d434b1 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,7 @@ +// Top-level build file +plugins { + id("com.android.application") version "8.2.0" apply false + id("org.jetbrains.kotlin.android") version "1.9.20" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "1.9.20" apply false + id("com.google.dagger.hilt.android") version "2.48" apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..1e268b1 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,35 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true + +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official + +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true + +# Enable Gradle build cache +org.gradle.caching=true + +# Enable configuration cache +org.gradle.configuration-cache=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..7f93135 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..3fa8f86 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..97de990 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..f271ddc --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,34 @@ +@rem +@rem Gradle startup script for Windows +@rem +@echo off +setlocal +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Locate Java +if defined JAVA_HOME goto findJavaFromJavaHome +where java.exe >NUL 2>&1 +if %ERRORLEVEL% EQU 0 goto execute + +@rem Common Android Studio JBR location +if exist "%ProgramFiles%\Android\Android Studio\jbr\bin\java.exe" ( + set JAVA_EXE=%ProgramFiles%\Android\Android Studio\jbr\bin\java.exe + goto execute +) + +echo ERROR: Java no fue encontrado. Instala/abre Android Studio o configura JAVA_HOME. +exit /b 1 + +:findJavaFromJavaHome +set JAVA_EXE=%JAVA_HOME%\bin\java.exe +if exist "%JAVA_EXE%" goto execute +echo ERROR: JAVA_HOME apunta a una ruta invalida: %JAVA_HOME% +exit /b 1 + +:execute +if not defined JAVA_EXE set JAVA_EXE=java.exe +"%JAVA_EXE%" -classpath "%APP_HOME%gradle\wrapper\gradle-wrapper.jar" org.gradle.wrapper.GradleWrapperMain %* +endlocal diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..f68c0f2 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "main_app" +include(":app")