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-lifecycle: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
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">
<!-- Permisos de red -->
@ -12,6 +12,7 @@
<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" />
@ -39,6 +40,11 @@
android:supportsRtl="true"
android:theme="@style/Theme.MainApp"
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" />
@ -52,8 +58,9 @@
<!-- 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.HOME" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
@ -67,7 +74,16 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</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>
</manifest>

View file

@ -1,4 +1,4 @@
package com.bodycamera.twentyfoulabs
package com.bodycamera.twentyfoulabs
import android.Manifest
import android.content.BroadcastReceiver
@ -44,10 +44,128 @@ import androidx.hilt.navigation.compose.hiltViewModel
import javax.inject.Inject
import androidx.compose.runtime.collectAsState
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 kotlinx.coroutines.launch
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
class MainActivity : ComponentActivity() {
@ -63,6 +181,15 @@ class MainActivity : ComponentActivity() {
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 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
@ -70,8 +197,51 @@ class MainActivity : ComponentActivity() {
private val hardwareReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
ACTION_VIDEO_DOWN -> handleVideoButton()
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()
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
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")
toast("Inicia sesion para usar la bodycam")
return true
}
return when (keyCode) {
@ -182,13 +375,11 @@ class MainActivity : ComponentActivity() {
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.
// Algunas versiones del firmware tambien emiten ACTION_VIDEO_DOWN.
// El ViewModel aplica debounce para evitar doble ejecucion.
handleVideoButton()
true
}
@ -200,24 +391,53 @@ class MainActivity : ComponentActivity() {
}
}
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
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)
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")
toast("Inicia sesion para usar VIDEO")
return
}
startActivity(
@ -254,13 +474,40 @@ class MainActivity : ComponentActivity() {
).addFlags(android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP)
)
}
private fun handleSosButton() {
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)
toast("Enviando SOS...")
deviceController.triggerSos { result ->
runOnUiThread {
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() {
@ -268,6 +515,9 @@ class MainActivity : ComponentActivity() {
val filter = IntentFilter().apply {
addAction(ACTION_VIDEO_DOWN)
addAction(ACTION_VIDEO_UP)
addAction(ACTION_PTT_DOWN)
addAction(ACTION_PTT_UP)
addAction(ACTION_PTT_LONG_PRESS)
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@ -296,10 +546,14 @@ class MainActivity : ComponentActivity() {
override fun 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() {
unregisterHardwareReceiver()
deviceController.release()
super.onDestroy()
}
@ -361,6 +615,7 @@ fun AppNavigation(viewModel: MainViewModel, onOpenPhotoCamera: () -> Unit, onOpe
LoginScreen(
state = authState,
onLogin = authViewModel::login,
onQrLogin = authViewModel::qrLogin,
onRetrySession = authViewModel::restoreSession,
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.content.ContentValues
@ -44,9 +44,59 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import javax.inject.Inject
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
@AndroidEntryPoint
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
lateinit var backend: BackendIntegrationManager
@ -692,7 +742,10 @@ class M530CameraActivity : ComponentActivity() {
}
private fun enterRecordingState() {
stateText.text = "GRABANDO"
vibrateRecordingStarted()
stateText.text = "GRABANDO"
physicalHintText.text = "F5 DETENER"
recordDot.visibility = View.VISIBLE
@ -710,6 +763,13 @@ class M530CameraActivity : ComponentActivity() {
}
private fun exitRecordingState(error: Boolean) {
activeRecording = null
if (!error) {
vibrateRecordingStopped()
}
chronometer.stop()
activeRecording = null
chronometer.stop()
chronometer.visibility = View.GONE
@ -804,8 +864,3 @@ class M530CameraActivity : ComponentActivity() {
private fun timestamp(): String =
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.net.HttpURLConnection
import java.net.URL
@ -13,55 +13,160 @@ 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)
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
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
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 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 {
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 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) {
val apiError = runCatching { json.decodeFromString<ApiError>(responseBody) }.getOrNull()
val message = apiError?.error ?: apiError?.message ?: "HTTP $code"
throw AuthApiException(code, message)
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)
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
)
@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
data class AuthUser(
val id: Int? = null,
val email: String = "",
val name: String = "",
val role: String = "",
val active: Boolean? = null,
@SerialName("assigned_device") val assignedDevice: AuthDevice? = null
)
@ -39,10 +48,20 @@ data class AuthDevice(
@Serializable
data class LoginResponse(
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,
@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
data class MeResponse(
@ -58,10 +77,14 @@ data class LogoutResponse(
)
@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(
val token: String,
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 store: AuthSessionStore
) {
suspend fun login(email: String, password: String): AuthSession {
suspend fun login(
email: String,
password: String
): AuthSession {
val response = api.login(
LoginRequest(
email = email.trim(),
@ -19,30 +24,86 @@ class AuthRepository @Inject constructor(
deviceModel = BackendConfig.DEVICE_MODEL
)
)
val session = AuthSession(
token = response.token,
token = response.effectiveToken(),
user = response.user,
device = response.device ?: response.user.assignedDevice
device = response.device ?: response.user.assignedDevice,
refreshToken = response.refreshToken
)
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
}
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 {
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) {
if (e.statusCode == 401) store.clear()
if (e.statusCode == 401) {
store.clear()
}
throw e
}
}
suspend fun logout() {
val token = store.token.first()
try {
if (!token.isNullOrBlank()) api.logout(token)
if (!token.isNullOrBlank()) {
api.logout(token)
}
} finally {
store.clear()
}

View file

@ -16,28 +16,55 @@ private val Context.authDataStore by preferencesDataStore(name = "auth_session")
class AuthSessionStore @Inject constructor(
@ApplicationContext private val context: Context
) {
private object Keys {
val token = stringPreferencesKey("jwt_token")
val refreshToken = stringPreferencesKey("refresh_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] }
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) {
context.authDataStore.edit { prefs ->
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.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
val serial =
session.device?.serialNumber
?: session.user.assignedDevice?.serialNumber
if (!serial.isNullOrBlank()) {
prefs[Keys.deviceSerial] = serial
}
}
}
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"
// 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_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() {
mqttExecutor.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 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.content.Context
@ -284,11 +284,70 @@ Result.success(file)
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)
val location =
Location("M530").apply {
latitude = -12.077
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(
@ -376,7 +435,7 @@ Result.success(file)
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("network") -> "${payload.optString("network")} | ${payload.optInt("batteryPercent", -1)}%"
payload.has("latitude") -> "GPS ${payload.optDouble("latitude", 0.0)}, ${payload.optDouble("longitude", 0.0)}"
else -> ""
}
@ -389,4 +448,3 @@ Result.success(file)
}
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
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
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() }
private val _state =
MutableStateFlow<AuthUiState>(
AuthUiState.Checking
)
val state: StateFlow<AuthUiState> =
_state.asStateFlow()
init {
restoreSession()
}
fun restoreSession() {
viewModelScope.launch {
_state.value = AuthUiState.Checking
_state.value =
AuthUiState.Checking
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) {
_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) {
_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) {
if (email.isBlank() || password.isBlank()) {
_state.value = AuthUiState.Error("Ingresa correo y contraseña")
fun login(
email: String,
password: String
) {
if (
email.isBlank() ||
password.isBlank()
) {
_state.value =
AuthUiState.Error(
"Ingresa correo y contrasena"
)
return
}
viewModelScope.launch {
_state.value = AuthUiState.Loading
_state.value =
AuthUiState.Loading
try {
_state.value = AuthUiState.Authenticated(repository.login(email, password))
_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)
_state.value =
AuthUiState.Error(
if (e.statusCode == 401) {
"Correo o contrasena incorrectos"
} else {
e.message
}
)
} 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() {
viewModelScope.launch {
runCatching { repository.logout() }
_state.value = AuthUiState.LoggedOut
runCatching {
repository.logout()
}
_state.value =
AuthUiState.LoggedOut
}
}
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
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.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.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Person
@ -14,90 +20,784 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
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 androidx.core.content.ContextCompat
import com.bodycamera.twentyfoulabs.data.backend.BackendConfig
import com.bodycamera.twentyfoulabs.qr.QrScannerActivity
import org.json.JSONObject
private val LoginNavy = Color(0xFF0B1F33)
private val LoginSurface = Color(0xFF132A3E)
private val LoginAccent = Color(0xFF2F80ED)
private enum class LoginMode {
QR,
PIN,
MANUAL
}
@Composable
fun LoginScreen(
state: AuthUiState,
onLogin: (String, String) -> Unit,
onQrLogin: (String, String) -> Unit,
onRetrySession: () -> Unit,
onClearError: () -> Unit
) {
var email by remember { mutableStateOf("supervisor@bodycam.local") }
var password by remember { mutableStateOf("") }
val context = LocalContext.current
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(
modifier = Modifier.fillMaxSize().background(LoginNavy).padding(18.dp),
modifier =
Modifier
.fillMaxSize()
.background(LoginNavy)
.padding(14.dp),
contentAlignment = Alignment.Center
) {
Card(
colors = CardDefaults.cardColors(containerColor = LoginSurface),
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
modifier =
Modifier
.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(
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()
Text(
text = "M530 COMMAND",
color = Color.White,
style =
MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
if (state is AuthUiState.Error) {
Text(state.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
Text(
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) },
enabled = state !is AuthUiState.Loading && state !is AuthUiState.Checking,
colors = ButtonDefaults.buttonColors(containerColor = LoginAccent),
modifier = Modifier.fillMaxWidth().height(48.dp)
localError?.let {
Text(
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)
Spacer(Modifier.width(8.dp))
}
Text(if (state is AuthUiState.Loading) "VALIDANDO…" else "INICIAR SESIÓN")
Text(
text = state.message,
color =
MaterialTheme
.colorScheme
.error,
style =
MaterialTheme
.typography
.bodySmall
)
}
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)
if (
state is AuthUiState.Checking
) {
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.background
@ -70,14 +70,14 @@ fun MainScreen(
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)
Icon(Icons.Default.Logout, "Cerrar sesion", tint = Color.White)
}
}
)
}
) { padding ->
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.Success -> {
Column(
@ -106,7 +106,7 @@ fun MainScreen(
)
Spacer(Modifier.height(10.dp))
Text(
"F2 FOTO • F4 PTT • F5 VIDEO • F7 SOS",
"F2 FOTO | F4 PTT | F5 VIDEO | F7 SOS",
color = Color(0xFF8FAEC7),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.fillMaxWidth(),
@ -244,8 +244,8 @@ private fun TelemetryPanel(status: DeviceStatus) {
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.BatteryStd, "Bateria", 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")
}
@ -323,7 +323,7 @@ private fun EventRow(event: M530DeviceController.EventEntry) {
@Composable
private fun SettingsPanel(locked: Boolean, onConnection: () -> Unit, onVideo: () -> Unit, onRefresh: () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
SectionTitle("CONFIGURACIÓN")
SectionTitle("CONFIGURACION")
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") }
@ -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 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
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 androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -34,14 +38,61 @@ class MainViewModel @Inject constructor(
private val adminLockRepository: AdminLockRepository,
private val videoConfigRepository: VideoConfigRepository,
private val webrtcManager: WebRTCManager,
private val deviceController: M530DeviceController
private val deviceController: M530DeviceController,
@ApplicationContext private val appContext: Context
) : ViewModel() {
companion object {
private const val TAG = "MainViewModel"
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(
repository.getConfigFlow(),
networkFallbackManager.fallbackState
@ -67,11 +118,11 @@ class MainViewModel @Inject constructor(
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
// 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
private var streamingRequested = false
@ -108,7 +159,7 @@ class MainViewModel @Inject constructor(
}
fun triggerSosFromUi() {
deviceController.triggerSos()
deviceController.triggerSos { }
refreshDeviceStatus()
}
@ -130,28 +181,34 @@ class MainViewModel @Inject constructor(
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
}
}
Log.e(TAG, "Stream started - updating state to Active")
deviceController.notifyVideoStarted(
com.bodycamera.twentyfoulabs.data.backend.BackendConfig.whipUrl
)
override fun onStreamStopped() {
Log.e(TAG, "Stream stopped - updating state to Stopped")
deviceController.notifyVideoStopped()
streamingRequested = false
mainImmediateScope.launch {
_streamingState.value = StreamingState.Stopped
}
}
mainImmediateScope.launch {
_streamingState.value = StreamingState.Active(600)
vibrateRecordingStarted()
}
}
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) {
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
// Si estaba conectando y ahora está conectado, pasar a Active
if (isConnected) {
Log.e(TAG, "Connection established, changing to Active")
StreamingState.Active(600)
@ -176,7 +233,7 @@ class MainViewModel @Inject constructor(
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.
// Si WHIP/signaling falla, no dejar la cámara capturando indefinidamente.
webrtcManager.stopStreaming()
mainImmediateScope.launch {
_streamingState.value = StreamingState.Error(error)
@ -203,7 +260,7 @@ class MainViewModel @Inject constructor(
fun onEvent(event: MainUiEvent) {
when (event) {
MainUiEvent.RetryLoad -> {
// El flow se actualiza automáticamente
// El flow se actualiza automáticamente
}
MainUiEvent.ImportConfigFromFile -> {
// 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).
* Verifica si la app está bloqueada antes de iniciar/detener streaming.
* 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)")
Log.e(TAG, "Botón presionado muy rápido, ignorando (debounce)")
return
}
lastButtonPressTime = currentTime
@ -237,7 +294,7 @@ class MainViewModel @Inject constructor(
val isLocked = adminLockRepository.isLocked()
Log.e(TAG, "isLocked: $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
}
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}")
viewModelScope.launch {
if (streamingRequested) {
Log.e(TAG, "F5 STOP solicitado - liberando cámara/WebRTC")
Log.e(TAG, "F5 STOP solicitado - liberando cámara/WebRTC")
streamingRequested = false
webrtcManager.stopStreaming()
_streamingState.value = StreamingState.Stopped
@ -258,9 +315,9 @@ class MainViewModel @Inject constructor(
val config = videoConfigRepository.getConfig()
if (config == null) {
Log.e(TAG, "No hay configuración de video")
Log.e(TAG, "No hay configuración de video")
streamingRequested = false
_streamingState.value = StreamingState.Error("No hay configuración de video")
_streamingState.value = StreamingState.Error("No hay configuración de video")
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"?>
<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>
</style>
</resources>