Initial version M530 COMMAND - photo video audio backend integration
This commit is contained in:
commit
5be04b0217
72 changed files with 8764 additions and 0 deletions
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
|
|
@ -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
|
||||||
|
{
|
||||||
84
BUILD_AND_INSTALL_M530.bat
Normal file
84
BUILD_AND_INSTALL_M530.bat
Normal file
|
|
@ -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
|
||||||
113
app/build.gradle.kts
Normal file
113
app/build.gradle.kts
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
29
app/proguard-rules.pro
vendored
Normal file
29
app/proguard-rules.pro
vendored
Normal file
|
|
@ -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.* { *; }
|
||||||
73
app/src/main/AndroidManifest.xml
Normal file
73
app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!-- Permisos de red -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<!-- Permisos para configuración WiFi (requieren ubicación) -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
|
||||||
|
<!-- Permisos para cámara y streaming -->
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
|
||||||
|
<!-- CaracterÃÂsticas de hardware -->
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
|
||||||
|
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||||
|
<uses-feature android:name="android.hardware.microphone" android:required="false" />
|
||||||
|
|
||||||
|
<!-- Android 13+ (API 33+) -->
|
||||||
|
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||||
|
android:usesPermissionFlags="neverForLocation" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:name=".MainApplication"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.MainApp"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config">
|
||||||
|
|
||||||
|
<activity android:name=".camera.M530CameraActivity" android:exported="false" android:screenOrientation="portrait" android:theme="@style/Theme.MainApp" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.MainApp"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:clearTaskOnLaunch="true">
|
||||||
|
|
||||||
|
<!-- Intent filter para launcher principal -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- FileProvider para importar archivos -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
|
|
||||||
376
app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainActivity.kt
Normal file
376
app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainActivity.kt
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<Recorder>? = 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<FrameLayout>(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())
|
||||||
|
}
|
||||||
|
|
@ -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<LoginResponse>(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun me(token: String): MeResponse = withContext(Dispatchers.IO) {
|
||||||
|
val response = request("GET", "/mobile/v1/auth/me", token = token)
|
||||||
|
json.decodeFromString<MeResponse>(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun logout(token: String): LogoutResponse = withContext(Dispatchers.IO) {
|
||||||
|
val response = request("DELETE", "/mobile/v1/auth/logout", token = token)
|
||||||
|
json.decodeFromString<LogoutResponse>(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<ApiError>(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)
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String?> = 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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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<Int>) -> 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<String>()
|
||||||
|
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_-]"), "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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<Byte>()
|
||||||
|
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<Byte>()
|
||||||
|
do {
|
||||||
|
var digit = x % 128
|
||||||
|
x /= 128
|
||||||
|
if (x > 0) digit = digit or 0x80
|
||||||
|
bytes += digit.toByte()
|
||||||
|
} while (x > 0)
|
||||||
|
return bytes.toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<File>) -> 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<File> {
|
||||||
|
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<File?> {
|
||||||
|
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<File> {
|
||||||
|
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<File?> {
|
||||||
|
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<Int>) -> 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<EventEntry> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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}$"))
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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()
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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<String> = emptyList()
|
||||||
|
)
|
||||||
|
|
@ -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<IceServerConfig> = 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)")
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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> = _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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<NetworkStatus> = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<Preferences> 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<AdminLockConfig> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<Preferences> 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<NetworkConfiguration>(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<NetworkConfiguration?> {
|
||||||
|
return context.dataStore.data.map { preferences ->
|
||||||
|
val configJson = preferences[CONFIG_KEY]
|
||||||
|
configJson?.let {
|
||||||
|
try {
|
||||||
|
json.decodeFromString<NetworkConfiguration>(it)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<Preferences> 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<VideoConfiguration>(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<VideoConfiguration?> {
|
||||||
|
return context.videoConfigDataStore.data.map { preferences ->
|
||||||
|
val configJson = preferences[CONFIG_KEY]
|
||||||
|
configJson?.let {
|
||||||
|
try {
|
||||||
|
json.decodeFromString<VideoConfiguration>(it)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<CameraInfo> = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 <base64(username:password)>
|
||||||
|
* 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 <base64(username:password)>
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<org.webrtc.VideoCodecInfo> {
|
||||||
|
val hardwareCodecs = hardwareDecoderFactory.supportedCodecs
|
||||||
|
val softwareCodecs = softwareDecoderFactory.supportedCodecs
|
||||||
|
return (hardwareCodecs.toList() + softwareCodecs.toList()).distinctBy { it.name }.toTypedArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<org.webrtc.VideoCodecInfo> {
|
||||||
|
val hardwareCodecs = hardwareEncoderFactory.supportedCodecs
|
||||||
|
val softwareCodecs = softwareEncoderFactory.supportedCodecs
|
||||||
|
return (hardwareCodecs + softwareCodecs).distinctBy { it.name }.toTypedArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<out IceCandidate>?) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<NavigationHandler> {
|
||||||
|
error("No NavigationHandler provided")
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>(AdminLockUiState.Idle)
|
||||||
|
val uiState: StateFlow<AdminLockUiState> = _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
|
||||||
|
}
|
||||||
|
|
@ -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>(AuthUiState.Checking)
|
||||||
|
val state: StateFlow<AuthUiState> = _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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>(ConnectionSettingsUiState.Loading)
|
||||||
|
val uiState: StateFlow<ConnectionSettingsUiState> = _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
|
||||||
|
}
|
||||||
|
|
@ -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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<M530DeviceController.EventEntry>) {
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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<MainUiState> = combine(
|
||||||
|
repository.getConfigFlow(),
|
||||||
|
networkFallbackManager.fallbackState
|
||||||
|
) { config, fallbackState ->
|
||||||
|
MainUiState.Success(config, fallbackState)
|
||||||
|
}.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = MainUiState.Loading
|
||||||
|
)
|
||||||
|
|
||||||
|
val isLocked: StateFlow<Boolean> = adminLockRepository.getLockState()
|
||||||
|
.map { it.isLocked }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = true
|
||||||
|
)
|
||||||
|
|
||||||
|
// Estado del streaming WebRTC
|
||||||
|
private val _streamingState = MutableStateFlow<StreamingState>(StreamingState.Stopped)
|
||||||
|
val streamingState: StateFlow<StreamingState> = _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> = _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<M530DeviceController.EventEntry> = emptyList()
|
||||||
|
)
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>(VideoConfigUiState.Loading)
|
||||||
|
val uiState: StateFlow<VideoConfigUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _testConnectionState = MutableStateFlow<TestConnectionState>(TestConnectionState.Idle)
|
||||||
|
val testConnectionState: StateFlow<TestConnectionState> = _testConnectionState.asStateFlow()
|
||||||
|
|
||||||
|
private val _testStreamState = MutableStateFlow<TestStreamState>(TestStreamState.Idle)
|
||||||
|
val testStreamState: StateFlow<TestStreamState> = _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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
BIN
app/src/main/res/drawable/geosatelital_logo.jpg
Normal file
BIN
app/src/main/res/drawable/geosatelital_logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
20
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
20
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<group
|
||||||
|
android:scaleX="0.5"
|
||||||
|
android:scaleY="0.5"
|
||||||
|
android:translateX="27"
|
||||||
|
android:translateY="27">
|
||||||
|
<!-- Camera icon -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M12,2 L4,2 L2,8 L2,18 C2,19.1 2.9,20 4,20 L20,20 C21.1,20 22,19.1 22,18 L22,8 L20,2 L12,2 Z M12,16 C9.24,16 7,13.76 7,11 C7,8.24 9.24,6 12,6 C14.76,6 17,8.24 17,11 C17,13.76 14.76,16 12,16 Z" />
|
||||||
|
<circle
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M12,14 C13.66,14 15,12.66 15,11 C15,9.34 13.66,8 12,8 C10.34,8 9,9.34 9,11 C9,12.66 10.34,14 12,14 Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/geosatelital_logo"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<group
|
||||||
|
android:scaleX="0.5"
|
||||||
|
android:scaleY="0.5"
|
||||||
|
android:translateX="27"
|
||||||
|
android:translateY="27">
|
||||||
|
<!-- Camera icon -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M12,2 L4,2 L2,8 L2,18 C2,19.1 2.9,20 4,20 L20,20 C21.1,20 22,19.1 22,18 L22,8 L20,2 L12,2 Z M12,16 C9.24,16 7,13.76 7,11 C7,8.24 9.24,6 12,6 C14.76,6 17,8.24 17,11 C17,13.76 14.76,16 12,16 Z" />
|
||||||
|
<circle
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M12,14 C13.66,14 15,12.66 15,11 C15,9.34 13.66,8 12,8 C10.34,8 9,9.34 9,11 C9,12.66 10.34,14 12,14 Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/geosatelital_logo"/>
|
||||||
|
</adaptive-icon>
|
||||||
20
app/src/main/res/values/colors.xml
Normal file
20
app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
|
||||||
|
<!-- Primary colors -->
|
||||||
|
<color name="primary">#FF1976D2</color>
|
||||||
|
<color name="primary_dark">#FF0D47A1</color>
|
||||||
|
<color name="primary_container">#FFB3E5FC</color>
|
||||||
|
<color name="on_primary_container">#FF001D25</color>
|
||||||
|
|
||||||
|
<!-- Secondary colors -->
|
||||||
|
<color name="secondary">#FF03A9F4</color>
|
||||||
|
<color name="secondary_container">#FFB3E5FC</color>
|
||||||
|
|
||||||
|
<!-- Status colors -->
|
||||||
|
<color name="success">#FF4CAF50</color>
|
||||||
|
<color name="warning">#FFFF9800</color>
|
||||||
|
<color name="error">#FFF44336</color>
|
||||||
|
</resources>
|
||||||
4
app/src/main/res/values/ic_launcher_background.xml
Normal file
4
app/src/main/res/values/ic_launcher_background.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#1976D2</color>
|
||||||
|
</resources>
|
||||||
125
app/src/main/res/values/strings.xml
Normal file
125
app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">M530 COMMAND</string>
|
||||||
|
|
||||||
|
<!-- Main Screen -->
|
||||||
|
<string name="connection_settings">Configuración de Conexión</string>
|
||||||
|
<string name="wifi_networks">Redes WiFi</string>
|
||||||
|
<string name="camera">Cámara</string>
|
||||||
|
<string name="sos">SOS</string>
|
||||||
|
<string name="import_config">Importar Configuración</string>
|
||||||
|
|
||||||
|
<!-- Connection Settings -->
|
||||||
|
<string name="connection_settings_title">Configuración de Conexión</string>
|
||||||
|
<string name="wifi_configuration">Configuración WiFi</string>
|
||||||
|
<string name="dns_configuration">Configuración DNS</string>
|
||||||
|
<string name="proxy_configuration">Configuración Proxy</string>
|
||||||
|
<string name="network_priority">Prioridad de Red</string>
|
||||||
|
<string name="save_configuration">Guardar Configuración</string>
|
||||||
|
<string name="configure">Configurar</string>
|
||||||
|
|
||||||
|
<!-- WiFi -->
|
||||||
|
<string name="not_configured">No configurado</string>
|
||||||
|
<string name="auto_dhcp">Automático (DHCP)</string>
|
||||||
|
<string name="ssid">Nombre de red (SSID)</string>
|
||||||
|
<string name="password">Contraseña</string>
|
||||||
|
<string name="security_type">Tipo de seguridad</string>
|
||||||
|
<string name="ip_address">Dirección IP</string>
|
||||||
|
<string name="gateway">Puerta de enlace</string>
|
||||||
|
<string name="subnet_mask">Máscara de subred</string>
|
||||||
|
<string name="use_static_ip">Usar IP estática</string>
|
||||||
|
|
||||||
|
<!-- DNS -->
|
||||||
|
<string name="dns1">DNS 1</string>
|
||||||
|
<string name="dns2">DNS 2</string>
|
||||||
|
<string name="use_custom_dns">Usar DNS personalizado</string>
|
||||||
|
|
||||||
|
<!-- Proxy -->
|
||||||
|
<string name="enabled">Habilitado</string>
|
||||||
|
<string name="disabled">Deshabilitado</string>
|
||||||
|
<string name="proxy_host">Servidor Proxy</string>
|
||||||
|
<string name="proxy_port">Puerto Proxy</string>
|
||||||
|
|
||||||
|
<!-- Network Priority -->
|
||||||
|
<string name="wifi_first">WiFi Primero</string>
|
||||||
|
<string name="mobile_first">Datos Móviles Primero</string>
|
||||||
|
<string name="wifi_only">Solo WiFi</string>
|
||||||
|
<string name="mobile_only">Solo Datos Móviles</string>
|
||||||
|
|
||||||
|
<!-- Dialogs -->
|
||||||
|
<string name="cancel">Cancelar</string>
|
||||||
|
<string name="save">Guardar</string>
|
||||||
|
<string name="ok">Aceptar</string>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<string name="connected">Conectado</string>
|
||||||
|
<string name="disconnected">Desconectado</string>
|
||||||
|
<string name="connecting">Conectando…</string>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<string name="loading_configuration">Cargando configuración…</string>
|
||||||
|
<string name="error">Error</string>
|
||||||
|
<string name="retry">Reintentar</string>
|
||||||
|
<string name="configuration_saved">Configuración guardada</string>
|
||||||
|
<string name="back">Volver</string>
|
||||||
|
|
||||||
|
<!-- Network Status -->
|
||||||
|
<string name="network_status">Estado de Red</string>
|
||||||
|
<string name="wifi_not_configured">WiFi: No configurado</string>
|
||||||
|
<string name="mobile_data_enabled">Datos Móviles: Habilitado</string>
|
||||||
|
<string name="mobile_data_disabled">Datos Móviles: Deshabilitado</string>
|
||||||
|
<string name="priority_wifi_first">Prioridad: WiFi Primero</string>
|
||||||
|
|
||||||
|
<!-- Internet Status -->
|
||||||
|
<string name="no_internet">Sin Internet</string>
|
||||||
|
<string name="wifi_active">WiFi Activo</string>
|
||||||
|
<string name="mobile_active">Datos Móviles Activos (%s)</string>
|
||||||
|
<string name="fallback_mode">Modo Fallback (WiFi sin internet)</string>
|
||||||
|
|
||||||
|
<!-- Security Types -->
|
||||||
|
<string name="security_none">Abierta (sin contraseña)</string>
|
||||||
|
<string name="security_wep">WEP (inseguro)</string>
|
||||||
|
<string name="security_wpa">WPA</string>
|
||||||
|
<string name="security_wpa2">WPA2 (recomendado)</string>
|
||||||
|
<string name="security_wpa3">WPA3 (más seguro)</string>
|
||||||
|
|
||||||
|
<!-- Admin Lock -->
|
||||||
|
<string name="locked">Bloqueado</string>
|
||||||
|
<string name="unlocked">Desbloqueado</string>
|
||||||
|
<string name="admin_unlock">Desbloquear</string>
|
||||||
|
<string name="enter_pin">Ingrese PIN</string>
|
||||||
|
<string name="change_pin">Cambiar PIN</string>
|
||||||
|
<string name="change_pin_enter_current">Cambiar PIN: Ingrese PIN actual</string>
|
||||||
|
<string name="change_pin_enter_new">Cambiar PIN: Ingrese nuevo PIN</string>
|
||||||
|
<string name="change_pin_confirm">Cambiar PIN: Confirme nuevo PIN</string>
|
||||||
|
<string name="current_pin">PIN Actual</string>
|
||||||
|
<string name="new_pin">Nuevo PIN</string>
|
||||||
|
<string name="confirm_pin">Confirmar PIN</string>
|
||||||
|
<string name="pin_changed">PIN cambiado exitosamente</string>
|
||||||
|
<string name="pin_error">PIN incorrecto</string>
|
||||||
|
<string name="pin_mismatch">Los PIN no coinciden</string>
|
||||||
|
<string name="pin_invalid">PIN debe ser 6 dígitos</string>
|
||||||
|
|
||||||
|
<!-- Video Configuration -->
|
||||||
|
<string name="video_config_title">Configuración de Video</string>
|
||||||
|
<string name="webrtc_server">Servidor WebRTC</string>
|
||||||
|
<string name="camera_selection">Selección de Cámara</string>
|
||||||
|
<string name="video_quality">Calidad de Video</string>
|
||||||
|
<string name="test_connection">Probar Conexión</string>
|
||||||
|
<string name="connection_successful">Conexión Exitosa</string>
|
||||||
|
<string name="connection_failed">Conexión Fallida</string>
|
||||||
|
<string name="server_url">URL del Servidor</string>
|
||||||
|
<string name="server_port">Puerto</string>
|
||||||
|
<string name="stream_name">Nombre del Stream</string>
|
||||||
|
<string name="username">Usuario</string>
|
||||||
|
<string name="front_camera">Cámara Frontal</string>
|
||||||
|
<string name="back_camera">Cámara Trasera</string>
|
||||||
|
<string name="external_camera">Cámara Externa</string>
|
||||||
|
<string name="streaming">Streaming</string>
|
||||||
|
<string name="resolution">Resolución</string>
|
||||||
|
<string name="frame_rate">Frame Rate (FPS)</string>
|
||||||
|
<string name="bitrate">Bitrate (Mbps)</string>
|
||||||
|
<string name="configure_server">Configurar Servidor</string>
|
||||||
|
<string name="select_camera">Seleccionar Cámara</string>
|
||||||
|
<string name="configure_quality">Configurar Calidad</string>
|
||||||
|
</resources>
|
||||||
7
app/src/main/res/values/themes.xml
Normal file
7
app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.MainApp" parent="android:Theme.Material.Light.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
7
app/src/main/res/xml/file_paths.xml
Normal file
7
app/src/main/res/xml/file_paths.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<external-path name="external_files" path="." />
|
||||||
|
<external-files-path name="external_files_path" path="." />
|
||||||
|
<cache-path name="cache" path="." />
|
||||||
|
<files-path name="files" path="." />
|
||||||
|
</paths>
|
||||||
21
app/src/main/res/xml/network_security_config.xml
Normal file
21
app/src/main/res/xml/network_security_config.xml
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<!-- Permitir tráfico HTTP para redes locales (LAN) -->
|
||||||
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
|
<!-- IPs locales (192.168.x.x, 10.x.x.x, etc.) -->
|
||||||
|
<domain includeSubdomains="true">192.168.0.25</domain>
|
||||||
|
<domain includeSubdomains="true">192.168</domain>
|
||||||
|
<domain includeSubdomains="true">10</domain>
|
||||||
|
<domain includeSubdomains="true">172.16</domain>
|
||||||
|
<domain includeSubdomains="true">localhost</domain>
|
||||||
|
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||||
|
</domain-config>
|
||||||
|
|
||||||
|
<!-- Permitir todo en debug para pruebas -->
|
||||||
|
<base-config cleartextTrafficPermitted="false">
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system" />
|
||||||
|
<certificates src="user" />
|
||||||
|
</trust-anchors>
|
||||||
|
</base-config>
|
||||||
|
</network-security-config>
|
||||||
7
build.gradle.kts
Normal file
7
build.gradle.kts
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
35
gradle.properties
Normal file
35
gradle.properties
Normal file
|
|
@ -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
|
||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -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
|
||||||
249
gradlew
vendored
Normal file
249
gradlew
vendored
Normal file
|
|
@ -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" "$@"
|
||||||
34
gradlew.bat
vendored
Normal file
34
gradlew.bat
vendored
Normal file
|
|
@ -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
|
||||||
18
settings.gradle.kts
Normal file
18
settings.gradle.kts
Normal file
|
|
@ -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")
|
||||||
Loading…
Add table
Add a link
Reference in a new issue