From bea09b691debcd901a05a5d324b7b86e0ddab5d7 Mon Sep 17 00:00:00 2001 From: Marlon Date: Tue, 22 Sep 2026 12:43:08 -0500 Subject: [PATCH 1/7] remove: proxy apk at mediamtx --- .../twentyfoulabs/data/ptt/PttAudioClient.kt | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) 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 dc16712..7a7cabf 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 @@ -858,22 +858,27 @@ class PttAudioClient @Inject constructor( suppliedUrl: String?, fallbackPath: String ): String { - if (suppliedUrl.isNullOrBlank()) { return "$MEDIA_BASE_URL/$fallbackPath" } - return when { - suppliedUrl.startsWith( - "http://" - ) || - suppliedUrl.startsWith( - "https://" - ) -> suppliedUrl + // Rails (BROWSER_PROXY) envía "/media-mtx/ptt/u/:id/whip". + // MediaMTX espera "http://host:8889/ptt/u/:id/whip" (sin prefijo media-mtx). + val raw = suppliedUrl.trim() - else -> - "$MEDIA_BASE_URL/" + - suppliedUrl.trimStart('/') + return when { + raw.startsWith("http://") || raw.startsWith("https://") -> { + if (raw.contains("/media-mtx/")) { + "$MEDIA_BASE_URL/" + raw.substringAfter("/media-mtx/").trimStart('/') + } else { + raw + } + } + + else -> { + val path = raw.trimStart('/').removePrefix("media-mtx/") + "$MEDIA_BASE_URL/$path" + } } } From f9923c8981e602adc1449af2c4af78511bfce3a5 Mon Sep 17 00:00:00 2001 From: Miguel Grandez Date: Tue, 22 Sep 2026 17:56:27 -0500 Subject: [PATCH 2/7] Integrar telemetria HTTPS cada 5 minutos conservando PTT estable --- .../data/backend/BackendIntegrationManager.kt | 52 ++++++++++++++++++- .../twentyfoulabs/ui/screens/MainViewModel.kt | 2 +- 2 files changed, 51 insertions(+), 3 deletions(-) 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 3da381f..ab65547 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 @@ -92,10 +92,58 @@ class BackendIntegrationManager @Inject constructor( fun publishTelemetry(payload: JSONObject) { val envelope = JSONObject() - .put("deviceId", deviceId) + .put("deviceId", BackendConfig.DEVICE_SERIAL) .put("timestamp", System.currentTimeMillis()) .put("payload", payload) - enqueuePublish("m530/$deviceId/telemetry", envelope) + httpExecutor.execute { + var conn: HttpURLConnection? = null + try { + val token = runBlocking { + authSessionStore.token.first() + }?.takeIf { it.isNotBlank() } + ?: error("No hay sesion JWT activa") + + val url = URL( + BackendConfig.API_BASE_URL.trimEnd('/') + + "/mobile/v1/devices/telemetry" + ) + + conn = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = 15_000 + readTimeout = 30_000 + doOutput = true + setRequestProperty("Content-Type", "application/json") + setRequestProperty("Accept", "application/json") + setRequestProperty("Authorization", "Bearer $token") + setRequestProperty( + "X-Device-Serial", + BackendConfig.DEVICE_SERIAL + ) + } + + 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") + } + } catch (e: Exception) { + Log.e(TAG, "TELEMETRY HTTPS ERROR", e) + } finally { + conn?.disconnect() + } + } } fun uploadMedia(file: File, kind: String, metadata: JSONObject = JSONObject()) { 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 c70ec4c..212877a 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 @@ -143,7 +143,7 @@ private fun vibrateRecordingStopped() { var ticks = 0 while (true) { refreshDeviceStatus() - if (ticks % 3 == 0) deviceController.logTelemetry() + if (ticks > 0 && ticks % 60 == 0) deviceController.logTelemetry() ticks++ delay(5000) } From a12ae5da8f3f9b075b2b295e6353b977a119008c Mon Sep 17 00:00:00 2001 From: Miguel Grandez Date: Wed, 23 Sep 2026 09:13:48 -0500 Subject: [PATCH 3/7] Integrar GPS cada cinco minutos --- .../data/backend/BackendIntegrationManager.kt | 49 +++++++++++++++++++ .../data/device/M530DeviceController.kt | 24 +++++++++ .../twentyfoulabs/ui/screens/MainViewModel.kt | 5 +- 3 files changed, 77 insertions(+), 1 deletion(-) 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 ab65547..0f678e2 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 @@ -90,6 +90,55 @@ class BackendIntegrationManager @Inject constructor( enqueuePublish(topic, envelope) } + 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 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", + BackendConfig.DEVICE_SERIAL + ) + } + + 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") + } + } catch (e: Exception) { + Log.e(TAG, "GPS HTTPS ERROR", e) + } finally { + conn?.disconnect() + } + } + } fun publishTelemetry(payload: JSONObject) { val envelope = JSONObject() .put("deviceId", BackendConfig.DEVICE_SERIAL) 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 49ac878..f489efd 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 @@ -410,6 +410,30 @@ Result.success(file) }.getOrDefault(emptyList()) } + fun logGps() { + // COORDENADAS SIMULADAS: exclusivamente para pruebas. + val latitude = -12.077 + val longitude = -77.092 + + 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()) + + val payload = JSONObject() + .put("latitude", latitude) + .put("longitude", longitude) + .put("speed", 0) + .put("heading", 0) + .put("accuracy", 10) + .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) 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 212877a..aac0489 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 @@ -143,7 +143,10 @@ private fun vibrateRecordingStopped() { var ticks = 0 while (true) { refreshDeviceStatus() - if (ticks > 0 && ticks % 60 == 0) deviceController.logTelemetry() + if (ticks > 0 && ticks % 60 == 0) { + deviceController.logTelemetry() + deviceController.logGps() + } ticks++ delay(5000) } From 01681a33d44df9ad334940ddbee1f77a6b8afb01 Mon Sep 17 00:00:00 2001 From: Marlon Date: Fri, 25 Sep 2026 09:02:14 -0500 Subject: [PATCH 4/7] revert: remove proxy apk at mediamtx --- .../twentyfoulabs/data/ptt/PttAudioClient.kt | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) 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 7a7cabf..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 @@ -858,27 +858,22 @@ class PttAudioClient @Inject constructor( suppliedUrl: String?, fallbackPath: String ): String { + if (suppliedUrl.isNullOrBlank()) { return "$MEDIA_BASE_URL/$fallbackPath" } - // Rails (BROWSER_PROXY) envía "/media-mtx/ptt/u/:id/whip". - // MediaMTX espera "http://host:8889/ptt/u/:id/whip" (sin prefijo media-mtx). - val raw = suppliedUrl.trim() - return when { - raw.startsWith("http://") || raw.startsWith("https://") -> { - if (raw.contains("/media-mtx/")) { - "$MEDIA_BASE_URL/" + raw.substringAfter("/media-mtx/").trimStart('/') - } else { - raw - } - } + suppliedUrl.startsWith( + "http://" + ) || + suppliedUrl.startsWith( + "https://" + ) -> suppliedUrl - else -> { - val path = raw.trimStart('/').removePrefix("media-mtx/") - "$MEDIA_BASE_URL/$path" - } + else -> + "$MEDIA_BASE_URL/" + + suppliedUrl.trimStart('/') } } From 51ccd80070e5d09817b77aefa3ec46089ca012d0 Mon Sep 17 00:00:00 2001 From: Miguel Grandez Date: Sat, 26 Sep 2026 10:49:54 -0500 Subject: [PATCH 5/7] feat: integrar mejoras de SOS, PTT, autenticacion y video M530 --- .../bodycamera/twentyfoulabs/MainActivity.kt | 16 +- .../twentyfoulabs/data/auth/AuthApiClient.kt | 7 +- .../twentyfoulabs/data/auth/AuthRepository.kt | 8 +- .../data/backend/BackendConfig.kt | 48 +++- .../data/backend/BackendIntegrationManager.kt | 85 +++---- .../data/backend/DeviceIdentity.kt | 45 ++++ .../data/device/M530DeviceController.kt | 70 ++++-- .../data/model/VideoConfiguration.kt | 4 +- .../twentyfoulabs/data/ptt/PttApiClient.kt | 7 +- .../twentyfoulabs/data/ptt/PttAudioClient.kt | 2 +- .../twentyfoulabs/data/ptt/PttCableClient.kt | 2 +- .../data/webrtc/WebRTCManager.kt | 237 +++++++++++------- .../twentyfoulabs/ui/screens/AuthViewModel.kt | 2 +- .../twentyfoulabs/ui/screens/LoginScreen.kt | 101 +++++++- .../twentyfoulabs/ui/screens/MainScreen.kt | 72 +++++- .../twentyfoulabs/ui/screens/MainViewModel.kt | 190 ++++++++++++-- .../main/res/xml/network_security_config.xml | 1 + 17 files changed, 695 insertions(+), 202 deletions(-) create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/DeviceIdentity.kt diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainActivity.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainActivity.kt index 7b57b9e..7120eac 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 (isAuthenticated && !frontPreviewOpened) { + if (false && isAuthenticated && !frontPreviewOpened) { frontPreviewOpened = true runOnUiThread { openFrontPreview() @@ -479,10 +479,18 @@ class MainActivity : ComponentActivity() { ) } private fun handleSosButton() { + // F7 alterna SOS/DETENER. La misma orden de parada que usa la pantalla. + if (viewModel.isSosSessionActive()) { + Log.i(TAG, "F7 DETENER SOS: solicitando cierre de WebRTC") + viewModel.stopVideoFromUi() + toast("Deteniendo video SOS...") + return + } + Log.i(TAG, "F7 INICIAR SOS") toast("Enviando SOS...") - deviceController.triggerSos { result -> + viewModel.triggerSosFromUi { result -> runOnUiThread { @@ -633,6 +641,8 @@ fun AppNavigation(viewModel: MainViewModel, onOpenPhotoCamera: () -> Unit, onOpe onOpenPhotoCamera = onOpenPhotoCamera, onOpenVideoCamera = onOpenVideoCamera, onLogout = { + // Liberar WebRTC antes de mostrar nuevamente el lector del fotocheck. + viewModel.stopVideoFromUi() authViewModel.logout() navController.navigate("login") { popUpTo("main") { inclusive = true } } } 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 94de5cf..e09a228 100644 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthApiClient.kt +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthApiClient.kt @@ -1,6 +1,7 @@ package com.bodycamera.twentyfoulabs.data.auth import com.bodycamera.twentyfoulabs.data.backend.BackendConfig +import com.bodycamera.twentyfoulabs.data.backend.DeviceIdentity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.decodeFromString @@ -12,7 +13,9 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class AuthApiClient @Inject constructor() { +class AuthApiClient @Inject constructor( + private val deviceIdentity: DeviceIdentity +) { private val json = Json { ignoreUnknownKeys = true @@ -96,7 +99,7 @@ class AuthApiClient @Inject constructor() { setRequestProperty( "X-Device-Serial", - BackendConfig.DEVICE_SERIAL + deviceIdentity.requireSerial() ) if (!token.isNullOrBlank()) { 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 52df2d5..e10ddbc 100644 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthRepository.kt +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthRepository.kt @@ -1,6 +1,7 @@ package com.bodycamera.twentyfoulabs.data.auth import com.bodycamera.twentyfoulabs.data.backend.BackendConfig +import com.bodycamera.twentyfoulabs.data.backend.DeviceIdentity import kotlinx.coroutines.flow.first import javax.inject.Inject import javax.inject.Singleton @@ -8,7 +9,8 @@ import javax.inject.Singleton @Singleton class AuthRepository @Inject constructor( private val api: AuthApiClient, - private val store: AuthSessionStore + private val store: AuthSessionStore, + private val deviceIdentity: DeviceIdentity ) { suspend fun login( @@ -20,7 +22,7 @@ class AuthRepository @Inject constructor( LoginRequest( email = email.trim(), password = password, - deviceSerial = BackendConfig.DEVICE_SERIAL, + deviceSerial = deviceIdentity.requireSerial(), deviceModel = BackendConfig.DEVICE_MODEL ) ) @@ -46,7 +48,7 @@ class AuthRepository @Inject constructor( QrLoginRequest( qrToken = qrToken.trim(), pin = pin.trim(), - deviceSerial = BackendConfig.DEVICE_SERIAL, + deviceSerial = deviceIdentity.requireSerial(), deviceModel = BackendConfig.DEVICE_MODEL ) ) 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 393967d..b1e4d2d 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,20 +1,48 @@ -package com.bodycamera.twentyfoulabs.data.backend +package com.bodycamera.twentyfoulabs.data.backend -/** Centralized endpoints for the M530 PoC. */ +/** Centralized endpoints for the M530 APK. */ object BackendConfig { - // Local LAN services used by MQTT and MediaMTX during the current PoC. + + // New GeoSatelital infrastructure. + const val API_BASE_URL = "https://bodycam.geosatelital.red" + + const val MEDIAMTX_HOST = "172.93.110.24" + const val MEDIAMTX_WEBRTC_PORT = 18889 + const val MEDIAMTX_HLS_PORT = 18888 + const val MEDIAMTX_ICE_PORT = 18189 + + const val MEDIAMTX_BASE_URL = + "http://172.93.110.24:18889" + + const val MEDIAMTX_HLS_BASE_URL = + "http://172.93.110.24:18888" + + // Device identification. + const val DEVICE_MODEL = "Recoda M530" + + // Legacy constants retained until dependent components are migrated. const val HOST = "192.168.18.57" const val MQTT_PORT = 1883 const val HTTP_PORT = 8000 const val MQTT_CLIENT_PREFIX = "m530" const val STREAM_ID = "webrtc_camera_stream" - // Rails mobile API exposed through ngrok HTTPS. - const val API_BASE_URL = "https://bodycam-prd-web-nefjf74quq-uc.a.run.app" - const val DEVICE_SERIAL = "BC-M530-001" - const val DEVICE_MODEL = "Recoda M530" + val httpBaseUrl: String + get() = "http://$HOST:$HTTP_PORT" - val httpBaseUrl: String get() = "http://$HOST:$HTTP_PORT" - val whipUrl: String get() = "http://$HOST:8889/$STREAM_ID/whip" + val whipUrl: String + get() = "$MEDIAMTX_BASE_URL/$STREAM_ID/whip" + + // MediaMTX paths based on the authenticated user. + fun sosWhipUrl(userId: Long): String = + "$MEDIAMTX_BASE_URL/sos/u/$userId/whip" + + fun sosWhepUrl(userId: Long): String = + "$MEDIAMTX_BASE_URL/sos/u/$userId/whep" + + fun pttWhipUrl(userId: Long): String = + "$MEDIAMTX_BASE_URL/ptt/u/$userId/whip" + + fun pttWhepUrl(userId: Long): String = + "$MEDIAMTX_BASE_URL/ptt/u/$userId/whep" } - 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 0f678e2..a6c79ed 100644 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/BackendIntegrationManager.kt +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/BackendIntegrationManager.kt @@ -21,14 +21,15 @@ import javax.inject.Singleton @Singleton class BackendIntegrationManager @Inject constructor( @ApplicationContext private val context: Context, - private val authSessionStore: AuthSessionStore + private val authSessionStore: AuthSessionStore, + private val deviceIdentity: DeviceIdentity ) { companion object { private const val TAG = "BackendIntegration" } private val mqttExecutor = Executors.newSingleThreadExecutor() private val httpExecutor = Executors.newSingleThreadExecutor() private val mqtt = SimpleMqttPublisher(BackendConfig.HOST, BackendConfig.MQTT_PORT) - private val deviceId: String by lazy { sanitize(runCatching { Build.getSerial() }.getOrNull() ?: Build.MODEL ?: "M530") } + private val deviceId: String by lazy { sanitize(deviceIdentity.requireSerial()) } private val clientId: String get() = "${BackendConfig.MQTT_CLIENT_PREFIX}-$deviceId-${UUID.randomUUID().toString().take(6)}" private val pendingFile by lazy { File(context.filesDir, "mqtt_pending.jsonl") } @@ -114,7 +115,7 @@ class BackendIntegrationManager @Inject constructor( setRequestProperty("Authorization", "Bearer $token") setRequestProperty( "X-Device-Serial", - BackendConfig.DEVICE_SERIAL + deviceIdentity.requireSerial() ) } @@ -140,8 +141,14 @@ class BackendIntegrationManager @Inject constructor( } } 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", BackendConfig.DEVICE_SERIAL) + .put("deviceId", serial) .put("timestamp", System.currentTimeMillis()) .put("payload", payload) httpExecutor.execute { @@ -167,7 +174,7 @@ class BackendIntegrationManager @Inject constructor( setRequestProperty("Authorization", "Bearer $token") setRequestProperty( "X-Device-Serial", - BackendConfig.DEVICE_SERIAL + deviceIdentity.requireSerial() ) } @@ -214,24 +221,16 @@ class BackendIntegrationManager @Inject constructor( "FAVORITE ultimo media_item guardado: id=$uploadedMediaId" ) } - val mediaPayload = JSONObject() - .put("kind", kind) - .put("fileName", file.name) - .put("sizeBytes", file.length()) - .put("localPath", file.absolutePath) - .put("server", response) - val event = JSONObject() - .put("eventId", UUID.randomUUID().toString()) - .put("deviceId", deviceId) - .put("type", "MEDIA_UPLOADED") - .put("timestamp", System.currentTimeMillis()) - .put("payload", mediaPayload) - publishNow("m530/$deviceId/media/$kind", event) + Log.i( + TAG, + "MEDIA HTTPS OK: archivo=${file.name}, tipo=$kind, id=${uploadedMediaId ?: "sin_id"}" + ) } catch (t: Throwable) { - Log.e(TAG, "Upload failed: ${file.name}", t) - publishNow("m530/$deviceId/events/media_upload_failed", JSONObject() - .put("deviceId", deviceId).put("timestamp", System.currentTimeMillis()) - .put("fileName", file.name).put("kind", kind).put("error", t.message ?: "unknown")) + Log.e( + TAG, + "MEDIA HTTPS ERROR: archivo=${file.name}, tipo=$kind", + t + ) } } } @@ -297,7 +296,7 @@ class BackendIntegrationManager @Inject constructor( setRequestProperty( "X-Device-Serial", - BackendConfig.DEVICE_SERIAL + deviceIdentity.requireSerial() ) setRequestProperty( @@ -343,23 +342,7 @@ class BackendIntegrationManager @Inject constructor( ) } - publishNow( - "m530/$deviceId/events/media_bookmarked", - JSONObject() - .put("deviceId", deviceId) - .put( - "timestamp", - System.currentTimeMillis() - ) - .put( - "mediaItemId", - mediaId - ) - .put( - "isBookmarked", - true - ) - ) + callback( Result.success(mediaId) @@ -388,7 +371,8 @@ class BackendIntegrationManager @Inject constructor( fun sendSosAlert( latitude: Double?, longitude: Double?, - callback: (Result) -> Unit + callback: (Result) -> Unit, + onVideoTarget: ((String, String) -> Unit)? = null ) { httpExecutor.execute { @@ -438,7 +422,7 @@ class BackendIntegrationManager @Inject constructor( setRequestProperty( "X-Device-Serial", - BackendConfig.DEVICE_SERIAL + deviceIdentity.requireSerial() ) setRequestProperty( @@ -454,7 +438,7 @@ class BackendIntegrationManager @Inject constructor( .put("notes", "Boton panico F7") .put( "device_serial", - BackendConfig.DEVICE_SERIAL + deviceIdentity.requireSerial() ) .toString() @@ -524,6 +508,19 @@ class BackendIntegrationManager @Inject constructor( ?: error( "Respuesta SOS sin ID de alerta" ) + val liveStream = responseJson + .optJSONObject("alert") + ?.optJSONObject("live_stream") + + val streamPath = liveStream?.optString("path").orEmpty() + val whipUrl = liveStream?.optString("whip_url").orEmpty() + + Log.i(TAG, "SOS video path=$streamPath WHIP=$whipUrl") + + if (Regex("^sos/u/[1-9][0-9]*$").matches(streamPath)) { + onVideoTarget?.invoke(streamPath, whipUrl) + } + Log.i( TAG, @@ -692,7 +689,7 @@ class BackendIntegrationManager @Inject constructor( setRequestProperty( "X-Device-Serial", - BackendConfig.DEVICE_SERIAL + deviceIdentity.requireSerial() ) 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 new file mode 100644 index 0000000..ecadb59 --- /dev/null +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/DeviceIdentity.kt @@ -0,0 +1,45 @@ +package com.bodycamera.twentyfoulabs.data.backend + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DeviceIdentity @Inject constructor( + @ApplicationContext private val context: Context +) { + private val preferences = context.getSharedPreferences( + "device_identity", + Context.MODE_PRIVATE + ) + + fun configuredSerial(): String? = + preferences.getString("device_serial", null) + ?.trim() + ?.takeIf { it.isNotEmpty() } + + fun requireSerial(): String = + configuredSerial() + ?: error("El dispositivo no tiene un serial configurado") + + fun configureSerial(serial: String) { + val normalized = serial.trim() + + require(Regex("^M530[A-Z0-9]+$").matches(normalized)) { + "Formato de serial no válido" + } + + check(configuredSerial() == null || configuredSerial() == normalized) { + "El dispositivo ya tiene un serial diferente" + } + + check( + preferences.edit() + .putString("device_serial", normalized) + .commit() + ) { + "No se pudo guardar el serial" + } + } +} \ 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 f489efd..238c515 100644 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/device/M530DeviceController.kt +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/device/M530DeviceController.kt @@ -22,6 +22,7 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner import dagger.hilt.android.qualifiers.ApplicationContext import com.bodycamera.twentyfoulabs.data.backend.BackendIntegrationManager +import com.bodycamera.twentyfoulabs.data.backend.DeviceIdentity import org.json.JSONObject import java.io.File import java.text.SimpleDateFormat @@ -34,7 +35,8 @@ import javax.inject.Singleton @Singleton class M530DeviceController @Inject constructor( @ApplicationContext private val context: Context, - private val backend: BackendIntegrationManager + private val backend: BackendIntegrationManager, + private val deviceIdentity: DeviceIdentity ) { companion object { private const val TAG = "M530DeviceController" } @@ -68,7 +70,7 @@ class M530DeviceController @Inject constructor( manufacturer = Build.MANUFACTURER ?: "Android", androidVersion = Build.VERSION.RELEASE ?: "12", sdk = Build.VERSION.SDK_INT, - serial = runCatching { Build.getSerial() }.getOrDefault("M530") + serial = deviceIdentity.configuredSerial() ?: "Sin configurar" ) fun capturePhoto(lifecycleOwner: LifecycleOwner, callback: (Result) -> Unit) { @@ -86,7 +88,13 @@ class M530DeviceController @Inject constructor( capture.takePicture(ImageCapture.OutputFileOptions.Builder(outputFile).build(), ioExecutor, object : ImageCapture.OnImageSavedCallback { override fun onImageSaved(r: ImageCapture.OutputFileResults) { - logEvent("PHOTO_CAPTURED", JSONObject().put("path", outputFile.absolutePath)); backend.publish("PHOTO_CAPTURED", JSONObject().put("fileName", outputFile.name).put("sizeBytes", outputFile.length())); backend.uploadMedia(outputFile, "photo"); provider.unbindAll(); callback(Result.success(outputFile)) + logEvent( + "PHOTO_CAPTURED", + JSONObject().put("path", outputFile.absolutePath) + ) + backend.uploadMedia(outputFile, "photo") + provider.unbindAll() + callback(Result.success(outputFile)) } override fun onError(e: ImageCaptureException) { logEvent("PHOTO_ERROR", JSONObject().put("error", e.message ?: "unknown")); provider.unbindAll(); callback(Result.failure(e)) @@ -285,12 +293,14 @@ Result.success(file) micRecorder != null fun triggerSos(): SosResult { - val location = - Location("M530").apply { - latitude = -12.077 - longitude = -77.092 - accuracy = 10f - } + val location = bestLastKnownLocation()?.takeIf { + System.currentTimeMillis() - it.time in 0..300_000L + } ?: Location("simulated").apply { + latitude = -12.077 + longitude = -77.092 + accuracy = 10f + time = System.currentTimeMillis() + } val payload = JSONObject() @@ -309,15 +319,15 @@ Result.success(file) ) .put( "latitude", - location.latitude + location?.latitude ?: JSONObject.NULL ) .put( "longitude", - location.longitude + location?.longitude ?: JSONObject.NULL ) .put( "accuracy", - location.accuracy + location?.accuracy ?: JSONObject.NULL ) backend.publish( @@ -334,6 +344,22 @@ 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 { @@ -411,9 +437,17 @@ Result.success(file) } fun logGps() { - // COORDENADAS SIMULADAS: exclusivamente para pruebas. - val latitude = -12.077 - val longitude = -77.092 + val location = bestLastKnownLocation()?.takeIf { + System.currentTimeMillis() - it.time in 0..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'", @@ -425,9 +459,9 @@ Result.success(file) val payload = JSONObject() .put("latitude", latitude) .put("longitude", longitude) - .put("speed", 0) - .put("heading", 0) - .put("accuracy", 10) + .put("speed", if (location.hasSpeed()) location.speed else JSONObject.NULL) + .put("heading", if (location.hasBearing()) location.bearing else JSONObject.NULL) + .put("accuracy", location.accuracy) .put("recorded_at", recordedAt) backend.publishGps(payload) 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 9e81870..2f0d4bb 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 = "192.168.18.57", - val serverPort: Int = 8889, + val serverUrl: String = "172.93.110.24", + val serverPort: Int = 18889, val streamName: String = "webrtc_camera_stream", val username: String = "admin", val password: String = "admin", 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 92ab41c..f46cbad 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,6 +1,7 @@ package com.bodycamera.twentyfoulabs.data.ptt import com.bodycamera.twentyfoulabs.data.backend.BackendConfig +import com.bodycamera.twentyfoulabs.data.backend.DeviceIdentity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONObject @@ -10,7 +11,9 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class PttApiClient @Inject constructor() { +class PttApiClient @Inject constructor( + private val deviceIdentity: DeviceIdentity +) { suspend fun joinRoom( token: String, @@ -46,7 +49,7 @@ class PttApiClient @Inject constructor() { setRequestProperty( "X-Device-Serial", - BackendConfig.DEVICE_SERIAL + deviceIdentity.requireSerial() ) } 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 dc16712..0119af9 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://35.224.197.127:8889" + "http://172.93.110.24:18889" 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 57db518..bb7dd33 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-prd-web-nefjf74quq-uc.a.run.app/cable" + "wss://bodycam.geosatelital.red/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 e3fb9ba..dc9db28 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,6 +9,9 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.concurrent.atomic.AtomicLong import org.webrtc.* import java.net.HttpURLConnection import java.net.URL @@ -28,12 +31,39 @@ class WebRTCManager @Inject constructor( } private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val streamMutex = Mutex() + private val streamGeneration = AtomicLong(0) + @Volatile private var activeGeneration = -1L + @Volatile private var releasing = false private var peerConnectionFactory: PeerConnectionFactory? = null private var peerConnection: PeerConnection? = null private var videoSource: VideoSource? = null + private var audioSource: AudioSource? = null + private var localAudioTrack: AudioTrack? = null private var cameraVideoCapturer: CameraVideoCapturer? = null private var localVideoTrack: VideoTrack? = null + private val previewLock = Any() + private var previewSink: VideoSink? = null + + fun previewEglContext(): EglBase.Context? = eglBase?.eglBaseContext + + fun attachPreviewSink(sink: VideoSink) { + synchronized(previewLock) { + previewSink?.let { old -> localVideoTrack?.removeSink(old) } + previewSink = sink + localVideoTrack?.addSink(sink) + } + } + + fun detachPreviewSink(sink: VideoSink) { + synchronized(previewLock) { + if (previewSink === sink) { + localVideoTrack?.removeSink(sink) + previewSink = null + } + } + } private var surfaceTextureHelper: SurfaceTextureHelper? = null private var eglBase: EglBase? = null private var signalingClient: SignalingClient? = null @@ -143,48 +173,50 @@ class WebRTCManager @Inject constructor( * Inicia el streaming de video real */ fun startStreaming(config: VideoConfiguration) { + if (releasing) return + val generation = streamGeneration.incrementAndGet() scope.launch { - try { - val server = config.webrtcServer - - if (server.serverUrl.isBlank()) { - callback?.onError("URL del servidor está vacía") - return@launch + streamMutex.withLock { + if (releasing || generation != streamGeneration.get()) return@withLock + try { + // Nunca iniciar una segunda cámara mientras se limpia la anterior. + cleanupStream() + if (releasing || generation != streamGeneration.get()) return@withLock + val server = config.webrtcServer + if (server.serverUrl.isBlank()) { + callback?.onError("URL del servidor está vacía") + return@withLock + } + activeGeneration = generation + val serverUrl = "http://${server.serverUrl}:${server.serverPort}" + Log.i(TAG, "Starting streaming to: $serverUrl/${server.streamName}/whip") + signalingClient = SignalingClient( + serverUrl = serverUrl, + streamName = server.streamName, + username = server.username, + password = server.password + ) + createPeerConnection(config) + if (generation != streamGeneration.get()) { + cleanupStream() + return@withLock + } + startCameraCapture(config) + if (generation != streamGeneration.get()) { + cleanupStream() + return@withLock + } + if (config.video.autoStopTimeSeconds > 0) { + startAutoStopTimer(config.video.autoStopTimeSeconds) + } + if (generation == streamGeneration.get()) callback?.onStreamStarted() + } catch (e: Exception) { + Log.e(TAG, "Error starting streaming", e) + cleanupStream() + if (generation == streamGeneration.get()) { + callback?.onError("Error al iniciar streaming: ${e.message}") + } } - - // Detener cualquier streaming activo antes de iniciar uno nuevo - if (cameraVideoCapturer != null || peerConnection != null) { - Log.e(TAG, "Stopping active stream before starting new one") - stopStreaming() - delay(500) // Esperar a que se limpien los recursos - } - - val serverUrl = "http://${server.serverUrl}:${server.serverPort}" - Log.e(TAG, "Starting streaming to: $serverUrl/${server.streamName}/whip") - - // Crear SignalingClient - signalingClient = SignalingClient( - serverUrl = serverUrl, - streamName = server.streamName, - username = server.username, - password = server.password - ) - - // Crear PeerConnection - createPeerConnection(config) - - // Iniciar captura de cámara - startCameraCapture(config) - - // Iniciar timer de auto-stop - if (config.video.autoStopTimeSeconds > 0) { - startAutoStopTimer(config.video.autoStopTimeSeconds) - } - - callback?.onStreamStarted() - } catch (e: Exception) { - Log.e(TAG, "Error starting streaming", e) - callback?.onError("Error al iniciar streaming: ${e.message}") } } } @@ -338,8 +370,8 @@ class WebRTCManager @Inject constructor( override fun onFirstFrameAvailable() { Log.e(TAG, "First frame available - Creating SDP offer") - // Después que la cámara está lista, crear la oferta SDP - createOffer() + // Un primer frame tardío no puede reactivar una sesión detenida. + if (activeGeneration == streamGeneration.get() && !releasing) createOffer() } override fun onCameraClosed() { @@ -367,19 +399,31 @@ class WebRTCManager @Inject constructor( capturerObserver ) - // Iniciar captura + // Crear y conectar el track antes del primer frame y de la oferta SDP. + synchronized(previewLock) { + localVideoTrack = peerConnectionFactory!!.createVideoTrack("video", videoSource) + previewSink?.let { localVideoTrack?.addSink(it) } + } + peerConnection?.addTrack(localVideoTrack!!, listOf("stream")) + + // Publicar también el micrófono en la misma sesión WHIP que el video. + // Se agrega ANTES de crear la oferta SDP (primer frame de cámara). + val audioConstraints = MediaConstraints() + audioSource = peerConnectionFactory!!.createAudioSource(audioConstraints) + localAudioTrack = peerConnectionFactory!!.createAudioTrack("audio", audioSource) + localAudioTrack!!.setEnabled(true) + checkNotNull(peerConnection?.addTrack(localAudioTrack!!, listOf("stream"))) { + "No se pudo agregar la pista de audio a WebRTC" + } + Log.i(TAG, "Audio SOS: pista de micrófono agregada a WebRTC") + + // Iniciar captura cuando el track ya está preparado. cameraVideoCapturer?.startCapture( config.video.resolution.width, config.video.resolution.height, config.video.frameRate ) - // Crear video track - localVideoTrack = peerConnectionFactory!!.createVideoTrack("video", videoSource) - - // Agregar al PeerConnection - peerConnection?.addTrack(localVideoTrack!!, listOf("stream")) - Log.e(TAG, "Video track created: ${config.video.resolution.label} @ ${config.video.frameRate}fps") } @@ -488,53 +532,68 @@ class WebRTCManager @Inject constructor( /** * Detiene el streaming de video */ + // Se ejecuta exclusivamente dentro de streamMutex. + private fun cleanupStream() { + activeGeneration = -1L + autoStopJob?.cancel() + autoStopJob = null + // Detener el productor antes de destruir track, source y EGL. + val capturer = cameraVideoCapturer + cameraVideoCapturer = null + runCatching { capturer?.stopCapture() } + .onFailure { Log.e(TAG, "stopCapture failed", it) } + runCatching { capturer?.dispose() } + .onFailure { Log.e(TAG, "capturer dispose failed", it) } + synchronized(previewLock) { + previewSink?.let { sink -> + runCatching { localVideoTrack?.removeSink(sink) } + } + runCatching { localVideoTrack?.dispose() } + localVideoTrack = null + } + // Liberar el micrófono al detener SOS, incluso si hubo error de conexión. + runCatching { localAudioTrack?.setEnabled(false) } + runCatching { peerConnection?.close() } + runCatching { peerConnection?.dispose() } + peerConnection = null + runCatching { localAudioTrack?.dispose() } + localAudioTrack = null + runCatching { audioSource?.dispose() } + audioSource = null + runCatching { videoSource?.dispose() } + videoSource = null + runCatching { surfaceTextureHelper?.dispose() } + surfaceTextureHelper = null + runCatching { signalingClient?.close() } + signalingClient = null + } + fun stopStreaming() { + // Invalidar inmediatamente cualquier inicio pendiente, antes de lanzar coroutine. + val generation = streamGeneration.incrementAndGet() scope.launch { - try { - Log.e(TAG, "Stopping streaming") - - autoStopJob?.cancel() - autoStopJob = null - - localVideoTrack?.dispose() - localVideoTrack = null - - cameraVideoCapturer?.stopCapture() - cameraVideoCapturer = null - - videoSource?.dispose() - videoSource = null - - surfaceTextureHelper?.dispose() - surfaceTextureHelper = null - - peerConnection?.close() - peerConnection = null - - signalingClient?.close() - signalingClient = null - + streamMutex.withLock { + // Si un nuevo inicio legítimo llegó después, no detenerlo. + if (generation != streamGeneration.get()) return@withLock + Log.i(TAG, "Stopping streaming generation=$generation") + cleanupStream() callback?.onStreamStopped() - } catch (e: Exception) { - Log.e(TAG, "Error stopping streaming", e) - callback?.onError("Error al detener streaming: ${e.message}") } } } - /** - * Libera recursos - */ fun release() { - Log.e(TAG, "Releasing WebRTCManager") - stopStreaming() - - peerConnectionFactory?.dispose() - peerConnectionFactory = null - - eglBase?.release() - eglBase = null - - callback = null + releasing = true + streamGeneration.incrementAndGet() + scope.launch { + streamMutex.withLock { + cleanupStream() + runCatching { peerConnectionFactory?.dispose() } + peerConnectionFactory = null + runCatching { eglBase?.release() } + eglBase = null + callback = null + } + } } } 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 da23942..7f19e7c 100644 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/AuthViewModel.kt +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/AuthViewModel.kt @@ -194,7 +194,7 @@ class AuthViewModel @Inject constructor( when (e.statusCode) { 401 -> - "QR o PIN incorrecto" + "Error 401: ${e.message}" 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 9449167..b15ab5e 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,6 +27,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import com.bodycamera.twentyfoulabs.data.backend.BackendConfig +import com.bodycamera.twentyfoulabs.data.backend.DeviceIdentity import com.bodycamera.twentyfoulabs.qr.QrScannerActivity import org.json.JSONObject @@ -50,6 +51,24 @@ fun LoginScreen( ) { val context = LocalContext.current + val deviceIdentity = remember(context) { + DeviceIdentity(context.applicationContext) + } + + var configuredSerial by remember { + mutableStateOf(deviceIdentity.configuredSerial()) + } + + if (configuredSerial == null) { + DeviceSerialSetup( + onConfigure = { serial -> + deviceIdentity.configureSerial(serial) + configuredSerial = deviceIdentity.requireSerial() + } + ) + return + } + var mode by remember { mutableStateOf(LoginMode.QR) } var email by remember { @@ -206,7 +225,7 @@ fun LoginScreen( Text( text = - "${BackendConfig.DEVICE_MODEL} - ${BackendConfig.DEVICE_SERIAL}", + "${BackendConfig.DEVICE_MODEL} - $configuredSerial", color = Color(0xFF8FAEC7), style = MaterialTheme.typography.labelSmall @@ -403,6 +422,14 @@ fun LoginScreen( ) } + Text( + text = if (state is AuthUiState.Error) + "ERROR: ${state.message}" + else + "PRUEBA VISUAL: ZONA DE MENSAJES", + color = Color.Yellow, + style = MaterialTheme.typography.bodySmall + ) TextButton( onClick = { @@ -801,3 +828,75 @@ private fun extractQrToken( it.length > 4 } } + + +@Composable +private fun DeviceSerialSetup( + onConfigure: (String) -> Unit +) { + var serial by remember { mutableStateOf("") } + var error by remember { mutableStateOf(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 4f61225..e6ea71d 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,6 +25,9 @@ import java.util.* import android.view.MotionEvent import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.input.pointer.pointerInteropFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import org.webrtc.SurfaceViewRenderer private val Navy = Color(0xFF0B1F33) private val Navy2 = Color(0xFF123551) @@ -85,11 +88,35 @@ fun MainScreen( verticalArrangement = Arrangement.spacedBy(10.dp) ) { OperationalHeader(deviceStatus, streamingState, isLocked) + if (streamingState is StreamingState.Connecting || streamingState is StreamingState.Active) { + SosWebRtcPreview(viewModel) + Button( + onClick = { + android.util.Log.i("MainScreen", "DETENER SOS pulsado (boton dedicado)") + viewModel.stopVideoFromUi() + }, + modifier = Modifier.fillMaxWidth().height(56.dp), + colors = ButtonDefaults.buttonColors(containerColor = Danger) + ) { + Icon(Icons.Default.StopCircle, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("DETENER TRANSMISION SOS", fontWeight = FontWeight.Bold) + } + } QuickActions( streamingState = streamingState, locked = isLocked, pttPressed = pttPressed, - onVideo = onOpenVideoCamera, + onVideo = { + if (streamingState is StreamingState.Active || + streamingState is StreamingState.Connecting + ) { + android.util.Log.i("MainScreen", "DETENER SOS pulsado (acciones rapidas)") + viewModel.stopVideoFromUi() + } else { + onOpenVideoCamera() + } + }, onPhoto = onOpenPhotoCamera, onPttDown = { if (!pttPressed) { pttPressed = viewModel.startPttFromUi() } }, onPttUp = { viewModel.stopPttFromUi(); pttPressed = false }, @@ -182,7 +209,9 @@ private fun QuickActions( ActionTile( Modifier.weight(1f), if (streamingState is StreamingState.Active || streamingState is StreamingState.Connecting) "DETENER" else "VIDEO", if (streamingState is StreamingState.Active) Icons.Default.StopCircle else Icons.Default.Videocam, - if (streamingState is StreamingState.Active) Danger else Accent, !locked, onVideo + if (streamingState is StreamingState.Active) Danger else Accent, + !locked || streamingState is StreamingState.Active || streamingState is StreamingState.Connecting, + onVideo ) ActionTile(Modifier.weight(1f), "FOTO", Icons.Default.PhotoCamera, Navy2, !locked, onPhoto) } @@ -346,3 +375,42 @@ private fun FullScreenMessage(padding: PaddingValues, text: String, progress: Bo private fun formatMb(mb: Long): String = if (mb >= 1024) String.format(Locale.US, "%.1f GB", mb / 1024.0) else "$mb MB" private fun formatTime(ts: Long): String = if (ts <= 0) "-" else SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(ts)) + + +/** Vista local del MISMO track WebRTC del SOS; no abre una segunda cámara. */ +@Composable +private fun SosWebRtcPreview(viewModel: MainViewModel) { + val context = LocalContext.current + val renderer = remember(context) { SurfaceViewRenderer(context) } + var initialized by remember(renderer) { mutableStateOf(false) } + + DisposableEffect(renderer, viewModel) { + val eglContext = viewModel.previewEglContext() + if (eglContext != null) { + renderer.init(eglContext, null) + renderer.setMirror(true) + renderer.setEnableHardwareScaler(true) + viewModel.attachPreviewSink(renderer) + initialized = true + } + onDispose { + if (initialized) { + viewModel.detachPreviewSink(renderer) + renderer.release() + initialized = false + } + } + } + + Card(colors = CardDefaults.cardColors(containerColor = SurfaceDark), shape = RoundedCornerShape(14.dp)) { + Column(Modifier.fillMaxWidth().padding(8.dp)) { + Text("VISTA PREVIA SOS - CAMARA FRONTAL", color = Color.White, + style = MaterialTheme.typography.labelMedium) + if (initialized) { + AndroidView(factory = { renderer }, modifier = Modifier.fillMaxWidth().height(190.dp)) + } else { + Text("Preparando vista previa...", color = Color.White) + } + } + } +} 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 aac0489..d537823 100644 --- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/MainViewModel.kt +++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/MainViewModel.kt @@ -93,6 +93,8 @@ private fun vibrateRecordingStopped() { vibrator.vibrate(pattern, -1) } } + private var activeWhipUrl: String? = null + val uiState: StateFlow = combine( repository.getConfigFlow(), networkFallbackManager.fallbackState @@ -118,11 +120,11 @@ private fun vibrateRecordingStopped() { private var streamingCallback: WebRTCManager.StreamingCallback? = null - // Debounce para prevenir múltiples presiones rápidas del botón + // Debounce para prevenir multiples presiones rapidas del boton private var lastButtonPressTime = 0L // Estado solicitado por el operador. Se mantiene separado del estado de red WebRTC - // para que una segunda pulsación de F5 SIEMPRE detenga cámara/stream aunque WHIP falle. + // para que una segunda pulsacion de F5 SIEMPRE detenga camara/stream aunque WHIP falle. @Volatile private var streamingRequested = false @@ -161,8 +163,90 @@ private fun vibrateRecordingStopped() { _deviceStatus.value = DeviceStatus(telemetry, info, evidence, events) } - fun triggerSosFromUi() { - deviceController.triggerSos { } + private val sosGeneration = java.util.concurrent.atomic.AtomicLong(0) + // Mantener el estado incluso mientras el backend confirma el SOS o WebRTC conecta. + private val sosSessionActive = java.util.concurrent.atomic.AtomicBoolean(false) + fun isSosSessionActive(): Boolean = sosSessionActive.get() + private val sosInProgress = + java.util.concurrent.atomic.AtomicBoolean(false) + + fun triggerSosFromUi( + onResult: ((Result) -> Unit)? = null + ) { + if (!sosSessionActive.compareAndSet(false, true)) { + Log.w(TAG, "SOS ya activo o conectando: no generar otra alerta") + return + } + if (!sosInProgress.compareAndSet(false, true)) { + sosSessionActive.set(false) + Log.w(TAG, "SOS ya en proceso") + return + } + + val requestGeneration = sosGeneration.incrementAndGet() + deviceController.triggerSosWithVideo( + callback = { result -> + sosInProgress.set(false) + + if (result.isSuccess) { + Log.i(TAG, "SOS registrado id=${result.getOrNull()}") + } else { + Log.e(TAG, "Error enviando SOS", result.exceptionOrNull()) + if (requestGeneration == sosGeneration.get()) sosSessionActive.set(false) + } + + onResult?.invoke(result) + refreshDeviceStatus() + }, + onVideoTarget = { streamPath, whipUrl -> + viewModelScope.launch { + try { + if (requestGeneration != sosGeneration.get()) return@launch + val directWhipUrl = + com.bodycamera.twentyfoulabs.data.backend.BackendConfig.MEDIAMTX_BASE_URL + + "/" + streamPath.trim('/') + "/whip" + +val uri = java.net.URI(directWhipUrl) + + require(uri.scheme == "http") + require(!uri.host.isNullOrBlank()) + require(uri.path == "/$streamPath/whip") + + val config = videoConfigRepository.getConfig() + ?: VideoConfiguration() + + val sosConfig = config.copy( + camera = config.camera.copy( + cameraFacing = com.bodycamera.twentyfoulabs.data.model.CameraFacing.FRONT, + cameraId = null + ), + webrtcServer = config.webrtcServer.copy( + serverUrl = uri.host, + serverPort = if (uri.port > 0) uri.port else 80, + streamName = streamPath + ) + ) + + if (requestGeneration != sosGeneration.get()) return@launch + Log.i(TAG, "Iniciando video SOS: $directWhipUrl") + + activeWhipUrl = directWhipUrl + streamingRequested = true + _streamingState.value = StreamingState.Connecting + + webrtcManager.startStreaming(sosConfig) + + } catch (e: Exception) { + Log.e(TAG, "Error iniciando video SOS", e) + streamingRequested = false + _streamingState.value = StreamingState.Error( + e.message ?: "Error iniciando video SOS" + ) + } + } + } + ) + refreshDeviceStatus() } @@ -184,14 +268,24 @@ private fun vibrateRecordingStopped() { private fun initializeStreamingCallback() { streamingCallback = object : WebRTCManager.StreamingCallback { override fun onStreamStarted() { + if (!streamingRequested) { + Log.w(TAG, "Ignoring late onStreamStarted after stop") + webrtcManager.stopStreaming() + return + } Log.e(TAG, "Stream started - updating state to Active") - deviceController.notifyVideoStarted( - com.bodycamera.twentyfoulabs.data.backend.BackendConfig.whipUrl - ) + val startedWhipUrl = activeWhipUrl + if (startedWhipUrl != null) { + deviceController.notifyVideoStarted(startedWhipUrl) + } else { + Log.w(TAG, "Video iniciado sin dirección WHIP registrada") + } mainImmediateScope.launch { - _streamingState.value = StreamingState.Active(600) - vibrateRecordingStarted() + if (streamingRequested) { + _streamingState.value = StreamingState.Active(600) + vibrateRecordingStarted() + } } } @@ -199,6 +293,7 @@ override fun onStreamStopped() { Log.e(TAG, "Stream stopped - updating state to Stopped") deviceController.notifyVideoStopped() streamingRequested = false + activeWhipUrl = null mainImmediateScope.launch { _streamingState.value = StreamingState.Stopped @@ -209,9 +304,10 @@ 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 está conectado, pasar a Active + // Si estaba conectando y ahora esta conectado, pasar a Active if (isConnected) { Log.e(TAG, "Connection established, changing to Active") StreamingState.Active(600) @@ -236,7 +332,8 @@ override fun onStreamStopped() { Log.e(TAG, "Streaming error: $error - forcing media cleanup") deviceController.notifyVideoError(error) streamingRequested = false - // Si WHIP/signaling falla, no dejar la cámara capturando indefinidamente. + activeWhipUrl = null + // Si WHIP/signaling falla, no dejar la camara capturando indefinidamente. webrtcManager.stopStreaming() mainImmediateScope.launch { _streamingState.value = StreamingState.Error(error) @@ -263,7 +360,7 @@ override fun onStreamStopped() { fun onEvent(event: MainUiEvent) { when (event) { MainUiEvent.RetryLoad -> { - // El flow se actualiza automáticamente + // El flow se actualiza automaticamente } MainUiEvent.ImportConfigFromFile -> { // TODO: Implement file picker @@ -281,13 +378,13 @@ override fun onStreamStopped() { } /** - * Método público para ser llamado desde el botón físico de cámara (KEYCODE_F5). - * Verifica si la app está bloqueada antes de iniciar/detener streaming. + * Metodo publico para ser llamado desde el boton fisico de camara (KEYCODE_F5). + * Verifica si la app esta bloqueada antes de iniciar/detener streaming. */ fun triggerCameraButton() { val currentTime = System.currentTimeMillis() if (currentTime - lastButtonPressTime < BUTTON_DEBOUNCE_MS) { - Log.e(TAG, "Botón presionado muy rápido, ignorando (debounce)") + Log.e(TAG, "Boton presionado muy rapido, ignorando (debounce)") return } lastButtonPressTime = currentTime @@ -297,7 +394,7 @@ override fun onStreamStopped() { val isLocked = adminLockRepository.isLocked() Log.e(TAG, "isLocked: $isLocked") if (isLocked) { - Log.e(TAG, "App bloqueada - botón cámara ignorado") + Log.e(TAG, "App bloqueada - boton camara ignorado") return@launch } Log.e(TAG, "App desbloqueada - llamando a toggleStreaming") @@ -309,22 +406,57 @@ override fun onStreamStopped() { Log.e(TAG, "toggleStreaming llamado, requested=$streamingRequested, estado=${_streamingState.value}") viewModelScope.launch { if (streamingRequested) { - Log.e(TAG, "F5 STOP solicitado - liberando cámara/WebRTC") + Log.e(TAG, "F5 STOP solicitado - liberando camara/WebRTC") streamingRequested = false webrtcManager.stopStreaming() _streamingState.value = StreamingState.Stopped return@launch } - val config = videoConfigRepository.getConfig() - if (config == null) { - Log.e(TAG, "No hay configuración de video") - streamingRequested = false - _streamingState.value = StreamingState.Error("No hay configuración de video") + val savedConfig = videoConfigRepository.getConfig() + + val config = when { + savedConfig == null -> VideoConfiguration().also { + videoConfigRepository.saveConfig(it) + } + + savedConfig.webrtcServer.serverUrl == "192.168.18.57" -> + savedConfig.copy( + webrtcServer = savedConfig.webrtcServer.copy( + serverUrl = "172.93.110.24", + serverPort = 18889 + ) + ).also { + videoConfigRepository.saveConfig(it) + } + + else -> savedConfig + } + + val server = config.webrtcServer + + if (server.serverUrl == "192.168.18.57") { + _streamingState.value = StreamingState.Error( + "La configuración guardada utiliza el servidor anterior. Actualiza la configuración de video." + ) return@launch } - Log.e(TAG, "F5 START solicitado: ${config.webrtcServer.serverUrl}:${config.webrtcServer.serverPort}") + if (server.serverUrl.isBlank() || + server.serverPort !in 1..65535 || + server.streamName.isBlank() + ) { + _streamingState.value = StreamingState.Error( + "La configuración del servidor de video no es válida." + ) + return@launch + } + + val normalWhipUrl = + "http://${server.serverUrl}:${server.serverPort}/${server.streamName.trim('/')}/whip" + + Log.i(TAG, "F5 START solicitado: $normalWhipUrl") + activeWhipUrl = normalWhipUrl streamingRequested = true _streamingState.value = StreamingState.Connecting try { @@ -338,6 +470,18 @@ override fun onStreamStopped() { } } + fun stopVideoFromUi() { + Log.i(TAG, "DETENER solicitado (pantalla o F7)") + sosSessionActive.set(false) + sosGeneration.incrementAndGet() + streamingRequested = false + // Mantener DETENER visible hasta que WebRTC confirme liberacion de camara. + webrtcManager.stopStreaming() + } + fun previewEglContext(): org.webrtc.EglBase.Context? = webrtcManager.previewEglContext() + fun attachPreviewSink(sink: org.webrtc.VideoSink) = webrtcManager.attachPreviewSink(sink) + fun detachPreviewSink(sink: org.webrtc.VideoSink) = webrtcManager.detachPreviewSink(sink) + override fun onCleared() { super.onCleared() webrtcManager.release() diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index 950f75b..7a3c868 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -10,6 +10,7 @@ localhost 127.0.0.1 35.224.197.127 + 172.93.110.24 From 41b81c972a9a21c583ca626066c00e92a53a0a95 Mon Sep 17 00:00:00 2001 From: Miguel Grandez Date: Wed, 30 Sep 2026 10:50:19 -0500 Subject: [PATCH 6/7] feat: integrar GPS offline, telemetria y manejo de sesion M530 --- app/build.gradle.kts | 7 + .../bodycamera/twentyfoulabs/MainActivity.kt | 14 +- .../twentyfoulabs/data/auth/AuthRepository.kt | 74 ++- .../data/auth/AuthSessionStore.kt | 8 + .../data/backend/BackendIntegrationManager.kt | 625 +++++++++++++++++- .../data/device/M530DeviceController.kt | 139 +++- .../data/gps/GpsBufferRepository.kt | 61 ++ .../twentyfoulabs/data/gps/GpsDatabase.kt | 14 + .../twentyfoulabs/data/gps/PendingGpsDao.kt | 46 ++ .../data/gps/PendingGpsEntity.kt | 27 + .../twentyfoulabs/di/DatabaseModule.kt | 34 + .../twentyfoulabs/ui/screens/AuthViewModel.kt | 12 +- .../twentyfoulabs/ui/screens/MainViewModel.kt | 215 +++++- 13 files changed, 1190 insertions(+), 86 deletions(-) create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/gps/GpsBufferRepository.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/gps/GpsDatabase.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/gps/PendingGpsDao.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/gps/PendingGpsEntity.kt create mode 100644 app/src/main/kotlin/com/bodycamera/twentyfoulabs/di/DatabaseModule.kt 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() ) + + + + + From 1b130a9ed8c87396eaf7704373d036bab4c06662 Mon Sep 17 00:00:00 2001 From: Miguel Grandez Date: Wed, 30 Sep 2026 15:59:13 -0500 Subject: [PATCH 7/7] feat: completar integracion M530 backend, telemetria y cierre SOS central --- .../twentyfoulabs/data/auth/AuthApiClient.kt | 20 +++++++++++ .../twentyfoulabs/data/auth/AuthModels.kt | 18 ++++++++++ .../twentyfoulabs/data/auth/AuthRepository.kt | 33 +++++++++++++++++-- .../data/device/M530DeviceController.kt | 11 +++++-- .../twentyfoulabs/ui/screens/MainViewModel.kt | 5 +++ 5 files changed, 82 insertions(+), 5 deletions(-) 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 e09a228..0d79252 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,5 +1,7 @@ package com.bodycamera.twentyfoulabs.data.auth +import android.util.Log + import com.bodycamera.twentyfoulabs.data.backend.BackendConfig import com.bodycamera.twentyfoulabs.data.backend.DeviceIdentity import kotlinx.coroutines.Dispatchers @@ -44,6 +46,18 @@ 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( @@ -173,3 +187,9 @@ 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 7aa7d8e..542ac50 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,6 +11,11 @@ data class LoginRequest( @SerialName("device_model") val deviceModel: String? = null ) +@Serializable +data class RefreshRequest( + @SerialName("refresh_token") val refreshToken: String +) + @Serializable data class QrLoginRequest( @SerialName("qr_token") val qrToken: String, @@ -63,6 +68,15 @@ data class LoginResponse( } } +@Serializable +data class RefreshResponse( + @SerialName("access_token") val accessToken: String? = null, + @SerialName("refresh_token") val refreshToken: String? = null, + @SerialName("token_type") val tokenType: String? = null, + @SerialName("expires_in") val expiresIn: Int? = null, + @SerialName("server_time") val serverTime: String? = null +) + @Serializable data class MeResponse( val user: AuthUser, @@ -88,3 +102,7 @@ data class AuthSession( val device: AuthDevice? = null, val refreshToken: String? = null ) + + + + 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 67f3454..b30c02d 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 @@ -127,11 +127,21 @@ session } catch (e: AuthApiException) { - if (e.statusCode == 401) { + if (e.statusCode == 401 && !refreshToken.isNullOrBlank()) { + android.util.Log.i("AuthRepository", "401 inicial detectado - intentando refresh") + val refreshed = api.refresh(RefreshRequest(refreshToken)) + val newToken = refreshed.accessToken?.takeIf { it.isNotBlank() } ?: throw e + val refreshedResponse = try { api.me(newToken) } catch (retryError: AuthApiException) { if (retryError.statusCode == 401) { store.clear(); android.util.Log.i("AuthRepository", "Segundo 401 - sesion eliminada"); throw retryError } else { throw retryError } } + val newRefreshToken = refreshed.refreshToken?.takeIf { it.isNotBlank() } ?: refreshToken + val refreshedSession = AuthSession(token = newToken, user = refreshedResponse.user, device = refreshedResponse.device ?: refreshedResponse.user.assignedDevice, refreshToken = newRefreshToken) + store.save(refreshedSession) + refreshedSession + } else if (e.statusCode == 401) { store.clear() + throw e + } else { + throw e } - - throw e } } @@ -153,3 +163,20 @@ suspend fun logout() { } } } + + + + + + + + + + + + + + + + + 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 c1483be..33a227d 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,6 +1,7 @@ -package com.bodycamera.twentyfoulabs.data.device +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 @@ -82,8 +83,11 @@ class M530DeviceController @Inject constructor( ) } + 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) }) } @@ -460,7 +464,7 @@ Result.success(file) } 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 } @@ -609,3 +613,6 @@ Result.success(file) + + + 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 366a996..ded9e2f 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 @@ -203,6 +203,10 @@ private fun vibrateRecordingStopped() { 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, @@ -668,3 +672,4 @@ data class DeviceStatus( +