diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fce476f..889e440 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -80,11 +80,6 @@ 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") @@ -123,5 +118,3 @@ implementation("com.google.mlkit:barcode-scanning:17.3.0") } - - diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainActivity.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainActivity.kt index 07342fe..7120eac 100644 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainActivity.kt +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainActivity.kt @@ -613,18 +613,8 @@ fun AppNavigation(viewModel: MainViewModel, onOpenPhotoCamera: () -> Unit, onOpe val authState by authViewModel.state.collectAsState() LaunchedEffect(authState) { - 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 + if (authState is AuthUiState.Authenticated && navController.currentDestination?.route != "main") { + navController.navigate("main") { popUpTo("login") { inclusive = true } } } } diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthApiClient.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthApiClient.kt index 0d79252..e09a228 100644 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthApiClient.kt +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthApiClient.kt @@ -1,7 +1,5 @@ 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 @@ -46,18 +44,6 @@ class AuthApiClient @Inject constructor( json.decodeFromString(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(response) - } - suspend fun me(token: String): MeResponse = withContext(Dispatchers.IO) { val response = request( @@ -187,9 +173,3 @@ class AuthApiException( val statusCode: Int, override val message: String ) : Exception(message) - - - - - - diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthModels.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthModels.kt index 542ac50..7aa7d8e 100644 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthModels.kt +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthModels.kt @@ -11,11 +11,6 @@ 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, @@ -68,15 +63,6 @@ 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, @@ -102,7 +88,3 @@ data class AuthSession( val device: AuthDevice? = null, val refreshToken: String? = null ) - - - - diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthRepository.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthRepository.kt index b30c02d..e10ddbc 100644 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthRepository.kt +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthRepository.kt @@ -1,10 +1,7 @@ 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 @@ -13,8 +10,7 @@ import javax.inject.Singleton class AuthRepository @Inject constructor( private val api: AuthApiClient, private val store: AuthSessionStore, - private val deviceIdentity: DeviceIdentity, - private val gpsBufferRepository: GpsBufferRepository + private val deviceIdentity: DeviceIdentity ) { suspend fun login( @@ -38,28 +34,8 @@ 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 } @@ -84,15 +60,6 @@ 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 @@ -110,73 +77,37 @@ class AuthRepository @Inject constructor( return try { - + val response = api.me(token) -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 + AuthSession( + token = token, + user = response.user, + device = response.device ?: response.user.assignedDevice, + refreshToken = refreshToken + ) } catch (e: AuthApiException) { - 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) { + 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() - // Cerrar la sesion local inmediatamente. - store.clear() + try { - // Descartar GPS pendientes del sereno anterior. - gpsBufferRepository.clear() + if (!token.isNullOrBlank()) { + api.logout(token) + } - // Notificar al backend sin impedir el logout local si no hay Internet. - if (!token.isNullOrBlank()) { - runCatching { - api.logout(token) + } finally { + store.clear() } } } -} - - - - - - - - - - - - - - - - - diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthSessionStore.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthSessionStore.kt index 13a5b09..fa74660 100644 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthSessionStore.kt +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthSessionStore.kt @@ -20,7 +20,6 @@ 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") @@ -36,19 +35,12 @@ val userId = stringPreferencesKey("user_id") context.authDataStore.data.map { it[Keys.refreshToken] } -val userId: Flow = - 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 diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/BackendIntegrationManager.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/BackendIntegrationManager.kt index 076ce71..a6c79ed 100644 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/BackendIntegrationManager.kt +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/BackendIntegrationManager.kt @@ -17,14 +17,12 @@ 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 deviceIdentity: DeviceIdentity, - private val gpsBufferRepository: GpsBufferRepository + private val deviceIdentity: DeviceIdentity ) { companion object { private const val TAG = "BackendIntegration" } @@ -35,29 +33,6 @@ class BackendIntegrationManager @Inject constructor( 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 { @@ -116,612 +91,55 @@ 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) -> 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) -> 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 - ) - + httpExecutor.execute { + var conn: HttpURLConnection? = null try { - val userId = runBlocking { - authSessionStore.userId.first() + 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/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() + ) } - 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}" - ) - } + conn.outputStream.use { + it.write(payload.toString().toByteArray(Charsets.UTF_8)) } - } catch (bufferError: Exception) { - - Log.e( - TAG, - "GPS BUFFER ERROR: no se pudo guardar el punto", - bufferError - ) - } - - } finally { - conn?.disconnect() - } - } -} - - - -private fun publishGpsBatch( - locations: List, - 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) + val status = conn.responseCode + if (status in 200..299) { + Log.i(TAG, "GPS HTTPS OK: HTTP $status") } 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") + val errorBody = conn.errorStream + ?.bufferedReader() + ?.use { it.readText() } + .orEmpty() + Log.e(TAG, "GPS HTTPS ERROR: HTTP $status $errorBody") } + } catch (e: Exception) { + Log.e(TAG, "GPS HTTPS ERROR", e) + } finally { + conn?.disconnect() } } } -} - - - - - - fun publishTelemetry(payload: JSONObject) { val serial = deviceIdentity.configuredSerial() if (serial == null) { @@ -756,11 +174,7 @@ fun flushPendingGps() { setRequestProperty("Authorization", "Bearer $token") setRequestProperty( "X-Device-Serial", - serial - ) - setRequestProperty( - "X-Device-Time", - System.currentTimeMillis().toString() + deviceIdentity.requireSerial() ) } @@ -779,13 +193,6 @@ fun flushPendingGps() { .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) diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/device/M530DeviceController.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/device/M530DeviceController.kt index 33a227d..238c515 100644 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/device/M530DeviceController.kt +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/device/M530DeviceController.kt @@ -1,13 +1,11 @@ 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 @@ -49,7 +47,6 @@ class M530DeviceController @Inject constructor( val network: String, val connected: Boolean, val freeStorageMb: Long, - val storageTotalMb: Long, val timestamp: Long = System.currentTimeMillis() ) data class DeviceInfo( @@ -68,29 +65,6 @@ 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", @@ -320,7 +294,12 @@ Result.success(file) fun triggerSos(): SosResult { val location = bestLastKnownLocation()?.takeIf { - System.currentTimeMillis() - it.time in -5_000L..300_000L + System.currentTimeMillis() - it.time in 0..300_000L + } ?: Location("simulated").apply { + latitude = -12.077 + longitude = -77.092 + accuracy = 10f + time = System.currentTimeMillis() } val payload = @@ -434,8 +413,7 @@ Result.success(file) else -> "Sin red" } val freeMb = context.filesDir.usableSpace / (1024L * 1024L) - val totalMb = context.filesDir.totalSpace / (1024L * 1024L) - return TelemetrySnapshot(batteryPercent, charging, networkName, connected, freeMb, totalMb) + return TelemetrySnapshot(batteryPercent, charging, networkName, connected, freeMb) } fun evidenceSummary(): EvidenceSummary { @@ -460,11 +438,11 @@ Result.success(file) fun logGps() { val location = bestLastKnownLocation()?.takeIf { - System.currentTimeMillis() - it.time in -5_000L..300_000L + System.currentTimeMillis() - it.time in 0..300_000L } 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 } @@ -476,12 +454,12 @@ Result.success(file) java.util.Locale.US ).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") - }.format(java.util.Date(location.time)) + }.format(java.util.Date()) val payload = JSONObject() .put("latitude", latitude) .put("longitude", longitude) - .put("speed", if (location.hasSpeed()) location.speed * 3.6 else JSONObject.NULL) + .put("speed", if (location.hasSpeed()) location.speed else JSONObject.NULL) .put("heading", if (location.hasBearing()) location.bearing else JSONObject.NULL) .put("accuracy", location.accuracy) .put("recorded_at", recordedAt) @@ -493,99 +471,20 @@ Result.success(file) 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("storageTotalMb", t.storageTotalMb).put("timestamp", t.timestamp); backend.publishTelemetry(p); return logEvent("TELEMETRY", p) + .put("network", t.network).put("connected", t.connected).put("freeStorageMb", t.freeStorageMb).put("timestamp", t.timestamp); backend.publishTelemetry(p); return logEvent("TELEMETRY", p) } fun notifyVideoStarted(endpoint: String) { backend.publish("VIDEO_STREAM_STARTED", JSONObject().put("whipUrl", endpoint).put("streamId", com.bodycamera.twentyfoulabs.data.backend.BackendConfig.STREAM_ID)) } fun notifyVideoStopped() { backend.publish("VIDEO_STREAM_STOPPED", JSONObject().put("streamId", com.bodycamera.twentyfoulabs.data.backend.BackendConfig.STREAM_ID)) } fun notifyVideoError(error: String) { backend.publish("VIDEO_STREAM_ERROR", JSONObject().put("error", error).put("streamId", com.bodycamera.twentyfoulabs.data.backend.BackendConfig.STREAM_ID)) } - fun release() { - try { mediaRecorder?.stop() } catch (_: Throwable) {} - try { mediaRecorder?.release() } catch (_: Throwable) {} - mediaRecorder = null - pttFile = null - - try { micRecorder?.stop() } catch (_: Throwable) {} - try { micRecorder?.release() } catch (_: Throwable) {} - micRecorder = null - micFile = null - - 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) - } - } + fun release() { try { mediaRecorder?.stop() } catch (_: Throwable) {}; try { mediaRecorder?.release() } catch (_: Throwable) {}; mediaRecorder = null; pttFile = null; try { micRecorder?.stop() } catch (_: Throwable) {}; try { micRecorder?.release() } catch (_: Throwable) {}; micRecorder = null; micFile = null } private fun bestLastKnownLocation(): Location? { - 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 } + if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) return null + val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + return runCatching { lm.getProviders(true).mapNotNull { p -> runCatching { lm.getLastKnownLocation(p) }.getOrNull() }.maxByOrNull { it.time } }.getOrNull() } private fun summarizePayload(payload: JSONObject?): String { @@ -607,12 +506,3 @@ Result.success(file) } private fun timestamp(): String = SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date()) } - - - - - - - - - diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/gps/GpsBufferRepository.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/gps/GpsBufferRepository.kt deleted file mode 100644 index 4ad63b7..0000000 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/gps/GpsBufferRepository.kt +++ /dev/null @@ -1,61 +0,0 @@ -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 = - 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) { - 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() - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/gps/GpsDatabase.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/gps/GpsDatabase.kt deleted file mode 100644 index e57152d..0000000 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/gps/GpsDatabase.kt +++ /dev/null @@ -1,14 +0,0 @@ -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 -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/gps/PendingGpsDao.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/gps/PendingGpsDao.kt deleted file mode 100644 index 39549d9..0000000 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/gps/PendingGpsDao.kt +++ /dev/null @@ -1,46 +0,0 @@ -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 -@Query("SELECT * FROM pending_gps WHERE userId = :userId ORDER BY id ASC LIMIT :limit") -fun getOldestForUser( - userId: Int, - limit: Int = 100 -): List - - @Query("SELECT COUNT(*) FROM pending_gps") - fun count(): Int - - @Query("DELETE FROM pending_gps WHERE id IN (:ids)") - fun deleteByIds(ids: List) - - // 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 -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/gps/PendingGpsEntity.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/gps/PendingGpsEntity.kt deleted file mode 100644 index 3c29d56..0000000 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/gps/PendingGpsEntity.kt +++ /dev/null @@ -1,27 +0,0 @@ -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() -) \ No newline at end of file diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/di/DatabaseModule.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/di/DatabaseModule.kt deleted file mode 100644 index 40f9730..0000000 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/di/DatabaseModule.kt +++ /dev/null @@ -1,34 +0,0 @@ -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() -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/AuthViewModel.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/AuthViewModel.kt index 9d31b36..7f19e7c 100644 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/AuthViewModel.kt +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/AuthViewModel.kt @@ -5,7 +5,6 @@ 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 @@ -32,8 +31,7 @@ sealed interface AuthUiState { @HiltViewModel class AuthViewModel @Inject constructor( - private val repository: AuthRepository, - private val authSessionStore: AuthSessionStore + private val repository: AuthRepository ) : ViewModel() { private val _state = @@ -45,14 +43,6 @@ 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() } diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/MainViewModel.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/MainViewModel.kt index ded9e2f..d537823 100644 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/MainViewModel.kt +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/MainViewModel.kt @@ -9,11 +9,8 @@ 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 @@ -25,7 +22,6 @@ 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 @@ -37,15 +33,12 @@ import javax.inject.Inject @HiltViewModel class MainViewModel @Inject constructor( - repository: NetworkConfigRepository, -networkFallbackManager: NetworkFallbackManager, -private val networkMonitor: NetworkMonitor, -private val adminLockRepository: AdminLockRepository, + repository: NetworkConfigRepository, + networkFallbackManager: NetworkFallbackManager, + 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() { @@ -143,133 +136,20 @@ private fun vibrateRecordingStopped() { val deviceStatus: StateFlow = _deviceStatus.asStateFlow() init { - 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 - ) - } - } + initializeStreamingCallback() + startDeviceStatusLoop() } private fun startDeviceStatusLoop() { viewModelScope.launch(Dispatchers.IO) { - var lastGpsAt = System.currentTimeMillis() - var lastTelemetryAt = System.currentTimeMillis() - var lastSettingsAt = 0L - var lastSosSupportAt = 0L - + var ticks = 0 while (true) { - val token = authSessionStore.token.first() - - if (token.isNullOrBlank()) { - delay(1000) - continue - } - val now = System.currentTimeMillis() - refreshDeviceStatus() - - 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 + if (ticks > 0 && ticks % 60 == 0) { + deviceController.logTelemetry() deviceController.logGps() } - - if (now - lastTelemetryAt >= settings.deviceStatusIntervalSeconds * 1000L) { - lastTelemetryAt = now - deviceController.logTelemetry() - } - + ticks++ delay(5000) } } @@ -297,8 +177,6 @@ private fun vibrateRecordingStopped() { 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") @@ -423,59 +301,33 @@ 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 -> { - if (isConnected) { - Log.e( - TAG, - "Connection established, changing to Active" - ) - StreamingState.Active(600) - } 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 -> { + // Si estaba conectando y ahora esta 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 + } } } - 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) @@ -667,9 +519,3 @@ data class DeviceStatus( val evidence: M530DeviceController.EvidenceSummary? = null, val recentEvents: List = emptyList() ) - - - - - -