feature/integracion-backend-sept2026 #3

Open
mgrandez wants to merge 5 commits from feature/integracion-backend-sept2026 into main
25 changed files with 2051 additions and 253 deletions

View file

@ -80,6 +80,11 @@ dependencies {
kapt("com.google.dagger:hilt-android-compiler:2.48")
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
// Room para buffer GPS offline
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
kapt("androidx.room:room-compiler:2.6.1")
// DataStore para persistencia
implementation("androidx.datastore:datastore-preferences:1.0.0")
@ -118,3 +123,5 @@ implementation("com.google.mlkit:barcode-scanning:17.3.0")
}

View file

@ -1,4 +1,4 @@
package com.bodycamera.twentyfoulabs
package com.bodycamera.twentyfoulabs
import android.Manifest
import android.content.BroadcastReceiver
@ -275,7 +275,7 @@ class MainActivity : ComponentActivity() {
pttManager.stop()
}
if (isAuthenticated && !frontPreviewOpened) {
if (false && isAuthenticated && !frontPreviewOpened) {
frontPreviewOpened = true
runOnUiThread {
openFrontPreview()
@ -479,10 +479,18 @@ class MainActivity : ComponentActivity() {
)
}
private fun handleSosButton() {
// F7 alterna SOS/DETENER. La misma orden de parada que usa la pantalla.
if (viewModel.isSosSessionActive()) {
Log.i(TAG, "F7 DETENER SOS: solicitando cierre de WebRTC")
viewModel.stopVideoFromUi()
toast("Deteniendo video SOS...")
return
}
Log.i(TAG, "F7 INICIAR SOS")
toast("Enviando SOS...")
deviceController.triggerSos { result ->
viewModel.triggerSosFromUi { result ->
runOnUiThread {
@ -605,8 +613,18 @@ fun AppNavigation(viewModel: MainViewModel, onOpenPhotoCamera: () -> Unit, onOpe
val authState by authViewModel.state.collectAsState()
LaunchedEffect(authState) {
if (authState is AuthUiState.Authenticated && navController.currentDestination?.route != "main") {
navController.navigate("main") { popUpTo("login") { inclusive = true } }
when (authState) {
is AuthUiState.Authenticated -> {
if (navController.currentDestination?.route != "main") {
navController.navigate("main") { popUpTo("login") { inclusive = true } }
}
}
AuthUiState.LoggedOut -> {
if (navController.currentDestination?.route != "login") {
navController.navigate("login") { popUpTo("main") { inclusive = true } }
}
}
else -> Unit
}
}
@ -633,6 +651,8 @@ fun AppNavigation(viewModel: MainViewModel, onOpenPhotoCamera: () -> Unit, onOpe
onOpenPhotoCamera = onOpenPhotoCamera,
onOpenVideoCamera = onOpenVideoCamera,
onLogout = {
// Liberar WebRTC antes de mostrar nuevamente el lector del fotocheck.
viewModel.stopVideoFromUi()
authViewModel.logout()
navController.navigate("login") { popUpTo("main") { inclusive = true } }
}

View file

@ -1,6 +1,9 @@
package com.bodycamera.twentyfoulabs.data.auth
import android.util.Log
import com.bodycamera.twentyfoulabs.data.backend.BackendConfig
import com.bodycamera.twentyfoulabs.data.backend.DeviceIdentity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.decodeFromString
@ -12,7 +15,9 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AuthApiClient @Inject constructor() {
class AuthApiClient @Inject constructor(
private val deviceIdentity: DeviceIdentity
) {
private val json = Json {
ignoreUnknownKeys = true
@ -41,6 +46,18 @@ class AuthApiClient @Inject constructor() {
json.decodeFromString<LoginResponse>(response)
}
suspend fun refresh(request: RefreshRequest): RefreshResponse =
withContext(Dispatchers.IO) {
val body = json.encodeToString(request)
val response = request(
"POST",
"/mobile/v1/auth/refresh",
body = body
)
Log.i("AuthApiClient", "AUTH REFRESH OK")
json.decodeFromString<RefreshResponse>(response)
}
suspend fun me(token: String): MeResponse =
withContext(Dispatchers.IO) {
val response = request(
@ -96,7 +113,7 @@ class AuthApiClient @Inject constructor() {
setRequestProperty(
"X-Device-Serial",
BackendConfig.DEVICE_SERIAL
deviceIdentity.requireSerial()
)
if (!token.isNullOrBlank()) {
@ -170,3 +187,9 @@ class AuthApiException(
val statusCode: Int,
override val message: String
) : Exception(message)

View file

@ -11,6 +11,11 @@ data class LoginRequest(
@SerialName("device_model") val deviceModel: String? = null
)
@Serializable
data class RefreshRequest(
@SerialName("refresh_token") val refreshToken: String
)
@Serializable
data class QrLoginRequest(
@SerialName("qr_token") val qrToken: String,
@ -63,6 +68,15 @@ data class LoginResponse(
}
}
@Serializable
data class RefreshResponse(
@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,
@SerialName("server_time") val serverTime: String? = null
)
@Serializable
data class MeResponse(
val user: AuthUser,
@ -88,3 +102,7 @@ data class AuthSession(
val device: AuthDevice? = null,
val refreshToken: String? = null
)

View file

@ -1,6 +1,10 @@
package com.bodycamera.twentyfoulabs.data.auth
import android.util.Log
import com.bodycamera.twentyfoulabs.data.backend.BackendConfig
import com.bodycamera.twentyfoulabs.data.backend.DeviceIdentity
import com.bodycamera.twentyfoulabs.data.gps.GpsBufferRepository
import kotlinx.coroutines.flow.first
import javax.inject.Inject
import javax.inject.Singleton
@ -8,7 +12,9 @@ import javax.inject.Singleton
@Singleton
class AuthRepository @Inject constructor(
private val api: AuthApiClient,
private val store: AuthSessionStore
private val store: AuthSessionStore,
private val deviceIdentity: DeviceIdentity,
private val gpsBufferRepository: GpsBufferRepository
) {
suspend fun login(
@ -20,7 +26,7 @@ class AuthRepository @Inject constructor(
LoginRequest(
email = email.trim(),
password = password,
deviceSerial = BackendConfig.DEVICE_SERIAL,
deviceSerial = deviceIdentity.requireSerial(),
deviceModel = BackendConfig.DEVICE_MODEL
)
)
@ -32,8 +38,28 @@ class AuthRepository @Inject constructor(
refreshToken = response.refreshToken
)
val previousUserId = store.userId.first()
val newUserId = session.user.id
if (previousUserId != null &&
newUserId != null &&
previousUserId != newUserId
) {
gpsBufferRepository.clear()
}
store.save(session)
return session
}
@ -46,7 +72,7 @@ class AuthRepository @Inject constructor(
QrLoginRequest(
qrToken = qrToken.trim(),
pin = pin.trim(),
deviceSerial = BackendConfig.DEVICE_SERIAL,
deviceSerial = deviceIdentity.requireSerial(),
deviceModel = BackendConfig.DEVICE_MODEL
)
)
@ -58,6 +84,15 @@ class AuthRepository @Inject constructor(
refreshToken = response.refreshToken
)
val previousUserId = store.userId.first()
val newUserId = session.user.id
if (previousUserId != null &&
newUserId != null &&
previousUserId != newUserId
) {
gpsBufferRepository.clear()
}
store.save(session)
return session
@ -75,37 +110,73 @@ class AuthRepository @Inject constructor(
return try {
val response = api.me(token)
AuthSession(
token = token,
user = response.user,
device = response.device ?: response.user.assignedDevice,
refreshToken = refreshToken
)
val response = api.me(token)
val session = AuthSession(
token = token,
user = response.user,
device = response.device ?: response.user.assignedDevice,
refreshToken = refreshToken
)
store.save(session)
session
} catch (e: AuthApiException) {
if (e.statusCode == 401) {
if (e.statusCode == 401 && !refreshToken.isNullOrBlank()) {
android.util.Log.i("AuthRepository", "401 inicial detectado - intentando refresh")
val refreshed = api.refresh(RefreshRequest(refreshToken))
val newToken = refreshed.accessToken?.takeIf { it.isNotBlank() } ?: throw e
val refreshedResponse = try { api.me(newToken) } catch (retryError: AuthApiException) { if (retryError.statusCode == 401) { store.clear(); android.util.Log.i("AuthRepository", "Segundo 401 - sesion eliminada"); throw retryError } else { throw retryError } }
val newRefreshToken = refreshed.refreshToken?.takeIf { it.isNotBlank() } ?: refreshToken
val refreshedSession = AuthSession(token = newToken, user = refreshedResponse.user, device = refreshedResponse.device ?: refreshedResponse.user.assignedDevice, refreshToken = newRefreshToken)
store.save(refreshedSession)
refreshedSession
} else if (e.statusCode == 401) {
store.clear()
throw e
} else {
throw e
}
throw e
}
}
suspend fun logout() {
suspend fun logout() {
val token = store.token.first()
val token = store.token.first()
try {
// Cerrar la sesion local inmediatamente.
store.clear()
if (!token.isNullOrBlank()) {
api.logout(token)
}
// Descartar GPS pendientes del sereno anterior.
gpsBufferRepository.clear()
} finally {
store.clear()
// Notificar al backend sin impedir el logout local si no hay Internet.
if (!token.isNullOrBlank()) {
runCatching {
api.logout(token)
}
}
}
}

View file

@ -20,6 +20,7 @@ class AuthSessionStore @Inject constructor(
private object Keys {
val token = stringPreferencesKey("jwt_token")
val refreshToken = stringPreferencesKey("refresh_token")
val userId = stringPreferencesKey("user_id")
val userName = stringPreferencesKey("user_name")
val userEmail = stringPreferencesKey("user_email")
val userRole = stringPreferencesKey("user_role")
@ -35,12 +36,19 @@ class AuthSessionStore @Inject constructor(
context.authDataStore.data.map {
it[Keys.refreshToken]
}
val userId: Flow<Int?> =
context.authDataStore.data.map {
it[Keys.userId]?.toIntOrNull()
}
suspend fun save(session: AuthSession) {
context.authDataStore.edit { prefs ->
prefs[Keys.token] = session.token
session.user.id?.let {
prefs[Keys.userId] = it.toString()
} ?: prefs.remove(Keys.userId)
if (!session.refreshToken.isNullOrBlank()) {
prefs[Keys.refreshToken] = session.refreshToken

View file

@ -1,20 +1,48 @@
package com.bodycamera.twentyfoulabs.data.backend
package com.bodycamera.twentyfoulabs.data.backend
/** Centralized endpoints for the M530 PoC. */
/** Centralized endpoints for the M530 APK. */
object BackendConfig {
// Local LAN services used by MQTT and MediaMTX during the current PoC.
// New GeoSatelital infrastructure.
const val API_BASE_URL = "https://bodycam.geosatelital.red"
const val MEDIAMTX_HOST = "172.93.110.24"
const val MEDIAMTX_WEBRTC_PORT = 18889
const val MEDIAMTX_HLS_PORT = 18888
const val MEDIAMTX_ICE_PORT = 18189
const val MEDIAMTX_BASE_URL =
"http://172.93.110.24:18889"
const val MEDIAMTX_HLS_BASE_URL =
"http://172.93.110.24:18888"
// Device identification.
const val DEVICE_MODEL = "Recoda M530"
// Legacy constants retained until dependent components are migrated.
const val HOST = "192.168.18.57"
const val MQTT_PORT = 1883
const val HTTP_PORT = 8000
const val MQTT_CLIENT_PREFIX = "m530"
const val STREAM_ID = "webrtc_camera_stream"
// Rails mobile API exposed through ngrok HTTPS.
const val API_BASE_URL = "https://bodycam-prd-web-nefjf74quq-uc.a.run.app"
const val DEVICE_SERIAL = "BC-M530-001"
const val DEVICE_MODEL = "Recoda M530"
val httpBaseUrl: String
get() = "http://$HOST:$HTTP_PORT"
val httpBaseUrl: String get() = "http://$HOST:$HTTP_PORT"
val whipUrl: String get() = "http://$HOST:8889/$STREAM_ID/whip"
val whipUrl: String
get() = "$MEDIAMTX_BASE_URL/$STREAM_ID/whip"
// MediaMTX paths based on the authenticated user.
fun sosWhipUrl(userId: Long): String =
"$MEDIAMTX_BASE_URL/sos/u/$userId/whip"
fun sosWhepUrl(userId: Long): String =
"$MEDIAMTX_BASE_URL/sos/u/$userId/whep"
fun pttWhipUrl(userId: Long): String =
"$MEDIAMTX_BASE_URL/ptt/u/$userId/whip"
fun pttWhepUrl(userId: Long): String =
"$MEDIAMTX_BASE_URL/ptt/u/$userId/whep"
}

View file

@ -17,21 +17,47 @@ import java.util.UUID
import java.util.concurrent.Executors
import javax.inject.Inject
import javax.inject.Singleton
import com.bodycamera.twentyfoulabs.data.gps.GpsBufferRepository
@Singleton
class BackendIntegrationManager @Inject constructor(
@ApplicationContext private val context: Context,
private val authSessionStore: AuthSessionStore
private val authSessionStore: AuthSessionStore,
private val deviceIdentity: DeviceIdentity,
private val gpsBufferRepository: GpsBufferRepository
) {
companion object { private const val TAG = "BackendIntegration" }
private val mqttExecutor = Executors.newSingleThreadExecutor()
private val httpExecutor = Executors.newSingleThreadExecutor()
private val mqtt = SimpleMqttPublisher(BackendConfig.HOST, BackendConfig.MQTT_PORT)
private val deviceId: String by lazy { sanitize(runCatching { Build.getSerial() }.getOrNull() ?: Build.MODEL ?: "M530") }
private val deviceId: String by lazy { sanitize(deviceIdentity.requireSerial()) }
private val clientId: String get() = "${BackendConfig.MQTT_CLIENT_PREFIX}-$deviceId-${UUID.randomUUID().toString().take(6)}"
private val pendingFile by lazy { File(context.filesDir, "mqtt_pending.jsonl") }
private enum class GpsBatchResult {
SENT,
DISCARD,
RETRY
}
private fun isDeviceNotAssigned(errorBody: String): Boolean {
val json = runCatching { JSONObject(errorBody) }.getOrNull()
val error = json?.optString("error").orEmpty()
val message = json?.optString("message").orEmpty()
return error.equals("device_not_assigned", ignoreCase = true) ||
message.equals("device_not_assigned", ignoreCase = true)
}
private fun invalidateSession(reason: String) {
runBlocking {
authSessionStore.clear()
gpsBufferRepository.clear()
}
Log.w(TAG, "AUTH: sesion invalidada: $reason")
}
@Volatile
private var lastMediaItemId: Int? = null
private val mediaStatePrefs by lazy {
@ -90,12 +116,683 @@ class BackendIntegrationManager @Inject constructor(
enqueuePublish(topic, envelope)
}
data class TelemetrySettings(
val intervalSeconds: Int = 300,
val sosIntervalSeconds: Int = 30,
val deviceStatusIntervalSeconds: Int = 300
)
fun fetchTelemetrySettings(
onResult: (Result<TelemetrySettings>) -> Unit
) {
httpExecutor.execute {
var conn: HttpURLConnection? = null
try {
val token = runBlocking {
authSessionStore.token.first()
}?.takeIf { it.isNotBlank() }
?: error("No hay sesion JWT activa")
val serial = deviceIdentity.requireSerial()
val url = URL(
BackendConfig.API_BASE_URL.trimEnd('/') +
"/mobile/v1/telemetry_settings"
)
conn = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = 15_000
readTimeout = 30_000
setRequestProperty("Accept", "application/json")
setRequestProperty("Authorization", "Bearer $token")
setRequestProperty("X-Device-Serial", serial)
setRequestProperty(
"X-Device-Time",
System.currentTimeMillis().toString()
)
}
val status = conn.responseCode
if (status in 200..299) {
val body = conn.inputStream
.bufferedReader()
.use { it.readText() }
val json = JSONObject(body)
val settings = TelemetrySettings(
intervalSeconds = json.optInt("interval_seconds", 300)
.coerceIn(15, 1800),
sosIntervalSeconds = json.optInt("sos_interval_seconds", 30)
.coerceIn(10, 300),
deviceStatusIntervalSeconds =
json.optInt("device_status_interval_seconds", 300)
.coerceIn(30, 300)
)
Log.i(
TAG,
"Telemetry settings OK: gps=${settings.intervalSeconds}s " +
"sos=${settings.sosIntervalSeconds}s " +
"device=${settings.deviceStatusIntervalSeconds}s"
)
onResult(Result.success(settings))
} else {
val errorBody = conn.errorStream
?.bufferedReader()
?.use { it.readText() }
.orEmpty()
val error = IllegalStateException(
"Telemetry settings HTTP $status $errorBody"
)
Log.e(TAG, error.message.orEmpty())
onResult(Result.failure(error))
}
} catch (e: Exception) {
Log.e(TAG, "Telemetry settings ERROR", e)
onResult(Result.failure(e))
} finally {
conn?.disconnect()
}
}
}
data class SosSupportState(
val ownSosActive: Boolean = false,
val supportRequestActive: Boolean = false
) {
val isSosCadenceActive: Boolean
get() = ownSosActive || supportRequestActive
}
fun fetchSosSupportState(
onResult: (Result<SosSupportState>) -> Unit
) {
httpExecutor.execute {
var conn: HttpURLConnection? = null
try {
val token = runBlocking {
authSessionStore.token.first()
}?.takeIf { it.isNotBlank() }
?: error("No hay sesion JWT activa")
val serial = deviceIdentity.requireSerial()
val url = URL(
BackendConfig.API_BASE_URL.trimEnd('/') +
"/api/v1/sos_support_requests?status=open"
)
conn = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = 15_000
readTimeout = 30_000
setRequestProperty("Accept", "application/json")
setRequestProperty("Authorization", "Bearer $token")
setRequestProperty("X-Device-Serial", serial)
setRequestProperty(
"X-Device-Time",
System.currentTimeMillis().toString()
)
}
val status = conn.responseCode
if (status in 200..299) {
val body = conn.inputStream
.bufferedReader()
.use { it.readText() }
val json = JSONObject(body)
val ownSosActive =
json.has("own_sos") && !json.isNull("own_sos")
val supportRequests = json.optJSONArray("support_requests")
var supportRequestActive = false
if (supportRequests != null) {
for (i in 0 until supportRequests.length()) {
val request = supportRequests.optJSONObject(i) ?: continue
when (request.optString("status").lowercase()) {
"pending", "acknowledged" -> {
supportRequestActive = true
break
}
}
}
}
val state = SosSupportState(
ownSosActive = ownSosActive,
supportRequestActive = supportRequestActive
)
Log.i(
TAG,
"SOS support OK: own_sos=${state.ownSosActive} " +
"support=${state.supportRequestActive} " +
"sosCadence=${state.isSosCadenceActive}"
)
onResult(Result.success(state))
} else {
val errorBody = conn.errorStream
?.bufferedReader()
?.use { it.readText() }
.orEmpty()
val error = IllegalStateException(
"SOS support HTTP $status $errorBody"
)
Log.e(TAG, error.message.orEmpty())
onResult(Result.failure(error))
}
} catch (e: Exception) {
Log.e(TAG, "SOS support ERROR", e)
onResult(Result.failure(e))
} finally {
conn?.disconnect()
}
}
}
fun publishGps(payload: JSONObject) {
httpExecutor.execute {
var conn: HttpURLConnection? = null
try {
val token = runBlocking {
authSessionStore.token.first()
}?.takeIf { it.isNotBlank() }
?: error("No hay sesion JWT activa")
val userId = runBlocking {
authSessionStore.userId.first()
}
if (userId == null) {
Log.e(
TAG,
"GPS BUFFER: no se puede guardar el punto; userId no disponible"
)
}
val url = URL(
BackendConfig.API_BASE_URL.trimEnd('/') +
"/mobile/v1/gps_locations"
)
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",
deviceIdentity.requireSerial()
)
setRequestProperty(
"X-Device-Time",
System.currentTimeMillis().toString()
)
}
conn.outputStream.use {
it.write(
payload.toString()
.toByteArray(Charsets.UTF_8)
)
}
val status = conn.responseCode
if (status in 200..299) {
Log.i(
TAG,
"GPS HTTPS OK: HTTP $status"
)
} else {
val errorBody = conn.errorStream
?.bufferedReader()
?.use { it.readText() }
.orEmpty()
Log.e(
TAG,
"GPS HTTPS ERROR: HTTP $status $errorBody"
)
if (status == HttpURLConnection.HTTP_FORBIDDEN && isDeviceNotAssigned(errorBody)) {
Log.w(TAG, "GPS HTTPS: device_not_assigned; cerrando sesion local")
invalidateSession("device_not_assigned")
return@execute
}
if (status == 422) {
Log.w(TAG, "GPS HTTPS: HTTP 422; punto descartado: $errorBody")
return@execute
}
// Solo conservamos para reintento los errores
// de servidor (5xx). Los 4xx no se reintentan.
if (status in 500..599 && userId != null) {
val recordedAt =
payload.optString(
"recorded_at",
""
)
if (recordedAt.isNotBlank()) {
val point = com.bodycamera.twentyfoulabs.data.gps.PendingGpsEntity(
userId = userId,
latitude = payload.optDouble(
"latitude",
0.0
),
longitude = payload.optDouble(
"longitude",
0.0
),
accuracy = payload.optDouble(
"accuracy",
0.0
).toFloat(),
speed =
if (payload.isNull("speed")) {
null
} else {
payload.optDouble(
"speed",
0.0
).toFloat()
},
heading =
if (payload.isNull("heading")) {
null
} else {
payload.optDouble(
"heading",
0.0
).toFloat()
},
recordedAt = recordedAt
)
val activeToken = runBlocking { authSessionStore.token.first() }
val activeUserId = runBlocking { authSessionStore.userId.first() }
if (!activeToken.isNullOrBlank() && activeUserId == userId) {
gpsBufferRepository.save(point)
} else {
Log.w(TAG, "GPS BUFFER: punto descartado porque la sesion fue cerrada o cambio de usuario")
return@execute
}
Log.w(
TAG,
"GPS BUFFER: punto guardado por HTTP $status " +
"userId=$userId " +
"lat=${point.latitude} " +
"lon=${point.longitude}"
)
}
}
}
} catch (e: Exception) {
Log.e(
TAG,
"GPS HTTPS ERROR: ${e.message}",
e
)
try {
val userId = runBlocking {
authSessionStore.userId.first()
}
if (userId != null) {
val recordedAt =
payload.optString(
"recorded_at",
""
)
if (recordedAt.isNotBlank()) {
val point = com.bodycamera.twentyfoulabs.data.gps.PendingGpsEntity(
userId = userId,
latitude = payload.optDouble(
"latitude",
0.0
),
longitude = payload.optDouble(
"longitude",
0.0
),
accuracy = payload.optDouble(
"accuracy",
0.0
).toFloat(),
speed =
if (payload.isNull("speed")) {
null
} else {
payload.optDouble(
"speed",
0.0
).toFloat()
},
heading =
if (payload.isNull("heading")) {
null
} else {
payload.optDouble(
"heading",
0.0
).toFloat()
},
recordedAt = recordedAt
)
val activeToken = runBlocking { authSessionStore.token.first() }
val activeUserId = runBlocking { authSessionStore.userId.first() }
if (!activeToken.isNullOrBlank() && activeUserId == userId) {
gpsBufferRepository.save(point)
} else {
Log.w(TAG, "GPS BUFFER: punto descartado porque la sesion fue cerrada o cambio de usuario")
return@execute
}
Log.w(
TAG,
"GPS BUFFER: punto guardado por excepción " +
"userId=$userId " +
"lat=${point.latitude} " +
"lon=${point.longitude}"
)
}
}
} catch (bufferError: Exception) {
Log.e(
TAG,
"GPS BUFFER ERROR: no se pudo guardar el punto",
bufferError
)
}
} finally {
conn?.disconnect()
}
}
}
private fun publishGpsBatch(
locations: List<JSONObject>,
onResult: (GpsBatchResult) -> Unit
) {
if (locations.isEmpty()) {
onResult(GpsBatchResult.SENT)
return
}
require(locations.size <= 100) {
"GPS batch no puede superar 100 puntos"
}
httpExecutor.execute {
var conn: HttpURLConnection? = null
try {
val token = runBlocking {
authSessionStore.token.first()
}?.takeIf { it.isNotBlank() }
?: error("No hay sesion JWT activa")
val serial = deviceIdentity.requireSerial()
val body = JSONObject()
.put("locations", org.json.JSONArray(locations))
val url = URL(
BackendConfig.API_BASE_URL.trimEnd('/') +
"/mobile/v1/gps_locations/batch"
)
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",
serial
)
setRequestProperty(
"X-Device-Time",
System.currentTimeMillis().toString()
)
}
conn.outputStream.use {
it.write(
body.toString()
.toByteArray(Charsets.UTF_8)
)
}
val status = conn.responseCode
if (status == HttpURLConnection.HTTP_CREATED) {
Log.i(
TAG,
"GPS BATCH HTTPS OK: HTTP 201 points=${locations.size}"
)
onResult(GpsBatchResult.SENT)
} else {
val errorBody = conn.errorStream
?.bufferedReader()
?.use { it.readText() }
.orEmpty()
Log.e(
TAG,
"GPS BATCH HTTPS ERROR: HTTP $status $errorBody"
)
if (status == HttpURLConnection.HTTP_FORBIDDEN && isDeviceNotAssigned(errorBody)) {
Log.w(TAG, "GPS BATCH: device_not_assigned; cerrando sesion local")
invalidateSession("device_not_assigned")
onResult(GpsBatchResult.DISCARD)
} else if (status == 422) {
Log.w(TAG, "GPS BATCH: HTTP 422; lote descartado")
onResult(GpsBatchResult.DISCARD)
} else {
onResult(GpsBatchResult.RETRY)
}
}
} catch (e: Exception) {
Log.e(
TAG,
"GPS BATCH HTTPS ERROR",
e
)
onResult(GpsBatchResult.RETRY)
} finally {
conn?.disconnect()
}
}
}
fun flushPendingGps() {
httpExecutor.execute {
val currentUserId = runBlocking {
authSessionStore.userId.first()
}
if (currentUserId == null) {
Log.w(
TAG,
"GPS BUFFER: no se puede sincronizar; userId no disponible"
)
return@execute
}
val pending = gpsBufferRepository.getBatch(currentUserId)
if (pending.isEmpty()) {
Log.i(
TAG,
"GPS BUFFER: no hay puntos pendientes para userId=$currentUserId"
)
return@execute
}
val locations = pending.map {
gpsBufferRepository.toJson(it)
}
Log.i(
TAG,
"GPS BUFFER: enviando ${locations.size} puntos " +
"pendientes para userId=$currentUserId"
)
publishGpsBatch(locations) { result ->
when (result) {
GpsBatchResult.SENT -> {
gpsBufferRepository.deleteSent(pending)
Log.i(TAG, "GPS BUFFER: ${pending.size} puntos eliminados despues de HTTP 201")
}
GpsBatchResult.DISCARD -> {
gpsBufferRepository.deleteSent(pending)
Log.w(TAG, "GPS BUFFER: ${pending.size} puntos descartados; no se reintentaran")
}
GpsBatchResult.RETRY -> {
Log.w(TAG, "GPS BUFFER: envio fallido; se conservan ${pending.size} puntos")
}
}
}
}
}
fun publishTelemetry(payload: JSONObject) {
val serial = deviceIdentity.configuredSerial()
if (serial == null) {
Log.w(TAG, "Telemetria omitida: serial aun no configurado")
return
}
val envelope = JSONObject()
.put("deviceId", deviceId)
.put("deviceId", serial)
.put("timestamp", System.currentTimeMillis())
.put("payload", payload)
enqueuePublish("m530/$deviceId/telemetry", envelope)
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/devices/telemetry"
)
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",
serial
)
setRequestProperty(
"X-Device-Time",
System.currentTimeMillis().toString()
)
}
conn.outputStream.use {
it.write(envelope.toString().toByteArray(Charsets.UTF_8))
}
val status = conn.responseCode
if (status in 200..299) {
Log.i(TAG, "TELEMETRY HTTPS OK: HTTP $status")
} else {
val errorBody = conn.errorStream
?.bufferedReader()
?.use { it.readText() }
.orEmpty()
Log.e(TAG, "TELEMETRY HTTPS ERROR: HTTP $status $errorBody")
if (status == HttpURLConnection.HTTP_FORBIDDEN && isDeviceNotAssigned(errorBody)) {
Log.w(TAG, "TELEMETRY: device_not_assigned; cerrando sesion local")
invalidateSession("device_not_assigned")
} else if (status == HttpURLConnection.HTTP_NOT_FOUND) {
Log.w(TAG, "TELEMETRY: HTTP 404; telemetria descartada, sin reintento")
}
}
} catch (e: Exception) {
Log.e(TAG, "TELEMETRY HTTPS ERROR", e)
} finally {
conn?.disconnect()
}
}
}
fun uploadMedia(file: File, kind: String, metadata: JSONObject = JSONObject()) {
@ -117,24 +814,16 @@ class BackendIntegrationManager @Inject constructor(
"FAVORITE ultimo media_item guardado: id=$uploadedMediaId"
)
}
val mediaPayload = JSONObject()
.put("kind", kind)
.put("fileName", file.name)
.put("sizeBytes", file.length())
.put("localPath", file.absolutePath)
.put("server", response)
val event = JSONObject()
.put("eventId", UUID.randomUUID().toString())
.put("deviceId", deviceId)
.put("type", "MEDIA_UPLOADED")
.put("timestamp", System.currentTimeMillis())
.put("payload", mediaPayload)
publishNow("m530/$deviceId/media/$kind", event)
Log.i(
TAG,
"MEDIA HTTPS OK: archivo=${file.name}, tipo=$kind, id=${uploadedMediaId ?: "sin_id"}"
)
} catch (t: Throwable) {
Log.e(TAG, "Upload failed: ${file.name}", t)
publishNow("m530/$deviceId/events/media_upload_failed", JSONObject()
.put("deviceId", deviceId).put("timestamp", System.currentTimeMillis())
.put("fileName", file.name).put("kind", kind).put("error", t.message ?: "unknown"))
Log.e(
TAG,
"MEDIA HTTPS ERROR: archivo=${file.name}, tipo=$kind",
t
)
}
}
}
@ -200,7 +889,7 @@ class BackendIntegrationManager @Inject constructor(
setRequestProperty(
"X-Device-Serial",
BackendConfig.DEVICE_SERIAL
deviceIdentity.requireSerial()
)
setRequestProperty(
@ -246,23 +935,7 @@ class BackendIntegrationManager @Inject constructor(
)
}
publishNow(
"m530/$deviceId/events/media_bookmarked",
JSONObject()
.put("deviceId", deviceId)
.put(
"timestamp",
System.currentTimeMillis()
)
.put(
"mediaItemId",
mediaId
)
.put(
"isBookmarked",
true
)
)
callback(
Result.success(mediaId)
@ -291,7 +964,8 @@ class BackendIntegrationManager @Inject constructor(
fun sendSosAlert(
latitude: Double?,
longitude: Double?,
callback: (Result<Int>) -> Unit
callback: (Result<Int>) -> Unit,
onVideoTarget: ((String, String) -> Unit)? = null
) {
httpExecutor.execute {
@ -341,7 +1015,7 @@ class BackendIntegrationManager @Inject constructor(
setRequestProperty(
"X-Device-Serial",
BackendConfig.DEVICE_SERIAL
deviceIdentity.requireSerial()
)
setRequestProperty(
@ -357,7 +1031,7 @@ class BackendIntegrationManager @Inject constructor(
.put("notes", "Boton panico F7")
.put(
"device_serial",
BackendConfig.DEVICE_SERIAL
deviceIdentity.requireSerial()
)
.toString()
@ -427,6 +1101,19 @@ class BackendIntegrationManager @Inject constructor(
?: error(
"Respuesta SOS sin ID de alerta"
)
val liveStream = responseJson
.optJSONObject("alert")
?.optJSONObject("live_stream")
val streamPath = liveStream?.optString("path").orEmpty()
val whipUrl = liveStream?.optString("whip_url").orEmpty()
Log.i(TAG, "SOS video path=$streamPath WHIP=$whipUrl")
if (Regex("^sos/u/[1-9][0-9]*$").matches(streamPath)) {
onVideoTarget?.invoke(streamPath, whipUrl)
}
Log.i(
TAG,
@ -595,7 +1282,7 @@ class BackendIntegrationManager @Inject constructor(
setRequestProperty(
"X-Device-Serial",
BackendConfig.DEVICE_SERIAL
deviceIdentity.requireSerial()
)
setRequestProperty(

View file

@ -0,0 +1,45 @@
package com.bodycamera.twentyfoulabs.data.backend
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DeviceIdentity @Inject constructor(
@ApplicationContext private val context: Context
) {
private val preferences = context.getSharedPreferences(
"device_identity",
Context.MODE_PRIVATE
)
fun configuredSerial(): String? =
preferences.getString("device_serial", null)
?.trim()
?.takeIf { it.isNotEmpty() }
fun requireSerial(): String =
configuredSerial()
?: error("El dispositivo no tiene un serial configurado")
fun configureSerial(serial: String) {
val normalized = serial.trim()
require(Regex("^M530[A-Z0-9]+$").matches(normalized)) {
"Formato de serial no válido"
}
check(configuredSerial() == null || configuredSerial() == normalized) {
"El dispositivo ya tiene un serial diferente"
}
check(
preferences.edit()
.putString("device_serial", normalized)
.commit()
) {
"No se pudo guardar el serial"
}
}
}

View file

@ -1,11 +1,13 @@
package com.bodycamera.twentyfoulabs.data.device
import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.media.MediaRecorder
import android.net.ConnectivityManager
@ -22,6 +24,7 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import dagger.hilt.android.qualifiers.ApplicationContext
import com.bodycamera.twentyfoulabs.data.backend.BackendIntegrationManager
import com.bodycamera.twentyfoulabs.data.backend.DeviceIdentity
import org.json.JSONObject
import java.io.File
import java.text.SimpleDateFormat
@ -34,7 +37,8 @@ import javax.inject.Singleton
@Singleton
class M530DeviceController @Inject constructor(
@ApplicationContext private val context: Context,
private val backend: BackendIntegrationManager
private val backend: BackendIntegrationManager,
private val deviceIdentity: DeviceIdentity
) {
companion object { private const val TAG = "M530DeviceController" }
@ -45,6 +49,7 @@ class M530DeviceController @Inject constructor(
val network: String,
val connected: Boolean,
val freeStorageMb: Long,
val storageTotalMb: Long,
val timestamp: Long = System.currentTimeMillis()
)
data class DeviceInfo(
@ -63,12 +68,35 @@ class M530DeviceController @Inject constructor(
private var micFile: File? = null
private val ioExecutor: Executor get() = ContextCompat.getMainExecutor(context)
@Volatile
private var latestGpsLocation: Location? = null
private val locationManager: LocationManager by lazy {
context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
}
private val gpsLocationListener = LocationListener { location ->
latestGpsLocation = location
Log.i(
TAG,
"GPS fix real recibido: accuracy=${location.accuracy}m time=${location.time}"
)
}
private val powerReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == Intent.ACTION_POWER_CONNECTED || intent?.action == Intent.ACTION_POWER_DISCONNECTED) { Log.i(TAG, "Cambio de cargador detectado: ${intent.action}"); logTelemetry() } } }
init {
startLocationUpdates()
context.registerReceiver(powerReceiver, IntentFilter().apply { addAction(Intent.ACTION_POWER_CONNECTED); addAction(Intent.ACTION_POWER_DISCONNECTED) })
}
fun deviceInfo(): DeviceInfo = DeviceInfo(
model = Build.MODEL ?: "M530",
manufacturer = Build.MANUFACTURER ?: "Android",
androidVersion = Build.VERSION.RELEASE ?: "12",
sdk = Build.VERSION.SDK_INT,
serial = runCatching { Build.getSerial() }.getOrDefault("M530")
serial = deviceIdentity.configuredSerial() ?: "Sin configurar"
)
fun capturePhoto(lifecycleOwner: LifecycleOwner, callback: (Result<File>) -> Unit) {
@ -86,7 +114,13 @@ class M530DeviceController @Inject constructor(
capture.takePicture(ImageCapture.OutputFileOptions.Builder(outputFile).build(), ioExecutor,
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(r: ImageCapture.OutputFileResults) {
logEvent("PHOTO_CAPTURED", JSONObject().put("path", outputFile.absolutePath)); backend.publish("PHOTO_CAPTURED", JSONObject().put("fileName", outputFile.name).put("sizeBytes", outputFile.length())); backend.uploadMedia(outputFile, "photo"); provider.unbindAll(); callback(Result.success(outputFile))
logEvent(
"PHOTO_CAPTURED",
JSONObject().put("path", outputFile.absolutePath)
)
backend.uploadMedia(outputFile, "photo")
provider.unbindAll()
callback(Result.success(outputFile))
}
override fun onError(e: ImageCaptureException) {
logEvent("PHOTO_ERROR", JSONObject().put("error", e.message ?: "unknown")); provider.unbindAll(); callback(Result.failure(e))
@ -285,12 +319,9 @@ Result.success(file)
micRecorder != null
fun triggerSos(): SosResult {
val location =
Location("M530").apply {
latitude = -12.077
longitude = -77.092
accuracy = 10f
}
val location = bestLastKnownLocation()?.takeIf {
System.currentTimeMillis() - it.time in -5_000L..300_000L
}
val payload =
JSONObject()
@ -309,15 +340,15 @@ Result.success(file)
)
.put(
"latitude",
location.latitude
location?.latitude ?: JSONObject.NULL
)
.put(
"longitude",
location.longitude
location?.longitude ?: JSONObject.NULL
)
.put(
"accuracy",
location.accuracy
location?.accuracy ?: JSONObject.NULL
)
backend.publish(
@ -334,6 +365,22 @@ Result.success(file)
)
}
fun triggerSosWithVideo(
callback: (Result<Int>) -> Unit,
onVideoTarget: (String, String) -> Unit
): SosResult {
val result = triggerSos()
backend.sendSosAlert(
result.location?.latitude,
result.location?.longitude,
callback,
onVideoTarget
)
return result
}
fun triggerSos(
callback: (Result<Int>) -> Unit
): SosResult {
@ -387,7 +434,8 @@ Result.success(file)
else -> "Sin red"
}
val freeMb = context.filesDir.usableSpace / (1024L * 1024L)
return TelemetrySnapshot(batteryPercent, charging, networkName, connected, freeMb)
val totalMb = context.filesDir.totalSpace / (1024L * 1024L)
return TelemetrySnapshot(batteryPercent, charging, networkName, connected, freeMb, totalMb)
}
fun evidenceSummary(): EvidenceSummary {
@ -410,23 +458,134 @@ Result.success(file)
}.getOrDefault(emptyList())
}
fun logGps() {
val location = bestLastKnownLocation()?.takeIf {
System.currentTimeMillis() - it.time in -5_000L..300_000L
}
if (location == null) {
Log.w(TAG, "GPS: sin ubicación reciente; se omite el envío")
return
}
val latitude = location.latitude
val longitude = location.longitude
val recordedAt = java.text.SimpleDateFormat(
"yyyy-MM-dd'T'HH:mm:ss'Z'",
java.util.Locale.US
).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}.format(java.util.Date(location.time))
val payload = JSONObject()
.put("latitude", latitude)
.put("longitude", longitude)
.put("speed", if (location.hasSpeed()) location.speed * 3.6 else JSONObject.NULL)
.put("heading", if (location.hasBearing()) location.bearing else JSONObject.NULL)
.put("accuracy", location.accuracy)
.put("recorded_at", recordedAt)
backend.publishGps(payload)
Log.i(TAG, "GPS: envio HTTPS solicitado")
}
fun logTelemetry(): File {
val t = telemetry()
val p = JSONObject().put("batteryPercent", t.batteryPercent).put("charging", t.charging)
.put("network", t.network).put("connected", t.connected).put("freeStorageMb", t.freeStorageMb).put("timestamp", t.timestamp); backend.publishTelemetry(p); return logEvent("TELEMETRY", p)
.put("network", t.network).put("connected", t.connected).put("freeStorageMb", t.freeStorageMb).put("storageTotalMb", t.storageTotalMb).put("timestamp", t.timestamp); backend.publishTelemetry(p); return logEvent("TELEMETRY", p)
}
fun notifyVideoStarted(endpoint: String) { backend.publish("VIDEO_STREAM_STARTED", JSONObject().put("whipUrl", endpoint).put("streamId", com.bodycamera.twentyfoulabs.data.backend.BackendConfig.STREAM_ID)) }
fun notifyVideoStopped() { backend.publish("VIDEO_STREAM_STOPPED", JSONObject().put("streamId", com.bodycamera.twentyfoulabs.data.backend.BackendConfig.STREAM_ID)) }
fun notifyVideoError(error: String) { backend.publish("VIDEO_STREAM_ERROR", JSONObject().put("error", error).put("streamId", com.bodycamera.twentyfoulabs.data.backend.BackendConfig.STREAM_ID)) }
fun release() { try { mediaRecorder?.stop() } catch (_: Throwable) {}; try { mediaRecorder?.release() } catch (_: Throwable) {}; mediaRecorder = null; pttFile = null; try { micRecorder?.stop() } catch (_: Throwable) {}; try { micRecorder?.release() } catch (_: Throwable) {}; micRecorder = null; micFile = null }
fun release() {
try { mediaRecorder?.stop() } catch (_: Throwable) {}
try { mediaRecorder?.release() } catch (_: Throwable) {}
mediaRecorder = null
pttFile = null
try { micRecorder?.stop() } catch (_: Throwable) {}
try { micRecorder?.release() } catch (_: Throwable) {}
micRecorder = null
micFile = null
stopLocationUpdates()
}
private fun startLocationUpdates() {
val fineGranted =
ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
val coarseGranted =
ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
if (!fineGranted && !coarseGranted) {
Log.w(TAG, "GPS activo no iniciado: permiso de ubicacion no concedido")
return
}
try {
if (!locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
Log.w(TAG, "GPS activo no iniciado: proveedor GPS deshabilitado")
return
}
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
1000L,
0f,
gpsLocationListener
)
Log.i(TAG, "GPS activo iniciado")
} catch (e: SecurityException) {
Log.e(TAG, "GPS activo: permiso rechazado", e)
} catch (e: Exception) {
Log.e(TAG, "GPS activo: error iniciando actualizaciones", e)
}
}
private fun stopLocationUpdates() {
runCatching {
locationManager.removeUpdates(gpsLocationListener)
}.onFailure {
Log.w(TAG, "GPS activo: error deteniendo actualizaciones", it)
}
}
private fun bestLastKnownLocation(): Location? {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) return null
val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
return runCatching { lm.getProviders(true).mapNotNull { p -> runCatching { lm.getLastKnownLocation(p) }.getOrNull() }.maxByOrNull { it.time } }.getOrNull()
val fineGranted =
ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
val coarseGranted =
ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
if (!fineGranted && !coarseGranted) return null
val activeFix = latestGpsLocation
val providerFix = runCatching {
locationManager.getProviders(true)
.mapNotNull { provider ->
runCatching {
locationManager.getLastKnownLocation(provider)
}.getOrNull()
}
.maxByOrNull { it.time }
}.getOrNull()
return listOfNotNull(activeFix, providerFix)
.maxByOrNull { it.time }
}
private fun summarizePayload(payload: JSONObject?): String {
@ -448,3 +607,12 @@ Result.success(file)
}
private fun timestamp(): String = SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date())
}

View file

@ -0,0 +1,61 @@
package com.bodycamera.twentyfoulabs.data.gps
import org.json.JSONObject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class GpsBufferRepository @Inject constructor(
private val dao: PendingGpsDao
) {
companion object {
private const val MAX_BUFFER_SIZE = 100
private const val MAX_BATCH_SIZE = 100
}
fun save(point: PendingGpsEntity) {
dao.insert(point)
val count = dao.count()
if (count > MAX_BUFFER_SIZE) {
dao.deleteOldest(count - MAX_BUFFER_SIZE)
}
}
fun getBatch(userId: Int): List<PendingGpsEntity> =
dao.getOldestForUser(userId, MAX_BATCH_SIZE)
fun toJson(point: PendingGpsEntity): JSONObject =
JSONObject()
.put("latitude", point.latitude)
.put("longitude", point.longitude)
.put("accuracy", point.accuracy)
.put(
"speed",
point.speed ?: JSONObject.NULL
)
.put(
"heading",
point.heading ?: JSONObject.NULL
)
.put("recorded_at", point.recordedAt)
fun deleteSent(points: List<PendingGpsEntity>) {
if (points.isEmpty()) return
dao.deleteByIds(points.map { it.id })
}
fun count(): Int =
dao.count()
fun hasPending(): Boolean =
dao.count() > 0
suspend fun clear() = withContext(Dispatchers.IO) {
dao.deleteAll()
}
}

View file

@ -0,0 +1,14 @@
package com.bodycamera.twentyfoulabs.data.gps
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(
entities = [PendingGpsEntity::class],
version = 1,
exportSchema = false
)
abstract class GpsDatabase : RoomDatabase() {
abstract fun pendingGpsDao(): PendingGpsDao
}

View file

@ -0,0 +1,46 @@
package com.bodycamera.twentyfoulabs.data.gps
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
@Dao
interface PendingGpsDao {
@Insert
fun insert(point: PendingGpsEntity): Long
// Obtiene como máximo 100 puntos, del más antiguo al más reciente.
@Query("SELECT * FROM pending_gps ORDER BY id ASC LIMIT :limit")
fun getOldest(limit: Int = 100): List<PendingGpsEntity>
@Query("SELECT * FROM pending_gps WHERE userId = :userId ORDER BY id ASC LIMIT :limit")
fun getOldestForUser(
userId: Int,
limit: Int = 100
): List<PendingGpsEntity>
@Query("SELECT COUNT(*) FROM pending_gps")
fun count(): Int
@Query("DELETE FROM pending_gps WHERE id IN (:ids)")
fun deleteByIds(ids: List<Long>)
// Mantiene el buffer limitado a los 100 puntos más recientes.
@Query(
"""
DELETE FROM pending_gps
WHERE id IN (
SELECT id FROM pending_gps
ORDER BY id ASC
LIMIT :amount
)
"""
)
fun deleteOldest(amount: Int)
@Query("DELETE FROM pending_gps")
fun deleteAll()
@Query("SELECT DISTINCT userId FROM pending_gps")
fun getPendingUserIds(): List<Int>
}

View file

@ -0,0 +1,27 @@
package com.bodycamera.twentyfoulabs.data.gps
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "pending_gps")
data class PendingGpsEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val userId: Int,
val latitude: Double,
val longitude: Double,
val accuracy: Float,
val speed: Float? = null,
val heading: Float? = null,
// Hora original en la que se obtuvo el fix GPS.
// Debe conservarse al reenviar el punto mediante /gps_locations/batch.
val recordedAt: String,
// Momento en que el punto fue almacenado localmente.
// Se usa para mantener el orden del buffer.
val createdAt: Long = System.currentTimeMillis()
)

View file

@ -19,8 +19,8 @@ data class VideoConfiguration(
*/
@Serializable
data class WebRTCServerConfig(
val serverUrl: String = "192.168.18.57",
val serverPort: Int = 8889,
val serverUrl: String = "172.93.110.24",
val serverPort: Int = 18889,
val streamName: String = "webrtc_camera_stream",
val username: String = "admin",
val password: String = "admin",

View file

@ -1,6 +1,7 @@
package com.bodycamera.twentyfoulabs.data.ptt
import com.bodycamera.twentyfoulabs.data.backend.BackendConfig
import com.bodycamera.twentyfoulabs.data.backend.DeviceIdentity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
@ -10,7 +11,9 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PttApiClient @Inject constructor() {
class PttApiClient @Inject constructor(
private val deviceIdentity: DeviceIdentity
) {
suspend fun joinRoom(
token: String,
@ -46,7 +49,7 @@ class PttApiClient @Inject constructor() {
setRequestProperty(
"X-Device-Serial",
BackendConfig.DEVICE_SERIAL
deviceIdentity.requireSerial()
)
}

View file

@ -39,7 +39,7 @@ class PttAudioClient @Inject constructor(
private const val TAG = "PttAudioClient"
private const val MEDIA_BASE_URL =
"http://35.224.197.127:8889"
"http://172.93.110.24:18889"
private const val STUN_URL =
"stun:stun.l.google.com:19302"

View file

@ -19,7 +19,7 @@ class PttCableClient @Inject constructor() {
companion object {
private const val TAG = "PttCableClient"
private const val CABLE_URL =
"wss://bodycam-prd-web-nefjf74quq-uc.a.run.app/cable"
"wss://bodycam.geosatelital.red/cable"
private const val RECONNECT_DELAY_MS = 3000L
}

View file

@ -9,6 +9,9 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.atomic.AtomicLong
import org.webrtc.*
import java.net.HttpURLConnection
import java.net.URL
@ -28,12 +31,39 @@ class WebRTCManager @Inject constructor(
}
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val streamMutex = Mutex()
private val streamGeneration = AtomicLong(0)
@Volatile private var activeGeneration = -1L
@Volatile private var releasing = false
private var peerConnectionFactory: PeerConnectionFactory? = null
private var peerConnection: PeerConnection? = null
private var videoSource: VideoSource? = null
private var audioSource: AudioSource? = null
private var localAudioTrack: AudioTrack? = null
private var cameraVideoCapturer: CameraVideoCapturer? = null
private var localVideoTrack: VideoTrack? = null
private val previewLock = Any()
private var previewSink: VideoSink? = null
fun previewEglContext(): EglBase.Context? = eglBase?.eglBaseContext
fun attachPreviewSink(sink: VideoSink) {
synchronized(previewLock) {
previewSink?.let { old -> localVideoTrack?.removeSink(old) }
previewSink = sink
localVideoTrack?.addSink(sink)
}
}
fun detachPreviewSink(sink: VideoSink) {
synchronized(previewLock) {
if (previewSink === sink) {
localVideoTrack?.removeSink(sink)
previewSink = null
}
}
}
private var surfaceTextureHelper: SurfaceTextureHelper? = null
private var eglBase: EglBase? = null
private var signalingClient: SignalingClient? = null
@ -143,48 +173,50 @@ class WebRTCManager @Inject constructor(
* Inicia el streaming de video real
*/
fun startStreaming(config: VideoConfiguration) {
if (releasing) return
val generation = streamGeneration.incrementAndGet()
scope.launch {
try {
val server = config.webrtcServer
if (server.serverUrl.isBlank()) {
callback?.onError("URL del servidor está vacía")
return@launch
streamMutex.withLock {
if (releasing || generation != streamGeneration.get()) return@withLock
try {
// Nunca iniciar una segunda cámara mientras se limpia la anterior.
cleanupStream()
if (releasing || generation != streamGeneration.get()) return@withLock
val server = config.webrtcServer
if (server.serverUrl.isBlank()) {
callback?.onError("URL del servidor está vacía")
return@withLock
}
activeGeneration = generation
val serverUrl = "http://${server.serverUrl}:${server.serverPort}"
Log.i(TAG, "Starting streaming to: $serverUrl/${server.streamName}/whip")
signalingClient = SignalingClient(
serverUrl = serverUrl,
streamName = server.streamName,
username = server.username,
password = server.password
)
createPeerConnection(config)
if (generation != streamGeneration.get()) {
cleanupStream()
return@withLock
}
startCameraCapture(config)
if (generation != streamGeneration.get()) {
cleanupStream()
return@withLock
}
if (config.video.autoStopTimeSeconds > 0) {
startAutoStopTimer(config.video.autoStopTimeSeconds)
}
if (generation == streamGeneration.get()) callback?.onStreamStarted()
} catch (e: Exception) {
Log.e(TAG, "Error starting streaming", e)
cleanupStream()
if (generation == streamGeneration.get()) {
callback?.onError("Error al iniciar streaming: ${e.message}")
}
}
// Detener cualquier streaming activo antes de iniciar uno nuevo
if (cameraVideoCapturer != null || peerConnection != null) {
Log.e(TAG, "Stopping active stream before starting new one")
stopStreaming()
delay(500) // Esperar a que se limpien los recursos
}
val serverUrl = "http://${server.serverUrl}:${server.serverPort}"
Log.e(TAG, "Starting streaming to: $serverUrl/${server.streamName}/whip")
// Crear SignalingClient
signalingClient = SignalingClient(
serverUrl = serverUrl,
streamName = server.streamName,
username = server.username,
password = server.password
)
// Crear PeerConnection
createPeerConnection(config)
// Iniciar captura de cámara
startCameraCapture(config)
// Iniciar timer de auto-stop
if (config.video.autoStopTimeSeconds > 0) {
startAutoStopTimer(config.video.autoStopTimeSeconds)
}
callback?.onStreamStarted()
} catch (e: Exception) {
Log.e(TAG, "Error starting streaming", e)
callback?.onError("Error al iniciar streaming: ${e.message}")
}
}
}
@ -338,8 +370,8 @@ class WebRTCManager @Inject constructor(
override fun onFirstFrameAvailable() {
Log.e(TAG, "First frame available - Creating SDP offer")
// Después que la cámara está lista, crear la oferta SDP
createOffer()
// Un primer frame tardío no puede reactivar una sesión detenida.
if (activeGeneration == streamGeneration.get() && !releasing) createOffer()
}
override fun onCameraClosed() {
@ -367,19 +399,31 @@ class WebRTCManager @Inject constructor(
capturerObserver
)
// Iniciar captura
// Crear y conectar el track antes del primer frame y de la oferta SDP.
synchronized(previewLock) {
localVideoTrack = peerConnectionFactory!!.createVideoTrack("video", videoSource)
previewSink?.let { localVideoTrack?.addSink(it) }
}
peerConnection?.addTrack(localVideoTrack!!, listOf("stream"))
// Publicar también el micrófono en la misma sesión WHIP que el video.
// Se agrega ANTES de crear la oferta SDP (primer frame de cámara).
val audioConstraints = MediaConstraints()
audioSource = peerConnectionFactory!!.createAudioSource(audioConstraints)
localAudioTrack = peerConnectionFactory!!.createAudioTrack("audio", audioSource)
localAudioTrack!!.setEnabled(true)
checkNotNull(peerConnection?.addTrack(localAudioTrack!!, listOf("stream"))) {
"No se pudo agregar la pista de audio a WebRTC"
}
Log.i(TAG, "Audio SOS: pista de micrófono agregada a WebRTC")
// Iniciar captura cuando el track ya está preparado.
cameraVideoCapturer?.startCapture(
config.video.resolution.width,
config.video.resolution.height,
config.video.frameRate
)
// Crear video track
localVideoTrack = peerConnectionFactory!!.createVideoTrack("video", videoSource)
// Agregar al PeerConnection
peerConnection?.addTrack(localVideoTrack!!, listOf("stream"))
Log.e(TAG, "Video track created: ${config.video.resolution.label} @ ${config.video.frameRate}fps")
}
@ -488,53 +532,68 @@ class WebRTCManager @Inject constructor(
/**
* Detiene el streaming de video
*/
// Se ejecuta exclusivamente dentro de streamMutex.
private fun cleanupStream() {
activeGeneration = -1L
autoStopJob?.cancel()
autoStopJob = null
// Detener el productor antes de destruir track, source y EGL.
val capturer = cameraVideoCapturer
cameraVideoCapturer = null
runCatching { capturer?.stopCapture() }
.onFailure { Log.e(TAG, "stopCapture failed", it) }
runCatching { capturer?.dispose() }
.onFailure { Log.e(TAG, "capturer dispose failed", it) }
synchronized(previewLock) {
previewSink?.let { sink ->
runCatching { localVideoTrack?.removeSink(sink) }
}
runCatching { localVideoTrack?.dispose() }
localVideoTrack = null
}
// Liberar el micrófono al detener SOS, incluso si hubo error de conexión.
runCatching { localAudioTrack?.setEnabled(false) }
runCatching { peerConnection?.close() }
runCatching { peerConnection?.dispose() }
peerConnection = null
runCatching { localAudioTrack?.dispose() }
localAudioTrack = null
runCatching { audioSource?.dispose() }
audioSource = null
runCatching { videoSource?.dispose() }
videoSource = null
runCatching { surfaceTextureHelper?.dispose() }
surfaceTextureHelper = null
runCatching { signalingClient?.close() }
signalingClient = null
}
fun stopStreaming() {
// Invalidar inmediatamente cualquier inicio pendiente, antes de lanzar coroutine.
val generation = streamGeneration.incrementAndGet()
scope.launch {
try {
Log.e(TAG, "Stopping streaming")
autoStopJob?.cancel()
autoStopJob = null
localVideoTrack?.dispose()
localVideoTrack = null
cameraVideoCapturer?.stopCapture()
cameraVideoCapturer = null
videoSource?.dispose()
videoSource = null
surfaceTextureHelper?.dispose()
surfaceTextureHelper = null
peerConnection?.close()
peerConnection = null
signalingClient?.close()
signalingClient = null
streamMutex.withLock {
// Si un nuevo inicio legítimo llegó después, no detenerlo.
if (generation != streamGeneration.get()) return@withLock
Log.i(TAG, "Stopping streaming generation=$generation")
cleanupStream()
callback?.onStreamStopped()
} catch (e: Exception) {
Log.e(TAG, "Error stopping streaming", e)
callback?.onError("Error al detener streaming: ${e.message}")
}
}
}
/**
* Libera recursos
*/
fun release() {
Log.e(TAG, "Releasing WebRTCManager")
stopStreaming()
peerConnectionFactory?.dispose()
peerConnectionFactory = null
eglBase?.release()
eglBase = null
callback = null
releasing = true
streamGeneration.incrementAndGet()
scope.launch {
streamMutex.withLock {
cleanupStream()
runCatching { peerConnectionFactory?.dispose() }
peerConnectionFactory = null
runCatching { eglBase?.release() }
eglBase = null
callback = null
}
}
}
}

View file

@ -0,0 +1,34 @@
package com.bodycamera.twentyfoulabs.di
import android.content.Context
import androidx.room.Room
import com.bodycamera.twentyfoulabs.data.gps.GpsDatabase
import com.bodycamera.twentyfoulabs.data.gps.PendingGpsDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideGpsDatabase(
@ApplicationContext context: Context
): GpsDatabase =
Room.databaseBuilder(
context,
GpsDatabase::class.java,
"gps_buffer.db"
).build()
@Provides
fun providePendingGpsDao(
database: GpsDatabase
): PendingGpsDao =
database.pendingGpsDao()
}

View file

@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import com.bodycamera.twentyfoulabs.data.auth.AuthApiException
import com.bodycamera.twentyfoulabs.data.auth.AuthRepository
import com.bodycamera.twentyfoulabs.data.auth.AuthSession
import com.bodycamera.twentyfoulabs.data.auth.AuthSessionStore
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -31,7 +32,8 @@ sealed interface AuthUiState {
@HiltViewModel
class AuthViewModel @Inject constructor(
private val repository: AuthRepository
private val repository: AuthRepository,
private val authSessionStore: AuthSessionStore
) : ViewModel() {
private val _state =
@ -43,6 +45,14 @@ class AuthViewModel @Inject constructor(
_state.asStateFlow()
init {
viewModelScope.launch {
authSessionStore.token.collect { token ->
if (token.isNullOrBlank() && _state.value is AuthUiState.Authenticated) {
_state.value = AuthUiState.LoggedOut
}
}
}
restoreSession()
}
@ -194,7 +204,7 @@ class AuthViewModel @Inject constructor(
when (e.statusCode) {
401 ->
"QR o PIN incorrecto"
"Error 401: ${e.message}"
else ->
e.message

View file

@ -27,6 +27,7 @@ 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.data.backend.DeviceIdentity
import com.bodycamera.twentyfoulabs.qr.QrScannerActivity
import org.json.JSONObject
@ -50,6 +51,24 @@ fun LoginScreen(
) {
val context = LocalContext.current
val deviceIdentity = remember(context) {
DeviceIdentity(context.applicationContext)
}
var configuredSerial by remember {
mutableStateOf(deviceIdentity.configuredSerial())
}
if (configuredSerial == null) {
DeviceSerialSetup(
onConfigure = { serial ->
deviceIdentity.configureSerial(serial)
configuredSerial = deviceIdentity.requireSerial()
}
)
return
}
var mode by remember { mutableStateOf(LoginMode.QR) }
var email by remember {
@ -206,7 +225,7 @@ fun LoginScreen(
Text(
text =
"${BackendConfig.DEVICE_MODEL} - ${BackendConfig.DEVICE_SERIAL}",
"${BackendConfig.DEVICE_MODEL} - $configuredSerial",
color = Color(0xFF8FAEC7),
style =
MaterialTheme.typography.labelSmall
@ -403,6 +422,14 @@ fun LoginScreen(
)
}
Text(
text = if (state is AuthUiState.Error)
"ERROR: ${state.message}"
else
"PRUEBA VISUAL: ZONA DE MENSAJES",
color = Color.Yellow,
style = MaterialTheme.typography.bodySmall
)
TextButton(
onClick = {
@ -801,3 +828,75 @@ private fun extractQrToken(
it.length > 4
}
}
@Composable
private fun DeviceSerialSetup(
onConfigure: (String) -> Unit
) {
var serial by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
Box(
modifier = Modifier
.fillMaxSize()
.background(LoginNavy)
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Card(
colors = CardDefaults.cardColors(
containerColor = LoginSurface
),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Configuración inicial M530",
color = Color.White,
style = MaterialTheme.typography.titleLarge
)
Text(
text = "Ingrese el serial físico registrado en el backend.",
color = Color.White
)
OutlinedTextField(
value = serial,
onValueChange = {
serial = it.trim().uppercase()
error = null
},
label = { Text("Serial físico") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
if (error != null) {
Text(
text = error.orEmpty(),
color = MaterialTheme.colorScheme.error
)
}
Button(
onClick = {
try {
onConfigure(serial)
} catch (e: Exception) {
error = e.message ?: "No se pudo guardar el serial"
}
},
enabled = Regex("^M530[A-Z0-9]+$").matches(serial),
modifier = Modifier.fillMaxWidth()
) {
Text("GUARDAR SERIAL")
}
}
}
}
}

View file

@ -25,6 +25,9 @@ import java.util.*
import android.view.MotionEvent
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import org.webrtc.SurfaceViewRenderer
private val Navy = Color(0xFF0B1F33)
private val Navy2 = Color(0xFF123551)
@ -85,11 +88,35 @@ fun MainScreen(
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
OperationalHeader(deviceStatus, streamingState, isLocked)
if (streamingState is StreamingState.Connecting || streamingState is StreamingState.Active) {
SosWebRtcPreview(viewModel)
Button(
onClick = {
android.util.Log.i("MainScreen", "DETENER SOS pulsado (boton dedicado)")
viewModel.stopVideoFromUi()
},
modifier = Modifier.fillMaxWidth().height(56.dp),
colors = ButtonDefaults.buttonColors(containerColor = Danger)
) {
Icon(Icons.Default.StopCircle, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("DETENER TRANSMISION SOS", fontWeight = FontWeight.Bold)
}
}
QuickActions(
streamingState = streamingState,
locked = isLocked,
pttPressed = pttPressed,
onVideo = onOpenVideoCamera,
onVideo = {
if (streamingState is StreamingState.Active ||
streamingState is StreamingState.Connecting
) {
android.util.Log.i("MainScreen", "DETENER SOS pulsado (acciones rapidas)")
viewModel.stopVideoFromUi()
} else {
onOpenVideoCamera()
}
},
onPhoto = onOpenPhotoCamera,
onPttDown = { if (!pttPressed) { pttPressed = viewModel.startPttFromUi() } },
onPttUp = { viewModel.stopPttFromUi(); pttPressed = false },
@ -182,7 +209,9 @@ private fun QuickActions(
ActionTile(
Modifier.weight(1f), if (streamingState is StreamingState.Active || streamingState is StreamingState.Connecting) "DETENER" else "VIDEO",
if (streamingState is StreamingState.Active) Icons.Default.StopCircle else Icons.Default.Videocam,
if (streamingState is StreamingState.Active) Danger else Accent, !locked, onVideo
if (streamingState is StreamingState.Active) Danger else Accent,
!locked || streamingState is StreamingState.Active || streamingState is StreamingState.Connecting,
onVideo
)
ActionTile(Modifier.weight(1f), "FOTO", Icons.Default.PhotoCamera, Navy2, !locked, onPhoto)
}
@ -346,3 +375,42 @@ 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))
/** Vista local del MISMO track WebRTC del SOS; no abre una segunda cámara. */
@Composable
private fun SosWebRtcPreview(viewModel: MainViewModel) {
val context = LocalContext.current
val renderer = remember(context) { SurfaceViewRenderer(context) }
var initialized by remember(renderer) { mutableStateOf(false) }
DisposableEffect(renderer, viewModel) {
val eglContext = viewModel.previewEglContext()
if (eglContext != null) {
renderer.init(eglContext, null)
renderer.setMirror(true)
renderer.setEnableHardwareScaler(true)
viewModel.attachPreviewSink(renderer)
initialized = true
}
onDispose {
if (initialized) {
viewModel.detachPreviewSink(renderer)
renderer.release()
initialized = false
}
}
}
Card(colors = CardDefaults.cardColors(containerColor = SurfaceDark), shape = RoundedCornerShape(14.dp)) {
Column(Modifier.fillMaxWidth().padding(8.dp)) {
Text("VISTA PREVIA SOS - CAMARA FRONTAL", color = Color.White,
style = MaterialTheme.typography.labelMedium)
if (initialized) {
AndroidView(factory = { renderer }, modifier = Modifier.fillMaxWidth().height(190.dp))
} else {
Text("Preparando vista previa...", color = Color.White)
}
}
}
}

View file

@ -9,8 +9,11 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.bodycamera.twentyfoulabs.data.model.NetworkConfiguration
import com.bodycamera.twentyfoulabs.data.device.M530DeviceController
import com.bodycamera.twentyfoulabs.data.backend.BackendIntegrationManager
import com.bodycamera.twentyfoulabs.data.auth.AuthSessionStore
import com.bodycamera.twentyfoulabs.data.model.VideoConfiguration
import com.bodycamera.twentyfoulabs.data.network.NetworkFallbackManager
import com.bodycamera.twentyfoulabs.data.network.NetworkMonitor
import com.bodycamera.twentyfoulabs.data.repository.AdminLockRepository
import com.bodycamera.twentyfoulabs.data.repository.NetworkConfigRepository
import com.bodycamera.twentyfoulabs.data.repository.VideoConfigRepository
@ -22,6 +25,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -33,12 +37,15 @@ import javax.inject.Inject
@HiltViewModel
class MainViewModel @Inject constructor(
repository: NetworkConfigRepository,
networkFallbackManager: NetworkFallbackManager,
private val adminLockRepository: AdminLockRepository,
repository: NetworkConfigRepository,
networkFallbackManager: NetworkFallbackManager,
private val networkMonitor: NetworkMonitor,
private val adminLockRepository: AdminLockRepository,
private val videoConfigRepository: VideoConfigRepository,
private val webrtcManager: WebRTCManager,
private val deviceController: M530DeviceController,
private val backendIntegrationManager: BackendIntegrationManager,
private val authSessionStore: AuthSessionStore,
@ApplicationContext private val appContext: Context
) : ViewModel() {
@ -93,6 +100,8 @@ private fun vibrateRecordingStopped() {
vibrator.vibrate(pattern, -1)
}
}
private var activeWhipUrl: String? = null
val uiState: StateFlow<MainUiState> = combine(
repository.getConfigFlow(),
networkFallbackManager.fallbackState
@ -118,11 +127,11 @@ private fun vibrateRecordingStopped() {
private var streamingCallback: WebRTCManager.StreamingCallback? = null
// Debounce para prevenir múltiples presiones rápidas del botón
// Debounce para prevenir multiples presiones rapidas del boton
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 pulsacion de F5 SIEMPRE detenga camara/stream aunque WHIP falle.
@Volatile
private var streamingRequested = false
@ -134,17 +143,133 @@ private fun vibrateRecordingStopped() {
val deviceStatus: StateFlow<DeviceStatus> = _deviceStatus.asStateFlow()
init {
initializeStreamingCallback()
startDeviceStatusLoop()
initializeStreamingCallback()
startDeviceStatusLoop()
viewModelScope.launch(Dispatchers.IO) {
var hadInternet = networkMonitor.hasInternet()
var firstEmission = true
networkMonitor.observeNetworkStatus().collect { status ->
val hasInternet = status.hasInternet
if (hasInternet && (firstEmission || !hadInternet)) {
Log.i(
TAG,
if (firstEmission) {
"Internet disponible al iniciar: sincronizando buffer GPS pendiente"
} else {
"Internet recuperado: sincronizando buffer GPS pendiente"
}
)
backendIntegrationManager.flushPendingGps()
}
hadInternet = hasInternet
firstEmission = false
}
}
}
@Volatile
private var telemetrySettings = BackendIntegrationManager.TelemetrySettings()
private fun refreshTelemetrySettings() {
backendIntegrationManager.fetchTelemetrySettings { result ->
result.onSuccess { settings ->
telemetrySettings = settings
Log.i(
TAG,
"Cadencias actualizadas: gps=${settings.intervalSeconds}s, " +
"sos=${settings.sosIntervalSeconds}s, " +
"device=${settings.deviceStatusIntervalSeconds}s"
)
}.onFailure { error ->
Log.w(
TAG,
"No se pudo actualizar telemetry_settings; se conserva la ultima configuracion",
error
)
}
}
}
@Volatile
private var centralSosCadenceActive = false
private fun refreshSosSupportState() {
backendIntegrationManager.fetchSosSupportState { result ->
result.onSuccess { state ->
val previous = centralSosCadenceActive
centralSosCadenceActive = state.isSosCadenceActive
if (previous && !centralSosCadenceActive && sosSessionActive.get()) {
Log.i(TAG, "SOS cerrado desde central - finalizando sesion SOS local")
stopVideoFromUi()
}
if (previous != centralSosCadenceActive) {
Log.i(
TAG,
"SOS central actualizado: active=$centralSosCadenceActive " +
"own=${state.ownSosActive} support=${state.supportRequestActive}"
)
}
}.onFailure { error ->
Log.w(
TAG,
"No se pudo consultar SOS central; se conserva el ultimo estado",
error
)
}
}
}
private fun startDeviceStatusLoop() {
viewModelScope.launch(Dispatchers.IO) {
var ticks = 0
var lastGpsAt = System.currentTimeMillis()
var lastTelemetryAt = System.currentTimeMillis()
var lastSettingsAt = 0L
var lastSosSupportAt = 0L
while (true) {
val token = authSessionStore.token.first()
if (token.isNullOrBlank()) {
delay(1000)
continue
}
val now = System.currentTimeMillis()
refreshDeviceStatus()
if (ticks % 3 == 0) deviceController.logTelemetry()
ticks++
if (lastSettingsAt == 0L || now - lastSettingsAt >= 300_000L) {
lastSettingsAt = now
refreshTelemetrySettings()
}
if (lastSosSupportAt == 0L || now - lastSosSupportAt >= 30_000L) {
lastSosSupportAt = now
refreshSosSupportState()
}
val settings = telemetrySettings
val gpsIntervalSeconds =
if (sosSessionActive.get() || centralSosCadenceActive) {
settings.sosIntervalSeconds
} else {
settings.intervalSeconds
}
if (now - lastGpsAt >= gpsIntervalSeconds * 1000L) {
lastGpsAt = now
deviceController.logGps()
}
if (now - lastTelemetryAt >= settings.deviceStatusIntervalSeconds * 1000L) {
lastTelemetryAt = now
deviceController.logTelemetry()
}
delay(5000)
}
}
@ -158,8 +283,92 @@ private fun vibrateRecordingStopped() {
_deviceStatus.value = DeviceStatus(telemetry, info, evidence, events)
}
fun triggerSosFromUi() {
deviceController.triggerSos { }
private val sosGeneration = java.util.concurrent.atomic.AtomicLong(0)
// Mantener el estado incluso mientras el backend confirma el SOS o WebRTC conecta.
private val sosSessionActive = java.util.concurrent.atomic.AtomicBoolean(false)
fun isSosSessionActive(): Boolean = sosSessionActive.get()
private val sosInProgress =
java.util.concurrent.atomic.AtomicBoolean(false)
fun triggerSosFromUi(
onResult: ((Result<Int>) -> Unit)? = null
) {
if (!sosSessionActive.compareAndSet(false, true)) {
Log.w(TAG, "SOS ya activo o conectando: no generar otra alerta")
return
}
// Al activar SOS, reportar ubicacion real inmediatamente.
deviceController.logGps()
if (!sosInProgress.compareAndSet(false, true)) {
sosSessionActive.set(false)
Log.w(TAG, "SOS ya en proceso")
return
}
val requestGeneration = sosGeneration.incrementAndGet()
deviceController.triggerSosWithVideo(
callback = { result ->
sosInProgress.set(false)
if (result.isSuccess) {
Log.i(TAG, "SOS registrado id=${result.getOrNull()}")
} else {
Log.e(TAG, "Error enviando SOS", result.exceptionOrNull())
if (requestGeneration == sosGeneration.get()) sosSessionActive.set(false)
}
onResult?.invoke(result)
refreshDeviceStatus()
},
onVideoTarget = { streamPath, whipUrl ->
viewModelScope.launch {
try {
if (requestGeneration != sosGeneration.get()) return@launch
val directWhipUrl =
com.bodycamera.twentyfoulabs.data.backend.BackendConfig.MEDIAMTX_BASE_URL +
"/" + streamPath.trim('/') + "/whip"
val uri = java.net.URI(directWhipUrl)
require(uri.scheme == "http")
require(!uri.host.isNullOrBlank())
require(uri.path == "/$streamPath/whip")
val config = videoConfigRepository.getConfig()
?: VideoConfiguration()
val sosConfig = config.copy(
camera = config.camera.copy(
cameraFacing = com.bodycamera.twentyfoulabs.data.model.CameraFacing.FRONT,
cameraId = null
),
webrtcServer = config.webrtcServer.copy(
serverUrl = uri.host,
serverPort = if (uri.port > 0) uri.port else 80,
streamName = streamPath
)
)
if (requestGeneration != sosGeneration.get()) return@launch
Log.i(TAG, "Iniciando video SOS: $directWhipUrl")
activeWhipUrl = directWhipUrl
streamingRequested = true
_streamingState.value = StreamingState.Connecting
webrtcManager.startStreaming(sosConfig)
} catch (e: Exception) {
Log.e(TAG, "Error iniciando video SOS", e)
streamingRequested = false
_streamingState.value = StreamingState.Error(
e.message ?: "Error iniciando video SOS"
)
}
}
}
)
refreshDeviceStatus()
}
@ -181,14 +390,24 @@ private fun vibrateRecordingStopped() {
private fun initializeStreamingCallback() {
streamingCallback = object : WebRTCManager.StreamingCallback {
override fun onStreamStarted() {
if (!streamingRequested) {
Log.w(TAG, "Ignoring late onStreamStarted after stop")
webrtcManager.stopStreaming()
return
}
Log.e(TAG, "Stream started - updating state to Active")
deviceController.notifyVideoStarted(
com.bodycamera.twentyfoulabs.data.backend.BackendConfig.whipUrl
)
val startedWhipUrl = activeWhipUrl
if (startedWhipUrl != null) {
deviceController.notifyVideoStarted(startedWhipUrl)
} else {
Log.w(TAG, "Video iniciado sin dirección WHIP registrada")
}
mainImmediateScope.launch {
_streamingState.value = StreamingState.Active(600)
vibrateRecordingStarted()
if (streamingRequested) {
_streamingState.value = StreamingState.Active(600)
vibrateRecordingStarted()
}
}
}
@ -196,6 +415,7 @@ override fun onStreamStopped() {
Log.e(TAG, "Stream stopped - updating state to Stopped")
deviceController.notifyVideoStopped()
streamingRequested = false
activeWhipUrl = null
mainImmediateScope.launch {
_streamingState.value = StreamingState.Stopped
@ -203,37 +423,65 @@ override fun onStreamStopped() {
}
}
override fun onConnectionChange(isConnected: Boolean) {
Log.e(TAG, "Connection changed: $isConnected, current state: ${_streamingState.value}")
mainImmediateScope.launch {
_streamingState.value = when (_streamingState.value) {
is StreamingState.Connecting -> {
// Si estaba conectando y ahora está conectado, pasar a Active
if (isConnected) {
Log.e(TAG, "Connection established, changing to Active")
StreamingState.Active(600)
} else {
_streamingState.value
}
}
is StreamingState.Active -> {
if (!isConnected) {
Log.e(TAG, "Connection lost, changing to Connecting")
StreamingState.Connecting
} else {
_streamingState.value
}
}
else -> _streamingState.value
}
override fun onConnectionChange(isConnected: Boolean) {
Log.e(
TAG,
"Connection changed: $isConnected, current state: ${_streamingState.value}"
)
mainImmediateScope.launch {
if (!streamingRequested) return@launch
_streamingState.value = when (_streamingState.value) {
is StreamingState.Connecting -> {
if (isConnected) {
Log.e(
TAG,
"Connection established, changing to Active"
)
StreamingState.Active(600)
} else {
_streamingState.value
}
}
is StreamingState.Active -> {
if (!isConnected) {
Log.e(
TAG,
"Connection lost, changing to Connecting"
)
StreamingState.Connecting
} else {
_streamingState.value
}
}
else -> _streamingState.value
}
}
}
override fun onError(error: String) {
Log.e(TAG, "Streaming error: $error - forcing media cleanup")
deviceController.notifyVideoError(error)
streamingRequested = false
// Si WHIP/signaling falla, no dejar la cámara capturando indefinidamente.
activeWhipUrl = null
// Si WHIP/signaling falla, no dejar la camara capturando indefinidamente.
webrtcManager.stopStreaming()
mainImmediateScope.launch {
_streamingState.value = StreamingState.Error(error)
@ -260,7 +508,7 @@ override fun onStreamStopped() {
fun onEvent(event: MainUiEvent) {
when (event) {
MainUiEvent.RetryLoad -> {
// El flow se actualiza automáticamente
// El flow se actualiza automaticamente
}
MainUiEvent.ImportConfigFromFile -> {
// TODO: Implement file picker
@ -278,13 +526,13 @@ override fun onStreamStopped() {
}
/**
* 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.
* Metodo publico para ser llamado desde el boton fisico de camara (KEYCODE_F5).
* Verifica si la app esta 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, "Boton presionado muy rapido, ignorando (debounce)")
return
}
lastButtonPressTime = currentTime
@ -294,7 +542,7 @@ override fun onStreamStopped() {
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 - boton camara ignorado")
return@launch
}
Log.e(TAG, "App desbloqueada - llamando a toggleStreaming")
@ -306,22 +554,57 @@ override fun onStreamStopped() {
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 camara/WebRTC")
streamingRequested = false
webrtcManager.stopStreaming()
_streamingState.value = StreamingState.Stopped
return@launch
}
val config = videoConfigRepository.getConfig()
if (config == null) {
Log.e(TAG, "No hay configuración de video")
streamingRequested = false
_streamingState.value = StreamingState.Error("No hay configuración de video")
val savedConfig = videoConfigRepository.getConfig()
val config = when {
savedConfig == null -> VideoConfiguration().also {
videoConfigRepository.saveConfig(it)
}
savedConfig.webrtcServer.serverUrl == "192.168.18.57" ->
savedConfig.copy(
webrtcServer = savedConfig.webrtcServer.copy(
serverUrl = "172.93.110.24",
serverPort = 18889
)
).also {
videoConfigRepository.saveConfig(it)
}
else -> savedConfig
}
val server = config.webrtcServer
if (server.serverUrl == "192.168.18.57") {
_streamingState.value = StreamingState.Error(
"La configuración guardada utiliza el servidor anterior. Actualiza la configuración de video."
)
return@launch
}
Log.e(TAG, "F5 START solicitado: ${config.webrtcServer.serverUrl}:${config.webrtcServer.serverPort}")
if (server.serverUrl.isBlank() ||
server.serverPort !in 1..65535 ||
server.streamName.isBlank()
) {
_streamingState.value = StreamingState.Error(
"La configuración del servidor de video no es válida."
)
return@launch
}
val normalWhipUrl =
"http://${server.serverUrl}:${server.serverPort}/${server.streamName.trim('/')}/whip"
Log.i(TAG, "F5 START solicitado: $normalWhipUrl")
activeWhipUrl = normalWhipUrl
streamingRequested = true
_streamingState.value = StreamingState.Connecting
try {
@ -335,6 +618,18 @@ override fun onStreamStopped() {
}
}
fun stopVideoFromUi() {
Log.i(TAG, "DETENER solicitado (pantalla o F7)")
sosSessionActive.set(false)
sosGeneration.incrementAndGet()
streamingRequested = false
// Mantener DETENER visible hasta que WebRTC confirme liberacion de camara.
webrtcManager.stopStreaming()
}
fun previewEglContext(): org.webrtc.EglBase.Context? = webrtcManager.previewEglContext()
fun attachPreviewSink(sink: org.webrtc.VideoSink) = webrtcManager.attachPreviewSink(sink)
fun detachPreviewSink(sink: org.webrtc.VideoSink) = webrtcManager.detachPreviewSink(sink)
override fun onCleared() {
super.onCleared()
webrtcManager.release()
@ -372,3 +667,9 @@ data class DeviceStatus(
val evidence: M530DeviceController.EvidenceSummary? = null,
val recentEvents: List<M530DeviceController.EventEntry> = emptyList()
)

View file

@ -10,6 +10,7 @@
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
<domain includeSubdomains="true">35.224.197.127</domain>
<domain includeSubdomains="true">172.93.110.24</domain>
</domain-config>
<!-- Permitir todo en debug para pruebas -->