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..7b57b9e 100644 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainActivity.kt +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainActivity.kt @@ -1,4 +1,4 @@ -package com.bodycamera.twentyfoulabs +package com.bodycamera.twentyfoulabs import android.Manifest import android.content.BroadcastReceiver @@ -275,7 +275,7 @@ class MainActivity : ComponentActivity() { pttManager.stop() } - if (false && isAuthenticated && !frontPreviewOpened) { + if (isAuthenticated && !frontPreviewOpened) { frontPreviewOpened = true runOnUiThread { openFrontPreview() @@ -479,18 +479,10 @@ class MainActivity : ComponentActivity() { ) } private fun handleSosButton() { - // F7 alterna SOS/DETENER. La misma orden de parada que usa la pantalla. - if (viewModel.isSosSessionActive()) { - Log.i(TAG, "F7 DETENER SOS: solicitando cierre de WebRTC") - viewModel.stopVideoFromUi() - toast("Deteniendo video SOS...") - return - } - Log.i(TAG, "F7 INICIAR SOS") toast("Enviando SOS...") - viewModel.triggerSosFromUi { result -> + deviceController.triggerSos { result -> runOnUiThread { @@ -613,18 +605,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 } } } } @@ -651,8 +633,6 @@ fun AppNavigation(viewModel: MainViewModel, onOpenPhotoCamera: () -> Unit, onOpe onOpenPhotoCamera = onOpenPhotoCamera, onOpenVideoCamera = onOpenVideoCamera, onLogout = { - // Liberar WebRTC antes de mostrar nuevamente el lector del fotocheck. - viewModel.stopVideoFromUi() authViewModel.logout() navController.navigate("login") { popUpTo("main") { inclusive = true } } } 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..94de5cf 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,9 +1,6 @@ package com.bodycamera.twentyfoulabs.data.auth -import android.util.Log - import com.bodycamera.twentyfoulabs.data.backend.BackendConfig -import com.bodycamera.twentyfoulabs.data.backend.DeviceIdentity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.decodeFromString @@ -15,9 +12,7 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class AuthApiClient @Inject constructor( - private val deviceIdentity: DeviceIdentity -) { +class AuthApiClient @Inject constructor() { private val json = Json { ignoreUnknownKeys = true @@ -46,18 +41,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( @@ -113,7 +96,7 @@ class AuthApiClient @Inject constructor( setRequestProperty( "X-Device-Serial", - deviceIdentity.requireSerial() + BackendConfig.DEVICE_SERIAL ) if (!token.isNullOrBlank()) { @@ -187,9 +170,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..52df2d5 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,6 @@ 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 @@ -12,9 +8,7 @@ import javax.inject.Singleton @Singleton class AuthRepository @Inject constructor( private val api: AuthApiClient, - private val store: AuthSessionStore, - private val deviceIdentity: DeviceIdentity, - private val gpsBufferRepository: GpsBufferRepository + private val store: AuthSessionStore ) { suspend fun login( @@ -26,7 +20,7 @@ class AuthRepository @Inject constructor( LoginRequest( email = email.trim(), password = password, - deviceSerial = deviceIdentity.requireSerial(), + deviceSerial = BackendConfig.DEVICE_SERIAL, deviceModel = BackendConfig.DEVICE_MODEL ) ) @@ -38,28 +32,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 } @@ -72,7 +46,7 @@ class AuthRepository @Inject constructor( QrLoginRequest( qrToken = qrToken.trim(), pin = pin.trim(), - deviceSerial = deviceIdentity.requireSerial(), + deviceSerial = BackendConfig.DEVICE_SERIAL, deviceModel = BackendConfig.DEVICE_MODEL ) ) @@ -84,15 +58,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 +75,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/BackendConfig.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/BackendConfig.kt index b1e4d2d..393967d 100644 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/BackendConfig.kt +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/BackendConfig.kt @@ -1,48 +1,20 @@ -package com.bodycamera.twentyfoulabs.data.backend +package com.bodycamera.twentyfoulabs.data.backend -/** Centralized endpoints for the M530 APK. */ +/** Centralized endpoints for the M530 PoC. */ object BackendConfig { - - // New GeoSatelital infrastructure. - const val API_BASE_URL = "https://bodycam.geosatelital.red" - - const val MEDIAMTX_HOST = "172.93.110.24" - const val MEDIAMTX_WEBRTC_PORT = 18889 - const val MEDIAMTX_HLS_PORT = 18888 - const val MEDIAMTX_ICE_PORT = 18189 - - const val MEDIAMTX_BASE_URL = - "http://172.93.110.24:18889" - - const val MEDIAMTX_HLS_BASE_URL = - "http://172.93.110.24:18888" - - // Device identification. - const val DEVICE_MODEL = "Recoda M530" - - // Legacy constants retained until dependent components are migrated. + // Local LAN services used by MQTT and MediaMTX during the current PoC. const val HOST = "192.168.18.57" const val MQTT_PORT = 1883 const val HTTP_PORT = 8000 const val MQTT_CLIENT_PREFIX = "m530" const val STREAM_ID = "webrtc_camera_stream" - val httpBaseUrl: String - get() = "http://$HOST:$HTTP_PORT" + // Rails mobile API exposed through ngrok HTTPS. + const val API_BASE_URL = "https://bodycam-prd-web-nefjf74quq-uc.a.run.app" + const val DEVICE_SERIAL = "BC-M530-001" + const val DEVICE_MODEL = "Recoda M530" - val whipUrl: String - get() = "$MEDIAMTX_BASE_URL/$STREAM_ID/whip" - - // MediaMTX paths based on the authenticated user. - fun sosWhipUrl(userId: Long): String = - "$MEDIAMTX_BASE_URL/sos/u/$userId/whip" - - fun sosWhepUrl(userId: Long): String = - "$MEDIAMTX_BASE_URL/sos/u/$userId/whep" - - fun pttWhipUrl(userId: Long): String = - "$MEDIAMTX_BASE_URL/ptt/u/$userId/whip" - - fun pttWhepUrl(userId: Long): String = - "$MEDIAMTX_BASE_URL/ptt/u/$userId/whep" + val httpBaseUrl: String get() = "http://$HOST:$HTTP_PORT" + val whipUrl: String get() = "http://$HOST:8889/$STREAM_ID/whip" } + 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..3da381f 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,47 +17,21 @@ 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 authSessionStore: AuthSessionStore ) { companion object { private const val TAG = "BackendIntegration" } private val mqttExecutor = Executors.newSingleThreadExecutor() private val httpExecutor = Executors.newSingleThreadExecutor() private val mqtt = SimpleMqttPublisher(BackendConfig.HOST, BackendConfig.MQTT_PORT) - private val deviceId: String by lazy { sanitize(deviceIdentity.requireSerial()) } + private val deviceId: String by lazy { sanitize(runCatching { Build.getSerial() }.getOrNull() ?: Build.MODEL ?: "M530") } 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,683 +90,12 @@ 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 - ) - - 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) { - Log.w(TAG, "Telemetria omitida: serial aun no configurado") - return - } - val envelope = JSONObject() - .put("deviceId", serial) + .put("deviceId", deviceId) .put("timestamp", System.currentTimeMillis()) .put("payload", payload) - httpExecutor.execute { - var conn: HttpURLConnection? = null - try { - val token = runBlocking { - authSessionStore.token.first() - }?.takeIf { it.isNotBlank() } - ?: error("No hay sesion JWT activa") - - val url = URL( - BackendConfig.API_BASE_URL.trimEnd('/') + - "/mobile/v1/devices/telemetry" - ) - - conn = (url.openConnection() as HttpURLConnection).apply { - requestMethod = "POST" - connectTimeout = 15_000 - readTimeout = 30_000 - doOutput = true - setRequestProperty("Content-Type", "application/json") - setRequestProperty("Accept", "application/json") - setRequestProperty("Authorization", "Bearer $token") - setRequestProperty( - "X-Device-Serial", - serial - ) - setRequestProperty( - "X-Device-Time", - System.currentTimeMillis().toString() - ) - } - - conn.outputStream.use { - it.write(envelope.toString().toByteArray(Charsets.UTF_8)) - } - - val status = conn.responseCode - - if (status in 200..299) { - Log.i(TAG, "TELEMETRY HTTPS OK: HTTP $status") - } else { - val errorBody = conn.errorStream - ?.bufferedReader() - ?.use { it.readText() } - .orEmpty() - - Log.e(TAG, "TELEMETRY HTTPS ERROR: HTTP $status $errorBody") - - if (status == HttpURLConnection.HTTP_FORBIDDEN && isDeviceNotAssigned(errorBody)) { - Log.w(TAG, "TELEMETRY: device_not_assigned; cerrando sesion local") - invalidateSession("device_not_assigned") - } else if (status == HttpURLConnection.HTTP_NOT_FOUND) { - Log.w(TAG, "TELEMETRY: HTTP 404; telemetria descartada, sin reintento") - } - } - } catch (e: Exception) { - Log.e(TAG, "TELEMETRY HTTPS ERROR", e) - } finally { - conn?.disconnect() - } - } + enqueuePublish("m530/$deviceId/telemetry", envelope) } fun uploadMedia(file: File, kind: String, metadata: JSONObject = JSONObject()) { @@ -814,16 +117,24 @@ fun flushPendingGps() { "FAVORITE ultimo media_item guardado: id=$uploadedMediaId" ) } - Log.i( - TAG, - "MEDIA HTTPS OK: archivo=${file.name}, tipo=$kind, id=${uploadedMediaId ?: "sin_id"}" - ) + val mediaPayload = JSONObject() + .put("kind", kind) + .put("fileName", file.name) + .put("sizeBytes", file.length()) + .put("localPath", file.absolutePath) + .put("server", response) + val event = JSONObject() + .put("eventId", UUID.randomUUID().toString()) + .put("deviceId", deviceId) + .put("type", "MEDIA_UPLOADED") + .put("timestamp", System.currentTimeMillis()) + .put("payload", mediaPayload) + publishNow("m530/$deviceId/media/$kind", event) } catch (t: Throwable) { - Log.e( - TAG, - "MEDIA HTTPS ERROR: archivo=${file.name}, tipo=$kind", - t - ) + Log.e(TAG, "Upload failed: ${file.name}", t) + publishNow("m530/$deviceId/events/media_upload_failed", JSONObject() + .put("deviceId", deviceId).put("timestamp", System.currentTimeMillis()) + .put("fileName", file.name).put("kind", kind).put("error", t.message ?: "unknown")) } } } @@ -889,7 +200,7 @@ fun flushPendingGps() { setRequestProperty( "X-Device-Serial", - deviceIdentity.requireSerial() + BackendConfig.DEVICE_SERIAL ) setRequestProperty( @@ -935,7 +246,23 @@ fun flushPendingGps() { ) } - + publishNow( + "m530/$deviceId/events/media_bookmarked", + JSONObject() + .put("deviceId", deviceId) + .put( + "timestamp", + System.currentTimeMillis() + ) + .put( + "mediaItemId", + mediaId + ) + .put( + "isBookmarked", + true + ) + ) callback( Result.success(mediaId) @@ -964,8 +291,7 @@ fun flushPendingGps() { fun sendSosAlert( latitude: Double?, longitude: Double?, - callback: (Result) -> Unit, - onVideoTarget: ((String, String) -> Unit)? = null + callback: (Result) -> Unit ) { httpExecutor.execute { @@ -1015,7 +341,7 @@ fun flushPendingGps() { setRequestProperty( "X-Device-Serial", - deviceIdentity.requireSerial() + BackendConfig.DEVICE_SERIAL ) setRequestProperty( @@ -1031,7 +357,7 @@ fun flushPendingGps() { .put("notes", "Boton panico F7") .put( "device_serial", - deviceIdentity.requireSerial() + BackendConfig.DEVICE_SERIAL ) .toString() @@ -1101,19 +427,6 @@ fun flushPendingGps() { ?: error( "Respuesta SOS sin ID de alerta" ) - val liveStream = responseJson - .optJSONObject("alert") - ?.optJSONObject("live_stream") - - val streamPath = liveStream?.optString("path").orEmpty() - val whipUrl = liveStream?.optString("whip_url").orEmpty() - - Log.i(TAG, "SOS video path=$streamPath WHIP=$whipUrl") - - if (Regex("^sos/u/[1-9][0-9]*$").matches(streamPath)) { - onVideoTarget?.invoke(streamPath, whipUrl) - } - Log.i( TAG, @@ -1282,7 +595,7 @@ fun flushPendingGps() { setRequestProperty( "X-Device-Serial", - deviceIdentity.requireSerial() + BackendConfig.DEVICE_SERIAL ) setRequestProperty( diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/DeviceIdentity.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/DeviceIdentity.kt deleted file mode 100644 index ecadb59..0000000 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/DeviceIdentity.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.bodycamera.twentyfoulabs.data.backend - -import android.content.Context -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class DeviceIdentity @Inject constructor( - @ApplicationContext private val context: Context -) { - private val preferences = context.getSharedPreferences( - "device_identity", - Context.MODE_PRIVATE - ) - - fun configuredSerial(): String? = - preferences.getString("device_serial", null) - ?.trim() - ?.takeIf { it.isNotEmpty() } - - fun requireSerial(): String = - configuredSerial() - ?: error("El dispositivo no tiene un serial configurado") - - fun configureSerial(serial: String) { - val normalized = serial.trim() - - require(Regex("^M530[A-Z0-9]+$").matches(normalized)) { - "Formato de serial no válido" - } - - check(configuredSerial() == null || configuredSerial() == normalized) { - "El dispositivo ya tiene un serial diferente" - } - - check( - preferences.edit() - .putString("device_serial", normalized) - .commit() - ) { - "No se pudo guardar el serial" - } - } -} \ No newline at end of file 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..49ac878 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 @@ -24,7 +22,6 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner import dagger.hilt.android.qualifiers.ApplicationContext import com.bodycamera.twentyfoulabs.data.backend.BackendIntegrationManager -import com.bodycamera.twentyfoulabs.data.backend.DeviceIdentity import org.json.JSONObject import java.io.File import java.text.SimpleDateFormat @@ -37,8 +34,7 @@ import javax.inject.Singleton @Singleton class M530DeviceController @Inject constructor( @ApplicationContext private val context: Context, - private val backend: BackendIntegrationManager, - private val deviceIdentity: DeviceIdentity + private val backend: BackendIntegrationManager ) { companion object { private const val TAG = "M530DeviceController" } @@ -49,7 +45,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,35 +63,12 @@ class M530DeviceController @Inject constructor( private var micFile: File? = null private val ioExecutor: Executor get() = ContextCompat.getMainExecutor(context) - @Volatile - private var latestGpsLocation: Location? = null - - private val locationManager: LocationManager by lazy { - context.getSystemService(Context.LOCATION_SERVICE) as LocationManager - } - - private val gpsLocationListener = LocationListener { location -> - latestGpsLocation = location - Log.i( - TAG, - "GPS fix real recibido: accuracy=${location.accuracy}m time=${location.time}" - ) - } - - private val powerReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == Intent.ACTION_POWER_CONNECTED || intent?.action == Intent.ACTION_POWER_DISCONNECTED) { Log.i(TAG, "Cambio de cargador detectado: ${intent.action}"); logTelemetry() } } } - - init { - startLocationUpdates() - context.registerReceiver(powerReceiver, IntentFilter().apply { addAction(Intent.ACTION_POWER_CONNECTED); addAction(Intent.ACTION_POWER_DISCONNECTED) }) - } - - fun deviceInfo(): DeviceInfo = DeviceInfo( model = Build.MODEL ?: "M530", manufacturer = Build.MANUFACTURER ?: "Android", androidVersion = Build.VERSION.RELEASE ?: "12", sdk = Build.VERSION.SDK_INT, - serial = deviceIdentity.configuredSerial() ?: "Sin configurar" + serial = runCatching { Build.getSerial() }.getOrDefault("M530") ) fun capturePhoto(lifecycleOwner: LifecycleOwner, callback: (Result) -> Unit) { @@ -114,13 +86,7 @@ class M530DeviceController @Inject constructor( capture.takePicture(ImageCapture.OutputFileOptions.Builder(outputFile).build(), ioExecutor, object : ImageCapture.OnImageSavedCallback { override fun onImageSaved(r: ImageCapture.OutputFileResults) { - logEvent( - "PHOTO_CAPTURED", - JSONObject().put("path", outputFile.absolutePath) - ) - backend.uploadMedia(outputFile, "photo") - provider.unbindAll() - callback(Result.success(outputFile)) + logEvent("PHOTO_CAPTURED", JSONObject().put("path", outputFile.absolutePath)); backend.publish("PHOTO_CAPTURED", JSONObject().put("fileName", outputFile.name).put("sizeBytes", outputFile.length())); backend.uploadMedia(outputFile, "photo"); provider.unbindAll(); callback(Result.success(outputFile)) } override fun onError(e: ImageCaptureException) { logEvent("PHOTO_ERROR", JSONObject().put("error", e.message ?: "unknown")); provider.unbindAll(); callback(Result.failure(e)) @@ -319,9 +285,12 @@ Result.success(file) micRecorder != null fun triggerSos(): SosResult { - val location = bestLastKnownLocation()?.takeIf { - System.currentTimeMillis() - it.time in -5_000L..300_000L - } + val location = + Location("M530").apply { + latitude = -12.077 + longitude = -77.092 + accuracy = 10f + } val payload = JSONObject() @@ -340,15 +309,15 @@ Result.success(file) ) .put( "latitude", - location?.latitude ?: JSONObject.NULL + location.latitude ) .put( "longitude", - location?.longitude ?: JSONObject.NULL + location.longitude ) .put( "accuracy", - location?.accuracy ?: JSONObject.NULL + location.accuracy ) backend.publish( @@ -365,22 +334,6 @@ Result.success(file) ) } - fun triggerSosWithVideo( - callback: (Result) -> Unit, - onVideoTarget: (String, String) -> Unit - ): SosResult { - val result = triggerSos() - - backend.sendSosAlert( - result.location?.latitude, - result.location?.longitude, - callback, - onVideoTarget - ) - - return result - } - fun triggerSos( callback: (Result) -> Unit ): SosResult { @@ -434,8 +387,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 { @@ -458,134 +410,23 @@ Result.success(file) }.getOrDefault(emptyList()) } - fun logGps() { - val location = bestLastKnownLocation()?.takeIf { - System.currentTimeMillis() - it.time in -5_000L..300_000L - } - - if (location == null) { - Log.w(TAG, "GPS: sin ubicación reciente; se omite el envío") - return - } - - val latitude = location.latitude - val longitude = location.longitude - - val recordedAt = java.text.SimpleDateFormat( - "yyyy-MM-dd'T'HH:mm:ss'Z'", - java.util.Locale.US - ).apply { - timeZone = java.util.TimeZone.getTimeZone("UTC") - }.format(java.util.Date(location.time)) - - val payload = JSONObject() - .put("latitude", latitude) - .put("longitude", longitude) - .put("speed", if (location.hasSpeed()) location.speed * 3.6 else JSONObject.NULL) - .put("heading", if (location.hasBearing()) location.bearing else JSONObject.NULL) - .put("accuracy", location.accuracy) - .put("recorded_at", recordedAt) - - backend.publishGps(payload) - - Log.i(TAG, "GPS: envio HTTPS solicitado") - } fun logTelemetry(): File { val t = telemetry() val p = JSONObject().put("batteryPercent", t.batteryPercent).put("charging", t.charging) - .put("network", t.network).put("connected", t.connected).put("freeStorageMb", t.freeStorageMb).put("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 +448,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/data/model/VideoConfiguration.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/VideoConfiguration.kt index 2f0d4bb..9e81870 100644 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/VideoConfiguration.kt +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/model/VideoConfiguration.kt @@ -19,8 +19,8 @@ data class VideoConfiguration( */ @Serializable data class WebRTCServerConfig( - val serverUrl: String = "172.93.110.24", - val serverPort: Int = 18889, + val serverUrl: String = "192.168.18.57", + val serverPort: Int = 8889, val streamName: String = "webrtc_camera_stream", val username: String = "admin", val password: String = "admin", diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/ptt/PttApiClient.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/ptt/PttApiClient.kt index f46cbad..92ab41c 100644 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/ptt/PttApiClient.kt +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/ptt/PttApiClient.kt @@ -1,7 +1,6 @@ package com.bodycamera.twentyfoulabs.data.ptt import com.bodycamera.twentyfoulabs.data.backend.BackendConfig -import com.bodycamera.twentyfoulabs.data.backend.DeviceIdentity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONObject @@ -11,9 +10,7 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class PttApiClient @Inject constructor( - private val deviceIdentity: DeviceIdentity -) { +class PttApiClient @Inject constructor() { suspend fun joinRoom( token: String, @@ -49,7 +46,7 @@ class PttApiClient @Inject constructor( setRequestProperty( "X-Device-Serial", - deviceIdentity.requireSerial() + BackendConfig.DEVICE_SERIAL ) } diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/ptt/PttAudioClient.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/ptt/PttAudioClient.kt index 0119af9..dc16712 100644 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/ptt/PttAudioClient.kt +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/ptt/PttAudioClient.kt @@ -39,7 +39,7 @@ class PttAudioClient @Inject constructor( private const val TAG = "PttAudioClient" private const val MEDIA_BASE_URL = - "http://172.93.110.24:18889" + "http://35.224.197.127:8889" private const val STUN_URL = "stun:stun.l.google.com:19302" diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/ptt/PttCableClient.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/ptt/PttCableClient.kt index bb7dd33..57db518 100644 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/ptt/PttCableClient.kt +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/ptt/PttCableClient.kt @@ -19,7 +19,7 @@ class PttCableClient @Inject constructor() { companion object { private const val TAG = "PttCableClient" private const val CABLE_URL = - "wss://bodycam.geosatelital.red/cable" + "wss://bodycam-prd-web-nefjf74quq-uc.a.run.app/cable" private const val RECONNECT_DELAY_MS = 3000L } diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/webrtc/WebRTCManager.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/webrtc/WebRTCManager.kt index dc9db28..e3fb9ba 100644 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/webrtc/WebRTCManager.kt +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/webrtc/WebRTCManager.kt @@ -9,9 +9,6 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import java.util.concurrent.atomic.AtomicLong import org.webrtc.* import java.net.HttpURLConnection import java.net.URL @@ -31,39 +28,12 @@ class WebRTCManager @Inject constructor( } private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val streamMutex = Mutex() - private val streamGeneration = AtomicLong(0) - @Volatile private var activeGeneration = -1L - @Volatile private var releasing = false private var peerConnectionFactory: PeerConnectionFactory? = null private var peerConnection: PeerConnection? = null private var videoSource: VideoSource? = null - private var audioSource: AudioSource? = null - private var localAudioTrack: AudioTrack? = null private var cameraVideoCapturer: CameraVideoCapturer? = null private var localVideoTrack: VideoTrack? = null - private val previewLock = Any() - private var previewSink: VideoSink? = null - - fun previewEglContext(): EglBase.Context? = eglBase?.eglBaseContext - - fun attachPreviewSink(sink: VideoSink) { - synchronized(previewLock) { - previewSink?.let { old -> localVideoTrack?.removeSink(old) } - previewSink = sink - localVideoTrack?.addSink(sink) - } - } - - fun detachPreviewSink(sink: VideoSink) { - synchronized(previewLock) { - if (previewSink === sink) { - localVideoTrack?.removeSink(sink) - previewSink = null - } - } - } private var surfaceTextureHelper: SurfaceTextureHelper? = null private var eglBase: EglBase? = null private var signalingClient: SignalingClient? = null @@ -173,50 +143,48 @@ class WebRTCManager @Inject constructor( * Inicia el streaming de video real */ fun startStreaming(config: VideoConfiguration) { - if (releasing) return - val generation = streamGeneration.incrementAndGet() scope.launch { - streamMutex.withLock { - if (releasing || generation != streamGeneration.get()) return@withLock - try { - // Nunca iniciar una segunda cámara mientras se limpia la anterior. - cleanupStream() - if (releasing || generation != streamGeneration.get()) return@withLock - val server = config.webrtcServer - if (server.serverUrl.isBlank()) { - callback?.onError("URL del servidor está vacía") - return@withLock - } - activeGeneration = generation - val serverUrl = "http://${server.serverUrl}:${server.serverPort}" - Log.i(TAG, "Starting streaming to: $serverUrl/${server.streamName}/whip") - signalingClient = SignalingClient( - serverUrl = serverUrl, - streamName = server.streamName, - username = server.username, - password = server.password - ) - createPeerConnection(config) - if (generation != streamGeneration.get()) { - cleanupStream() - return@withLock - } - startCameraCapture(config) - if (generation != streamGeneration.get()) { - cleanupStream() - return@withLock - } - if (config.video.autoStopTimeSeconds > 0) { - startAutoStopTimer(config.video.autoStopTimeSeconds) - } - if (generation == streamGeneration.get()) callback?.onStreamStarted() - } catch (e: Exception) { - Log.e(TAG, "Error starting streaming", e) - cleanupStream() - if (generation == streamGeneration.get()) { - callback?.onError("Error al iniciar streaming: ${e.message}") - } + try { + val server = config.webrtcServer + + if (server.serverUrl.isBlank()) { + callback?.onError("URL del servidor está vacía") + return@launch } + + // Detener cualquier streaming activo antes de iniciar uno nuevo + if (cameraVideoCapturer != null || peerConnection != null) { + Log.e(TAG, "Stopping active stream before starting new one") + stopStreaming() + delay(500) // Esperar a que se limpien los recursos + } + + val serverUrl = "http://${server.serverUrl}:${server.serverPort}" + Log.e(TAG, "Starting streaming to: $serverUrl/${server.streamName}/whip") + + // Crear SignalingClient + signalingClient = SignalingClient( + serverUrl = serverUrl, + streamName = server.streamName, + username = server.username, + password = server.password + ) + + // Crear PeerConnection + createPeerConnection(config) + + // Iniciar captura de cámara + startCameraCapture(config) + + // Iniciar timer de auto-stop + if (config.video.autoStopTimeSeconds > 0) { + startAutoStopTimer(config.video.autoStopTimeSeconds) + } + + callback?.onStreamStarted() + } catch (e: Exception) { + Log.e(TAG, "Error starting streaming", e) + callback?.onError("Error al iniciar streaming: ${e.message}") } } } @@ -370,8 +338,8 @@ class WebRTCManager @Inject constructor( override fun onFirstFrameAvailable() { Log.e(TAG, "First frame available - Creating SDP offer") - // Un primer frame tardío no puede reactivar una sesión detenida. - if (activeGeneration == streamGeneration.get() && !releasing) createOffer() + // Después que la cámara está lista, crear la oferta SDP + createOffer() } override fun onCameraClosed() { @@ -399,31 +367,19 @@ class WebRTCManager @Inject constructor( capturerObserver ) - // Crear y conectar el track antes del primer frame y de la oferta SDP. - synchronized(previewLock) { - localVideoTrack = peerConnectionFactory!!.createVideoTrack("video", videoSource) - previewSink?.let { localVideoTrack?.addSink(it) } - } - peerConnection?.addTrack(localVideoTrack!!, listOf("stream")) - - // Publicar también el micrófono en la misma sesión WHIP que el video. - // Se agrega ANTES de crear la oferta SDP (primer frame de cámara). - val audioConstraints = MediaConstraints() - audioSource = peerConnectionFactory!!.createAudioSource(audioConstraints) - localAudioTrack = peerConnectionFactory!!.createAudioTrack("audio", audioSource) - localAudioTrack!!.setEnabled(true) - checkNotNull(peerConnection?.addTrack(localAudioTrack!!, listOf("stream"))) { - "No se pudo agregar la pista de audio a WebRTC" - } - Log.i(TAG, "Audio SOS: pista de micrófono agregada a WebRTC") - - // Iniciar captura cuando el track ya está preparado. + // Iniciar captura cameraVideoCapturer?.startCapture( config.video.resolution.width, config.video.resolution.height, config.video.frameRate ) + // Crear video track + localVideoTrack = peerConnectionFactory!!.createVideoTrack("video", videoSource) + + // Agregar al PeerConnection + peerConnection?.addTrack(localVideoTrack!!, listOf("stream")) + Log.e(TAG, "Video track created: ${config.video.resolution.label} @ ${config.video.frameRate}fps") } @@ -532,68 +488,53 @@ class WebRTCManager @Inject constructor( /** * Detiene el streaming de video */ - // Se ejecuta exclusivamente dentro de streamMutex. - private fun cleanupStream() { - activeGeneration = -1L - autoStopJob?.cancel() - autoStopJob = null - // Detener el productor antes de destruir track, source y EGL. - val capturer = cameraVideoCapturer - cameraVideoCapturer = null - runCatching { capturer?.stopCapture() } - .onFailure { Log.e(TAG, "stopCapture failed", it) } - runCatching { capturer?.dispose() } - .onFailure { Log.e(TAG, "capturer dispose failed", it) } - synchronized(previewLock) { - previewSink?.let { sink -> - runCatching { localVideoTrack?.removeSink(sink) } - } - runCatching { localVideoTrack?.dispose() } - localVideoTrack = null - } - // Liberar el micrófono al detener SOS, incluso si hubo error de conexión. - runCatching { localAudioTrack?.setEnabled(false) } - runCatching { peerConnection?.close() } - runCatching { peerConnection?.dispose() } - peerConnection = null - runCatching { localAudioTrack?.dispose() } - localAudioTrack = null - runCatching { audioSource?.dispose() } - audioSource = null - runCatching { videoSource?.dispose() } - videoSource = null - runCatching { surfaceTextureHelper?.dispose() } - surfaceTextureHelper = null - runCatching { signalingClient?.close() } - signalingClient = null - } - fun stopStreaming() { - // Invalidar inmediatamente cualquier inicio pendiente, antes de lanzar coroutine. - val generation = streamGeneration.incrementAndGet() scope.launch { - streamMutex.withLock { - // Si un nuevo inicio legítimo llegó después, no detenerlo. - if (generation != streamGeneration.get()) return@withLock - Log.i(TAG, "Stopping streaming generation=$generation") - cleanupStream() + try { + Log.e(TAG, "Stopping streaming") + + autoStopJob?.cancel() + autoStopJob = null + + localVideoTrack?.dispose() + localVideoTrack = null + + cameraVideoCapturer?.stopCapture() + cameraVideoCapturer = null + + videoSource?.dispose() + videoSource = null + + surfaceTextureHelper?.dispose() + surfaceTextureHelper = null + + peerConnection?.close() + peerConnection = null + + signalingClient?.close() + signalingClient = null + callback?.onStreamStopped() + } catch (e: Exception) { + Log.e(TAG, "Error stopping streaming", e) + callback?.onError("Error al detener streaming: ${e.message}") } } } + /** + * Libera recursos + */ fun release() { - releasing = true - streamGeneration.incrementAndGet() - scope.launch { - streamMutex.withLock { - cleanupStream() - runCatching { peerConnectionFactory?.dispose() } - peerConnectionFactory = null - runCatching { eglBase?.release() } - eglBase = null - callback = null - } - } + Log.e(TAG, "Releasing WebRTCManager") + stopStreaming() + + peerConnectionFactory?.dispose() + peerConnectionFactory = null + + eglBase?.release() + eglBase = null + + callback = null } } 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..da23942 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() } @@ -204,7 +194,7 @@ class AuthViewModel @Inject constructor( when (e.statusCode) { 401 -> - "Error 401: ${e.message}" + "QR o PIN incorrecto" else -> e.message diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/LoginScreen.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/LoginScreen.kt index b15ab5e..9449167 100644 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/LoginScreen.kt +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/LoginScreen.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import com.bodycamera.twentyfoulabs.data.backend.BackendConfig -import com.bodycamera.twentyfoulabs.data.backend.DeviceIdentity import com.bodycamera.twentyfoulabs.qr.QrScannerActivity import org.json.JSONObject @@ -51,24 +50,6 @@ fun LoginScreen( ) { val context = LocalContext.current - val deviceIdentity = remember(context) { - DeviceIdentity(context.applicationContext) - } - - var configuredSerial by remember { - mutableStateOf(deviceIdentity.configuredSerial()) - } - - if (configuredSerial == null) { - DeviceSerialSetup( - onConfigure = { serial -> - deviceIdentity.configureSerial(serial) - configuredSerial = deviceIdentity.requireSerial() - } - ) - return - } - var mode by remember { mutableStateOf(LoginMode.QR) } var email by remember { @@ -225,7 +206,7 @@ fun LoginScreen( Text( text = - "${BackendConfig.DEVICE_MODEL} - $configuredSerial", + "${BackendConfig.DEVICE_MODEL} - ${BackendConfig.DEVICE_SERIAL}", color = Color(0xFF8FAEC7), style = MaterialTheme.typography.labelSmall @@ -422,14 +403,6 @@ fun LoginScreen( ) } - Text( - text = if (state is AuthUiState.Error) - "ERROR: ${state.message}" - else - "PRUEBA VISUAL: ZONA DE MENSAJES", - color = Color.Yellow, - style = MaterialTheme.typography.bodySmall - ) TextButton( onClick = { @@ -828,75 +801,3 @@ private fun extractQrToken( it.length > 4 } } - - -@Composable -private fun DeviceSerialSetup( - onConfigure: (String) -> Unit -) { - var serial by remember { mutableStateOf("") } - var error by remember { mutableStateOf(null) } - - Box( - modifier = Modifier - .fillMaxSize() - .background(LoginNavy) - .padding(16.dp), - contentAlignment = Alignment.Center - ) { - Card( - colors = CardDefaults.cardColors( - containerColor = LoginSurface - ), - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = "Configuración inicial M530", - color = Color.White, - style = MaterialTheme.typography.titleLarge - ) - - Text( - text = "Ingrese el serial físico registrado en el backend.", - color = Color.White - ) - - OutlinedTextField( - value = serial, - onValueChange = { - serial = it.trim().uppercase() - error = null - }, - label = { Text("Serial físico") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - - if (error != null) { - Text( - text = error.orEmpty(), - color = MaterialTheme.colorScheme.error - ) - } - - Button( - onClick = { - try { - onConfigure(serial) - } catch (e: Exception) { - error = e.message ?: "No se pudo guardar el serial" - } - }, - enabled = Regex("^M530[A-Z0-9]+$").matches(serial), - modifier = Modifier.fillMaxWidth() - ) { - Text("GUARDAR SERIAL") - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/MainScreen.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/MainScreen.kt index e6ea71d..4f61225 100644 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/MainScreen.kt +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/MainScreen.kt @@ -25,9 +25,6 @@ import java.util.* import android.view.MotionEvent import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.input.pointer.pointerInteropFilter -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.viewinterop.AndroidView -import org.webrtc.SurfaceViewRenderer private val Navy = Color(0xFF0B1F33) private val Navy2 = Color(0xFF123551) @@ -88,35 +85,11 @@ fun MainScreen( verticalArrangement = Arrangement.spacedBy(10.dp) ) { OperationalHeader(deviceStatus, streamingState, isLocked) - if (streamingState is StreamingState.Connecting || streamingState is StreamingState.Active) { - SosWebRtcPreview(viewModel) - Button( - onClick = { - android.util.Log.i("MainScreen", "DETENER SOS pulsado (boton dedicado)") - viewModel.stopVideoFromUi() - }, - modifier = Modifier.fillMaxWidth().height(56.dp), - colors = ButtonDefaults.buttonColors(containerColor = Danger) - ) { - Icon(Icons.Default.StopCircle, contentDescription = null) - Spacer(Modifier.width(8.dp)) - Text("DETENER TRANSMISION SOS", fontWeight = FontWeight.Bold) - } - } QuickActions( streamingState = streamingState, locked = isLocked, pttPressed = pttPressed, - onVideo = { - if (streamingState is StreamingState.Active || - streamingState is StreamingState.Connecting - ) { - android.util.Log.i("MainScreen", "DETENER SOS pulsado (acciones rapidas)") - viewModel.stopVideoFromUi() - } else { - onOpenVideoCamera() - } - }, + onVideo = onOpenVideoCamera, onPhoto = onOpenPhotoCamera, onPttDown = { if (!pttPressed) { pttPressed = viewModel.startPttFromUi() } }, onPttUp = { viewModel.stopPttFromUi(); pttPressed = false }, @@ -209,9 +182,7 @@ private fun QuickActions( ActionTile( Modifier.weight(1f), if (streamingState is StreamingState.Active || streamingState is StreamingState.Connecting) "DETENER" else "VIDEO", if (streamingState is StreamingState.Active) Icons.Default.StopCircle else Icons.Default.Videocam, - if (streamingState is StreamingState.Active) Danger else Accent, - !locked || streamingState is StreamingState.Active || streamingState is StreamingState.Connecting, - onVideo + if (streamingState is StreamingState.Active) Danger else Accent, !locked, onVideo ) ActionTile(Modifier.weight(1f), "FOTO", Icons.Default.PhotoCamera, Navy2, !locked, onPhoto) } @@ -375,42 +346,3 @@ private fun FullScreenMessage(padding: PaddingValues, text: String, progress: Bo private fun formatMb(mb: Long): String = if (mb >= 1024) String.format(Locale.US, "%.1f GB", mb / 1024.0) else "$mb MB" private fun formatTime(ts: Long): String = if (ts <= 0) "-" else SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(ts)) - - -/** Vista local del MISMO track WebRTC del SOS; no abre una segunda cámara. */ -@Composable -private fun SosWebRtcPreview(viewModel: MainViewModel) { - val context = LocalContext.current - val renderer = remember(context) { SurfaceViewRenderer(context) } - var initialized by remember(renderer) { mutableStateOf(false) } - - DisposableEffect(renderer, viewModel) { - val eglContext = viewModel.previewEglContext() - if (eglContext != null) { - renderer.init(eglContext, null) - renderer.setMirror(true) - renderer.setEnableHardwareScaler(true) - viewModel.attachPreviewSink(renderer) - initialized = true - } - onDispose { - if (initialized) { - viewModel.detachPreviewSink(renderer) - renderer.release() - initialized = false - } - } - } - - Card(colors = CardDefaults.cardColors(containerColor = SurfaceDark), shape = RoundedCornerShape(14.dp)) { - Column(Modifier.fillMaxWidth().padding(8.dp)) { - Text("VISTA PREVIA SOS - CAMARA FRONTAL", color = Color.White, - style = MaterialTheme.typography.labelMedium) - if (initialized) { - AndroidView(factory = { renderer }, modifier = Modifier.fillMaxWidth().height(190.dp)) - } else { - Text("Preparando vista previa...", color = Color.White) - } - } - } -} 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..c70ec4c 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() { @@ -100,8 +93,6 @@ private fun vibrateRecordingStopped() { vibrator.vibrate(pattern, -1) } } - private var activeWhipUrl: String? = null - val uiState: StateFlow = combine( repository.getConfigFlow(), networkFallbackManager.fallbackState @@ -127,11 +118,11 @@ private fun vibrateRecordingStopped() { private var streamingCallback: WebRTCManager.StreamingCallback? = null - // Debounce para prevenir multiples presiones rapidas del boton + // Debounce para prevenir múltiples presiones rápidas del botón private var lastButtonPressTime = 0L // Estado solicitado por el operador. Se mantiene separado del estado de red WebRTC - // para que una segunda pulsacion de F5 SIEMPRE detenga camara/stream aunque WHIP falle. + // para que una segunda pulsación de F5 SIEMPRE detenga cámara/stream aunque WHIP falle. @Volatile private var streamingRequested = false @@ -143,133 +134,17 @@ 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 - deviceController.logGps() - } - - if (now - lastTelemetryAt >= settings.deviceStatusIntervalSeconds * 1000L) { - lastTelemetryAt = now - deviceController.logTelemetry() - } - + if (ticks % 3 == 0) deviceController.logTelemetry() + ticks++ delay(5000) } } @@ -283,92 +158,8 @@ private fun vibrateRecordingStopped() { _deviceStatus.value = DeviceStatus(telemetry, info, evidence, events) } - private val sosGeneration = java.util.concurrent.atomic.AtomicLong(0) - // Mantener el estado incluso mientras el backend confirma el SOS o WebRTC conecta. - private val sosSessionActive = java.util.concurrent.atomic.AtomicBoolean(false) - fun isSosSessionActive(): Boolean = sosSessionActive.get() - private val sosInProgress = - java.util.concurrent.atomic.AtomicBoolean(false) - - fun triggerSosFromUi( - onResult: ((Result) -> Unit)? = null - ) { - if (!sosSessionActive.compareAndSet(false, true)) { - Log.w(TAG, "SOS ya activo o conectando: no generar otra alerta") - return - } - // Al activar SOS, reportar ubicacion real inmediatamente. - deviceController.logGps() - if (!sosInProgress.compareAndSet(false, true)) { - sosSessionActive.set(false) - Log.w(TAG, "SOS ya en proceso") - return - } - - val requestGeneration = sosGeneration.incrementAndGet() - deviceController.triggerSosWithVideo( - callback = { result -> - sosInProgress.set(false) - - if (result.isSuccess) { - Log.i(TAG, "SOS registrado id=${result.getOrNull()}") - } else { - Log.e(TAG, "Error enviando SOS", result.exceptionOrNull()) - if (requestGeneration == sosGeneration.get()) sosSessionActive.set(false) - } - - onResult?.invoke(result) - refreshDeviceStatus() - }, - onVideoTarget = { streamPath, whipUrl -> - viewModelScope.launch { - try { - if (requestGeneration != sosGeneration.get()) return@launch - val directWhipUrl = - com.bodycamera.twentyfoulabs.data.backend.BackendConfig.MEDIAMTX_BASE_URL + - "/" + streamPath.trim('/') + "/whip" - -val uri = java.net.URI(directWhipUrl) - - require(uri.scheme == "http") - require(!uri.host.isNullOrBlank()) - require(uri.path == "/$streamPath/whip") - - val config = videoConfigRepository.getConfig() - ?: VideoConfiguration() - - val sosConfig = config.copy( - camera = config.camera.copy( - cameraFacing = com.bodycamera.twentyfoulabs.data.model.CameraFacing.FRONT, - cameraId = null - ), - webrtcServer = config.webrtcServer.copy( - serverUrl = uri.host, - serverPort = if (uri.port > 0) uri.port else 80, - streamName = streamPath - ) - ) - - if (requestGeneration != sosGeneration.get()) return@launch - Log.i(TAG, "Iniciando video SOS: $directWhipUrl") - - activeWhipUrl = directWhipUrl - streamingRequested = true - _streamingState.value = StreamingState.Connecting - - webrtcManager.startStreaming(sosConfig) - - } catch (e: Exception) { - Log.e(TAG, "Error iniciando video SOS", e) - streamingRequested = false - _streamingState.value = StreamingState.Error( - e.message ?: "Error iniciando video SOS" - ) - } - } - } - ) - + fun triggerSosFromUi() { + deviceController.triggerSos { } refreshDeviceStatus() } @@ -390,24 +181,14 @@ val uri = java.net.URI(directWhipUrl) private fun initializeStreamingCallback() { streamingCallback = object : WebRTCManager.StreamingCallback { override fun onStreamStarted() { - if (!streamingRequested) { - Log.w(TAG, "Ignoring late onStreamStarted after stop") - webrtcManager.stopStreaming() - return - } Log.e(TAG, "Stream started - updating state to Active") - val startedWhipUrl = activeWhipUrl - if (startedWhipUrl != null) { - deviceController.notifyVideoStarted(startedWhipUrl) - } else { - Log.w(TAG, "Video iniciado sin dirección WHIP registrada") - } + deviceController.notifyVideoStarted( + com.bodycamera.twentyfoulabs.data.backend.BackendConfig.whipUrl + ) mainImmediateScope.launch { - if (streamingRequested) { - _streamingState.value = StreamingState.Active(600) - vibrateRecordingStarted() - } + _streamingState.value = StreamingState.Active(600) + vibrateRecordingStarted() } } @@ -415,7 +196,6 @@ override fun onStreamStopped() { Log.e(TAG, "Stream stopped - updating state to Stopped") deviceController.notifyVideoStopped() streamingRequested = false - activeWhipUrl = null mainImmediateScope.launch { _streamingState.value = StreamingState.Stopped @@ -423,65 +203,37 @@ 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 { + _streamingState.value = when (_streamingState.value) { + is StreamingState.Connecting -> { + // Si estaba conectando y ahora está conectado, pasar a Active + if (isConnected) { + Log.e(TAG, "Connection established, changing to Active") + StreamingState.Active(600) + } else { + _streamingState.value + } + } + is StreamingState.Active -> { + if (!isConnected) { + Log.e(TAG, "Connection lost, changing to Connecting") + StreamingState.Connecting + } else { + _streamingState.value + } + } + else -> _streamingState.value + } } } - is StreamingState.Active -> { - if (!isConnected) { - Log.e( - TAG, - "Connection lost, changing to Connecting" - ) - StreamingState.Connecting - } else { - _streamingState.value - } - } - - else -> _streamingState.value - } - } -} - - - - - override fun onError(error: String) { Log.e(TAG, "Streaming error: $error - forcing media cleanup") deviceController.notifyVideoError(error) streamingRequested = false - activeWhipUrl = null - // Si WHIP/signaling falla, no dejar la camara capturando indefinidamente. + // Si WHIP/signaling falla, no dejar la cámara capturando indefinidamente. webrtcManager.stopStreaming() mainImmediateScope.launch { _streamingState.value = StreamingState.Error(error) @@ -508,7 +260,7 @@ override fun onConnectionChange(isConnected: Boolean) { fun onEvent(event: MainUiEvent) { when (event) { MainUiEvent.RetryLoad -> { - // El flow se actualiza automaticamente + // El flow se actualiza automáticamente } MainUiEvent.ImportConfigFromFile -> { // TODO: Implement file picker @@ -526,13 +278,13 @@ override fun onConnectionChange(isConnected: Boolean) { } /** - * Metodo publico para ser llamado desde el boton fisico de camara (KEYCODE_F5). - * Verifica si la app esta bloqueada antes de iniciar/detener streaming. + * Método público para ser llamado desde el botón físico de cámara (KEYCODE_F5). + * Verifica si la app está bloqueada antes de iniciar/detener streaming. */ fun triggerCameraButton() { val currentTime = System.currentTimeMillis() if (currentTime - lastButtonPressTime < BUTTON_DEBOUNCE_MS) { - Log.e(TAG, "Boton presionado muy rapido, ignorando (debounce)") + Log.e(TAG, "Botón presionado muy rápido, ignorando (debounce)") return } lastButtonPressTime = currentTime @@ -542,7 +294,7 @@ override fun onConnectionChange(isConnected: Boolean) { val isLocked = adminLockRepository.isLocked() Log.e(TAG, "isLocked: $isLocked") if (isLocked) { - Log.e(TAG, "App bloqueada - boton camara ignorado") + Log.e(TAG, "App bloqueada - botón cámara ignorado") return@launch } Log.e(TAG, "App desbloqueada - llamando a toggleStreaming") @@ -554,57 +306,22 @@ override fun onConnectionChange(isConnected: Boolean) { Log.e(TAG, "toggleStreaming llamado, requested=$streamingRequested, estado=${_streamingState.value}") viewModelScope.launch { if (streamingRequested) { - Log.e(TAG, "F5 STOP solicitado - liberando camara/WebRTC") + Log.e(TAG, "F5 STOP solicitado - liberando cámara/WebRTC") streamingRequested = false webrtcManager.stopStreaming() _streamingState.value = StreamingState.Stopped return@launch } - val savedConfig = videoConfigRepository.getConfig() - - val config = when { - savedConfig == null -> VideoConfiguration().also { - videoConfigRepository.saveConfig(it) - } - - savedConfig.webrtcServer.serverUrl == "192.168.18.57" -> - savedConfig.copy( - webrtcServer = savedConfig.webrtcServer.copy( - serverUrl = "172.93.110.24", - serverPort = 18889 - ) - ).also { - videoConfigRepository.saveConfig(it) - } - - else -> savedConfig - } - - val server = config.webrtcServer - - if (server.serverUrl == "192.168.18.57") { - _streamingState.value = StreamingState.Error( - "La configuración guardada utiliza el servidor anterior. Actualiza la configuración de video." - ) + val config = videoConfigRepository.getConfig() + if (config == null) { + Log.e(TAG, "No hay configuración de video") + streamingRequested = false + _streamingState.value = StreamingState.Error("No hay configuración de video") return@launch } - if (server.serverUrl.isBlank() || - server.serverPort !in 1..65535 || - server.streamName.isBlank() - ) { - _streamingState.value = StreamingState.Error( - "La configuración del servidor de video no es válida." - ) - return@launch - } - - val normalWhipUrl = - "http://${server.serverUrl}:${server.serverPort}/${server.streamName.trim('/')}/whip" - - Log.i(TAG, "F5 START solicitado: $normalWhipUrl") - activeWhipUrl = normalWhipUrl + Log.e(TAG, "F5 START solicitado: ${config.webrtcServer.serverUrl}:${config.webrtcServer.serverPort}") streamingRequested = true _streamingState.value = StreamingState.Connecting try { @@ -618,18 +335,6 @@ override fun onConnectionChange(isConnected: Boolean) { } } - fun stopVideoFromUi() { - Log.i(TAG, "DETENER solicitado (pantalla o F7)") - sosSessionActive.set(false) - sosGeneration.incrementAndGet() - streamingRequested = false - // Mantener DETENER visible hasta que WebRTC confirme liberacion de camara. - webrtcManager.stopStreaming() - } - fun previewEglContext(): org.webrtc.EglBase.Context? = webrtcManager.previewEglContext() - fun attachPreviewSink(sink: org.webrtc.VideoSink) = webrtcManager.attachPreviewSink(sink) - fun detachPreviewSink(sink: org.webrtc.VideoSink) = webrtcManager.detachPreviewSink(sink) - override fun onCleared() { super.onCleared() webrtcManager.release() @@ -667,9 +372,3 @@ data class DeviceStatus( val evidence: M530DeviceController.EvidenceSummary? = null, val recentEvents: List = emptyList() ) - - - - - - diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index 7a3c868..950f75b 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -10,7 +10,6 @@ localhost 127.0.0.1 35.224.197.127 - 172.93.110.24