From 5be04b02179b830760a73ea06715bd0c7095b86c Mon Sep 17 00:00:00 2001 From: Miguel Grandez Date: Thu, 3 Sep 2026 16:36:46 -0500 Subject: [PATCH] Initial version M530 COMMAND - photo video audio backend integration --- .gitignore | 22 + BUILD_AND_INSTALL_M530.bat | 84 ++ app/build.gradle.kts | 113 +++ app/proguard-rules.pro | 29 + app/src/main/AndroidManifest.xml | 73 ++ .../bodycamera/twentyfoulabs/MainActivity.kt | 376 +++++++++ .../twentyfoulabs/MainApplication.kt | 11 + .../camera/M530CameraActivity.kt | 714 ++++++++++++++++ .../twentyfoulabs/data/auth/AuthApiClient.kt | 67 ++ .../twentyfoulabs/data/auth/AuthModels.kt | 67 ++ .../twentyfoulabs/data/auth/AuthRepository.kt | 50 ++ .../data/auth/AuthSessionStore.kt | 43 + .../data/backend/BackendConfig.kt | 19 + .../data/backend/BackendIntegrationManager.kt | 629 ++++++++++++++ .../data/backend/SimpleMqttPublisher.kt | 76 ++ .../data/device/M530DeviceController.kt | 392 +++++++++ .../data/model/AdminLockConfig.kt | 25 + .../twentyfoulabs/data/model/DnsConfig.kt | 10 + .../data/model/MobileDataConfig.kt | 11 + .../data/model/NetworkConfiguration.kt | 14 + .../data/model/NetworkPriority.kt | 11 + .../twentyfoulabs/data/model/ProxyConfig.kt | 11 + .../data/model/VideoConfiguration.kt | 84 ++ .../twentyfoulabs/data/model/WifiConfig.kt | 24 + .../data/network/NetworkFallbackManager.kt | 133 +++ .../data/network/NetworkMonitor.kt | 101 +++ .../data/network/WifiConnector.kt | 195 +++++ .../data/repository/AdminLockRepository.kt | 132 +++ .../repository/NetworkConfigRepository.kt | 65 ++ .../data/repository/VideoConfigRepository.kt | 77 ++ .../twentyfoulabs/data/webrtc/CameraHelper.kt | 180 ++++ .../data/webrtc/SignalingClient.kt | 166 ++++ .../data/webrtc/VideoDecoderFactory.kt | 51 ++ .../data/webrtc/VideoEncoderFactory.kt | 46 + .../data/webrtc/WebRTCManager.kt | 540 ++++++++++++ .../twentyfoulabs/di/WebRTCModule.kt | 51 ++ .../twentyfoulabs/navigation/BackButton.kt | 35 + .../navigation/LocalNavigationHandler.kt | 10 + .../navigation/NavigationHandler.kt | 53 ++ .../ui/components/IconButtonWithLabel.kt | 79 ++ .../ui/components/RecordingStatusBar.kt | 105 +++ .../ui/screens/AdminLockScreen.kt | 363 ++++++++ .../ui/screens/AdminLockViewModel.kt | 128 +++ .../twentyfoulabs/ui/screens/AuthViewModel.kt | 73 ++ .../ui/screens/ConnectionSettingsScreen.kt | 541 ++++++++++++ .../ui/screens/ConnectionSettingsViewModel.kt | 143 ++++ .../twentyfoulabs/ui/screens/LoginScreen.kt | 103 +++ .../twentyfoulabs/ui/screens/MainScreen.kt | 353 ++++++++ .../twentyfoulabs/ui/screens/MainViewModel.kt | 317 +++++++ .../ui/screens/VideoConfigScreen.kt | 794 ++++++++++++++++++ .../ui/screens/VideoConfigViewModel.kt | 203 +++++ .../twentyfoulabs/ui/theme/Color.kt | 18 + .../twentyfoulabs/ui/theme/Theme.kt | 55 ++ .../bodycamera/twentyfoulabs/ui/theme/Type.kt | 115 +++ .../main/res/drawable/geosatelital_logo.jpg | Bin 0 -> 9150 bytes .../res/drawable/ic_launcher_foreground.xml | 20 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../ic_launcher_foreground.xml | 20 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/values/colors.xml | 20 + .../res/values/ic_launcher_background.xml | 4 + app/src/main/res/values/strings.xml | 125 +++ app/src/main/res/values/themes.xml | 7 + app/src/main/res/xml/file_paths.xml | 7 + .../main/res/xml/network_security_config.xml | 21 + build.gradle.kts | 7 + gradle.properties | 35 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 63721 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 249 ++++++ gradlew.bat | 34 + settings.gradle.kts | 18 + 72 files changed, 8764 insertions(+) create mode 100644 .gitignore create mode 100644 BUILD_AND_INSTALL_M530.bat create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainActivity.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainApplication.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/camera/M530CameraActivity.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthApiClient.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthModels.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthRepository.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthSessionStore.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/BackendConfig.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/BackendIntegrationManager.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/SimpleMqttPublisher.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/device/M530DeviceController.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/AdminLockConfig.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/DnsConfig.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/MobileDataConfig.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/NetworkConfiguration.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/NetworkPriority.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/ProxyConfig.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/VideoConfiguration.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/WifiConfig.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/network/NetworkFallbackManager.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/network/NetworkMonitor.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/network/WifiConnector.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/repository/AdminLockRepository.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/repository/NetworkConfigRepository.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/repository/VideoConfigRepository.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/webrtc/CameraHelper.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/webrtc/SignalingClient.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/webrtc/VideoDecoderFactory.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/webrtc/VideoEncoderFactory.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/webrtc/WebRTCManager.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/di/WebRTCModule.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/navigation/BackButton.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/navigation/LocalNavigationHandler.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/navigation/NavigationHandler.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/components/IconButtonWithLabel.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/components/RecordingStatusBar.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/AdminLockScreen.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/AdminLockViewModel.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/AuthViewModel.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/ConnectionSettingsScreen.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/ConnectionSettingsViewModel.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/LoginScreen.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/MainScreen.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/MainViewModel.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/VideoConfigScreen.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/VideoConfigViewModel.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/theme/Color.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/theme/Theme.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/theme/Type.kt create mode 100644 app/src/main/res/drawable/geosatelital_logo.jpg create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/ic_launcher_background.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts 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 0000000000000000000000000000000000000000..2ed61329fd5528792fb30fc01122b252b85fab2b GIT binary patch literal 9150 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 + + + + + + + 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 0000000000000000000000000000000000000000..7f93135c49b765f8051ef9d0a6055ff8e46073d8 GIT binary patch literal 63721 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鯊%1Et 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⁢ zv5o9#E7!G0XmdzfsS{efPNi`1b44~SZ4Z8fuX!I}#8g+(wxzQwUT#Xb2(tbY1+EUhGKoT@KEU9Ktl>_0 z%bjDJg;#*gtJZv!-Zs`?^}v5eKmnbjqlvnSzE@_SP|LG_PJ6CYU+6zY6>92%E+ z=j@TZf-iW4(%U{lnYxQA;7Q!b;^brF8n0D>)`q5>|WDDXLrqYU_tKN2>=#@~OE7grMnNh?UOz-O~6 z6%rHy{#h9K0AT+lDC7q4{hw^|q6*Ry;;L%Q@)Ga}$60_q%D)rv(CtS$CQbpq9|y1e zRSrN4;$Jyl{m5bZw`$8TGvb}(LpY{-cQ)fcyJv7l3S52TLXVDsphtv&aPuDk1OzCA z4A^QtC(!11`IsNx_HnSy?>EKpHJWT^wmS~hc^p^zIIh@9f6U@I2 zC=Mve{j2^)mS#U$e{@Q?SO6%LDsXz@SY+=cK_QMmXBIU)j!$ajc-zLx3V60EXJ!qC zi<%2x8Q24YN+&8U@CIlN zrZkcT9yh%LrlGS9`G)KdP(@9Eo-AQz@8GEFWcb7U=a0H^ZVbLmz{+&M7W(nXJ4sN8 zJLR7eeK(K8`2-}j(T7JsO`L!+CvbueT%izanm-^A1Dn{`1Nw`9P?cq;7no+XfC`K(GO9?O^5zNIt4M+M8LM0=7Gz8UA@Z0N+lg+cX)NfazRu z5D)~HA^(u%w^cz+@2@_#S|u>GpB+j4KzQ^&Wcl9f z&hG#bCA(Yk0D&t&aJE^xME^&E-&xGHhXn%}psEIj641H+Nl-}boj;)Zt*t(4wZ5DN z@GXF$bL=&pBq-#vkTkh>7hl%K5|3 z{`Vn9b$iR-SoGENp}bn4;fR3>9sA%X2@1L3aE9yTra;Wb#_`xWwLSLdfu+PAu+o3| zGVnpzPr=ch{uuoHjtw7+_!L_2;knQ!DuDl0R`|%jr+}jFzXtrHIKc323?JO{l&;VF z*L1+}JU7%QJOg|5|Tc|D8fN zJORAg=_vsy{ak|o);@)Yh8Lkcg@$FG3k@ep36BRa^>~UmnRPziS>Z=`Jb2x*Q#`%A zU*i3&Vg?TluO@X0O;r2Jl6LKLUOVhSqg1*qOt^|8*c7 zo(298@+r$k_wQNGHv{|$tW(T8L+4_`FQ{kEW5Jgg{yf7ey4ss_(SNKfz(N9lx&a;< je(UuV8hP?p&}TPdm1I$XmG#(RzlD&B2izSj9sl%y5~4qc literal 0 HcmV?d00001 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")