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