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