Compare commits

...

2 commits

15 changed files with 1269 additions and 88 deletions

View file

@ -80,6 +80,11 @@ dependencies {
kapt("com.google.dagger:hilt-android-compiler:2.48") kapt("com.google.dagger:hilt-android-compiler:2.48")
implementation("androidx.hilt:hilt-navigation-compose:1.1.0") 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 // DataStore para persistencia
implementation("androidx.datastore:datastore-preferences:1.0.0") implementation("androidx.datastore:datastore-preferences:1.0.0")
@ -118,3 +123,5 @@ implementation("com.google.mlkit:barcode-scanning:17.3.0")
} }

View file

@ -613,8 +613,18 @@ fun AppNavigation(viewModel: MainViewModel, onOpenPhotoCamera: () -> Unit, onOpe
val authState by authViewModel.state.collectAsState() val authState by authViewModel.state.collectAsState()
LaunchedEffect(authState) { LaunchedEffect(authState) {
if (authState is AuthUiState.Authenticated && navController.currentDestination?.route != "main") { when (authState) {
navController.navigate("main") { popUpTo("login") { inclusive = true } } 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
} }
} }

View file

@ -1,5 +1,7 @@
package com.bodycamera.twentyfoulabs.data.auth package com.bodycamera.twentyfoulabs.data.auth
import android.util.Log
import com.bodycamera.twentyfoulabs.data.backend.BackendConfig import com.bodycamera.twentyfoulabs.data.backend.BackendConfig
import com.bodycamera.twentyfoulabs.data.backend.DeviceIdentity import com.bodycamera.twentyfoulabs.data.backend.DeviceIdentity
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -44,6 +46,18 @@ class AuthApiClient @Inject constructor(
json.decodeFromString<LoginResponse>(response) 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 = suspend fun me(token: String): MeResponse =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val response = request( val response = request(
@ -173,3 +187,9 @@ class AuthApiException(
val statusCode: Int, val statusCode: Int,
override val message: String override val message: String
) : Exception(message) ) : Exception(message)

View file

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

View file

@ -1,7 +1,10 @@
package com.bodycamera.twentyfoulabs.data.auth package com.bodycamera.twentyfoulabs.data.auth
import android.util.Log
import com.bodycamera.twentyfoulabs.data.backend.BackendConfig import com.bodycamera.twentyfoulabs.data.backend.BackendConfig
import com.bodycamera.twentyfoulabs.data.backend.DeviceIdentity import com.bodycamera.twentyfoulabs.data.backend.DeviceIdentity
import com.bodycamera.twentyfoulabs.data.gps.GpsBufferRepository
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -10,7 +13,8 @@ import javax.inject.Singleton
class AuthRepository @Inject constructor( class AuthRepository @Inject constructor(
private val api: AuthApiClient, private val api: AuthApiClient,
private val store: AuthSessionStore, private val store: AuthSessionStore,
private val deviceIdentity: DeviceIdentity private val deviceIdentity: DeviceIdentity,
private val gpsBufferRepository: GpsBufferRepository
) { ) {
suspend fun login( suspend fun login(
@ -34,8 +38,28 @@ class AuthRepository @Inject constructor(
refreshToken = response.refreshToken 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) store.save(session)
return session return session
} }
@ -60,6 +84,15 @@ class AuthRepository @Inject constructor(
refreshToken = response.refreshToken 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) store.save(session)
return session return session
@ -77,37 +110,73 @@ class AuthRepository @Inject constructor(
return try { return try {
val response = api.me(token)
AuthSession( val response = api.me(token)
token = token,
user = response.user, val session = AuthSession(
device = response.device ?: response.user.assignedDevice, token = token,
refreshToken = refreshToken user = response.user,
) device = response.device ?: response.user.assignedDevice,
refreshToken = refreshToken
)
store.save(session)
session
} catch (e: AuthApiException) { } 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() 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()) { // Descartar GPS pendientes del sereno anterior.
api.logout(token) gpsBufferRepository.clear()
}
} finally { // Notificar al backend sin impedir el logout local si no hay Internet.
store.clear() if (!token.isNullOrBlank()) {
runCatching {
api.logout(token)
} }
} }
} }
}

View file

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

View file

@ -17,12 +17,14 @@ import java.util.UUID
import java.util.concurrent.Executors import java.util.concurrent.Executors
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import com.bodycamera.twentyfoulabs.data.gps.GpsBufferRepository
@Singleton @Singleton
class BackendIntegrationManager @Inject constructor( class BackendIntegrationManager @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val authSessionStore: AuthSessionStore, private val authSessionStore: AuthSessionStore,
private val deviceIdentity: DeviceIdentity private val deviceIdentity: DeviceIdentity,
private val gpsBufferRepository: GpsBufferRepository
) { ) {
companion object { private const val TAG = "BackendIntegration" } companion object { private const val TAG = "BackendIntegration" }
@ -33,6 +35,29 @@ class BackendIntegrationManager @Inject constructor(
private val clientId: String get() = "${BackendConfig.MQTT_CLIENT_PREFIX}-$deviceId-${UUID.randomUUID().toString().take(6)}" 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 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 @Volatile
private var lastMediaItemId: Int? = null private var lastMediaItemId: Int? = null
private val mediaStatePrefs by lazy { private val mediaStatePrefs by lazy {
@ -91,7 +116,15 @@ class BackendIntegrationManager @Inject constructor(
enqueuePublish(topic, envelope) enqueuePublish(topic, envelope)
} }
fun publishGps(payload: JSONObject) { data class TelemetrySettings(
val intervalSeconds: Int = 300,
val sosIntervalSeconds: Int = 30,
val deviceStatusIntervalSeconds: Int = 300
)
fun fetchTelemetrySettings(
onResult: (Result<TelemetrySettings>) -> Unit
) {
httpExecutor.execute { httpExecutor.execute {
var conn: HttpURLConnection? = null var conn: HttpURLConnection? = null
try { try {
@ -100,46 +133,595 @@ class BackendIntegrationManager @Inject constructor(
}?.takeIf { it.isNotBlank() } }?.takeIf { it.isNotBlank() }
?: error("No hay sesion JWT activa") ?: error("No hay sesion JWT activa")
val serial = deviceIdentity.requireSerial()
val url = URL( val url = URL(
BackendConfig.API_BASE_URL.trimEnd('/') + BackendConfig.API_BASE_URL.trimEnd('/') +
"/mobile/v1/gps_locations" "/mobile/v1/telemetry_settings"
) )
conn = (url.openConnection() as HttpURLConnection).apply { conn = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "POST" requestMethod = "GET"
connectTimeout = 15_000 connectTimeout = 15_000
readTimeout = 30_000 readTimeout = 30_000
doOutput = true
setRequestProperty("Content-Type", "application/json")
setRequestProperty("Accept", "application/json") setRequestProperty("Accept", "application/json")
setRequestProperty("Authorization", "Bearer $token") setRequestProperty("Authorization", "Bearer $token")
setRequestProperty("X-Device-Serial", serial)
setRequestProperty( setRequestProperty(
"X-Device-Serial", "X-Device-Time",
deviceIdentity.requireSerial() System.currentTimeMillis().toString()
) )
} }
conn.outputStream.use {
it.write(payload.toString().toByteArray(Charsets.UTF_8))
}
val status = conn.responseCode val status = conn.responseCode
if (status in 200..299) { if (status in 200..299) {
Log.i(TAG, "GPS HTTPS OK: HTTP $status") 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 { } else {
val errorBody = conn.errorStream val errorBody = conn.errorStream
?.bufferedReader() ?.bufferedReader()
?.use { it.readText() } ?.use { it.readText() }
.orEmpty() .orEmpty()
Log.e(TAG, "GPS HTTPS ERROR: HTTP $status $errorBody") val error = IllegalStateException(
"Telemetry settings HTTP $status $errorBody"
)
Log.e(TAG, error.message.orEmpty())
onResult(Result.failure(error))
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "GPS HTTPS ERROR", e) Log.e(TAG, "Telemetry settings ERROR", e)
onResult(Result.failure(e))
} finally { } finally {
conn?.disconnect() 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) { fun publishTelemetry(payload: JSONObject) {
val serial = deviceIdentity.configuredSerial() val serial = deviceIdentity.configuredSerial()
if (serial == null) { if (serial == null) {
@ -174,7 +756,11 @@ class BackendIntegrationManager @Inject constructor(
setRequestProperty("Authorization", "Bearer $token") setRequestProperty("Authorization", "Bearer $token")
setRequestProperty( setRequestProperty(
"X-Device-Serial", "X-Device-Serial",
deviceIdentity.requireSerial() serial
)
setRequestProperty(
"X-Device-Time",
System.currentTimeMillis().toString()
) )
} }
@ -193,6 +779,13 @@ class BackendIntegrationManager @Inject constructor(
.orEmpty() .orEmpty()
Log.e(TAG, "TELEMETRY HTTPS ERROR: HTTP $status $errorBody") 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) { } catch (e: Exception) {
Log.e(TAG, "TELEMETRY HTTPS ERROR", e) Log.e(TAG, "TELEMETRY HTTPS ERROR", e)

View file

@ -1,11 +1,13 @@
package com.bodycamera.twentyfoulabs.data.device package com.bodycamera.twentyfoulabs.data.device
import android.Manifest import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.location.Location import android.location.Location
import android.location.LocationListener
import android.location.LocationManager import android.location.LocationManager
import android.media.MediaRecorder import android.media.MediaRecorder
import android.net.ConnectivityManager import android.net.ConnectivityManager
@ -47,6 +49,7 @@ class M530DeviceController @Inject constructor(
val network: String, val network: String,
val connected: Boolean, val connected: Boolean,
val freeStorageMb: Long, val freeStorageMb: Long,
val storageTotalMb: Long,
val timestamp: Long = System.currentTimeMillis() val timestamp: Long = System.currentTimeMillis()
) )
data class DeviceInfo( data class DeviceInfo(
@ -65,6 +68,29 @@ class M530DeviceController @Inject constructor(
private var micFile: File? = null private var micFile: File? = null
private val ioExecutor: Executor get() = ContextCompat.getMainExecutor(context) 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( fun deviceInfo(): DeviceInfo = DeviceInfo(
model = Build.MODEL ?: "M530", model = Build.MODEL ?: "M530",
manufacturer = Build.MANUFACTURER ?: "Android", manufacturer = Build.MANUFACTURER ?: "Android",
@ -294,12 +320,7 @@ Result.success(file)
fun triggerSos(): SosResult { fun triggerSos(): SosResult {
val location = bestLastKnownLocation()?.takeIf { val location = bestLastKnownLocation()?.takeIf {
System.currentTimeMillis() - it.time in 0..300_000L System.currentTimeMillis() - it.time in -5_000L..300_000L
} ?: Location("simulated").apply {
latitude = -12.077
longitude = -77.092
accuracy = 10f
time = System.currentTimeMillis()
} }
val payload = val payload =
@ -413,7 +434,8 @@ Result.success(file)
else -> "Sin red" else -> "Sin red"
} }
val freeMb = context.filesDir.usableSpace / (1024L * 1024L) 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 { fun evidenceSummary(): EvidenceSummary {
@ -438,11 +460,11 @@ Result.success(file)
fun logGps() { fun logGps() {
val location = bestLastKnownLocation()?.takeIf { val location = bestLastKnownLocation()?.takeIf {
System.currentTimeMillis() - it.time in 0..300_000L System.currentTimeMillis() - it.time in -5_000L..300_000L
} }
if (location == null) { if (location == null) {
Log.w(TAG, "GPS: sin ubicación reciente; se omite el envío") Log.w(TAG, "GPS: sin ubicación reciente; se omite el envío")
return return
} }
@ -454,12 +476,12 @@ Result.success(file)
java.util.Locale.US java.util.Locale.US
).apply { ).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC") timeZone = java.util.TimeZone.getTimeZone("UTC")
}.format(java.util.Date()) }.format(java.util.Date(location.time))
val payload = JSONObject() val payload = JSONObject()
.put("latitude", latitude) .put("latitude", latitude)
.put("longitude", longitude) .put("longitude", longitude)
.put("speed", if (location.hasSpeed()) location.speed else JSONObject.NULL) .put("speed", if (location.hasSpeed()) location.speed * 3.6 else JSONObject.NULL)
.put("heading", if (location.hasBearing()) location.bearing else JSONObject.NULL) .put("heading", if (location.hasBearing()) location.bearing else JSONObject.NULL)
.put("accuracy", location.accuracy) .put("accuracy", location.accuracy)
.put("recorded_at", recordedAt) .put("recorded_at", recordedAt)
@ -471,20 +493,99 @@ Result.success(file)
fun logTelemetry(): File { fun logTelemetry(): File {
val t = telemetry() val t = telemetry()
val p = JSONObject().put("batteryPercent", t.batteryPercent).put("charging", t.charging) 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 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 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 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? { private fun bestLastKnownLocation(): Location? {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED && val fineGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) return null ContextCompat.checkSelfPermission(
val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager context,
return runCatching { lm.getProviders(true).mapNotNull { p -> runCatching { lm.getLastKnownLocation(p) }.getOrNull() }.maxByOrNull { it.time } }.getOrNull() 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 { private fun summarizePayload(payload: JSONObject?): String {
@ -506,3 +607,12 @@ Result.success(file)
} }
private fun timestamp(): String = SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date()) private fun timestamp(): String = SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date())
} }

View file

@ -0,0 +1,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

@ -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.AuthApiException
import com.bodycamera.twentyfoulabs.data.auth.AuthRepository import com.bodycamera.twentyfoulabs.data.auth.AuthRepository
import com.bodycamera.twentyfoulabs.data.auth.AuthSession import com.bodycamera.twentyfoulabs.data.auth.AuthSession
import com.bodycamera.twentyfoulabs.data.auth.AuthSessionStore
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -31,7 +32,8 @@ sealed interface AuthUiState {
@HiltViewModel @HiltViewModel
class AuthViewModel @Inject constructor( class AuthViewModel @Inject constructor(
private val repository: AuthRepository private val repository: AuthRepository,
private val authSessionStore: AuthSessionStore
) : ViewModel() { ) : ViewModel() {
private val _state = private val _state =
@ -43,6 +45,14 @@ class AuthViewModel @Inject constructor(
_state.asStateFlow() _state.asStateFlow()
init { init {
viewModelScope.launch {
authSessionStore.token.collect { token ->
if (token.isNullOrBlank() && _state.value is AuthUiState.Authenticated) {
_state.value = AuthUiState.LoggedOut
}
}
}
restoreSession() restoreSession()
} }

View file

@ -9,8 +9,11 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.bodycamera.twentyfoulabs.data.model.NetworkConfiguration import com.bodycamera.twentyfoulabs.data.model.NetworkConfiguration
import com.bodycamera.twentyfoulabs.data.device.M530DeviceController 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.model.VideoConfiguration
import com.bodycamera.twentyfoulabs.data.network.NetworkFallbackManager 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.AdminLockRepository
import com.bodycamera.twentyfoulabs.data.repository.NetworkConfigRepository import com.bodycamera.twentyfoulabs.data.repository.NetworkConfigRepository
import com.bodycamera.twentyfoulabs.data.repository.VideoConfigRepository 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.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -33,12 +37,15 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class MainViewModel @Inject constructor( class MainViewModel @Inject constructor(
repository: NetworkConfigRepository, repository: NetworkConfigRepository,
networkFallbackManager: NetworkFallbackManager, networkFallbackManager: NetworkFallbackManager,
private val adminLockRepository: AdminLockRepository, private val networkMonitor: NetworkMonitor,
private val adminLockRepository: AdminLockRepository,
private val videoConfigRepository: VideoConfigRepository, private val videoConfigRepository: VideoConfigRepository,
private val webrtcManager: WebRTCManager, private val webrtcManager: WebRTCManager,
private val deviceController: M530DeviceController, private val deviceController: M530DeviceController,
private val backendIntegrationManager: BackendIntegrationManager,
private val authSessionStore: AuthSessionStore,
@ApplicationContext private val appContext: Context @ApplicationContext private val appContext: Context
) : ViewModel() { ) : ViewModel() {
@ -136,20 +143,133 @@ private fun vibrateRecordingStopped() {
val deviceStatus: StateFlow<DeviceStatus> = _deviceStatus.asStateFlow() val deviceStatus: StateFlow<DeviceStatus> = _deviceStatus.asStateFlow()
init { init {
initializeStreamingCallback() initializeStreamingCallback()
startDeviceStatusLoop() 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() { private fun startDeviceStatusLoop() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
var ticks = 0 var lastGpsAt = System.currentTimeMillis()
var lastTelemetryAt = System.currentTimeMillis()
var lastSettingsAt = 0L
var lastSosSupportAt = 0L
while (true) { while (true) {
val token = authSessionStore.token.first()
if (token.isNullOrBlank()) {
delay(1000)
continue
}
val now = System.currentTimeMillis()
refreshDeviceStatus() refreshDeviceStatus()
if (ticks > 0 && ticks % 60 == 0) {
deviceController.logTelemetry() 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() deviceController.logGps()
} }
ticks++
if (now - lastTelemetryAt >= settings.deviceStatusIntervalSeconds * 1000L) {
lastTelemetryAt = now
deviceController.logTelemetry()
}
delay(5000) delay(5000)
} }
} }
@ -177,6 +297,8 @@ private fun vibrateRecordingStopped() {
Log.w(TAG, "SOS ya activo o conectando: no generar otra alerta") Log.w(TAG, "SOS ya activo o conectando: no generar otra alerta")
return return
} }
// Al activar SOS, reportar ubicacion real inmediatamente.
deviceController.logGps()
if (!sosInProgress.compareAndSet(false, true)) { if (!sosInProgress.compareAndSet(false, true)) {
sosSessionActive.set(false) sosSessionActive.set(false)
Log.w(TAG, "SOS ya en proceso") Log.w(TAG, "SOS ya en proceso")
@ -301,33 +423,59 @@ override fun onStreamStopped() {
} }
} }
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 -> {
// Si estaba conectando y ahora esta conectado, pasar a Active
if (isConnected) { override fun onConnectionChange(isConnected: Boolean) {
Log.e(TAG, "Connection established, changing to Active") Log.e(
StreamingState.Active(600) TAG,
} else { "Connection changed: $isConnected, current state: ${_streamingState.value}"
_streamingState.value )
}
}
is StreamingState.Active -> {
if (!isConnected) { mainImmediateScope.launch {
Log.e(TAG, "Connection lost, changing to Connecting") if (!streamingRequested) return@launch
StreamingState.Connecting
} else { _streamingState.value = when (_streamingState.value) {
_streamingState.value
} is StreamingState.Connecting -> {
} if (isConnected) {
else -> _streamingState.value 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) { override fun onError(error: String) {
Log.e(TAG, "Streaming error: $error - forcing media cleanup") Log.e(TAG, "Streaming error: $error - forcing media cleanup")
deviceController.notifyVideoError(error) deviceController.notifyVideoError(error)
@ -519,3 +667,9 @@ data class DeviceStatus(
val evidence: M530DeviceController.EvidenceSummary? = null, val evidence: M530DeviceController.EvidenceSummary? = null,
val recentEvents: List<M530DeviceController.EventEntry> = emptyList() val recentEvents: List<M530DeviceController.EventEntry> = emptyList()
) )