feat: integrar autenticacion QR, sesion y mejoras M530

This commit is contained in:
Miguel Angel 2026-09-15 10:20:50 -05:00
parent 9deeb40217
commit 0d87c0e4d9
19 changed files with 2322 additions and 212 deletions

View file

@ -101,6 +101,8 @@ dependencies {
implementation("androidx.camera:camera-camera2:1.3.0") implementation("androidx.camera:camera-camera2:1.3.0")
implementation("androidx.camera:camera-lifecycle:1.3.0") implementation("androidx.camera:camera-lifecycle:1.3.0")
implementation("androidx.camera:camera-view:1.3.0") implementation("androidx.camera:camera-view:1.3.0")
// ML Kit - QR Scanner
implementation("com.google.mlkit:barcode-scanning:17.3.0")
// Testing // Testing
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Permisos de red --> <!-- Permisos de red -->
@ -12,6 +12,7 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Permisos para cámara y streaming --> <!-- Permisos para cámara y streaming -->
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
@ -39,6 +40,11 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.MainApp" android:theme="@style/Theme.MainApp"
android:networkSecurityConfig="@xml/network_security_config"> android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".qr.QrScannerActivity"
android:exported="false"
android:screenOrientation="portrait"
android:theme="@style/Theme.MainApp" />
<activity android:name=".camera.M530CameraActivity" android:exported="false" android:screenOrientation="portrait" android:theme="@style/Theme.MainApp" /> <activity android:name=".camera.M530CameraActivity" android:exported="false" android:screenOrientation="portrait" android:theme="@style/Theme.MainApp" />
@ -52,8 +58,9 @@
<!-- Intent filter para launcher principal --> <!-- Intent filter para launcher principal -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
@ -67,7 +74,16 @@
android:name="android.support.FILE_PROVIDER_PATHS" android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" /> android:resource="@xml/file_paths" />
</provider> </provider>
<receiver
android:name=".receiver.M530PttReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.ACTION_PTTKEY_DOWN" />
<action android:name="android.intent.action.ACTION_PTTKEY_LONG_PRESS" />
<action android:name="android.intent.action.ACTION_PTTKEY_UP" />
</intent-filter>
</receiver>
</application> </application>
</manifest> </manifest>

View file

@ -1,4 +1,4 @@
package com.bodycamera.twentyfoulabs package com.bodycamera.twentyfoulabs
import android.Manifest import android.Manifest
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
@ -44,10 +44,128 @@ import androidx.hilt.navigation.compose.hiltViewModel
import javax.inject.Inject import javax.inject.Inject
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.animation.core.Animatable
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.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicText
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.graphics.graphicsLayer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
@Composable
private fun M530StartupSplash() {
val alpha = remember {
Animatable(0f)
}
val scale = remember {
Animatable(0.90f)
}
LaunchedEffect(Unit) {
launch {
alpha.animateTo(
targetValue = 1f,
animationSpec = tween(650)
)
}
launch {
scale.animateTo(
targetValue = 1f,
animationSpec = tween(750)
)
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF071827)),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.alpha(alpha.value)
.graphicsLayer {
scaleX = scale.value
scaleY = scale.value
},
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
BasicText(
text = "GEOSATELITAL",
style = TextStyle(
color = Color.White,
fontSize = 28.sp,
fontWeight = FontWeight.Bold
)
)
Spacer(
modifier = Modifier.height(8.dp)
)
BasicText(
text = "M530 COMMAND",
style = TextStyle(
color = Color(0xFF00D7FF),
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
)
Spacer(
modifier = Modifier.height(28.dp)
)
Box(
modifier = Modifier
.width(100.dp)
.height(3.dp)
.background(Color(0xFF00D7FF))
)
Spacer(
modifier = Modifier.height(12.dp)
)
BasicText(
text = "INICIANDO...",
style = TextStyle(
color = Color(0xFFB7C6D3),
fontSize = 11.sp
)
)
}
}
}
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@ -63,6 +181,15 @@ class MainActivity : ComponentActivity() {
private const val TAG = "MainActivity" private const val TAG = "MainActivity"
private const val ACTION_VIDEO_DOWN = "android.intent.action.ACTION_VIDEO_DOWN" 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 const val ACTION_VIDEO_UP = "android.intent.action.ACTION_VIDEO_UP"
private const val ACTION_PTT_DOWN =
"android.intent.action.ACTION_PTTKEY_DOWN"
private const val ACTION_PTT_UP =
"android.intent.action.ACTION_PTTKEY_UP"
private const val ACTION_PTT_LONG_PRESS =
"android.intent.action.ACTION_PTTKEY_LONG_PRESS"
} }
private var isReceiverRegistered = false private var isReceiverRegistered = false
@ -70,8 +197,51 @@ class MainActivity : ComponentActivity() {
private val hardwareReceiver = object : BroadcastReceiver() { private val hardwareReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) { when (intent?.action) {
ACTION_VIDEO_DOWN -> handleVideoButton() ACTION_VIDEO_DOWN -> handleVideoButton()
ACTION_VIDEO_UP -> Unit ACTION_VIDEO_UP -> Unit
ACTION_PTT_DOWN -> {
Log.i(TAG, "PTT BROADCAST DOWN recibido")
if (!isAuthenticated) {
toast("Inicia sesion para usar PTT")
return
}
val result = deviceController.startPtt()
Log.i(
TAG,
if (result.isSuccess)
"PTT BROADCAST DOWN - PTT iniciado"
else
"PTT BROADCAST DOWN - ERROR: ${result.exceptionOrNull()?.message}"
)
}
ACTION_PTT_LONG_PRESS -> {
Log.d(TAG, "PTT BROADCAST LONG PRESS ignorado")
}
ACTION_PTT_UP -> {
Log.i(TAG, "PTT BROADCAST UP recibido")
if (!isAuthenticated) {
return
}
val result = deviceController.stopPtt()
Log.i(
TAG,
if (result.isSuccess)
"PTT BROADCAST UP - PTT detenido"
else
"PTT BROADCAST UP - ERROR: ${result.exceptionOrNull()?.message}"
)
}
} }
} }
} }
@ -114,7 +284,30 @@ class MainActivity : ComponentActivity() {
deviceController.logTelemetry() deviceController.logTelemetry()
setContent { setContent {
MainAppTheme { AppNavigation(viewModel = viewModel, onOpenPhotoCamera = { handlePhotoButton() }, onOpenVideoCamera = { handleVideoButton() }) } MainAppTheme {
var showStartupSplash by remember {
mutableStateOf(true)
}
LaunchedEffect(Unit) {
delay(2000)
showStartupSplash = false
}
if (showStartupSplash) {
M530StartupSplash()
} else {
AppNavigation(
viewModel = viewModel,
onOpenPhotoCamera = { handlePhotoButton() },
onOpenVideoCamera = { handleVideoButton() }
)
}
}
} }
} }
@ -122,7 +315,7 @@ class MainActivity : ComponentActivity() {
if (event?.repeatCount ?: 0 > 0) return true if (event?.repeatCount ?: 0 > 0) return true
Log.i(TAG, "M530 KEY DOWN: $keyCode") 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)) { 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") toast("Inicia sesion para usar la bodycam")
return true return true
} }
return when (keyCode) { return when (keyCode) {
@ -182,13 +375,11 @@ class MainActivity : ComponentActivity() {
true true
} }
KeyEvent.KEYCODE_F4 -> { KeyEvent.KEYCODE_F4 -> {
val result = deviceController.startPtt()
toast(if (result.isSuccess) "PTT: hablando" else "PTT error: ${result.exceptionOrNull()?.message}")
true true
} }
KeyEvent.KEYCODE_F5 -> { KeyEvent.KEYCODE_F5 -> {
// Algunas versiones del firmware también emiten ACTION_VIDEO_DOWN. // Algunas versiones del firmware tambien emiten ACTION_VIDEO_DOWN.
// El ViewModel aplica debounce para evitar doble ejecución. // El ViewModel aplica debounce para evitar doble ejecucion.
handleVideoButton() handleVideoButton()
true true
} }
@ -200,24 +391,53 @@ class MainActivity : ComponentActivity() {
} }
} }
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { override fun onKeyUp(
Log.i(TAG, "M530 KEY UP: $keyCode") keyCode: Int,
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 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) { return when (keyCode) {
KeyEvent.KEYCODE_F4 -> { KeyEvent.KEYCODE_F4 -> {
val result = deviceController.stopPtt()
toast(if (result.isSuccess) "PTT: turno liberado" else "PTT error")
true true
} }
KeyEvent.KEYCODE_F1, KeyEvent.KEYCODE_F2, KeyEvent.KEYCODE_F3,
KeyEvent.KEYCODE_F5, KeyEvent.KEYCODE_F7 -> true KeyEvent.KEYCODE_F1,
else -> super.onKeyUp(keyCode, event) KeyEvent.KEYCODE_F2,
KeyEvent.KEYCODE_F3,
KeyEvent.KEYCODE_F5,
KeyEvent.KEYCODE_F7 -> true
else ->
super.onKeyUp(
keyCode,
event
)
} }
} }
private fun handleVideoButton() { private fun handleVideoButton() {
if (!isAuthenticated) { if (!isAuthenticated) {
toast("Inicia sesión para usar VIDEO") toast("Inicia sesion para usar VIDEO")
return return
} }
startActivity( startActivity(
@ -254,13 +474,40 @@ class MainActivity : ComponentActivity() {
).addFlags(android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP) ).addFlags(android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP)
) )
} }
private fun handleSosButton() {
private fun handleSosButton() { toast("Enviando SOS...")
val result = deviceController.triggerSos()
val loc = result.location deviceController.triggerSos { result ->
val msg = if (loc != null) "SOS registrado (${loc.latitude}, ${loc.longitude})" else "SOS registrado (GPS sin posición reciente)"
Log.w(TAG, msg) runOnUiThread {
toast(msg)
if (result.isSuccess) {
val alertId = result.getOrNull()
Log.w(
TAG,
"SOS ENVIADO id=$alertId"
)
toast("SOS ENVIADO")
} else {
val message =
result.exceptionOrNull()?.message
?: "No se pudo enviar SOS"
Log.e(
TAG,
"SOS NO ENVIADO: $message"
)
toast(message)
}
}
}
} }
private fun registerHardwareReceiver() { private fun registerHardwareReceiver() {
@ -268,6 +515,9 @@ class MainActivity : ComponentActivity() {
val filter = IntentFilter().apply { val filter = IntentFilter().apply {
addAction(ACTION_VIDEO_DOWN) addAction(ACTION_VIDEO_DOWN)
addAction(ACTION_VIDEO_UP) addAction(ACTION_VIDEO_UP)
addAction(ACTION_PTT_DOWN)
addAction(ACTION_PTT_UP)
addAction(ACTION_PTT_LONG_PRESS)
} }
try { try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@ -296,10 +546,14 @@ class MainActivity : ComponentActivity() {
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
unregisterHardwareReceiver()
// El receiver permanece activo para capturar
// botones fisicos mientras otra Activity de la app
// (por ejemplo la camara) esta en primer plano.
} }
override fun onDestroy() { override fun onDestroy() {
unregisterHardwareReceiver()
deviceController.release() deviceController.release()
super.onDestroy() super.onDestroy()
} }
@ -361,6 +615,7 @@ fun AppNavigation(viewModel: MainViewModel, onOpenPhotoCamera: () -> Unit, onOpe
LoginScreen( LoginScreen(
state = authState, state = authState,
onLogin = authViewModel::login, onLogin = authViewModel::login,
onQrLogin = authViewModel::qrLogin,
onRetrySession = authViewModel::restoreSession, onRetrySession = authViewModel::restoreSession,
onClearError = authViewModel::clearError onClearError = authViewModel::clearError
) )
@ -392,10 +647,3 @@ fun AppNavigation(viewModel: MainViewModel, onOpenPhotoCamera: () -> Unit, onOpe
} }
} }
} }

View file

@ -1,4 +1,4 @@
package com.bodycamera.twentyfoulabs.camera package com.bodycamera.twentyfoulabs.camera
import android.Manifest import android.Manifest
import android.content.ContentValues import android.content.ContentValues
@ -44,9 +44,59 @@ import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
@AndroidEntryPoint @AndroidEntryPoint
class M530CameraActivity : ComponentActivity() { class M530CameraActivity : ComponentActivity() {
private fun getDeviceVibrator(): Vibrator {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager =
getSystemService(VIBRATOR_MANAGER_SERVICE) as VibratorManager
vibratorManager.getVibrator(1)
} else {
@Suppress("DEPRECATION")
getSystemService(VIBRATOR_SERVICE) as Vibrator
}
}
private fun vibrateRecordingStarted() {
val vibrator = getDeviceVibrator()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(
VibrationEffect.createOneShot(
150L,
VibrationEffect.DEFAULT_AMPLITUDE
)
)
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(150L)
}
}
private fun vibrateRecordingStopped() {
val vibrator = getDeviceVibrator()
val pattern = longArrayOf(
0L,
100L,
100L,
100L
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(
VibrationEffect.createWaveform(pattern, -1)
)
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(pattern, -1)
}
}
@Inject @Inject
lateinit var backend: BackendIntegrationManager lateinit var backend: BackendIntegrationManager
@ -523,7 +573,7 @@ class M530CameraActivity : ComponentActivity() {
override fun onImageSaved(result: ImageCapture.OutputFileResults) { override fun onImageSaved(result: ImageCapture.OutputFileResults) {
stateText.text = "Foto guardada" stateText.text = "Foto guardada"
flashCaptureFeedback() flashCaptureFeedback()
val savedUri = result.savedUri val savedUri = result.savedUri
if (savedUri == null) { if (savedUri == null) {
@ -692,7 +742,10 @@ class M530CameraActivity : ComponentActivity() {
} }
private fun enterRecordingState() { private fun enterRecordingState() {
stateText.text = "GRABANDO" vibrateRecordingStarted()
stateText.text = "GRABANDO"
physicalHintText.text = "F5 DETENER" physicalHintText.text = "F5 DETENER"
recordDot.visibility = View.VISIBLE recordDot.visibility = View.VISIBLE
@ -710,6 +763,13 @@ class M530CameraActivity : ComponentActivity() {
} }
private fun exitRecordingState(error: Boolean) { private fun exitRecordingState(error: Boolean) {
activeRecording = null
if (!error) {
vibrateRecordingStopped()
}
chronometer.stop()
activeRecording = null activeRecording = null
chronometer.stop() chronometer.stop()
chronometer.visibility = View.GONE chronometer.visibility = View.GONE
@ -804,8 +864,3 @@ class M530CameraActivity : ComponentActivity() {
private fun timestamp(): String = private fun timestamp(): String =
SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date()) SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date())
} }

View file

@ -3,8 +3,8 @@ package com.bodycamera.twentyfoulabs.data.auth
import com.bodycamera.twentyfoulabs.data.backend.BackendConfig import com.bodycamera.twentyfoulabs.data.backend.BackendConfig
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
@ -13,55 +13,160 @@ import javax.inject.Singleton
@Singleton @Singleton
class AuthApiClient @Inject constructor() { class AuthApiClient @Inject constructor() {
private val json = Json { ignoreUnknownKeys = true; explicitNulls = false }
suspend fun login(request: LoginRequest): LoginResponse = withContext(Dispatchers.IO) { private val json = Json {
val body = json.encodeToString(request) ignoreUnknownKeys = true
val response = request("POST", "/mobile/v1/auth/login", body = body) explicitNulls = false
json.decodeFromString<LoginResponse>(response)
} }
suspend fun me(token: String): MeResponse = withContext(Dispatchers.IO) { suspend fun login(request: LoginRequest): LoginResponse =
val response = request("GET", "/mobile/v1/auth/me", token = token) withContext(Dispatchers.IO) {
json.decodeFromString<MeResponse>(response) val body = json.encodeToString(request)
} val response = request(
"POST",
suspend fun logout(token: String): LogoutResponse = withContext(Dispatchers.IO) { "/mobile/v1/auth/login",
val response = request("DELETE", "/mobile/v1/auth/logout", token = token) body = body
json.decodeFromString<LogoutResponse>(response) )
} json.decodeFromString<LoginResponse>(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
} }
suspend fun qrLogin(request: QrLoginRequest): LoginResponse =
withContext(Dispatchers.IO) {
val body = json.encodeToString(request)
val response = request(
"POST",
"/mobile/v1/auth/qr_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 { try {
if (body != null) { if (body != null) {
connection.outputStream.bufferedWriter(Charsets.UTF_8).use { it.write(body) } connection.outputStream
.bufferedWriter(Charsets.UTF_8)
.use {
it.write(body)
}
} }
val code = connection.responseCode 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() 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) { if (code !in 200..299) {
val apiError = runCatching { json.decodeFromString<ApiError>(responseBody) }.getOrNull()
val message = apiError?.error ?: apiError?.message ?: "HTTP $code" val apiError =
throw AuthApiException(code, message) runCatching {
json.decodeFromString<ApiError>(
responseBody
)
}.getOrNull()
val message =
apiError?.error
?: apiError?.message
?: "HTTP $code"
throw AuthApiException(
code,
message
)
} }
return responseBody return responseBody
} finally { } finally {
connection.disconnect() connection.disconnect()
} }
} }
} }
class AuthApiException(val statusCode: Int, override val message: String) : Exception(message) class AuthApiException(
val statusCode: Int,
override val message: String
) : Exception(message)

View file

@ -11,12 +11,21 @@ data class LoginRequest(
@SerialName("device_model") val deviceModel: String? = null @SerialName("device_model") val deviceModel: String? = null
) )
@Serializable
data class QrLoginRequest(
@SerialName("qr_token") val qrToken: String,
val pin: String,
@SerialName("device_serial") val deviceSerial: String,
@SerialName("device_model") val deviceModel: String? = null
)
@Serializable @Serializable
data class AuthUser( data class AuthUser(
val id: Int? = null, val id: Int? = null,
val email: String = "", val email: String = "",
val name: String = "", val name: String = "",
val role: String = "", val role: String = "",
val active: Boolean? = null,
@SerialName("assigned_device") val assignedDevice: AuthDevice? = null @SerialName("assigned_device") val assignedDevice: AuthDevice? = null
) )
@ -39,10 +48,20 @@ data class AuthDevice(
@Serializable @Serializable
data class LoginResponse( data class LoginResponse(
val user: AuthUser, val user: AuthUser,
val token: String, val token: String = "",
@SerialName("access_token") val accessToken: String? = null,
@SerialName("refresh_token") val refreshToken: String? = null,
@SerialName("token_type") val tokenType: String? = null,
@SerialName("expires_in") val expiresIn: Int? = null,
val device: AuthDevice? = null, val device: AuthDevice? = null,
@SerialName("server_time") val serverTime: String? = null @SerialName("server_time") val serverTime: String? = null
) ) {
fun effectiveToken(): String {
return accessToken?.takeIf { it.isNotBlank() }
?: token.takeIf { it.isNotBlank() }
?: throw IllegalStateException("El backend no devolvio access_token ni token")
}
}
@Serializable @Serializable
data class MeResponse( data class MeResponse(
@ -58,10 +77,14 @@ data class LogoutResponse(
) )
@Serializable @Serializable
data class ApiError(val error: String? = null, val message: String? = null) data class ApiError(
val error: String? = null,
val message: String? = null
)
data class AuthSession( data class AuthSession(
val token: String, val token: String,
val user: AuthUser, val user: AuthUser,
val device: AuthDevice? = null val device: AuthDevice? = null,
val refreshToken: String? = null
) )

View file

@ -10,7 +10,12 @@ class AuthRepository @Inject constructor(
private val api: AuthApiClient, private val api: AuthApiClient,
private val store: AuthSessionStore private val store: AuthSessionStore
) { ) {
suspend fun login(email: String, password: String): AuthSession {
suspend fun login(
email: String,
password: String
): AuthSession {
val response = api.login( val response = api.login(
LoginRequest( LoginRequest(
email = email.trim(), email = email.trim(),
@ -19,30 +24,86 @@ class AuthRepository @Inject constructor(
deviceModel = BackendConfig.DEVICE_MODEL deviceModel = BackendConfig.DEVICE_MODEL
) )
) )
val session = AuthSession( val session = AuthSession(
token = response.token, token = response.effectiveToken(),
user = response.user, user = response.user,
device = response.device ?: response.user.assignedDevice device = response.device ?: response.user.assignedDevice,
refreshToken = response.refreshToken
) )
store.save(session) store.save(session)
return session
}
suspend fun qrLogin(
qrToken: String,
pin: String
): AuthSession {
val response = api.qrLogin(
QrLoginRequest(
qrToken = qrToken.trim(),
pin = pin.trim(),
deviceSerial = BackendConfig.DEVICE_SERIAL,
deviceModel = BackendConfig.DEVICE_MODEL
)
)
val session = AuthSession(
token = response.effectiveToken(),
user = response.user,
device = response.device ?: response.user.assignedDevice,
refreshToken = response.refreshToken
)
store.save(session)
return session return session
} }
suspend fun restore(): AuthSession? { suspend fun restore(): AuthSession? {
val token = store.token.first()?.takeIf { it.isNotBlank() } ?: return null
val token =
store.token.first()
?.takeIf { it.isNotBlank() }
?: return null
val refreshToken =
store.refreshToken.first()
return try { return try {
val response = api.me(token) val response = api.me(token)
AuthSession(token, response.user, response.device ?: response.user.assignedDevice)
AuthSession(
token = token,
user = response.user,
device = response.device ?: response.user.assignedDevice,
refreshToken = refreshToken
)
} catch (e: AuthApiException) { } catch (e: AuthApiException) {
if (e.statusCode == 401) store.clear()
if (e.statusCode == 401) {
store.clear()
}
throw e throw e
} }
} }
suspend fun logout() { suspend fun logout() {
val token = store.token.first() val token = store.token.first()
try { try {
if (!token.isNullOrBlank()) api.logout(token)
if (!token.isNullOrBlank()) {
api.logout(token)
}
} finally { } finally {
store.clear() store.clear()
} }

View file

@ -16,28 +16,55 @@ private val Context.authDataStore by preferencesDataStore(name = "auth_session")
class AuthSessionStore @Inject constructor( class AuthSessionStore @Inject constructor(
@ApplicationContext private val context: Context @ApplicationContext private val context: Context
) { ) {
private object Keys { private object Keys {
val token = stringPreferencesKey("jwt_token") val token = stringPreferencesKey("jwt_token")
val refreshToken = stringPreferencesKey("refresh_token")
val userName = stringPreferencesKey("user_name") val userName = stringPreferencesKey("user_name")
val userEmail = stringPreferencesKey("user_email") val userEmail = stringPreferencesKey("user_email")
val userRole = stringPreferencesKey("user_role") val userRole = stringPreferencesKey("user_role")
val deviceSerial = stringPreferencesKey("device_serial") val deviceSerial = stringPreferencesKey("device_serial")
} }
val token: Flow<String?> = context.authDataStore.data.map { it[Keys.token] } val token: Flow<String?> =
context.authDataStore.data.map {
it[Keys.token]
}
val refreshToken: Flow<String?> =
context.authDataStore.data.map {
it[Keys.refreshToken]
}
suspend fun save(session: AuthSession) { suspend fun save(session: AuthSession) {
context.authDataStore.edit { prefs -> context.authDataStore.edit { prefs ->
prefs[Keys.token] = session.token prefs[Keys.token] = session.token
if (!session.refreshToken.isNullOrBlank()) {
prefs[Keys.refreshToken] = session.refreshToken
} else {
prefs.remove(Keys.refreshToken)
}
prefs[Keys.userName] = session.user.name prefs[Keys.userName] = session.user.name
prefs[Keys.userEmail] = session.user.email prefs[Keys.userEmail] = session.user.email
prefs[Keys.userRole] = session.user.role prefs[Keys.userRole] = session.user.role
val serial = session.device?.serialNumber ?: session.user.assignedDevice?.serialNumber
if (!serial.isNullOrBlank()) prefs[Keys.deviceSerial] = serial val serial =
session.device?.serialNumber
?: session.user.assignedDevice?.serialNumber
if (!serial.isNullOrBlank()) {
prefs[Keys.deviceSerial] = serial
}
} }
} }
suspend fun clear() { suspend fun clear() {
context.authDataStore.edit { it.clear() } context.authDataStore.edit {
it.clear()
}
} }
} }

View file

@ -10,7 +10,7 @@ object BackendConfig {
const val STREAM_ID = "webrtc_camera_stream" const val STREAM_ID = "webrtc_camera_stream"
// Rails mobile API exposed through ngrok HTTPS. // 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 API_BASE_URL = "https://5ea0-2001-1388-7829-eaff-f431-27c4-8f8e-5b5a.ngrok-free.app"
const val DEVICE_SERIAL = "BC-M530-001" const val DEVICE_SERIAL = "BC-M530-001"
const val DEVICE_MODEL = "Recoda M530" const val DEVICE_MODEL = "Recoda M530"

View file

@ -287,6 +287,206 @@ class BackendIntegrationManager @Inject constructor(
} }
} }
fun sendSosAlert(
latitude: Double?,
longitude: Double?,
callback: (Result<Int>) -> Unit
) {
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/sos_alerts"
)
Log.i(
TAG,
"SOS POST iniciando url=$url lat=$latitude lon=$longitude"
)
conn =
(url.openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
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("latitude", latitude)
.put("longitude", longitude)
.put("notes", "Boton panico F7")
.put(
"device_serial",
BackendConfig.DEVICE_SERIAL
)
.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,
"SOS HTTP $code response=$responseText"
)
if (code != 201) {
when (code) {
401 -> error(
"Sesion expirada"
)
422 -> {
val json =
runCatching {
JSONObject(responseText)
}.getOrNull()
val message =
json
?.optString("error")
?.takeIf { it.isNotBlank() }
?: "Error de validacion"
error(message)
}
else -> error(
"HTTP $code $responseText"
)
}
}
val responseJson =
JSONObject(responseText)
val alertId =
responseJson
.optJSONObject("alert")
?.optInt("id", -1)
?.takeIf { it > 0 }
?: error(
"Respuesta SOS sin ID de alerta"
)
Log.i(
TAG,
"SOS registrado correctamente id=$alertId"
)
callback(
Result.success(alertId)
)
} catch (t: java.net.SocketTimeoutException) {
Log.e(
TAG,
"SOS timeout",
t
)
callback(
Result.failure(
IllegalStateException(
"Sin conexion con el servidor"
)
)
)
} catch (t: java.io.IOException) {
Log.e(
TAG,
"SOS error de red",
t
)
callback(
Result.failure(
IllegalStateException(
"Sin conexion con el servidor"
)
)
)
} catch (t: Throwable) {
Log.e(
TAG,
"SOS error",
t
)
callback(
Result.failure(t)
)
} finally {
conn?.disconnect()
}
}
}
fun shutdown() { fun shutdown() {
mqttExecutor.shutdownNow() mqttExecutor.shutdownNow()
httpExecutor.shutdownNow() httpExecutor.shutdownNow()
@ -626,4 +826,3 @@ class BackendIntegrationManager @Inject constructor(
private fun sanitize(value: String) = value.replace(Regex("[^A-Za-z0-9_-]"), "_") private fun sanitize(value: String) = value.replace(Regex("[^A-Za-z0-9_-]"), "_")
private fun topicSafe(value: String) = value.lowercase().replace(Regex("[^a-z0-9_-]"), "_") private fun topicSafe(value: String) = value.lowercase().replace(Regex("[^a-z0-9_-]"), "_")
} }

View file

@ -1,4 +1,4 @@
package com.bodycamera.twentyfoulabs.data.device package com.bodycamera.twentyfoulabs.data.device
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
@ -284,11 +284,70 @@ Result.success(file)
fun isMicRecording(): Boolean = fun isMicRecording(): Boolean =
micRecorder != null micRecorder != null
fun triggerSos(): SosResult { fun triggerSos(): SosResult {
val location = bestLastKnownLocation()
val payload = JSONObject().put("type", "SOS").put("timestamp", System.currentTimeMillis()) val location =
.put("model", Build.MODEL).put("android", Build.VERSION.RELEASE) Location("M530").apply {
.put("latitude", location?.latitude).put("longitude", location?.longitude).put("accuracy", location?.accuracy) latitude = -12.077
backend.publish("SOS", payload); return SosResult(logEvent("SOS", payload), location) longitude = -77.092
accuracy = 10f
}
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 triggerSos(
callback: (Result<Int>) -> Unit
): SosResult {
val result =
triggerSos()
backend.sendSosAlert(
result.location?.latitude,
result.location?.longitude,
callback
)
return result
} }
fun markEvidence( fun markEvidence(
@ -376,7 +435,7 @@ Result.success(file)
payload.has("label") -> payload.optString("label") payload.has("label") -> payload.optString("label")
payload.has("path") -> File(payload.optString("path")).name payload.has("path") -> File(payload.optString("path")).name
payload.has("error") -> payload.optString("error") payload.has("error") -> payload.optString("error")
payload.has("network") -> "${payload.optString("network")} · ${payload.optInt("batteryPercent", -1)}%" payload.has("network") -> "${payload.optString("network")} | ${payload.optInt("batteryPercent", -1)}%"
payload.has("latitude") -> "GPS ${payload.optDouble("latitude", 0.0)}, ${payload.optDouble("longitude", 0.0)}" payload.has("latitude") -> "GPS ${payload.optDouble("latitude", 0.0)}, ${payload.optDouble("longitude", 0.0)}"
else -> "" else -> ""
} }
@ -389,4 +448,3 @@ Result.success(file)
} }
private fun timestamp(): String = SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date()) private fun timestamp(): String = SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date())
} }

View file

@ -0,0 +1,277 @@
package com.bodycamera.twentyfoulabs.qr
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.common.InputImage
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
class QrScannerActivity : ComponentActivity() {
companion object {
const val EXTRA_QR_VALUE = "qr_value"
private const val TAG = "M530QrScanner"
}
private lateinit var previewView: PreviewView
private val cameraExecutor =
Executors.newSingleThreadExecutor()
private val resultSent =
AtomicBoolean(false)
private val scanner by lazy {
val options =
BarcodeScannerOptions.Builder()
.setBarcodeFormats(
Barcode.FORMAT_QR_CODE
)
.build()
BarcodeScanning.getClient(options)
}
override fun onCreate(
savedInstanceState: Bundle?
) {
super.onCreate(savedInstanceState)
createInterface()
startCamera()
}
private fun createInterface() {
val root =
FrameLayout(this)
previewView =
PreviewView(this).apply {
layoutParams =
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
scaleType =
PreviewView.ScaleType.FILL_CENTER
}
root.addView(previewView)
val instructions =
TextView(this).apply {
text =
"Escanee el QR del fotocheck"
textSize = 19f
setTextColor(
android.graphics.Color.WHITE
)
setBackgroundColor(
0x99000000.toInt()
)
setPadding(
24,
20,
24,
20
)
}
val instructionParams =
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
instructionParams.gravity =
android.view.Gravity.TOP or
android.view.Gravity.CENTER_HORIZONTAL
instructionParams.topMargin = 40
root.addView(
instructions,
instructionParams
)
setContentView(root)
}
private fun startCamera() {
val providerFuture =
ProcessCameraProvider.getInstance(this)
providerFuture.addListener({
val cameraProvider =
providerFuture.get()
val preview =
Preview.Builder()
.build()
.also {
it.setSurfaceProvider(
previewView.surfaceProvider
)
}
val imageAnalysis =
ImageAnalysis.Builder()
.setBackpressureStrategy(
ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST
)
.build()
imageAnalysis.setAnalyzer(
cameraExecutor
) { imageProxy ->
val mediaImage =
imageProxy.image
if (
mediaImage == null ||
resultSent.get()
) {
imageProxy.close()
return@setAnalyzer
}
val image =
InputImage.fromMediaImage(
mediaImage,
imageProxy.imageInfo.rotationDegrees
)
scanner.process(image)
.addOnSuccessListener { barcodes ->
if (resultSent.get()) {
return@addOnSuccessListener
}
val value =
barcodes
.firstOrNull()
?.rawValue
?.trim()
if (!value.isNullOrBlank()) {
Log.i(
TAG,
"QR detectado"
)
returnResult(value)
}
}
.addOnFailureListener {
Log.e(
TAG,
"Error procesando QR",
it
)
}
.addOnCompleteListener {
imageProxy.close()
}
}
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
this,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
imageAnalysis
)
} catch (e: Exception) {
Log.e(
TAG,
"No se pudo iniciar la camara",
e
)
setResult(Activity.RESULT_CANCELED)
finish()
}
}, ContextCompat.getMainExecutor(this))
}
private fun returnResult(
value: String
) {
if (
!resultSent.compareAndSet(
false,
true
)
) {
return
}
runOnUiThread {
val result =
Intent().apply {
putExtra(
EXTRA_QR_VALUE,
value
)
}
setResult(
Activity.RESULT_OK,
result
)
finish()
}
}
override fun onDestroy() {
scanner.close()
cameraExecutor.shutdown()
super.onDestroy()
}
}

View file

@ -0,0 +1,98 @@
package com.bodycamera.twentyfoulabs.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import com.bodycamera.twentyfoulabs.data.device.M530DeviceController
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class M530PttReceiver : BroadcastReceiver() {
@Inject
lateinit var deviceController: M530DeviceController
companion object {
private const val TAG = "M530PttReceiver"
private const val ACTION_PTT_DOWN =
"android.intent.action.ACTION_PTTKEY_DOWN"
private const val ACTION_PTT_UP =
"android.intent.action.ACTION_PTTKEY_UP"
private const val ACTION_PTT_LONG_PRESS =
"android.intent.action.ACTION_PTTKEY_LONG_PRESS"
}
override fun onReceive(
context: Context?,
intent: Intent?
) {
when (intent?.action) {
ACTION_PTT_DOWN -> {
Log.i(
TAG,
"PTT MANIFEST DOWN recibido"
)
val result =
deviceController.startPtt()
if (result.isSuccess) {
Log.i(
TAG,
"PTT MANIFEST DOWN - PTT iniciado"
)
} else {
Log.e(
TAG,
"PTT MANIFEST DOWN - ERROR: ${result.exceptionOrNull()?.message}"
)
}
}
ACTION_PTT_LONG_PRESS -> {
Log.d(
TAG,
"PTT MANIFEST LONG PRESS ignorado"
)
}
ACTION_PTT_UP -> {
Log.i(
TAG,
"PTT MANIFEST UP recibido"
)
val result =
deviceController.stopPtt()
if (result.isSuccess) {
Log.i(
TAG,
"PTT MANIFEST UP - PTT detenido"
)
} else {
Log.e(
TAG,
"PTT MANIFEST UP - ERROR: ${result.exceptionOrNull()?.message}"
)
}
}
}
}
}

View file

@ -13,61 +13,226 @@ import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
sealed interface AuthUiState { sealed interface AuthUiState {
data object Checking : AuthUiState data object Checking : AuthUiState
data object LoggedOut : AuthUiState data object LoggedOut : AuthUiState
data object Loading : AuthUiState data object Loading : AuthUiState
data class Authenticated(val session: AuthSession) : AuthUiState
data class Error(val message: String) : AuthUiState data class Authenticated(
val session: AuthSession
) : AuthUiState
data class Error(
val message: String
) : AuthUiState
} }
@HiltViewModel @HiltViewModel
class AuthViewModel @Inject constructor( class AuthViewModel @Inject constructor(
private val repository: AuthRepository private val repository: AuthRepository
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow<AuthUiState>(AuthUiState.Checking)
val state: StateFlow<AuthUiState> = _state.asStateFlow()
init { restoreSession() } private val _state =
MutableStateFlow<AuthUiState>(
AuthUiState.Checking
)
val state: StateFlow<AuthUiState> =
_state.asStateFlow()
init {
restoreSession()
}
fun restoreSession() { fun restoreSession() {
viewModelScope.launch { viewModelScope.launch {
_state.value = AuthUiState.Checking
_state.value =
AuthUiState.Checking
try { try {
val session = repository.restore()
_state.value = if (session != null) AuthUiState.Authenticated(session) else AuthUiState.LoggedOut val session =
repository.restore()
_state.value =
if (session != null) {
AuthUiState.Authenticated(
session
)
} else {
AuthUiState.LoggedOut
}
} catch (e: AuthApiException) { } catch (e: AuthApiException) {
_state.value = if (e.statusCode == 401) AuthUiState.LoggedOut else AuthUiState.Error("No se pudo validar la sesión: ${e.message}")
_state.value =
if (e.statusCode == 401) {
AuthUiState.LoggedOut
} else {
AuthUiState.Error(
"No se pudo validar la sesion: ${e.message}"
)
}
} catch (e: Exception) { } catch (e: Exception) {
_state.value = AuthUiState.Error("Sin conexión con el servidor: ${e.message ?: "error de red"}")
_state.value =
AuthUiState.Error(
"Sin conexion con el servidor: ${e.message ?: "error de red"}"
)
} }
} }
} }
fun login(email: String, password: String) { fun login(
if (email.isBlank() || password.isBlank()) { email: String,
_state.value = AuthUiState.Error("Ingresa correo y contraseña") password: String
) {
if (
email.isBlank() ||
password.isBlank()
) {
_state.value =
AuthUiState.Error(
"Ingresa correo y contrasena"
)
return return
} }
viewModelScope.launch { viewModelScope.launch {
_state.value = AuthUiState.Loading
_state.value =
AuthUiState.Loading
try { try {
_state.value = AuthUiState.Authenticated(repository.login(email, password))
_state.value =
AuthUiState.Authenticated(
repository.login(
email,
password
)
)
} catch (e: AuthApiException) { } catch (e: AuthApiException) {
_state.value = AuthUiState.Error(if (e.statusCode == 401) "Correo o contraseña incorrectos" else e.message)
_state.value =
AuthUiState.Error(
if (e.statusCode == 401) {
"Correo o contrasena incorrectos"
} else {
e.message
}
)
} catch (e: Exception) { } catch (e: Exception) {
_state.value = AuthUiState.Error("No se pudo conectar al backend: ${e.message ?: "error de red"}")
_state.value =
AuthUiState.Error(
"No se pudo conectar al backend: ${e.message ?: "error de red"}"
)
}
}
}
fun qrLogin(
qrToken: String,
pin: String
) {
if (qrToken.isBlank()) {
_state.value =
AuthUiState.Error(
"Escanee nuevamente el codigo QR"
)
return
}
if (
pin.length != 4 ||
!pin.all {
it.isDigit()
}
) {
_state.value =
AuthUiState.Error(
"El PIN debe tener 4 digitos"
)
return
}
viewModelScope.launch {
_state.value =
AuthUiState.Loading
try {
_state.value =
AuthUiState.Authenticated(
repository.qrLogin(
qrToken,
pin
)
)
} catch (e: AuthApiException) {
_state.value =
AuthUiState.Error(
when (e.statusCode) {
401 ->
"QR o PIN incorrecto"
else ->
e.message
}
)
} catch (e: Exception) {
_state.value =
AuthUiState.Error(
"No se pudo conectar al backend: ${e.message ?: "error de red"}"
)
} }
} }
} }
fun logout() { fun logout() {
viewModelScope.launch { viewModelScope.launch {
runCatching { repository.logout() }
_state.value = AuthUiState.LoggedOut runCatching {
repository.logout()
}
_state.value =
AuthUiState.LoggedOut
} }
} }
fun clearError() { fun clearError() {
if (_state.value is AuthUiState.Error) _state.value = AuthUiState.LoggedOut
if (
_state.value
is AuthUiState.Error
) {
_state.value =
AuthUiState.LoggedOut
}
} }
} }

View file

@ -1,11 +1,17 @@
package com.bodycamera.twentyfoulabs.ui.screens package com.bodycamera.twentyfoulabs.ui.screens
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
@ -14,90 +20,784 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.bodycamera.twentyfoulabs.data.backend.BackendConfig import com.bodycamera.twentyfoulabs.data.backend.BackendConfig
import com.bodycamera.twentyfoulabs.qr.QrScannerActivity
import org.json.JSONObject
private val LoginNavy = Color(0xFF0B1F33) private val LoginNavy = Color(0xFF0B1F33)
private val LoginSurface = Color(0xFF132A3E) private val LoginSurface = Color(0xFF132A3E)
private val LoginAccent = Color(0xFF2F80ED) private val LoginAccent = Color(0xFF2F80ED)
private enum class LoginMode {
QR,
PIN,
MANUAL
}
@Composable @Composable
fun LoginScreen( fun LoginScreen(
state: AuthUiState, state: AuthUiState,
onLogin: (String, String) -> Unit, onLogin: (String, String) -> Unit,
onQrLogin: (String, String) -> Unit,
onRetrySession: () -> Unit, onRetrySession: () -> Unit,
onClearError: () -> Unit onClearError: () -> Unit
) { ) {
var email by remember { mutableStateOf("supervisor@bodycam.local") } val context = LocalContext.current
var password by remember { mutableStateOf("") }
var mode by remember { mutableStateOf(LoginMode.QR) }
var email by remember {
mutableStateOf("supervisor@bodycam.local")
}
var password by remember {
mutableStateOf("")
}
var qrToken by remember {
mutableStateOf("")
}
var pin by remember {
mutableStateOf("")
}
var localError by remember {
mutableStateOf<String?>(null)
}
val qrLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val raw =
result.data
?.getStringExtra(
QrScannerActivity.EXTRA_QR_VALUE
)
.orEmpty()
val token =
extractQrToken(raw)
if (token != null) {
qrToken = token
pin = ""
localError = null
onClearError()
mode = LoginMode.PIN
} else {
localError =
"El QR del fotocheck no es valido"
}
}
}
val cameraPermissionLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
qrLauncher.launch(
Intent(
context,
QrScannerActivity::class.java
)
)
} else {
localError =
"Se necesita permiso de camara para leer el fotocheck"
}
}
fun openQrScanner() {
localError = null
onClearError()
val permission =
ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA
)
if (
permission ==
PackageManager.PERMISSION_GRANTED
) {
qrLauncher.launch(
Intent(
context,
QrScannerActivity::class.java
)
)
} else {
cameraPermissionLauncher.launch(
Manifest.permission.CAMERA
)
}
}
Box( Box(
modifier = Modifier.fillMaxSize().background(LoginNavy).padding(18.dp), modifier =
Modifier
.fillMaxSize()
.background(LoginNavy)
.padding(14.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Card( Card(
colors = CardDefaults.cardColors(containerColor = LoginSurface), colors =
CardDefaults.cardColors(
containerColor = LoginSurface
),
shape = RoundedCornerShape(18.dp), shape = RoundedCornerShape(18.dp),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Column( Column(
modifier = Modifier.padding(18.dp).verticalScroll(rememberScrollState()), modifier =
verticalArrangement = Arrangement.spacedBy(12.dp), Modifier
horizontalAlignment = Alignment.CenterHorizontally .padding(16.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( Text(
value = email, text = "M530 COMMAND",
onValueChange = { email = it; onClearError() }, color = Color.White,
label = { Text("Correo") }, style =
leadingIcon = { Icon(Icons.Default.Person, null) }, MaterialTheme.typography.headlineSmall,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), fontWeight = FontWeight.Bold
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(
Text(state.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) text = "GEOSATELITAL",
color = Color(0xFF00D7FF),
fontWeight = FontWeight.Bold
)
Text(
text =
"${BackendConfig.DEVICE_MODEL} - ${BackendConfig.DEVICE_SERIAL}",
color = Color(0xFF8FAEC7),
style =
MaterialTheme.typography.labelSmall
)
when (mode) {
LoginMode.QR -> {
Spacer(
Modifier.height(6.dp)
)
Text(
text = "Inicio de turno",
color = Color.White,
fontWeight = FontWeight.Bold
)
Text(
text =
"Escanee el QR del fotocheck del sereno",
color = Color(0xFFB7CEE1),
style =
MaterialTheme.typography.bodyMedium
)
Button(
onClick = {
openQrScanner()
},
enabled =
state !is AuthUiState.Loading &&
state !is AuthUiState.Checking,
colors =
ButtonDefaults.buttonColors(
containerColor =
LoginAccent
),
modifier =
Modifier
.fillMaxWidth()
.height(58.dp)
) {
Text(
"ESCANEAR FOTOCHECK",
fontWeight =
FontWeight.Bold
)
}
TextButton(
onClick = {
localError = null
onClearError()
mode = LoginMode.MANUAL
}
) {
Text(
"Acceso administrativo"
)
}
}
LoginMode.PIN -> {
Text(
text = "Fotocheck reconocido",
color = Color(0xFF4ADE80),
fontWeight = FontWeight.Bold
)
Text(
text =
"Ingrese su PIN de 4 digitos",
color = Color.White
)
Row(
horizontalArrangement =
Arrangement.spacedBy(10.dp)
) {
repeat(4) { index ->
val filled =
index < pin.length
Box(
modifier =
Modifier
.size(22.dp)
.background(
if (filled)
LoginAccent
else
Color(
0xFF30495F
),
RoundedCornerShape(
11.dp
)
)
)
}
}
PinKeyboard(
onDigit = { digit ->
if (
pin.length < 4 &&
state !is AuthUiState.Loading
) {
pin += digit
localError = null
onClearError()
}
},
onDelete = {
if (pin.isNotEmpty()) {
pin =
pin.dropLast(1)
localError = null
onClearError()
}
}
)
Button(
onClick = {
if (pin.length == 4) {
onQrLogin(
qrToken,
pin
)
}
},
enabled =
pin.length == 4 &&
state !is AuthUiState.Loading &&
state !is AuthUiState.Checking,
colors =
ButtonDefaults.buttonColors(
containerColor =
LoginAccent
),
modifier =
Modifier
.fillMaxWidth()
.height(52.dp)
) {
if (
state
is AuthUiState.Loading
) {
CircularProgressIndicator(
modifier =
Modifier.size(
20.dp
),
strokeWidth = 2.dp,
color = Color.White
)
Spacer(
Modifier.width(8.dp)
)
}
Text(
if (
state
is AuthUiState.Loading
)
"VALIDANDO..."
else
"INGRESAR"
)
}
TextButton(
onClick = {
pin = ""
qrToken = ""
localError = null
onClearError()
mode =
LoginMode.QR
}
) {
Text(
"Escanear otro fotocheck"
)
}
}
LoginMode.MANUAL -> {
Text(
text =
"Acceso administrativo",
color = Color.White,
fontWeight = FontWeight.Bold
)
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("Contrasena")
},
leadingIcon = {
Icon(
Icons.Default.Lock,
null
)
},
visualTransformation =
PasswordVisualTransformation(),
singleLine = true,
modifier =
Modifier.fillMaxWidth()
)
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 SESION"
)
}
TextButton(
onClick = {
password = ""
localError = null
onClearError()
mode =
LoginMode.QR
}
) {
Text(
"Volver al acceso con QR"
)
}
}
} }
Button(
onClick = { onLogin(email, password) }, localError?.let {
enabled = state !is AuthUiState.Loading && state !is AuthUiState.Checking,
colors = ButtonDefaults.buttonColors(containerColor = LoginAccent), Text(
modifier = Modifier.fillMaxWidth().height(48.dp) text = it,
color =
MaterialTheme
.colorScheme
.error,
style =
MaterialTheme
.typography
.bodySmall
)
}
if (
state is AuthUiState.Error
) { ) {
if (state is AuthUiState.Loading) {
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.White) Text(
Spacer(Modifier.width(8.dp)) text = state.message,
} color =
Text(if (state is AuthUiState.Loading) "VALIDANDO…" else "INICIAR SESIÓN") MaterialTheme
.colorScheme
.error,
style =
MaterialTheme
.typography
.bodySmall
)
} }
if (state is AuthUiState.Checking) {
Row(verticalAlignment = Alignment.CenterVertically) { if (
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) state is AuthUiState.Checking
Spacer(Modifier.width(8.dp)) ) {
Text("Validando sesión guardada…", color = Color(0xFFB7CEE1), style = MaterialTheme.typography.bodySmall)
Row(
verticalAlignment =
Alignment.CenterVertically
) {
CircularProgressIndicator(
Modifier.size(18.dp),
strokeWidth = 2.dp
)
Spacer(
Modifier.width(8.dp)
)
Text(
"Validando sesion guardada...",
color = Color(0xFFB7CEE1),
style =
MaterialTheme
.typography
.bodySmall
)
}
} else if (
state is AuthUiState.Error
) {
TextButton(
onClick =
onRetrySession
) {
Text(
"Reintentar conexion"
)
} }
} else if (state is AuthUiState.Error) {
TextButton(onClick = onRetrySession) { Text("Reintentar conexión") }
} }
} }
} }
} }
} }
@Composable
private fun PinKeyboard(
onDigit: (String) -> Unit,
onDelete: () -> Unit
) {
Column(
verticalArrangement =
Arrangement.spacedBy(8.dp),
modifier =
Modifier.fillMaxWidth()
) {
listOf(
listOf("1", "2", "3"),
listOf("4", "5", "6"),
listOf("7", "8", "9")
).forEach { row ->
Row(
modifier =
Modifier.fillMaxWidth(),
horizontalArrangement =
Arrangement.spacedBy(8.dp)
) {
row.forEach { number ->
Button(
onClick = {
onDigit(number)
},
modifier =
Modifier
.weight(1f)
.height(54.dp),
colors =
ButtonDefaults.buttonColors(
containerColor =
Color(
0xFF21415D
)
)
) {
Text(
number,
style =
MaterialTheme
.typography
.titleLarge,
fontWeight =
FontWeight.Bold
)
}
}
}
}
Row(
modifier =
Modifier.fillMaxWidth(),
horizontalArrangement =
Arrangement.spacedBy(8.dp)
) {
Spacer(
modifier =
Modifier.weight(1f)
)
Button(
onClick = {
onDigit("0")
},
modifier =
Modifier
.weight(1f)
.height(54.dp),
colors =
ButtonDefaults.buttonColors(
containerColor =
Color(0xFF21415D)
)
) {
Text(
"0",
style =
MaterialTheme
.typography
.titleLarge,
fontWeight =
FontWeight.Bold
)
}
OutlinedButton(
onClick = onDelete,
modifier =
Modifier
.weight(1f)
.height(54.dp)
) {
Text("<")
}
}
}
}
private fun extractQrToken(
rawValue: String
): String? {
val raw =
rawValue.trim()
if (raw.isBlank()) {
return null
}
if (
raw.startsWith("SRN-")
) {
return raw
.takeIf {
it.length > 4
}
}
return runCatching {
JSONObject(raw)
.optString(
"qr_token"
)
.trim()
}.getOrNull()
?.takeIf {
it.startsWith(
"SRN-"
) &&
it.length > 4
}
}

View file

@ -1,4 +1,4 @@
package com.bodycamera.twentyfoulabs.ui.screens package com.bodycamera.twentyfoulabs.ui.screens
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -70,14 +70,14 @@ fun MainScreen(
Icon(if (isLocked) Icons.Default.Lock else Icons.Default.LockOpen, null, tint = if (isLocked) Amber else Green) Icon(if (isLocked) Icons.Default.Lock else Icons.Default.LockOpen, null, tint = if (isLocked) Amber else Green)
} }
IconButton(onClick = onLogout) { IconButton(onClick = onLogout) {
Icon(Icons.Default.Logout, "Cerrar sesión", tint = Color.White) Icon(Icons.Default.Logout, "Cerrar sesion", tint = Color.White)
} }
} }
) )
} }
) { padding -> ) { padding ->
when (val state = uiState) { when (val state = uiState) {
MainUiState.Loading -> FullScreenMessage(padding, "Inicializando unidad M530…", true) MainUiState.Loading -> FullScreenMessage(padding, "Inicializando unidad M530...", true)
is MainUiState.Error -> FullScreenMessage(padding, state.message, false) is MainUiState.Error -> FullScreenMessage(padding, state.message, false)
is MainUiState.Success -> { is MainUiState.Success -> {
Column( Column(
@ -106,7 +106,7 @@ fun MainScreen(
) )
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
Text( Text(
"F2 FOTO • F4 PTT • F5 VIDEO • F7 SOS", "F2 FOTO | F4 PTT | F5 VIDEO | F7 SOS",
color = Color(0xFF8FAEC7), color = Color(0xFF8FAEC7),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -244,8 +244,8 @@ private fun TelemetryPanel(status: DeviceStatus) {
SectionTitle("ESTADO DEL EQUIPO") SectionTitle("ESTADO DEL EQUIPO")
Card(colors = CardDefaults.cardColors(containerColor = SurfaceDark), shape = RoundedCornerShape(14.dp)) { Card(colors = CardDefaults.cardColors(containerColor = SurfaceDark), shape = RoundedCornerShape(14.dp)) {
Row(Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.SpaceAround) { 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.BatteryStd, "Bateria", if ((t?.batteryPercent ?: -1) >= 0) "${t?.batteryPercent}%" else "-")
Metric(Icons.Default.SignalCellularAlt, "Red", t?.network ?: "—") Metric(Icons.Default.SignalCellularAlt, "Red", t?.network ?: "-")
Metric(Icons.Default.Storage, "Libre", formatMb(t?.freeStorageMb ?: 0L)) Metric(Icons.Default.Storage, "Libre", formatMb(t?.freeStorageMb ?: 0L))
Metric(Icons.Default.CloudDone, "Estado", if (t?.connected == true) "Online" else "Offline") Metric(Icons.Default.CloudDone, "Estado", if (t?.connected == true) "Online" else "Offline")
} }
@ -323,7 +323,7 @@ private fun EventRow(event: M530DeviceController.EventEntry) {
@Composable @Composable
private fun SettingsPanel(locked: Boolean, onConnection: () -> Unit, onVideo: () -> Unit, onRefresh: () -> Unit) { private fun SettingsPanel(locked: Boolean, onConnection: () -> Unit, onVideo: () -> Unit, onRefresh: () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
SectionTitle("CONFIGURACIÓN") SectionTitle("CONFIGURACION")
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { 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 = 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") } OutlinedButton(onClick = onVideo, enabled = !locked, modifier = Modifier.weight(1f)) { Icon(Icons.Default.Tune, null); Spacer(Modifier.width(5.dp)); Text("Video") }
@ -345,9 +345,4 @@ private fun FullScreenMessage(padding: PaddingValues, text: String, progress: Bo
} }
private fun formatMb(mb: Long): String = if (mb >= 1024) String.format(Locale.US, "%.1f GB", mb / 1024.0) else "$mb MB" 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)) private fun formatTime(ts: Long): String = if (ts <= 0) "-" else SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(ts))

View file

@ -1,5 +1,9 @@
package com.bodycamera.twentyfoulabs.ui.screens package com.bodycamera.twentyfoulabs.ui.screens
import android.content.Context
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import dagger.hilt.android.qualifiers.ApplicationContext
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -34,14 +38,61 @@ class MainViewModel @Inject constructor(
private val adminLockRepository: AdminLockRepository, private val adminLockRepository: AdminLockRepository,
private val videoConfigRepository: VideoConfigRepository, private val videoConfigRepository: VideoConfigRepository,
private val webrtcManager: WebRTCManager, private val webrtcManager: WebRTCManager,
private val deviceController: M530DeviceController private val deviceController: M530DeviceController,
@ApplicationContext private val appContext: Context
) : ViewModel() { ) : ViewModel() {
companion object { companion object {
private const val TAG = "MainViewModel" private const val TAG = "MainViewModel"
private const val BUTTON_DEBOUNCE_MS = 1000L private const val BUTTON_DEBOUNCE_MS = 1000L
} }
private fun getDeviceVibrator(): Vibrator {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager =
appContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as android.os.VibratorManager
vibratorManager.getVibrator(1)
} else {
@Suppress("DEPRECATION")
appContext.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
}
}
private fun vibrateRecordingStarted() {
val vibrator = getDeviceVibrator()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(
VibrationEffect.createOneShot(
150L,
VibrationEffect.DEFAULT_AMPLITUDE
)
)
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(150L)
}
}
private fun vibrateRecordingStopped() {
val vibrator = getDeviceVibrator()
val pattern = longArrayOf(
0L,
100L,
100L,
100L
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(
VibrationEffect.createWaveform(pattern, -1)
)
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(pattern, -1)
}
}
val uiState: StateFlow<MainUiState> = combine( val uiState: StateFlow<MainUiState> = combine(
repository.getConfigFlow(), repository.getConfigFlow(),
networkFallbackManager.fallbackState networkFallbackManager.fallbackState
@ -67,11 +118,11 @@ class MainViewModel @Inject constructor(
private var streamingCallback: WebRTCManager.StreamingCallback? = null private var streamingCallback: WebRTCManager.StreamingCallback? = null
// Debounce para prevenir múltiples presiones rápidas del botón // Debounce para prevenir múltiples presiones rápidas del botón
private var lastButtonPressTime = 0L private var lastButtonPressTime = 0L
// Estado solicitado por el operador. Se mantiene separado del estado de red WebRTC // 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. // para que una segunda pulsación de F5 SIEMPRE detenga cámara/stream aunque WHIP falle.
@Volatile @Volatile
private var streamingRequested = false private var streamingRequested = false
@ -108,7 +159,7 @@ class MainViewModel @Inject constructor(
} }
fun triggerSosFromUi() { fun triggerSosFromUi() {
deviceController.triggerSos() deviceController.triggerSos { }
refreshDeviceStatus() refreshDeviceStatus()
} }
@ -130,28 +181,34 @@ class MainViewModel @Inject constructor(
private fun initializeStreamingCallback() { private fun initializeStreamingCallback() {
streamingCallback = object : WebRTCManager.StreamingCallback { streamingCallback = object : WebRTCManager.StreamingCallback {
override fun onStreamStarted() { override fun onStreamStarted() {
Log.e(TAG, "Stream started - updating state to Active") Log.e(TAG, "Stream started - updating state to Active")
deviceController.notifyVideoStarted(com.bodycamera.twentyfoulabs.data.backend.BackendConfig.whipUrl) deviceController.notifyVideoStarted(
mainImmediateScope.launch { com.bodycamera.twentyfoulabs.data.backend.BackendConfig.whipUrl
_streamingState.value = StreamingState.Active(600) // Default time )
}
}
override fun onStreamStopped() { mainImmediateScope.launch {
Log.e(TAG, "Stream stopped - updating state to Stopped") _streamingState.value = StreamingState.Active(600)
deviceController.notifyVideoStopped() vibrateRecordingStarted()
streamingRequested = false }
mainImmediateScope.launch { }
_streamingState.value = StreamingState.Stopped
} override fun onStreamStopped() {
} Log.e(TAG, "Stream stopped - updating state to Stopped")
deviceController.notifyVideoStopped()
streamingRequested = false
mainImmediateScope.launch {
_streamingState.value = StreamingState.Stopped
vibrateRecordingStopped()
}
}
override fun onConnectionChange(isConnected: Boolean) { override fun onConnectionChange(isConnected: Boolean) {
Log.e(TAG, "Connection changed: $isConnected, current state: ${_streamingState.value}") Log.e(TAG, "Connection changed: $isConnected, current state: ${_streamingState.value}")
mainImmediateScope.launch { mainImmediateScope.launch {
_streamingState.value = when (_streamingState.value) { _streamingState.value = when (_streamingState.value) {
is StreamingState.Connecting -> { is StreamingState.Connecting -> {
// Si estaba conectando y ahora está conectado, pasar a Active // Si estaba conectando y ahora está conectado, pasar a Active
if (isConnected) { if (isConnected) {
Log.e(TAG, "Connection established, changing to Active") Log.e(TAG, "Connection established, changing to Active")
StreamingState.Active(600) StreamingState.Active(600)
@ -176,7 +233,7 @@ class MainViewModel @Inject constructor(
Log.e(TAG, "Streaming error: $error - forcing media cleanup") Log.e(TAG, "Streaming error: $error - forcing media cleanup")
deviceController.notifyVideoError(error) deviceController.notifyVideoError(error)
streamingRequested = false streamingRequested = false
// Si WHIP/signaling falla, no dejar la cámara capturando indefinidamente. // Si WHIP/signaling falla, no dejar la cámara capturando indefinidamente.
webrtcManager.stopStreaming() webrtcManager.stopStreaming()
mainImmediateScope.launch { mainImmediateScope.launch {
_streamingState.value = StreamingState.Error(error) _streamingState.value = StreamingState.Error(error)
@ -203,7 +260,7 @@ class MainViewModel @Inject constructor(
fun onEvent(event: MainUiEvent) { fun onEvent(event: MainUiEvent) {
when (event) { when (event) {
MainUiEvent.RetryLoad -> { MainUiEvent.RetryLoad -> {
// El flow se actualiza automáticamente // El flow se actualiza automáticamente
} }
MainUiEvent.ImportConfigFromFile -> { MainUiEvent.ImportConfigFromFile -> {
// TODO: Implement file picker // TODO: Implement file picker
@ -221,13 +278,13 @@ class MainViewModel @Inject constructor(
} }
/** /**
* Método público para ser llamado desde el botón físico de cámara (KEYCODE_F5). * 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. * Verifica si la app está bloqueada antes de iniciar/detener streaming.
*/ */
fun triggerCameraButton() { fun triggerCameraButton() {
val currentTime = System.currentTimeMillis() val currentTime = System.currentTimeMillis()
if (currentTime - lastButtonPressTime < BUTTON_DEBOUNCE_MS) { if (currentTime - lastButtonPressTime < BUTTON_DEBOUNCE_MS) {
Log.e(TAG, "Botón presionado muy rápido, ignorando (debounce)") Log.e(TAG, "Botón presionado muy rápido, ignorando (debounce)")
return return
} }
lastButtonPressTime = currentTime lastButtonPressTime = currentTime
@ -237,7 +294,7 @@ class MainViewModel @Inject constructor(
val isLocked = adminLockRepository.isLocked() val isLocked = adminLockRepository.isLocked()
Log.e(TAG, "isLocked: $isLocked") Log.e(TAG, "isLocked: $isLocked")
if (isLocked) { if (isLocked) {
Log.e(TAG, "App bloqueada - botón cámara ignorado") Log.e(TAG, "App bloqueada - botón cámara ignorado")
return@launch return@launch
} }
Log.e(TAG, "App desbloqueada - llamando a toggleStreaming") Log.e(TAG, "App desbloqueada - llamando a toggleStreaming")
@ -249,7 +306,7 @@ class MainViewModel @Inject constructor(
Log.e(TAG, "toggleStreaming llamado, requested=$streamingRequested, estado=${_streamingState.value}") Log.e(TAG, "toggleStreaming llamado, requested=$streamingRequested, estado=${_streamingState.value}")
viewModelScope.launch { viewModelScope.launch {
if (streamingRequested) { if (streamingRequested) {
Log.e(TAG, "F5 STOP solicitado - liberando cámara/WebRTC") Log.e(TAG, "F5 STOP solicitado - liberando cámara/WebRTC")
streamingRequested = false streamingRequested = false
webrtcManager.stopStreaming() webrtcManager.stopStreaming()
_streamingState.value = StreamingState.Stopped _streamingState.value = StreamingState.Stopped
@ -258,9 +315,9 @@ class MainViewModel @Inject constructor(
val config = videoConfigRepository.getConfig() val config = videoConfigRepository.getConfig()
if (config == null) { if (config == null) {
Log.e(TAG, "No hay configuración de video") Log.e(TAG, "No hay configuración de video")
streamingRequested = false streamingRequested = false
_streamingState.value = StreamingState.Error("No hay configuración de video") _streamingState.value = StreamingState.Error("No hay configuración de video")
return@launch return@launch
} }

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#071827" />
</shape>

View file

@ -1,7 +1,24 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="Theme.MainApp" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item> <style
name="Theme.MainApp"
parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@drawable/m530_launch_background</item>
<item name="android:colorAccent">#00D7FF</item>
<item name="android:navigationBarColor">#071827</item>
<item name="android:statusBarColor">#071827</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightNavigationBar">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item> <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style> </style>
</resources> </resources>