Initial version M530 COMMAND - photo video audio backend integration

This commit is contained in:
Miguel Angel 2026-09-03 16:36:46 -05:00
commit 5be04b0217
72 changed files with 8764 additions and 0 deletions

113
app/build.gradle.kts Normal file
View 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
View 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.* { *; }

View 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>

View 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)
}
}
}
}

View file

@ -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()
}
}

View file

@ -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())
}

View file

@ -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)

View file

@ -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
)

View file

@ -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()
}
}
}

View file

@ -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() }
}
}

View file

@ -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"
}

View file

@ -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_-]"), "_")
}

View file

@ -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()
}
}

View file

@ -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())
}

View file

@ -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}$"))
}

View file

@ -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
)

View file

@ -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
)

View file

@ -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()
)

View file

@ -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
}

View file

@ -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()
)

View file

@ -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)")
}

View file

@ -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
}

View file

@ -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"
}
}
}

View file

@ -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
}
}

View file

@ -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
}
}
}

View file

@ -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
}
}

View file

@ -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
}
}
}
}
}

View file

@ -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
}
}
}
}
}

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

@ -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
}
}

View file

@ -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) }
* ```
*/

View file

@ -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))
}
}

View file

@ -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")
}

View file

@ -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
}

View file

@ -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
)
}
}
}

View file

@ -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
)
}
}
}

View file

@ -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
)
}
}
}
}
}

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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))
}
}
)
}

View file

@ -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
}

View file

@ -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") }
}
}
}
}
}

View file

@ -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))

View file

@ -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()
)

View file

@ -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")
}
}
)
}

View file

@ -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
}

View file

@ -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)

View file

@ -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
)
}

View file

@ -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
)
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1976D2</color>
</resources>

View 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>

View 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>

View 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>

View 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>