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