feat: integrar mejoras de SOS, PTT, autenticacion y video M530

This commit is contained in:
Miguel Angel 2026-09-26 10:49:54 -05:00
parent a12ae5da8f
commit 51ccd80070
17 changed files with 695 additions and 202 deletions

View file

@ -1,4 +1,4 @@
package com.bodycamera.twentyfoulabs package com.bodycamera.twentyfoulabs
import android.Manifest import android.Manifest
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
@ -275,7 +275,7 @@ class MainActivity : ComponentActivity() {
pttManager.stop() pttManager.stop()
} }
if (isAuthenticated && !frontPreviewOpened) { if (false && isAuthenticated && !frontPreviewOpened) {
frontPreviewOpened = true frontPreviewOpened = true
runOnUiThread { runOnUiThread {
openFrontPreview() openFrontPreview()
@ -479,10 +479,18 @@ class MainActivity : ComponentActivity() {
) )
} }
private fun handleSosButton() { 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...") toast("Enviando SOS...")
deviceController.triggerSos { result -> viewModel.triggerSosFromUi { result ->
runOnUiThread { runOnUiThread {
@ -633,6 +641,8 @@ fun AppNavigation(viewModel: MainViewModel, onOpenPhotoCamera: () -> Unit, onOpe
onOpenPhotoCamera = onOpenPhotoCamera, onOpenPhotoCamera = onOpenPhotoCamera,
onOpenVideoCamera = onOpenVideoCamera, onOpenVideoCamera = onOpenVideoCamera,
onLogout = { onLogout = {
// Liberar WebRTC antes de mostrar nuevamente el lector del fotocheck.
viewModel.stopVideoFromUi()
authViewModel.logout() authViewModel.logout()
navController.navigate("login") { popUpTo("main") { inclusive = true } } navController.navigate("login") { popUpTo("main") { inclusive = true } }
} }

View file

@ -1,6 +1,7 @@
package com.bodycamera.twentyfoulabs.data.auth package com.bodycamera.twentyfoulabs.data.auth
import com.bodycamera.twentyfoulabs.data.backend.BackendConfig import com.bodycamera.twentyfoulabs.data.backend.BackendConfig
import com.bodycamera.twentyfoulabs.data.backend.DeviceIdentity
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
@ -12,7 +13,9 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class AuthApiClient @Inject constructor() { class AuthApiClient @Inject constructor(
private val deviceIdentity: DeviceIdentity
) {
private val json = Json { private val json = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
@ -96,7 +99,7 @@ class AuthApiClient @Inject constructor() {
setRequestProperty( setRequestProperty(
"X-Device-Serial", "X-Device-Serial",
BackendConfig.DEVICE_SERIAL deviceIdentity.requireSerial()
) )
if (!token.isNullOrBlank()) { if (!token.isNullOrBlank()) {

View file

@ -1,6 +1,7 @@
package com.bodycamera.twentyfoulabs.data.auth package com.bodycamera.twentyfoulabs.data.auth
import com.bodycamera.twentyfoulabs.data.backend.BackendConfig import com.bodycamera.twentyfoulabs.data.backend.BackendConfig
import com.bodycamera.twentyfoulabs.data.backend.DeviceIdentity
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -8,7 +9,8 @@ import javax.inject.Singleton
@Singleton @Singleton
class AuthRepository @Inject constructor( class AuthRepository @Inject constructor(
private val api: AuthApiClient, private val api: AuthApiClient,
private val store: AuthSessionStore private val store: AuthSessionStore,
private val deviceIdentity: DeviceIdentity
) { ) {
suspend fun login( suspend fun login(
@ -20,7 +22,7 @@ class AuthRepository @Inject constructor(
LoginRequest( LoginRequest(
email = email.trim(), email = email.trim(),
password = password, password = password,
deviceSerial = BackendConfig.DEVICE_SERIAL, deviceSerial = deviceIdentity.requireSerial(),
deviceModel = BackendConfig.DEVICE_MODEL deviceModel = BackendConfig.DEVICE_MODEL
) )
) )
@ -46,7 +48,7 @@ class AuthRepository @Inject constructor(
QrLoginRequest( QrLoginRequest(
qrToken = qrToken.trim(), qrToken = qrToken.trim(),
pin = pin.trim(), pin = pin.trim(),
deviceSerial = BackendConfig.DEVICE_SERIAL, deviceSerial = deviceIdentity.requireSerial(),
deviceModel = BackendConfig.DEVICE_MODEL deviceModel = BackendConfig.DEVICE_MODEL
) )
) )

View file

@ -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 { 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 HOST = "192.168.18.57"
const val MQTT_PORT = 1883 const val MQTT_PORT = 1883
const val HTTP_PORT = 8000 const val HTTP_PORT = 8000
const val MQTT_CLIENT_PREFIX = "m530" const val MQTT_CLIENT_PREFIX = "m530"
const val STREAM_ID = "webrtc_camera_stream" const val STREAM_ID = "webrtc_camera_stream"
// Rails mobile API exposed through ngrok HTTPS. val httpBaseUrl: String
const val API_BASE_URL = "https://bodycam-prd-web-nefjf74quq-uc.a.run.app" get() = "http://$HOST:$HTTP_PORT"
const val DEVICE_SERIAL = "BC-M530-001"
const val DEVICE_MODEL = "Recoda M530"
val httpBaseUrl: String get() = "http://$HOST:$HTTP_PORT" val whipUrl: String
val whipUrl: String get() = "http://$HOST:8889/$STREAM_ID/whip" 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"
} }

View file

@ -21,14 +21,15 @@ import javax.inject.Singleton
@Singleton @Singleton
class BackendIntegrationManager @Inject constructor( class BackendIntegrationManager @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val authSessionStore: AuthSessionStore private val authSessionStore: AuthSessionStore,
private val deviceIdentity: DeviceIdentity
) { ) {
companion object { private const val TAG = "BackendIntegration" } companion object { private const val TAG = "BackendIntegration" }
private val mqttExecutor = Executors.newSingleThreadExecutor() private val mqttExecutor = Executors.newSingleThreadExecutor()
private val httpExecutor = Executors.newSingleThreadExecutor() private val httpExecutor = Executors.newSingleThreadExecutor()
private val mqtt = SimpleMqttPublisher(BackendConfig.HOST, BackendConfig.MQTT_PORT) 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 clientId: String get() = "${BackendConfig.MQTT_CLIENT_PREFIX}-$deviceId-${UUID.randomUUID().toString().take(6)}"
private val pendingFile by lazy { File(context.filesDir, "mqtt_pending.jsonl") } private val pendingFile by lazy { File(context.filesDir, "mqtt_pending.jsonl") }
@ -114,7 +115,7 @@ class BackendIntegrationManager @Inject constructor(
setRequestProperty("Authorization", "Bearer $token") setRequestProperty("Authorization", "Bearer $token")
setRequestProperty( setRequestProperty(
"X-Device-Serial", "X-Device-Serial",
BackendConfig.DEVICE_SERIAL deviceIdentity.requireSerial()
) )
} }
@ -140,8 +141,14 @@ class BackendIntegrationManager @Inject constructor(
} }
} }
fun publishTelemetry(payload: JSONObject) { fun publishTelemetry(payload: JSONObject) {
val serial = deviceIdentity.configuredSerial()
if (serial == null) {
Log.w(TAG, "Telemetria omitida: serial aun no configurado")
return
}
val envelope = JSONObject() val envelope = JSONObject()
.put("deviceId", BackendConfig.DEVICE_SERIAL) .put("deviceId", serial)
.put("timestamp", System.currentTimeMillis()) .put("timestamp", System.currentTimeMillis())
.put("payload", payload) .put("payload", payload)
httpExecutor.execute { httpExecutor.execute {
@ -167,7 +174,7 @@ class BackendIntegrationManager @Inject constructor(
setRequestProperty("Authorization", "Bearer $token") setRequestProperty("Authorization", "Bearer $token")
setRequestProperty( setRequestProperty(
"X-Device-Serial", "X-Device-Serial",
BackendConfig.DEVICE_SERIAL deviceIdentity.requireSerial()
) )
} }
@ -214,24 +221,16 @@ class BackendIntegrationManager @Inject constructor(
"FAVORITE ultimo media_item guardado: id=$uploadedMediaId" "FAVORITE ultimo media_item guardado: id=$uploadedMediaId"
) )
} }
val mediaPayload = JSONObject() Log.i(
.put("kind", kind) TAG,
.put("fileName", file.name) "MEDIA HTTPS OK: archivo=${file.name}, tipo=$kind, id=${uploadedMediaId ?: "sin_id"}"
.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) { } catch (t: Throwable) {
Log.e(TAG, "Upload failed: ${file.name}", t) Log.e(
publishNow("m530/$deviceId/events/media_upload_failed", JSONObject() TAG,
.put("deviceId", deviceId).put("timestamp", System.currentTimeMillis()) "MEDIA HTTPS ERROR: archivo=${file.name}, tipo=$kind",
.put("fileName", file.name).put("kind", kind).put("error", t.message ?: "unknown")) t
)
} }
} }
} }
@ -297,7 +296,7 @@ class BackendIntegrationManager @Inject constructor(
setRequestProperty( setRequestProperty(
"X-Device-Serial", "X-Device-Serial",
BackendConfig.DEVICE_SERIAL deviceIdentity.requireSerial()
) )
setRequestProperty( 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( callback(
Result.success(mediaId) Result.success(mediaId)
@ -388,7 +371,8 @@ class BackendIntegrationManager @Inject constructor(
fun sendSosAlert( fun sendSosAlert(
latitude: Double?, latitude: Double?,
longitude: Double?, longitude: Double?,
callback: (Result<Int>) -> Unit callback: (Result<Int>) -> Unit,
onVideoTarget: ((String, String) -> Unit)? = null
) { ) {
httpExecutor.execute { httpExecutor.execute {
@ -438,7 +422,7 @@ class BackendIntegrationManager @Inject constructor(
setRequestProperty( setRequestProperty(
"X-Device-Serial", "X-Device-Serial",
BackendConfig.DEVICE_SERIAL deviceIdentity.requireSerial()
) )
setRequestProperty( setRequestProperty(
@ -454,7 +438,7 @@ class BackendIntegrationManager @Inject constructor(
.put("notes", "Boton panico F7") .put("notes", "Boton panico F7")
.put( .put(
"device_serial", "device_serial",
BackendConfig.DEVICE_SERIAL deviceIdentity.requireSerial()
) )
.toString() .toString()
@ -524,6 +508,19 @@ class BackendIntegrationManager @Inject constructor(
?: error( ?: error(
"Respuesta SOS sin ID de alerta" "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( Log.i(
TAG, TAG,
@ -692,7 +689,7 @@ class BackendIntegrationManager @Inject constructor(
setRequestProperty( setRequestProperty(
"X-Device-Serial", "X-Device-Serial",
BackendConfig.DEVICE_SERIAL deviceIdentity.requireSerial()
) )
setRequestProperty( setRequestProperty(

View file

@ -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"
}
}
}

View file

@ -22,6 +22,7 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import com.bodycamera.twentyfoulabs.data.backend.BackendIntegrationManager import com.bodycamera.twentyfoulabs.data.backend.BackendIntegrationManager
import com.bodycamera.twentyfoulabs.data.backend.DeviceIdentity
import org.json.JSONObject import org.json.JSONObject
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -34,7 +35,8 @@ import javax.inject.Singleton
@Singleton @Singleton
class M530DeviceController @Inject constructor( class M530DeviceController @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val backend: BackendIntegrationManager private val backend: BackendIntegrationManager,
private val deviceIdentity: DeviceIdentity
) { ) {
companion object { private const val TAG = "M530DeviceController" } companion object { private const val TAG = "M530DeviceController" }
@ -68,7 +70,7 @@ class M530DeviceController @Inject constructor(
manufacturer = Build.MANUFACTURER ?: "Android", manufacturer = Build.MANUFACTURER ?: "Android",
androidVersion = Build.VERSION.RELEASE ?: "12", androidVersion = Build.VERSION.RELEASE ?: "12",
sdk = Build.VERSION.SDK_INT, sdk = Build.VERSION.SDK_INT,
serial = runCatching { Build.getSerial() }.getOrDefault("M530") serial = deviceIdentity.configuredSerial() ?: "Sin configurar"
) )
fun capturePhoto(lifecycleOwner: LifecycleOwner, callback: (Result<File>) -> Unit) { fun capturePhoto(lifecycleOwner: LifecycleOwner, callback: (Result<File>) -> Unit) {
@ -86,7 +88,13 @@ class M530DeviceController @Inject constructor(
capture.takePicture(ImageCapture.OutputFileOptions.Builder(outputFile).build(), ioExecutor, capture.takePicture(ImageCapture.OutputFileOptions.Builder(outputFile).build(), ioExecutor,
object : ImageCapture.OnImageSavedCallback { object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(r: ImageCapture.OutputFileResults) { 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) { override fun onError(e: ImageCaptureException) {
logEvent("PHOTO_ERROR", JSONObject().put("error", e.message ?: "unknown")); provider.unbindAll(); callback(Result.failure(e)) logEvent("PHOTO_ERROR", JSONObject().put("error", e.message ?: "unknown")); provider.unbindAll(); callback(Result.failure(e))
@ -285,12 +293,14 @@ Result.success(file)
micRecorder != null micRecorder != null
fun triggerSos(): SosResult { fun triggerSos(): SosResult {
val location = val location = bestLastKnownLocation()?.takeIf {
Location("M530").apply { System.currentTimeMillis() - it.time in 0..300_000L
latitude = -12.077 } ?: Location("simulated").apply {
longitude = -77.092 latitude = -12.077
accuracy = 10f longitude = -77.092
} accuracy = 10f
time = System.currentTimeMillis()
}
val payload = val payload =
JSONObject() JSONObject()
@ -309,15 +319,15 @@ Result.success(file)
) )
.put( .put(
"latitude", "latitude",
location.latitude location?.latitude ?: JSONObject.NULL
) )
.put( .put(
"longitude", "longitude",
location.longitude location?.longitude ?: JSONObject.NULL
) )
.put( .put(
"accuracy", "accuracy",
location.accuracy location?.accuracy ?: JSONObject.NULL
) )
backend.publish( backend.publish(
@ -334,6 +344,22 @@ Result.success(file)
) )
} }
fun triggerSosWithVideo(
callback: (Result<Int>) -> Unit,
onVideoTarget: (String, String) -> Unit
): SosResult {
val result = triggerSos()
backend.sendSosAlert(
result.location?.latitude,
result.location?.longitude,
callback,
onVideoTarget
)
return result
}
fun triggerSos( fun triggerSos(
callback: (Result<Int>) -> Unit callback: (Result<Int>) -> Unit
): SosResult { ): SosResult {
@ -411,9 +437,17 @@ Result.success(file)
} }
fun logGps() { fun logGps() {
// COORDENADAS SIMULADAS: exclusivamente para pruebas. val location = bestLastKnownLocation()?.takeIf {
val latitude = -12.077 System.currentTimeMillis() - it.time in 0..300_000L
val longitude = -77.092 }
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( val recordedAt = java.text.SimpleDateFormat(
"yyyy-MM-dd'T'HH:mm:ss'Z'", "yyyy-MM-dd'T'HH:mm:ss'Z'",
@ -425,9 +459,9 @@ Result.success(file)
val payload = JSONObject() val payload = JSONObject()
.put("latitude", latitude) .put("latitude", latitude)
.put("longitude", longitude) .put("longitude", longitude)
.put("speed", 0) .put("speed", if (location.hasSpeed()) location.speed else JSONObject.NULL)
.put("heading", 0) .put("heading", if (location.hasBearing()) location.bearing else JSONObject.NULL)
.put("accuracy", 10) .put("accuracy", location.accuracy)
.put("recorded_at", recordedAt) .put("recorded_at", recordedAt)
backend.publishGps(payload) backend.publishGps(payload)

View file

@ -19,8 +19,8 @@ data class VideoConfiguration(
*/ */
@Serializable @Serializable
data class WebRTCServerConfig( data class WebRTCServerConfig(
val serverUrl: String = "192.168.18.57", val serverUrl: String = "172.93.110.24",
val serverPort: Int = 8889, val serverPort: Int = 18889,
val streamName: String = "webrtc_camera_stream", val streamName: String = "webrtc_camera_stream",
val username: String = "admin", val username: String = "admin",
val password: String = "admin", val password: String = "admin",

View file

@ -1,6 +1,7 @@
package com.bodycamera.twentyfoulabs.data.ptt package com.bodycamera.twentyfoulabs.data.ptt
import com.bodycamera.twentyfoulabs.data.backend.BackendConfig import com.bodycamera.twentyfoulabs.data.backend.BackendConfig
import com.bodycamera.twentyfoulabs.data.backend.DeviceIdentity
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONObject import org.json.JSONObject
@ -10,7 +11,9 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class PttApiClient @Inject constructor() { class PttApiClient @Inject constructor(
private val deviceIdentity: DeviceIdentity
) {
suspend fun joinRoom( suspend fun joinRoom(
token: String, token: String,
@ -46,7 +49,7 @@ class PttApiClient @Inject constructor() {
setRequestProperty( setRequestProperty(
"X-Device-Serial", "X-Device-Serial",
BackendConfig.DEVICE_SERIAL deviceIdentity.requireSerial()
) )
} }

View file

@ -39,7 +39,7 @@ class PttAudioClient @Inject constructor(
private const val TAG = "PttAudioClient" private const val TAG = "PttAudioClient"
private const val MEDIA_BASE_URL = private const val MEDIA_BASE_URL =
"http://35.224.197.127:8889" "http://172.93.110.24:18889"
private const val STUN_URL = private const val STUN_URL =
"stun:stun.l.google.com:19302" "stun:stun.l.google.com:19302"

View file

@ -19,7 +19,7 @@ class PttCableClient @Inject constructor() {
companion object { companion object {
private const val TAG = "PttCableClient" private const val TAG = "PttCableClient"
private const val CABLE_URL = 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 private const val RECONNECT_DELAY_MS = 3000L
} }

View file

@ -9,6 +9,9 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.atomic.AtomicLong
import org.webrtc.* import org.webrtc.*
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
@ -28,12 +31,39 @@ class WebRTCManager @Inject constructor(
} }
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) 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 peerConnectionFactory: PeerConnectionFactory? = null
private var peerConnection: PeerConnection? = null private var peerConnection: PeerConnection? = null
private var videoSource: VideoSource? = null private var videoSource: VideoSource? = null
private var audioSource: AudioSource? = null
private var localAudioTrack: AudioTrack? = null
private var cameraVideoCapturer: CameraVideoCapturer? = null private var cameraVideoCapturer: CameraVideoCapturer? = null
private var localVideoTrack: VideoTrack? = 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 surfaceTextureHelper: SurfaceTextureHelper? = null
private var eglBase: EglBase? = null private var eglBase: EglBase? = null
private var signalingClient: SignalingClient? = null private var signalingClient: SignalingClient? = null
@ -143,48 +173,50 @@ class WebRTCManager @Inject constructor(
* Inicia el streaming de video real * Inicia el streaming de video real
*/ */
fun startStreaming(config: VideoConfiguration) { fun startStreaming(config: VideoConfiguration) {
if (releasing) return
val generation = streamGeneration.incrementAndGet()
scope.launch { scope.launch {
try { streamMutex.withLock {
val server = config.webrtcServer if (releasing || generation != streamGeneration.get()) return@withLock
try {
if (server.serverUrl.isBlank()) { // Nunca iniciar una segunda cámara mientras se limpia la anterior.
callback?.onError("URL del servidor está vacía") cleanupStream()
return@launch 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() { override fun onFirstFrameAvailable() {
Log.e(TAG, "First frame available - Creating SDP offer") Log.e(TAG, "First frame available - Creating SDP offer")
// Después que la cámara está lista, crear la oferta SDP // Un primer frame tardío no puede reactivar una sesión detenida.
createOffer() if (activeGeneration == streamGeneration.get() && !releasing) createOffer()
} }
override fun onCameraClosed() { override fun onCameraClosed() {
@ -367,19 +399,31 @@ class WebRTCManager @Inject constructor(
capturerObserver 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( cameraVideoCapturer?.startCapture(
config.video.resolution.width, config.video.resolution.width,
config.video.resolution.height, config.video.resolution.height,
config.video.frameRate 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") 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 * 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() { fun stopStreaming() {
// Invalidar inmediatamente cualquier inicio pendiente, antes de lanzar coroutine.
val generation = streamGeneration.incrementAndGet()
scope.launch { scope.launch {
try { streamMutex.withLock {
Log.e(TAG, "Stopping streaming") // Si un nuevo inicio legítimo llegó después, no detenerlo.
if (generation != streamGeneration.get()) return@withLock
autoStopJob?.cancel() Log.i(TAG, "Stopping streaming generation=$generation")
autoStopJob = null cleanupStream()
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() callback?.onStreamStopped()
} catch (e: Exception) {
Log.e(TAG, "Error stopping streaming", e)
callback?.onError("Error al detener streaming: ${e.message}")
} }
} }
} }
/**
* Libera recursos
*/
fun release() { fun release() {
Log.e(TAG, "Releasing WebRTCManager") releasing = true
stopStreaming() streamGeneration.incrementAndGet()
scope.launch {
peerConnectionFactory?.dispose() streamMutex.withLock {
peerConnectionFactory = null cleanupStream()
runCatching { peerConnectionFactory?.dispose() }
eglBase?.release() peerConnectionFactory = null
eglBase = null runCatching { eglBase?.release() }
eglBase = null
callback = null callback = null
}
}
} }
} }

View file

@ -194,7 +194,7 @@ class AuthViewModel @Inject constructor(
when (e.statusCode) { when (e.statusCode) {
401 -> 401 ->
"QR o PIN incorrecto" "Error 401: ${e.message}"
else -> else ->
e.message e.message

View file

@ -27,6 +27,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.bodycamera.twentyfoulabs.data.backend.BackendConfig import com.bodycamera.twentyfoulabs.data.backend.BackendConfig
import com.bodycamera.twentyfoulabs.data.backend.DeviceIdentity
import com.bodycamera.twentyfoulabs.qr.QrScannerActivity import com.bodycamera.twentyfoulabs.qr.QrScannerActivity
import org.json.JSONObject import org.json.JSONObject
@ -50,6 +51,24 @@ fun LoginScreen(
) { ) {
val context = LocalContext.current 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 mode by remember { mutableStateOf(LoginMode.QR) }
var email by remember { var email by remember {
@ -206,7 +225,7 @@ fun LoginScreen(
Text( Text(
text = text =
"${BackendConfig.DEVICE_MODEL} - ${BackendConfig.DEVICE_SERIAL}", "${BackendConfig.DEVICE_MODEL} - $configuredSerial",
color = Color(0xFF8FAEC7), color = Color(0xFF8FAEC7),
style = style =
MaterialTheme.typography.labelSmall 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( TextButton(
onClick = { onClick = {
@ -801,3 +828,75 @@ private fun extractQrToken(
it.length > 4 it.length > 4
} }
} }
@Composable
private fun DeviceSerialSetup(
onConfigure: (String) -> Unit
) {
var serial by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(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")
}
}
}
}
}

View file

@ -25,6 +25,9 @@ import java.util.*
import android.view.MotionEvent import android.view.MotionEvent
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.input.pointer.pointerInteropFilter 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 Navy = Color(0xFF0B1F33)
private val Navy2 = Color(0xFF123551) private val Navy2 = Color(0xFF123551)
@ -85,11 +88,35 @@ fun MainScreen(
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
OperationalHeader(deviceStatus, streamingState, isLocked) 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( QuickActions(
streamingState = streamingState, streamingState = streamingState,
locked = isLocked, locked = isLocked,
pttPressed = pttPressed, 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, onPhoto = onOpenPhotoCamera,
onPttDown = { if (!pttPressed) { pttPressed = viewModel.startPttFromUi() } }, onPttDown = { if (!pttPressed) { pttPressed = viewModel.startPttFromUi() } },
onPttUp = { viewModel.stopPttFromUi(); pttPressed = false }, onPttUp = { viewModel.stopPttFromUi(); pttPressed = false },
@ -182,7 +209,9 @@ private fun QuickActions(
ActionTile( ActionTile(
Modifier.weight(1f), if (streamingState is StreamingState.Active || streamingState is StreamingState.Connecting) "DETENER" else "VIDEO", 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) 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) 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 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)) 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)
}
}
}
}

View file

@ -93,6 +93,8 @@ private fun vibrateRecordingStopped() {
vibrator.vibrate(pattern, -1) vibrator.vibrate(pattern, -1)
} }
} }
private var activeWhipUrl: String? = null
val uiState: StateFlow<MainUiState> = combine( val uiState: StateFlow<MainUiState> = combine(
repository.getConfigFlow(), repository.getConfigFlow(),
networkFallbackManager.fallbackState networkFallbackManager.fallbackState
@ -118,11 +120,11 @@ private fun vibrateRecordingStopped() {
private var streamingCallback: WebRTCManager.StreamingCallback? = null 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 private var lastButtonPressTime = 0L
// Estado solicitado por el operador. Se mantiene separado del estado de red WebRTC // 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 @Volatile
private var streamingRequested = false private var streamingRequested = false
@ -161,8 +163,90 @@ private fun vibrateRecordingStopped() {
_deviceStatus.value = DeviceStatus(telemetry, info, evidence, events) _deviceStatus.value = DeviceStatus(telemetry, info, evidence, events)
} }
fun triggerSosFromUi() { private val sosGeneration = java.util.concurrent.atomic.AtomicLong(0)
deviceController.triggerSos { } // 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<Int>) -> 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() refreshDeviceStatus()
} }
@ -184,14 +268,24 @@ private fun vibrateRecordingStopped() {
private fun initializeStreamingCallback() { private fun initializeStreamingCallback() {
streamingCallback = object : WebRTCManager.StreamingCallback { streamingCallback = object : WebRTCManager.StreamingCallback {
override fun onStreamStarted() { 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") Log.e(TAG, "Stream started - updating state to Active")
deviceController.notifyVideoStarted( val startedWhipUrl = activeWhipUrl
com.bodycamera.twentyfoulabs.data.backend.BackendConfig.whipUrl if (startedWhipUrl != null) {
) deviceController.notifyVideoStarted(startedWhipUrl)
} else {
Log.w(TAG, "Video iniciado sin dirección WHIP registrada")
}
mainImmediateScope.launch { mainImmediateScope.launch {
_streamingState.value = StreamingState.Active(600) if (streamingRequested) {
vibrateRecordingStarted() _streamingState.value = StreamingState.Active(600)
vibrateRecordingStarted()
}
} }
} }
@ -199,6 +293,7 @@ override fun onStreamStopped() {
Log.e(TAG, "Stream stopped - updating state to Stopped") Log.e(TAG, "Stream stopped - updating state to Stopped")
deviceController.notifyVideoStopped() deviceController.notifyVideoStopped()
streamingRequested = false streamingRequested = false
activeWhipUrl = null
mainImmediateScope.launch { mainImmediateScope.launch {
_streamingState.value = StreamingState.Stopped _streamingState.value = StreamingState.Stopped
@ -209,9 +304,10 @@ override fun onStreamStopped() {
override fun onConnectionChange(isConnected: Boolean) { override fun onConnectionChange(isConnected: Boolean) {
Log.e(TAG, "Connection changed: $isConnected, current state: ${_streamingState.value}") Log.e(TAG, "Connection changed: $isConnected, current state: ${_streamingState.value}")
mainImmediateScope.launch { mainImmediateScope.launch {
if (!streamingRequested) return@launch
_streamingState.value = when (_streamingState.value) { _streamingState.value = when (_streamingState.value) {
is StreamingState.Connecting -> { 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) { if (isConnected) {
Log.e(TAG, "Connection established, changing to Active") Log.e(TAG, "Connection established, changing to Active")
StreamingState.Active(600) StreamingState.Active(600)
@ -236,7 +332,8 @@ override fun onStreamStopped() {
Log.e(TAG, "Streaming error: $error - forcing media cleanup") Log.e(TAG, "Streaming error: $error - forcing media cleanup")
deviceController.notifyVideoError(error) deviceController.notifyVideoError(error)
streamingRequested = false 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() webrtcManager.stopStreaming()
mainImmediateScope.launch { mainImmediateScope.launch {
_streamingState.value = StreamingState.Error(error) _streamingState.value = StreamingState.Error(error)
@ -263,7 +360,7 @@ override fun onStreamStopped() {
fun onEvent(event: MainUiEvent) { fun onEvent(event: MainUiEvent) {
when (event) { when (event) {
MainUiEvent.RetryLoad -> { MainUiEvent.RetryLoad -> {
// El flow se actualiza automáticamente // El flow se actualiza automaticamente
} }
MainUiEvent.ImportConfigFromFile -> { MainUiEvent.ImportConfigFromFile -> {
// TODO: Implement file picker // 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). * Metodo publico para ser llamado desde el boton fisico de camara (KEYCODE_F5).
* Verifica si la app está bloqueada antes de iniciar/detener streaming. * Verifica si la app esta bloqueada antes de iniciar/detener streaming.
*/ */
fun triggerCameraButton() { fun triggerCameraButton() {
val currentTime = System.currentTimeMillis() val currentTime = System.currentTimeMillis()
if (currentTime - lastButtonPressTime < BUTTON_DEBOUNCE_MS) { 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 return
} }
lastButtonPressTime = currentTime lastButtonPressTime = currentTime
@ -297,7 +394,7 @@ override fun onStreamStopped() {
val isLocked = adminLockRepository.isLocked() val isLocked = adminLockRepository.isLocked()
Log.e(TAG, "isLocked: $isLocked") Log.e(TAG, "isLocked: $isLocked")
if (isLocked) { if (isLocked) {
Log.e(TAG, "App bloqueada - botón cámara ignorado") Log.e(TAG, "App bloqueada - boton camara ignorado")
return@launch return@launch
} }
Log.e(TAG, "App desbloqueada - llamando a toggleStreaming") 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}") Log.e(TAG, "toggleStreaming llamado, requested=$streamingRequested, estado=${_streamingState.value}")
viewModelScope.launch { viewModelScope.launch {
if (streamingRequested) { if (streamingRequested) {
Log.e(TAG, "F5 STOP solicitado - liberando cámara/WebRTC") Log.e(TAG, "F5 STOP solicitado - liberando camara/WebRTC")
streamingRequested = false streamingRequested = false
webrtcManager.stopStreaming() webrtcManager.stopStreaming()
_streamingState.value = StreamingState.Stopped _streamingState.value = StreamingState.Stopped
return@launch return@launch
} }
val config = videoConfigRepository.getConfig() val savedConfig = videoConfigRepository.getConfig()
if (config == null) {
Log.e(TAG, "No hay configuración de video") val config = when {
streamingRequested = false savedConfig == null -> VideoConfiguration().also {
_streamingState.value = StreamingState.Error("No hay configuración de video") 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 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 streamingRequested = true
_streamingState.value = StreamingState.Connecting _streamingState.value = StreamingState.Connecting
try { 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() { override fun onCleared() {
super.onCleared() super.onCleared()
webrtcManager.release() webrtcManager.release()

View file

@ -10,6 +10,7 @@
<domain includeSubdomains="true">localhost</domain> <domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain> <domain includeSubdomains="true">127.0.0.1</domain>
<domain includeSubdomains="true">35.224.197.127</domain> <domain includeSubdomains="true">35.224.197.127</domain>
<domain includeSubdomains="true">172.93.110.24</domain>
</domain-config> </domain-config>
<!-- Permitir todo en debug para pruebas --> <!-- Permitir todo en debug para pruebas -->