diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 889e440..fce476f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -80,6 +80,11 @@ dependencies { kapt("com.google.dagger:hilt-android-compiler:2.48") implementation("androidx.hilt:hilt-navigation-compose:1.1.0") + // Room para buffer GPS offline + implementation("androidx.room:room-runtime:2.6.1") + implementation("androidx.room:room-ktx:2.6.1") + kapt("androidx.room:room-compiler:2.6.1") + // DataStore para persistencia implementation("androidx.datastore:datastore-preferences:1.0.0") @@ -118,3 +123,5 @@ implementation("com.google.mlkit:barcode-scanning:17.3.0") } + + diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainActivity.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainActivity.kt index 7120eac..07342fe 100644 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainActivity.kt +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainActivity.kt @@ -613,8 +613,18 @@ fun AppNavigation(viewModel: MainViewModel, onOpenPhotoCamera: () -> Unit, onOpe val authState by authViewModel.state.collectAsState() LaunchedEffect(authState) { - if (authState is AuthUiState.Authenticated && navController.currentDestination?.route != "main") { - navController.navigate("main") { popUpTo("login") { inclusive = true } } + when (authState) { + is AuthUiState.Authenticated -> { + if (navController.currentDestination?.route != "main") { + navController.navigate("main") { popUpTo("login") { inclusive = true } } + } + } + AuthUiState.LoggedOut -> { + if (navController.currentDestination?.route != "login") { + navController.navigate("login") { popUpTo("main") { inclusive = true } } + } + } + else -> Unit } } 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 e10ddbc..67f3454 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,7 +1,10 @@ package com.bodycamera.twentyfoulabs.data.auth +import android.util.Log + import com.bodycamera.twentyfoulabs.data.backend.BackendConfig import com.bodycamera.twentyfoulabs.data.backend.DeviceIdentity +import com.bodycamera.twentyfoulabs.data.gps.GpsBufferRepository import kotlinx.coroutines.flow.first import javax.inject.Inject import javax.inject.Singleton @@ -10,7 +13,8 @@ import javax.inject.Singleton class AuthRepository @Inject constructor( private val api: AuthApiClient, private val store: AuthSessionStore, - private val deviceIdentity: DeviceIdentity + private val deviceIdentity: DeviceIdentity, + private val gpsBufferRepository: GpsBufferRepository ) { suspend fun login( @@ -34,8 +38,28 @@ class AuthRepository @Inject constructor( refreshToken = response.refreshToken ) + + + + + val previousUserId = store.userId.first() + val newUserId = session.user.id + + if (previousUserId != null && + newUserId != null && + previousUserId != newUserId + ) { + gpsBufferRepository.clear() + } + store.save(session) + + + + + + return session } @@ -60,6 +84,15 @@ class AuthRepository @Inject constructor( refreshToken = response.refreshToken ) + val previousUserId = store.userId.first() + val newUserId = session.user.id + + if (previousUserId != null && + newUserId != null && + previousUserId != newUserId + ) { + gpsBufferRepository.clear() + } store.save(session) return session @@ -77,14 +110,20 @@ class AuthRepository @Inject constructor( return try { - val response = api.me(token) + - AuthSession( - token = token, - user = response.user, - device = response.device ?: response.user.assignedDevice, - refreshToken = refreshToken - ) +val response = api.me(token) + +val session = AuthSession( + token = token, + user = response.user, + device = response.device ?: response.user.assignedDevice, + refreshToken = refreshToken +) + +store.save(session) + +session } catch (e: AuthApiException) { @@ -96,18 +135,21 @@ class AuthRepository @Inject constructor( } } - suspend fun logout() { +suspend fun logout() { - val token = store.token.first() + val token = store.token.first() - try { + // Cerrar la sesion local inmediatamente. + store.clear() - if (!token.isNullOrBlank()) { - api.logout(token) - } + // Descartar GPS pendientes del sereno anterior. + gpsBufferRepository.clear() - } finally { - store.clear() + // Notificar al backend sin impedir el logout local si no hay Internet. + if (!token.isNullOrBlank()) { + runCatching { + api.logout(token) } } } +} 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 fa74660..13a5b09 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,6 +20,7 @@ class AuthSessionStore @Inject constructor( private object Keys { val token = stringPreferencesKey("jwt_token") val refreshToken = stringPreferencesKey("refresh_token") +val userId = stringPreferencesKey("user_id") val userName = stringPreferencesKey("user_name") val userEmail = stringPreferencesKey("user_email") val userRole = stringPreferencesKey("user_role") @@ -35,12 +36,19 @@ class AuthSessionStore @Inject constructor( context.authDataStore.data.map { it[Keys.refreshToken] } +val userId: Flow = + 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 a6c79ed..076ce71 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,12 +17,14 @@ 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 deviceIdentity: DeviceIdentity, + private val gpsBufferRepository: GpsBufferRepository ) { 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 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 { @@ -91,7 +116,15 @@ class BackendIntegrationManager @Inject constructor( 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) -> Unit + ) { httpExecutor.execute { var conn: HttpURLConnection? = null try { @@ -100,46 +133,595 @@ class BackendIntegrationManager @Inject constructor( }?.takeIf { it.isNotBlank() } ?: error("No hay sesion JWT activa") + val serial = deviceIdentity.requireSerial() val url = URL( BackendConfig.API_BASE_URL.trimEnd('/') + - "/mobile/v1/gps_locations" + "/mobile/v1/telemetry_settings" ) conn = (url.openConnection() as HttpURLConnection).apply { - requestMethod = "POST" + requestMethod = "GET" 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-Serial", - deviceIdentity.requireSerial() + "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") + 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() - 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) { - Log.e(TAG, "GPS HTTPS ERROR", e) + 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 + ) + + 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, + onResult: (GpsBatchResult) -> Unit +) { + if (locations.isEmpty()) { + onResult(GpsBatchResult.SENT) + return + } + + require(locations.size <= 100) { + "GPS batch no puede superar 100 puntos" + } + + httpExecutor.execute { + var conn: HttpURLConnection? = null + + try { + val token = runBlocking { + authSessionStore.token.first() + }?.takeIf { it.isNotBlank() } + ?: error("No hay sesion JWT activa") + + val serial = deviceIdentity.requireSerial() + + val body = JSONObject() + .put("locations", org.json.JSONArray(locations)) + + val url = URL( + BackendConfig.API_BASE_URL.trimEnd('/') + + "/mobile/v1/gps_locations/batch" + ) + + conn = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = 15_000 + readTimeout = 30_000 + doOutput = true + + setRequestProperty( + "Content-Type", + "application/json" + ) + setRequestProperty( + "Accept", + "application/json" + ) + setRequestProperty( + "Authorization", + "Bearer $token" + ) + setRequestProperty( + "X-Device-Serial", + serial + ) + setRequestProperty( + "X-Device-Time", + System.currentTimeMillis().toString() + ) + } + + conn.outputStream.use { + it.write( + body.toString() + .toByteArray(Charsets.UTF_8) + ) + } + + val status = conn.responseCode + + if (status == HttpURLConnection.HTTP_CREATED) { + Log.i( + TAG, + "GPS BATCH HTTPS OK: HTTP 201 points=${locations.size}" + ) + onResult(GpsBatchResult.SENT) + } else { + val errorBody = conn.errorStream + ?.bufferedReader() + ?.use { it.readText() } + .orEmpty() + + Log.e( + TAG, + "GPS BATCH HTTPS ERROR: HTTP $status $errorBody" + ) + + if (status == HttpURLConnection.HTTP_FORBIDDEN && isDeviceNotAssigned(errorBody)) { + Log.w(TAG, "GPS BATCH: device_not_assigned; cerrando sesion local") + invalidateSession("device_not_assigned") + onResult(GpsBatchResult.DISCARD) + } else if (status == 422) { + Log.w(TAG, "GPS BATCH: HTTP 422; lote descartado") + onResult(GpsBatchResult.DISCARD) + } else { + onResult(GpsBatchResult.RETRY) + } + } + + } catch (e: Exception) { + Log.e( + TAG, + "GPS BATCH HTTPS ERROR", + e + ) + onResult(GpsBatchResult.RETRY) + + } finally { + conn?.disconnect() + } + } +} + + + + + + + + +fun flushPendingGps() { + httpExecutor.execute { + val currentUserId = runBlocking { + authSessionStore.userId.first() + } + + if (currentUserId == null) { + Log.w( + TAG, + "GPS BUFFER: no se puede sincronizar; userId no disponible" + ) + return@execute + } + + val pending = gpsBufferRepository.getBatch(currentUserId) + + if (pending.isEmpty()) { + Log.i( + TAG, + "GPS BUFFER: no hay puntos pendientes para userId=$currentUserId" + ) + return@execute + } + + val locations = pending.map { + gpsBufferRepository.toJson(it) + } + + Log.i( + TAG, + "GPS BUFFER: enviando ${locations.size} puntos " + + "pendientes para userId=$currentUserId" + ) + + publishGpsBatch(locations) { result -> + when (result) { + GpsBatchResult.SENT -> { + gpsBufferRepository.deleteSent(pending) + Log.i(TAG, "GPS BUFFER: ${pending.size} puntos eliminados despues de HTTP 201") + } + GpsBatchResult.DISCARD -> { + gpsBufferRepository.deleteSent(pending) + Log.w(TAG, "GPS BUFFER: ${pending.size} puntos descartados; no se reintentaran") + } + GpsBatchResult.RETRY -> { + Log.w(TAG, "GPS BUFFER: envio fallido; se conservan ${pending.size} puntos") + } + } + } + } +} + + + + + + fun publishTelemetry(payload: JSONObject) { val serial = deviceIdentity.configuredSerial() if (serial == null) { @@ -174,7 +756,11 @@ class BackendIntegrationManager @Inject constructor( setRequestProperty("Authorization", "Bearer $token") setRequestProperty( "X-Device-Serial", - deviceIdentity.requireSerial() + serial + ) + setRequestProperty( + "X-Device-Time", + System.currentTimeMillis().toString() ) } @@ -193,6 +779,13 @@ class BackendIntegrationManager @Inject constructor( .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 238c515..c1483be 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,4 +1,4 @@ -package com.bodycamera.twentyfoulabs.data.device +package com.bodycamera.twentyfoulabs.data.device import android.Manifest import android.content.Context @@ -6,6 +6,7 @@ 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 @@ -47,6 +48,7 @@ class M530DeviceController @Inject constructor( val network: String, val connected: Boolean, val freeStorageMb: Long, + val storageTotalMb: Long, val timestamp: Long = System.currentTimeMillis() ) data class DeviceInfo( @@ -65,6 +67,26 @@ 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}" + ) + } + + init { + startLocationUpdates() + } + + fun deviceInfo(): DeviceInfo = DeviceInfo( model = Build.MODEL ?: "M530", manufacturer = Build.MANUFACTURER ?: "Android", @@ -294,12 +316,7 @@ Result.success(file) fun triggerSos(): SosResult { val location = bestLastKnownLocation()?.takeIf { - System.currentTimeMillis() - it.time in 0..300_000L - } ?: Location("simulated").apply { - latitude = -12.077 - longitude = -77.092 - accuracy = 10f - time = System.currentTimeMillis() + System.currentTimeMillis() - it.time in -5_000L..300_000L } val payload = @@ -413,7 +430,8 @@ Result.success(file) else -> "Sin red" } val freeMb = context.filesDir.usableSpace / (1024L * 1024L) - return TelemetrySnapshot(batteryPercent, charging, networkName, connected, freeMb) + val totalMb = context.filesDir.totalSpace / (1024L * 1024L) + return TelemetrySnapshot(batteryPercent, charging, networkName, connected, freeMb, totalMb) } fun evidenceSummary(): EvidenceSummary { @@ -438,11 +456,11 @@ Result.success(file) fun logGps() { val location = bestLastKnownLocation()?.takeIf { - System.currentTimeMillis() - it.time in 0..300_000L + System.currentTimeMillis() - it.time in -5_000L..300_000L } if (location == null) { - Log.w(TAG, "GPS: sin ubicación reciente; se omite el envío") + Log.w(TAG, "GPS: sin ubicación reciente; se omite el envío") return } @@ -454,12 +472,12 @@ Result.success(file) java.util.Locale.US ).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") - }.format(java.util.Date()) + }.format(java.util.Date(location.time)) val payload = JSONObject() .put("latitude", latitude) .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("accuracy", location.accuracy) .put("recorded_at", recordedAt) @@ -471,20 +489,99 @@ 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("timestamp", t.timestamp); backend.publishTelemetry(p); return logEvent("TELEMETRY", p) + .put("network", t.network).put("connected", t.connected).put("freeStorageMb", t.freeStorageMb).put("storageTotalMb", t.storageTotalMb).put("timestamp", t.timestamp); backend.publishTelemetry(p); return logEvent("TELEMETRY", p) } fun notifyVideoStarted(endpoint: String) { backend.publish("VIDEO_STREAM_STARTED", JSONObject().put("whipUrl", endpoint).put("streamId", com.bodycamera.twentyfoulabs.data.backend.BackendConfig.STREAM_ID)) } fun notifyVideoStopped() { backend.publish("VIDEO_STREAM_STOPPED", JSONObject().put("streamId", com.bodycamera.twentyfoulabs.data.backend.BackendConfig.STREAM_ID)) } fun notifyVideoError(error: String) { backend.publish("VIDEO_STREAM_ERROR", JSONObject().put("error", error).put("streamId", com.bodycamera.twentyfoulabs.data.backend.BackendConfig.STREAM_ID)) } - fun release() { try { mediaRecorder?.stop() } catch (_: Throwable) {}; try { mediaRecorder?.release() } catch (_: Throwable) {}; mediaRecorder = null; pttFile = null; try { micRecorder?.stop() } catch (_: Throwable) {}; try { micRecorder?.release() } catch (_: Throwable) {}; micRecorder = null; micFile = null } + fun release() { + try { mediaRecorder?.stop() } catch (_: Throwable) {} + try { mediaRecorder?.release() } catch (_: Throwable) {} + mediaRecorder = null + pttFile = null + + try { micRecorder?.stop() } catch (_: Throwable) {} + try { micRecorder?.release() } catch (_: Throwable) {} + micRecorder = null + micFile = null + + stopLocationUpdates() + } + + private fun startLocationUpdates() { + val fineGranted = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + val coarseGranted = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + + if (!fineGranted && !coarseGranted) { + Log.w(TAG, "GPS activo no iniciado: permiso de ubicacion no concedido") + return + } + + try { + if (!locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) { + Log.w(TAG, "GPS activo no iniciado: proveedor GPS deshabilitado") + return + } + + locationManager.requestLocationUpdates( + LocationManager.GPS_PROVIDER, + 1000L, + 0f, + gpsLocationListener + ) + Log.i(TAG, "GPS activo iniciado") + } catch (e: SecurityException) { + Log.e(TAG, "GPS activo: permiso rechazado", e) + } catch (e: Exception) { + Log.e(TAG, "GPS activo: error iniciando actualizaciones", e) + } + } + + private fun stopLocationUpdates() { + runCatching { + locationManager.removeUpdates(gpsLocationListener) + }.onFailure { + Log.w(TAG, "GPS activo: error deteniendo actualizaciones", it) + } + } private fun bestLastKnownLocation(): Location? { - if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED && - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) return null - val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager - return runCatching { lm.getProviders(true).mapNotNull { p -> runCatching { lm.getLastKnownLocation(p) }.getOrNull() }.maxByOrNull { it.time } }.getOrNull() + val fineGranted = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + val coarseGranted = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + + if (!fineGranted && !coarseGranted) return null + + val activeFix = latestGpsLocation + val providerFix = runCatching { + locationManager.getProviders(true) + .mapNotNull { provider -> + runCatching { + locationManager.getLastKnownLocation(provider) + }.getOrNull() + } + .maxByOrNull { it.time } + }.getOrNull() + + return listOfNotNull(activeFix, providerFix) + .maxByOrNull { it.time } } private fun summarizePayload(payload: JSONObject?): String { @@ -506,3 +603,9 @@ 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 new file mode 100644 index 0000000..4ad63b7 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/gps/GpsBufferRepository.kt @@ -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 = + 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 new file mode 100644 index 0000000..e57152d --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/gps/GpsDatabase.kt @@ -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 +} \ 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 new file mode 100644 index 0000000..39549d9 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/gps/PendingGpsDao.kt @@ -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 +@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 new file mode 100644 index 0000000..3c29d56 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/gps/PendingGpsEntity.kt @@ -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() +) \ 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 new file mode 100644 index 0000000..40f9730 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/di/DatabaseModule.kt @@ -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() +} \ 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 7f19e7c..9d31b36 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,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.bodycamera.twentyfoulabs.data.auth.AuthApiException import com.bodycamera.twentyfoulabs.data.auth.AuthRepository import com.bodycamera.twentyfoulabs.data.auth.AuthSession +import com.bodycamera.twentyfoulabs.data.auth.AuthSessionStore import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -31,7 +32,8 @@ sealed interface AuthUiState { @HiltViewModel class AuthViewModel @Inject constructor( - private val repository: AuthRepository + private val repository: AuthRepository, + private val authSessionStore: AuthSessionStore ) : ViewModel() { private val _state = @@ -43,6 +45,14 @@ class AuthViewModel @Inject constructor( _state.asStateFlow() init { + viewModelScope.launch { + authSessionStore.token.collect { token -> + if (token.isNullOrBlank() && _state.value is AuthUiState.Authenticated) { + _state.value = AuthUiState.LoggedOut + } + } + } + restoreSession() } 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 d537823..366a996 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,8 +9,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.bodycamera.twentyfoulabs.data.model.NetworkConfiguration import com.bodycamera.twentyfoulabs.data.device.M530DeviceController +import com.bodycamera.twentyfoulabs.data.backend.BackendIntegrationManager +import com.bodycamera.twentyfoulabs.data.auth.AuthSessionStore import com.bodycamera.twentyfoulabs.data.model.VideoConfiguration import com.bodycamera.twentyfoulabs.data.network.NetworkFallbackManager +import com.bodycamera.twentyfoulabs.data.network.NetworkMonitor import com.bodycamera.twentyfoulabs.data.repository.AdminLockRepository import com.bodycamera.twentyfoulabs.data.repository.NetworkConfigRepository import com.bodycamera.twentyfoulabs.data.repository.VideoConfigRepository @@ -22,6 +25,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -33,12 +37,15 @@ import javax.inject.Inject @HiltViewModel class MainViewModel @Inject constructor( - repository: NetworkConfigRepository, - networkFallbackManager: NetworkFallbackManager, - private val adminLockRepository: AdminLockRepository, + repository: NetworkConfigRepository, +networkFallbackManager: NetworkFallbackManager, +private val networkMonitor: NetworkMonitor, +private val adminLockRepository: AdminLockRepository, private val videoConfigRepository: VideoConfigRepository, private val webrtcManager: WebRTCManager, private val deviceController: M530DeviceController, + private val backendIntegrationManager: BackendIntegrationManager, + private val authSessionStore: AuthSessionStore, @ApplicationContext private val appContext: Context ) : ViewModel() { @@ -136,20 +143,129 @@ private fun vibrateRecordingStopped() { val deviceStatus: StateFlow = _deviceStatus.asStateFlow() init { - initializeStreamingCallback() - startDeviceStatusLoop() + initializeStreamingCallback() + startDeviceStatusLoop() + + viewModelScope.launch(Dispatchers.IO) { + var hadInternet = networkMonitor.hasInternet() + var firstEmission = true + + networkMonitor.observeNetworkStatus().collect { status -> + val hasInternet = status.hasInternet + + if (hasInternet && (firstEmission || !hadInternet)) { + Log.i( + TAG, + if (firstEmission) { + "Internet disponible al iniciar: sincronizando buffer GPS pendiente" + } else { + "Internet recuperado: sincronizando buffer GPS pendiente" + } + ) + + backendIntegrationManager.flushPendingGps() + } + + hadInternet = hasInternet + firstEmission = false + } +} +} + + @Volatile + private var telemetrySettings = BackendIntegrationManager.TelemetrySettings() + + private fun refreshTelemetrySettings() { + backendIntegrationManager.fetchTelemetrySettings { result -> + result.onSuccess { settings -> + telemetrySettings = settings + Log.i( + TAG, + "Cadencias actualizadas: gps=${settings.intervalSeconds}s, " + + "sos=${settings.sosIntervalSeconds}s, " + + "device=${settings.deviceStatusIntervalSeconds}s" + ) + }.onFailure { error -> + Log.w( + TAG, + "No se pudo actualizar telemetry_settings; se conserva la ultima configuracion", + error + ) + } + } + } + + @Volatile + private var centralSosCadenceActive = false + + private fun refreshSosSupportState() { + backendIntegrationManager.fetchSosSupportState { result -> + result.onSuccess { state -> + val previous = centralSosCadenceActive + centralSosCadenceActive = state.isSosCadenceActive + if (previous != centralSosCadenceActive) { + Log.i( + TAG, + "SOS central actualizado: active=$centralSosCadenceActive " + + "own=${state.ownSosActive} support=${state.supportRequestActive}" + ) + } + }.onFailure { error -> + Log.w( + TAG, + "No se pudo consultar SOS central; se conserva el ultimo estado", + error + ) + } + } } private fun startDeviceStatusLoop() { viewModelScope.launch(Dispatchers.IO) { - var ticks = 0 + var lastGpsAt = System.currentTimeMillis() + var lastTelemetryAt = System.currentTimeMillis() + var lastSettingsAt = 0L + var lastSosSupportAt = 0L + while (true) { + val token = authSessionStore.token.first() + + if (token.isNullOrBlank()) { + delay(1000) + continue + } + val now = System.currentTimeMillis() + refreshDeviceStatus() - if (ticks > 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() } - ticks++ + + if (now - lastTelemetryAt >= settings.deviceStatusIntervalSeconds * 1000L) { + lastTelemetryAt = now + deviceController.logTelemetry() + } + delay(5000) } } @@ -177,6 +293,8 @@ 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") @@ -301,33 +419,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) { - Log.e(TAG, "Connection established, changing to Active") - StreamingState.Active(600) - } else { - _streamingState.value - } - } - is StreamingState.Active -> { - if (!isConnected) { - Log.e(TAG, "Connection lost, changing to Connecting") - StreamingState.Connecting - } else { - _streamingState.value - } - } - else -> _streamingState.value - } + + + + + + + +override fun onConnectionChange(isConnected: Boolean) { + Log.e( + TAG, + "Connection changed: $isConnected, current state: ${_streamingState.value}" + ) + + + + mainImmediateScope.launch { + if (!streamingRequested) return@launch + + _streamingState.value = when (_streamingState.value) { + + is StreamingState.Connecting -> { + if (isConnected) { + Log.e( + TAG, + "Connection established, changing to Active" + ) + StreamingState.Active(600) + } else { + _streamingState.value } } + is StreamingState.Active -> { + if (!isConnected) { + Log.e( + TAG, + "Connection lost, changing to Connecting" + ) + StreamingState.Connecting + } else { + _streamingState.value + } + } + + else -> _streamingState.value + } + } +} + + + + + override fun onError(error: String) { Log.e(TAG, "Streaming error: $error - forcing media cleanup") deviceController.notifyVideoError(error) @@ -519,3 +663,8 @@ data class DeviceStatus( val evidence: M530DeviceController.EvidenceSummary? = null, val recentEvents: List = emptyList() ) + + + + +