feat: integrar mejoras de SOS, PTT, autenticacion y video M530
This commit is contained in:
parent
a12ae5da8f
commit
51ccd80070
17 changed files with 695 additions and 202 deletions
|
|
@ -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 } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Int>) -> Unit
|
||||
callback: (Result<Int>) -> 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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<File>) -> 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<Int>) -> 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<Int>) -> 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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ class AuthViewModel @Inject constructor(
|
|||
when (e.statusCode) {
|
||||
|
||||
401 ->
|
||||
"QR o PIN incorrecto"
|
||||
"Error 401: ${e.message}"
|
||||
|
||||
else ->
|
||||
e.message
|
||||
|
|
|
|||
|
|
@ -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<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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,6 +93,8 @@ private fun vibrateRecordingStopped() {
|
|||
vibrator.vibrate(pattern, -1)
|
||||
}
|
||||
}
|
||||
private var activeWhipUrl: String? = null
|
||||
|
||||
val uiState: StateFlow<MainUiState> = 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<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()
|
||||
}
|
||||
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
<domain includeSubdomains="true">localhost</domain>
|
||||
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||
<domain includeSubdomains="true">35.224.197.127</domain>
|
||||
<domain includeSubdomains="true">172.93.110.24</domain>
|
||||
</domain-config>
|
||||
|
||||
<!-- Permitir todo en debug para pruebas -->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue