Compare commits
4 commits
feature/in
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5835418ed4 | |||
|
|
01681a33d4 | ||
| e073dc31e9 | |||
|
|
bea09b691d |
25 changed files with 253 additions and 2051 deletions
|
|
@ -80,11 +80,6 @@ dependencies {
|
||||||
kapt("com.google.dagger:hilt-android-compiler:2.48")
|
kapt("com.google.dagger:hilt-android-compiler:2.48")
|
||||||
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
||||||
|
|
||||||
// Room para buffer GPS offline
|
|
||||||
implementation("androidx.room:room-runtime:2.6.1")
|
|
||||||
implementation("androidx.room:room-ktx:2.6.1")
|
|
||||||
kapt("androidx.room:room-compiler:2.6.1")
|
|
||||||
|
|
||||||
// DataStore para persistencia
|
// DataStore para persistencia
|
||||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||||
|
|
||||||
|
|
@ -123,5 +118,3 @@ implementation("com.google.mlkit:barcode-scanning:17.3.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 (false && isAuthenticated && !frontPreviewOpened) {
|
if (isAuthenticated && !frontPreviewOpened) {
|
||||||
frontPreviewOpened = true
|
frontPreviewOpened = true
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
openFrontPreview()
|
openFrontPreview()
|
||||||
|
|
@ -479,18 +479,10 @@ 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...")
|
||||||
|
|
||||||
viewModel.triggerSosFromUi { result ->
|
deviceController.triggerSos { result ->
|
||||||
|
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
|
|
||||||
|
|
@ -613,18 +605,8 @@ fun AppNavigation(viewModel: MainViewModel, onOpenPhotoCamera: () -> Unit, onOpe
|
||||||
val authState by authViewModel.state.collectAsState()
|
val authState by authViewModel.state.collectAsState()
|
||||||
|
|
||||||
LaunchedEffect(authState) {
|
LaunchedEffect(authState) {
|
||||||
when (authState) {
|
if (authState is AuthUiState.Authenticated && navController.currentDestination?.route != "main") {
|
||||||
is AuthUiState.Authenticated -> {
|
navController.navigate("main") { popUpTo("login") { inclusive = true } }
|
||||||
if (navController.currentDestination?.route != "main") {
|
|
||||||
navController.navigate("main") { popUpTo("login") { inclusive = true } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AuthUiState.LoggedOut -> {
|
|
||||||
if (navController.currentDestination?.route != "login") {
|
|
||||||
navController.navigate("login") { popUpTo("main") { inclusive = true } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> Unit
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -651,8 +633,6 @@ 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 } }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
package com.bodycamera.twentyfoulabs.data.auth
|
package com.bodycamera.twentyfoulabs.data.auth
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -15,9 +12,7 @@ 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
|
||||||
|
|
@ -46,18 +41,6 @@ class AuthApiClient @Inject constructor(
|
||||||
json.decodeFromString<LoginResponse>(response)
|
json.decodeFromString<LoginResponse>(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun refresh(request: RefreshRequest): RefreshResponse =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val body = json.encodeToString(request)
|
|
||||||
val response = request(
|
|
||||||
"POST",
|
|
||||||
"/mobile/v1/auth/refresh",
|
|
||||||
body = body
|
|
||||||
)
|
|
||||||
Log.i("AuthApiClient", "AUTH REFRESH OK")
|
|
||||||
json.decodeFromString<RefreshResponse>(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun me(token: String): MeResponse =
|
suspend fun me(token: String): MeResponse =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val response = request(
|
val response = request(
|
||||||
|
|
@ -113,7 +96,7 @@ class AuthApiClient @Inject constructor(
|
||||||
|
|
||||||
setRequestProperty(
|
setRequestProperty(
|
||||||
"X-Device-Serial",
|
"X-Device-Serial",
|
||||||
deviceIdentity.requireSerial()
|
BackendConfig.DEVICE_SERIAL
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!token.isNullOrBlank()) {
|
if (!token.isNullOrBlank()) {
|
||||||
|
|
@ -187,9 +170,3 @@ class AuthApiException(
|
||||||
val statusCode: Int,
|
val statusCode: Int,
|
||||||
override val message: String
|
override val message: String
|
||||||
) : Exception(message)
|
) : Exception(message)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,6 @@ data class LoginRequest(
|
||||||
@SerialName("device_model") val deviceModel: String? = null
|
@SerialName("device_model") val deviceModel: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class RefreshRequest(
|
|
||||||
@SerialName("refresh_token") val refreshToken: String
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class QrLoginRequest(
|
data class QrLoginRequest(
|
||||||
@SerialName("qr_token") val qrToken: String,
|
@SerialName("qr_token") val qrToken: String,
|
||||||
|
|
@ -68,15 +63,6 @@ data class LoginResponse(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class RefreshResponse(
|
|
||||||
@SerialName("access_token") val accessToken: String? = null,
|
|
||||||
@SerialName("refresh_token") val refreshToken: String? = null,
|
|
||||||
@SerialName("token_type") val tokenType: String? = null,
|
|
||||||
@SerialName("expires_in") val expiresIn: Int? = null,
|
|
||||||
@SerialName("server_time") val serverTime: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MeResponse(
|
data class MeResponse(
|
||||||
val user: AuthUser,
|
val user: AuthUser,
|
||||||
|
|
@ -102,7 +88,3 @@ data class AuthSession(
|
||||||
val device: AuthDevice? = null,
|
val device: AuthDevice? = null,
|
||||||
val refreshToken: String? = null
|
val refreshToken: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
package com.bodycamera.twentyfoulabs.data.auth
|
package com.bodycamera.twentyfoulabs.data.auth
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
|
|
||||||
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.data.gps.GpsBufferRepository
|
|
||||||
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
|
||||||
|
|
@ -12,9 +8,7 @@ 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,
|
|
||||||
private val gpsBufferRepository: GpsBufferRepository
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun login(
|
suspend fun login(
|
||||||
|
|
@ -26,7 +20,7 @@ class AuthRepository @Inject constructor(
|
||||||
LoginRequest(
|
LoginRequest(
|
||||||
email = email.trim(),
|
email = email.trim(),
|
||||||
password = password,
|
password = password,
|
||||||
deviceSerial = deviceIdentity.requireSerial(),
|
deviceSerial = BackendConfig.DEVICE_SERIAL,
|
||||||
deviceModel = BackendConfig.DEVICE_MODEL
|
deviceModel = BackendConfig.DEVICE_MODEL
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -38,28 +32,8 @@ class AuthRepository @Inject constructor(
|
||||||
refreshToken = response.refreshToken
|
refreshToken = response.refreshToken
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
val previousUserId = store.userId.first()
|
|
||||||
val newUserId = session.user.id
|
|
||||||
|
|
||||||
if (previousUserId != null &&
|
|
||||||
newUserId != null &&
|
|
||||||
previousUserId != newUserId
|
|
||||||
) {
|
|
||||||
gpsBufferRepository.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
store.save(session)
|
store.save(session)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,7 +46,7 @@ class AuthRepository @Inject constructor(
|
||||||
QrLoginRequest(
|
QrLoginRequest(
|
||||||
qrToken = qrToken.trim(),
|
qrToken = qrToken.trim(),
|
||||||
pin = pin.trim(),
|
pin = pin.trim(),
|
||||||
deviceSerial = deviceIdentity.requireSerial(),
|
deviceSerial = BackendConfig.DEVICE_SERIAL,
|
||||||
deviceModel = BackendConfig.DEVICE_MODEL
|
deviceModel = BackendConfig.DEVICE_MODEL
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -84,15 +58,6 @@ class AuthRepository @Inject constructor(
|
||||||
refreshToken = response.refreshToken
|
refreshToken = response.refreshToken
|
||||||
)
|
)
|
||||||
|
|
||||||
val previousUserId = store.userId.first()
|
|
||||||
val newUserId = session.user.id
|
|
||||||
|
|
||||||
if (previousUserId != null &&
|
|
||||||
newUserId != null &&
|
|
||||||
previousUserId != newUserId
|
|
||||||
) {
|
|
||||||
gpsBufferRepository.clear()
|
|
||||||
}
|
|
||||||
store.save(session)
|
store.save(session)
|
||||||
|
|
||||||
return session
|
return session
|
||||||
|
|
@ -110,73 +75,37 @@ class AuthRepository @Inject constructor(
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
|
|
||||||
|
val response = api.me(token)
|
||||||
|
|
||||||
val response = api.me(token)
|
AuthSession(
|
||||||
|
token = token,
|
||||||
val session = AuthSession(
|
user = response.user,
|
||||||
token = token,
|
device = response.device ?: response.user.assignedDevice,
|
||||||
user = response.user,
|
refreshToken = refreshToken
|
||||||
device = response.device ?: response.user.assignedDevice,
|
)
|
||||||
refreshToken = refreshToken
|
|
||||||
)
|
|
||||||
|
|
||||||
store.save(session)
|
|
||||||
|
|
||||||
session
|
|
||||||
|
|
||||||
} catch (e: AuthApiException) {
|
} catch (e: AuthApiException) {
|
||||||
|
|
||||||
if (e.statusCode == 401 && !refreshToken.isNullOrBlank()) {
|
if (e.statusCode == 401) {
|
||||||
android.util.Log.i("AuthRepository", "401 inicial detectado - intentando refresh")
|
|
||||||
val refreshed = api.refresh(RefreshRequest(refreshToken))
|
|
||||||
val newToken = refreshed.accessToken?.takeIf { it.isNotBlank() } ?: throw e
|
|
||||||
val refreshedResponse = try { api.me(newToken) } catch (retryError: AuthApiException) { if (retryError.statusCode == 401) { store.clear(); android.util.Log.i("AuthRepository", "Segundo 401 - sesion eliminada"); throw retryError } else { throw retryError } }
|
|
||||||
val newRefreshToken = refreshed.refreshToken?.takeIf { it.isNotBlank() } ?: refreshToken
|
|
||||||
val refreshedSession = AuthSession(token = newToken, user = refreshedResponse.user, device = refreshedResponse.device ?: refreshedResponse.user.assignedDevice, refreshToken = newRefreshToken)
|
|
||||||
store.save(refreshedSession)
|
|
||||||
refreshedSession
|
|
||||||
} else if (e.statusCode == 401) {
|
|
||||||
store.clear()
|
store.clear()
|
||||||
throw e
|
|
||||||
} else {
|
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun logout() {
|
suspend fun logout() {
|
||||||
|
|
||||||
val token = store.token.first()
|
val token = store.token.first()
|
||||||
|
|
||||||
// Cerrar la sesion local inmediatamente.
|
try {
|
||||||
store.clear()
|
|
||||||
|
|
||||||
// Descartar GPS pendientes del sereno anterior.
|
if (!token.isNullOrBlank()) {
|
||||||
gpsBufferRepository.clear()
|
api.logout(token)
|
||||||
|
}
|
||||||
|
|
||||||
// Notificar al backend sin impedir el logout local si no hay Internet.
|
} finally {
|
||||||
if (!token.isNullOrBlank()) {
|
store.clear()
|
||||||
runCatching {
|
|
||||||
api.logout(token)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ class AuthSessionStore @Inject constructor(
|
||||||
private object Keys {
|
private object Keys {
|
||||||
val token = stringPreferencesKey("jwt_token")
|
val token = stringPreferencesKey("jwt_token")
|
||||||
val refreshToken = stringPreferencesKey("refresh_token")
|
val refreshToken = stringPreferencesKey("refresh_token")
|
||||||
val userId = stringPreferencesKey("user_id")
|
|
||||||
val userName = stringPreferencesKey("user_name")
|
val userName = stringPreferencesKey("user_name")
|
||||||
val userEmail = stringPreferencesKey("user_email")
|
val userEmail = stringPreferencesKey("user_email")
|
||||||
val userRole = stringPreferencesKey("user_role")
|
val userRole = stringPreferencesKey("user_role")
|
||||||
|
|
@ -36,19 +35,12 @@ val userId = stringPreferencesKey("user_id")
|
||||||
context.authDataStore.data.map {
|
context.authDataStore.data.map {
|
||||||
it[Keys.refreshToken]
|
it[Keys.refreshToken]
|
||||||
}
|
}
|
||||||
val userId: Flow<Int?> =
|
|
||||||
context.authDataStore.data.map {
|
|
||||||
it[Keys.userId]?.toIntOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun save(session: AuthSession) {
|
suspend fun save(session: AuthSession) {
|
||||||
|
|
||||||
context.authDataStore.edit { prefs ->
|
context.authDataStore.edit { prefs ->
|
||||||
|
|
||||||
prefs[Keys.token] = session.token
|
prefs[Keys.token] = session.token
|
||||||
session.user.id?.let {
|
|
||||||
prefs[Keys.userId] = it.toString()
|
|
||||||
} ?: prefs.remove(Keys.userId)
|
|
||||||
|
|
||||||
if (!session.refreshToken.isNullOrBlank()) {
|
if (!session.refreshToken.isNullOrBlank()) {
|
||||||
prefs[Keys.refreshToken] = session.refreshToken
|
prefs[Keys.refreshToken] = session.refreshToken
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,20 @@
|
||||||
package com.bodycamera.twentyfoulabs.data.backend
|
package com.bodycamera.twentyfoulabs.data.backend
|
||||||
|
|
||||||
/** Centralized endpoints for the M530 APK. */
|
/** Centralized endpoints for the M530 PoC. */
|
||||||
object BackendConfig {
|
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"
|
||||||
|
|
||||||
val httpBaseUrl: String
|
// Rails mobile API exposed through ngrok HTTPS.
|
||||||
get() = "http://$HOST:$HTTP_PORT"
|
const val API_BASE_URL = "https://bodycam-prd-web-nefjf74quq-uc.a.run.app"
|
||||||
|
const val DEVICE_SERIAL = "BC-M530-001"
|
||||||
|
const val DEVICE_MODEL = "Recoda M530"
|
||||||
|
|
||||||
val whipUrl: String
|
val httpBaseUrl: String get() = "http://$HOST:$HTTP_PORT"
|
||||||
get() = "$MEDIAMTX_BASE_URL/$STREAM_ID/whip"
|
val whipUrl: String get() = "http://$HOST:8889/$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"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,47 +17,21 @@ import java.util.UUID
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import com.bodycamera.twentyfoulabs.data.gps.GpsBufferRepository
|
|
||||||
|
|
||||||
@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,
|
|
||||||
private val gpsBufferRepository: GpsBufferRepository
|
|
||||||
) {
|
) {
|
||||||
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(deviceIdentity.requireSerial()) }
|
private val deviceId: String by lazy { sanitize(runCatching { Build.getSerial() }.getOrNull() ?: Build.MODEL ?: "M530") }
|
||||||
private val clientId: String get() = "${BackendConfig.MQTT_CLIENT_PREFIX}-$deviceId-${UUID.randomUUID().toString().take(6)}"
|
private val 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") }
|
||||||
|
|
||||||
private enum class GpsBatchResult {
|
|
||||||
SENT,
|
|
||||||
DISCARD,
|
|
||||||
RETRY
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isDeviceNotAssigned(errorBody: String): Boolean {
|
|
||||||
val json = runCatching { JSONObject(errorBody) }.getOrNull()
|
|
||||||
val error = json?.optString("error").orEmpty()
|
|
||||||
val message = json?.optString("message").orEmpty()
|
|
||||||
return error.equals("device_not_assigned", ignoreCase = true) ||
|
|
||||||
message.equals("device_not_assigned", ignoreCase = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun invalidateSession(reason: String) {
|
|
||||||
runBlocking {
|
|
||||||
authSessionStore.clear()
|
|
||||||
gpsBufferRepository.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.w(TAG, "AUTH: sesion invalidada: $reason")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var lastMediaItemId: Int? = null
|
private var lastMediaItemId: Int? = null
|
||||||
private val mediaStatePrefs by lazy {
|
private val mediaStatePrefs by lazy {
|
||||||
|
|
@ -116,683 +90,12 @@ class BackendIntegrationManager @Inject constructor(
|
||||||
enqueuePublish(topic, envelope)
|
enqueuePublish(topic, envelope)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class TelemetrySettings(
|
|
||||||
val intervalSeconds: Int = 300,
|
|
||||||
val sosIntervalSeconds: Int = 30,
|
|
||||||
val deviceStatusIntervalSeconds: Int = 300
|
|
||||||
)
|
|
||||||
|
|
||||||
fun fetchTelemetrySettings(
|
|
||||||
onResult: (Result<TelemetrySettings>) -> Unit
|
|
||||||
) {
|
|
||||||
httpExecutor.execute {
|
|
||||||
var conn: HttpURLConnection? = null
|
|
||||||
try {
|
|
||||||
val token = runBlocking {
|
|
||||||
authSessionStore.token.first()
|
|
||||||
}?.takeIf { it.isNotBlank() }
|
|
||||||
?: error("No hay sesion JWT activa")
|
|
||||||
|
|
||||||
val serial = deviceIdentity.requireSerial()
|
|
||||||
val url = URL(
|
|
||||||
BackendConfig.API_BASE_URL.trimEnd('/') +
|
|
||||||
"/mobile/v1/telemetry_settings"
|
|
||||||
)
|
|
||||||
|
|
||||||
conn = (url.openConnection() as HttpURLConnection).apply {
|
|
||||||
requestMethod = "GET"
|
|
||||||
connectTimeout = 15_000
|
|
||||||
readTimeout = 30_000
|
|
||||||
setRequestProperty("Accept", "application/json")
|
|
||||||
setRequestProperty("Authorization", "Bearer $token")
|
|
||||||
setRequestProperty("X-Device-Serial", serial)
|
|
||||||
setRequestProperty(
|
|
||||||
"X-Device-Time",
|
|
||||||
System.currentTimeMillis().toString()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val status = conn.responseCode
|
|
||||||
if (status in 200..299) {
|
|
||||||
val body = conn.inputStream
|
|
||||||
.bufferedReader()
|
|
||||||
.use { it.readText() }
|
|
||||||
val json = JSONObject(body)
|
|
||||||
|
|
||||||
val settings = TelemetrySettings(
|
|
||||||
intervalSeconds = json.optInt("interval_seconds", 300)
|
|
||||||
.coerceIn(15, 1800),
|
|
||||||
sosIntervalSeconds = json.optInt("sos_interval_seconds", 30)
|
|
||||||
.coerceIn(10, 300),
|
|
||||||
deviceStatusIntervalSeconds =
|
|
||||||
json.optInt("device_status_interval_seconds", 300)
|
|
||||||
.coerceIn(30, 300)
|
|
||||||
)
|
|
||||||
|
|
||||||
Log.i(
|
|
||||||
TAG,
|
|
||||||
"Telemetry settings OK: gps=${settings.intervalSeconds}s " +
|
|
||||||
"sos=${settings.sosIntervalSeconds}s " +
|
|
||||||
"device=${settings.deviceStatusIntervalSeconds}s"
|
|
||||||
)
|
|
||||||
onResult(Result.success(settings))
|
|
||||||
} else {
|
|
||||||
val errorBody = conn.errorStream
|
|
||||||
?.bufferedReader()
|
|
||||||
?.use { it.readText() }
|
|
||||||
.orEmpty()
|
|
||||||
val error = IllegalStateException(
|
|
||||||
"Telemetry settings HTTP $status $errorBody"
|
|
||||||
)
|
|
||||||
Log.e(TAG, error.message.orEmpty())
|
|
||||||
onResult(Result.failure(error))
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Telemetry settings ERROR", e)
|
|
||||||
onResult(Result.failure(e))
|
|
||||||
} finally {
|
|
||||||
conn?.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class SosSupportState(
|
|
||||||
val ownSosActive: Boolean = false,
|
|
||||||
val supportRequestActive: Boolean = false
|
|
||||||
) {
|
|
||||||
val isSosCadenceActive: Boolean
|
|
||||||
get() = ownSosActive || supportRequestActive
|
|
||||||
}
|
|
||||||
|
|
||||||
fun fetchSosSupportState(
|
|
||||||
onResult: (Result<SosSupportState>) -> Unit
|
|
||||||
) {
|
|
||||||
httpExecutor.execute {
|
|
||||||
var conn: HttpURLConnection? = null
|
|
||||||
try {
|
|
||||||
val token = runBlocking {
|
|
||||||
authSessionStore.token.first()
|
|
||||||
}?.takeIf { it.isNotBlank() }
|
|
||||||
?: error("No hay sesion JWT activa")
|
|
||||||
|
|
||||||
val serial = deviceIdentity.requireSerial()
|
|
||||||
val url = URL(
|
|
||||||
BackendConfig.API_BASE_URL.trimEnd('/') +
|
|
||||||
"/api/v1/sos_support_requests?status=open"
|
|
||||||
)
|
|
||||||
|
|
||||||
conn = (url.openConnection() as HttpURLConnection).apply {
|
|
||||||
requestMethod = "GET"
|
|
||||||
connectTimeout = 15_000
|
|
||||||
readTimeout = 30_000
|
|
||||||
setRequestProperty("Accept", "application/json")
|
|
||||||
setRequestProperty("Authorization", "Bearer $token")
|
|
||||||
setRequestProperty("X-Device-Serial", serial)
|
|
||||||
setRequestProperty(
|
|
||||||
"X-Device-Time",
|
|
||||||
System.currentTimeMillis().toString()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val status = conn.responseCode
|
|
||||||
if (status in 200..299) {
|
|
||||||
val body = conn.inputStream
|
|
||||||
.bufferedReader()
|
|
||||||
.use { it.readText() }
|
|
||||||
val json = JSONObject(body)
|
|
||||||
|
|
||||||
val ownSosActive =
|
|
||||||
json.has("own_sos") && !json.isNull("own_sos")
|
|
||||||
|
|
||||||
val supportRequests = json.optJSONArray("support_requests")
|
|
||||||
var supportRequestActive = false
|
|
||||||
if (supportRequests != null) {
|
|
||||||
for (i in 0 until supportRequests.length()) {
|
|
||||||
val request = supportRequests.optJSONObject(i) ?: continue
|
|
||||||
when (request.optString("status").lowercase()) {
|
|
||||||
"pending", "acknowledged" -> {
|
|
||||||
supportRequestActive = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val state = SosSupportState(
|
|
||||||
ownSosActive = ownSosActive,
|
|
||||||
supportRequestActive = supportRequestActive
|
|
||||||
)
|
|
||||||
|
|
||||||
Log.i(
|
|
||||||
TAG,
|
|
||||||
"SOS support OK: own_sos=${state.ownSosActive} " +
|
|
||||||
"support=${state.supportRequestActive} " +
|
|
||||||
"sosCadence=${state.isSosCadenceActive}"
|
|
||||||
)
|
|
||||||
onResult(Result.success(state))
|
|
||||||
} else {
|
|
||||||
val errorBody = conn.errorStream
|
|
||||||
?.bufferedReader()
|
|
||||||
?.use { it.readText() }
|
|
||||||
.orEmpty()
|
|
||||||
val error = IllegalStateException(
|
|
||||||
"SOS support HTTP $status $errorBody"
|
|
||||||
)
|
|
||||||
Log.e(TAG, error.message.orEmpty())
|
|
||||||
onResult(Result.failure(error))
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "SOS support ERROR", e)
|
|
||||||
onResult(Result.failure(e))
|
|
||||||
} finally {
|
|
||||||
conn?.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun publishGps(payload: JSONObject) {
|
|
||||||
httpExecutor.execute {
|
|
||||||
var conn: HttpURLConnection? = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
val token = runBlocking {
|
|
||||||
authSessionStore.token.first()
|
|
||||||
}?.takeIf { it.isNotBlank() }
|
|
||||||
?: error("No hay sesion JWT activa")
|
|
||||||
|
|
||||||
val userId = runBlocking {
|
|
||||||
authSessionStore.userId.first()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userId == null) {
|
|
||||||
Log.e(
|
|
||||||
TAG,
|
|
||||||
"GPS BUFFER: no se puede guardar el punto; userId no disponible"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val url = URL(
|
|
||||||
BackendConfig.API_BASE_URL.trimEnd('/') +
|
|
||||||
"/mobile/v1/gps_locations"
|
|
||||||
)
|
|
||||||
|
|
||||||
conn = (url.openConnection() as HttpURLConnection).apply {
|
|
||||||
requestMethod = "POST"
|
|
||||||
connectTimeout = 15_000
|
|
||||||
readTimeout = 30_000
|
|
||||||
doOutput = true
|
|
||||||
|
|
||||||
setRequestProperty(
|
|
||||||
"Content-Type",
|
|
||||||
"application/json"
|
|
||||||
)
|
|
||||||
setRequestProperty(
|
|
||||||
"Accept",
|
|
||||||
"application/json"
|
|
||||||
)
|
|
||||||
setRequestProperty(
|
|
||||||
"Authorization",
|
|
||||||
"Bearer $token"
|
|
||||||
)
|
|
||||||
setRequestProperty(
|
|
||||||
"X-Device-Serial",
|
|
||||||
deviceIdentity.requireSerial()
|
|
||||||
)
|
|
||||||
setRequestProperty(
|
|
||||||
"X-Device-Time",
|
|
||||||
System.currentTimeMillis().toString()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.outputStream.use {
|
|
||||||
it.write(
|
|
||||||
payload.toString()
|
|
||||||
.toByteArray(Charsets.UTF_8)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val status = conn.responseCode
|
|
||||||
|
|
||||||
if (status in 200..299) {
|
|
||||||
|
|
||||||
Log.i(
|
|
||||||
TAG,
|
|
||||||
"GPS HTTPS OK: HTTP $status"
|
|
||||||
)
|
|
||||||
|
|
||||||
} else {
|
|
||||||
|
|
||||||
val errorBody = conn.errorStream
|
|
||||||
?.bufferedReader()
|
|
||||||
?.use { it.readText() }
|
|
||||||
.orEmpty()
|
|
||||||
|
|
||||||
Log.e(
|
|
||||||
TAG,
|
|
||||||
"GPS HTTPS ERROR: HTTP $status $errorBody"
|
|
||||||
)
|
|
||||||
if (status == HttpURLConnection.HTTP_FORBIDDEN && isDeviceNotAssigned(errorBody)) {
|
|
||||||
Log.w(TAG, "GPS HTTPS: device_not_assigned; cerrando sesion local")
|
|
||||||
invalidateSession("device_not_assigned")
|
|
||||||
return@execute
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status == 422) {
|
|
||||||
Log.w(TAG, "GPS HTTPS: HTTP 422; punto descartado: $errorBody")
|
|
||||||
return@execute
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Solo conservamos para reintento los errores
|
|
||||||
// de servidor (5xx). Los 4xx no se reintentan.
|
|
||||||
if (status in 500..599 && userId != null) {
|
|
||||||
|
|
||||||
val recordedAt =
|
|
||||||
payload.optString(
|
|
||||||
"recorded_at",
|
|
||||||
""
|
|
||||||
)
|
|
||||||
|
|
||||||
if (recordedAt.isNotBlank()) {
|
|
||||||
|
|
||||||
val point = com.bodycamera.twentyfoulabs.data.gps.PendingGpsEntity(
|
|
||||||
userId = userId,
|
|
||||||
latitude = payload.optDouble(
|
|
||||||
"latitude",
|
|
||||||
0.0
|
|
||||||
),
|
|
||||||
longitude = payload.optDouble(
|
|
||||||
"longitude",
|
|
||||||
0.0
|
|
||||||
),
|
|
||||||
accuracy = payload.optDouble(
|
|
||||||
"accuracy",
|
|
||||||
0.0
|
|
||||||
).toFloat(),
|
|
||||||
speed =
|
|
||||||
if (payload.isNull("speed")) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
payload.optDouble(
|
|
||||||
"speed",
|
|
||||||
0.0
|
|
||||||
).toFloat()
|
|
||||||
},
|
|
||||||
heading =
|
|
||||||
if (payload.isNull("heading")) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
payload.optDouble(
|
|
||||||
"heading",
|
|
||||||
0.0
|
|
||||||
).toFloat()
|
|
||||||
},
|
|
||||||
recordedAt = recordedAt
|
|
||||||
)
|
|
||||||
|
|
||||||
val activeToken = runBlocking { authSessionStore.token.first() }
|
|
||||||
val activeUserId = runBlocking { authSessionStore.userId.first() }
|
|
||||||
|
|
||||||
if (!activeToken.isNullOrBlank() && activeUserId == userId) {
|
|
||||||
gpsBufferRepository.save(point)
|
|
||||||
} else {
|
|
||||||
Log.w(TAG, "GPS BUFFER: punto descartado porque la sesion fue cerrada o cambio de usuario")
|
|
||||||
return@execute
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.w(
|
|
||||||
TAG,
|
|
||||||
"GPS BUFFER: punto guardado por HTTP $status " +
|
|
||||||
"userId=$userId " +
|
|
||||||
"lat=${point.latitude} " +
|
|
||||||
"lon=${point.longitude}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
|
|
||||||
Log.e(
|
|
||||||
TAG,
|
|
||||||
"GPS HTTPS ERROR: ${e.message}",
|
|
||||||
e
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
|
||||||
val userId = runBlocking {
|
|
||||||
authSessionStore.userId.first()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userId != null) {
|
|
||||||
|
|
||||||
val recordedAt =
|
|
||||||
payload.optString(
|
|
||||||
"recorded_at",
|
|
||||||
""
|
|
||||||
)
|
|
||||||
|
|
||||||
if (recordedAt.isNotBlank()) {
|
|
||||||
|
|
||||||
val point = com.bodycamera.twentyfoulabs.data.gps.PendingGpsEntity(
|
|
||||||
userId = userId,
|
|
||||||
latitude = payload.optDouble(
|
|
||||||
"latitude",
|
|
||||||
0.0
|
|
||||||
),
|
|
||||||
longitude = payload.optDouble(
|
|
||||||
"longitude",
|
|
||||||
0.0
|
|
||||||
),
|
|
||||||
accuracy = payload.optDouble(
|
|
||||||
"accuracy",
|
|
||||||
0.0
|
|
||||||
).toFloat(),
|
|
||||||
speed =
|
|
||||||
if (payload.isNull("speed")) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
payload.optDouble(
|
|
||||||
"speed",
|
|
||||||
0.0
|
|
||||||
).toFloat()
|
|
||||||
},
|
|
||||||
heading =
|
|
||||||
if (payload.isNull("heading")) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
payload.optDouble(
|
|
||||||
"heading",
|
|
||||||
0.0
|
|
||||||
).toFloat()
|
|
||||||
},
|
|
||||||
recordedAt = recordedAt
|
|
||||||
)
|
|
||||||
|
|
||||||
val activeToken = runBlocking { authSessionStore.token.first() }
|
|
||||||
val activeUserId = runBlocking { authSessionStore.userId.first() }
|
|
||||||
|
|
||||||
if (!activeToken.isNullOrBlank() && activeUserId == userId) {
|
|
||||||
gpsBufferRepository.save(point)
|
|
||||||
} else {
|
|
||||||
Log.w(TAG, "GPS BUFFER: punto descartado porque la sesion fue cerrada o cambio de usuario")
|
|
||||||
return@execute
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.w(
|
|
||||||
TAG,
|
|
||||||
"GPS BUFFER: punto guardado por excepción " +
|
|
||||||
"userId=$userId " +
|
|
||||||
"lat=${point.latitude} " +
|
|
||||||
"lon=${point.longitude}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (bufferError: Exception) {
|
|
||||||
|
|
||||||
Log.e(
|
|
||||||
TAG,
|
|
||||||
"GPS BUFFER ERROR: no se pudo guardar el punto",
|
|
||||||
bufferError
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
conn?.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private fun publishGpsBatch(
|
|
||||||
locations: List<JSONObject>,
|
|
||||||
onResult: (GpsBatchResult) -> Unit
|
|
||||||
) {
|
|
||||||
if (locations.isEmpty()) {
|
|
||||||
onResult(GpsBatchResult.SENT)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
require(locations.size <= 100) {
|
|
||||||
"GPS batch no puede superar 100 puntos"
|
|
||||||
}
|
|
||||||
|
|
||||||
httpExecutor.execute {
|
|
||||||
var conn: HttpURLConnection? = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
val token = runBlocking {
|
|
||||||
authSessionStore.token.first()
|
|
||||||
}?.takeIf { it.isNotBlank() }
|
|
||||||
?: error("No hay sesion JWT activa")
|
|
||||||
|
|
||||||
val serial = deviceIdentity.requireSerial()
|
|
||||||
|
|
||||||
val body = JSONObject()
|
|
||||||
.put("locations", org.json.JSONArray(locations))
|
|
||||||
|
|
||||||
val url = URL(
|
|
||||||
BackendConfig.API_BASE_URL.trimEnd('/') +
|
|
||||||
"/mobile/v1/gps_locations/batch"
|
|
||||||
)
|
|
||||||
|
|
||||||
conn = (url.openConnection() as HttpURLConnection).apply {
|
|
||||||
requestMethod = "POST"
|
|
||||||
connectTimeout = 15_000
|
|
||||||
readTimeout = 30_000
|
|
||||||
doOutput = true
|
|
||||||
|
|
||||||
setRequestProperty(
|
|
||||||
"Content-Type",
|
|
||||||
"application/json"
|
|
||||||
)
|
|
||||||
setRequestProperty(
|
|
||||||
"Accept",
|
|
||||||
"application/json"
|
|
||||||
)
|
|
||||||
setRequestProperty(
|
|
||||||
"Authorization",
|
|
||||||
"Bearer $token"
|
|
||||||
)
|
|
||||||
setRequestProperty(
|
|
||||||
"X-Device-Serial",
|
|
||||||
serial
|
|
||||||
)
|
|
||||||
setRequestProperty(
|
|
||||||
"X-Device-Time",
|
|
||||||
System.currentTimeMillis().toString()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.outputStream.use {
|
|
||||||
it.write(
|
|
||||||
body.toString()
|
|
||||||
.toByteArray(Charsets.UTF_8)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val status = conn.responseCode
|
|
||||||
|
|
||||||
if (status == HttpURLConnection.HTTP_CREATED) {
|
|
||||||
Log.i(
|
|
||||||
TAG,
|
|
||||||
"GPS BATCH HTTPS OK: HTTP 201 points=${locations.size}"
|
|
||||||
)
|
|
||||||
onResult(GpsBatchResult.SENT)
|
|
||||||
} else {
|
|
||||||
val errorBody = conn.errorStream
|
|
||||||
?.bufferedReader()
|
|
||||||
?.use { it.readText() }
|
|
||||||
.orEmpty()
|
|
||||||
|
|
||||||
Log.e(
|
|
||||||
TAG,
|
|
||||||
"GPS BATCH HTTPS ERROR: HTTP $status $errorBody"
|
|
||||||
)
|
|
||||||
|
|
||||||
if (status == HttpURLConnection.HTTP_FORBIDDEN && isDeviceNotAssigned(errorBody)) {
|
|
||||||
Log.w(TAG, "GPS BATCH: device_not_assigned; cerrando sesion local")
|
|
||||||
invalidateSession("device_not_assigned")
|
|
||||||
onResult(GpsBatchResult.DISCARD)
|
|
||||||
} else if (status == 422) {
|
|
||||||
Log.w(TAG, "GPS BATCH: HTTP 422; lote descartado")
|
|
||||||
onResult(GpsBatchResult.DISCARD)
|
|
||||||
} else {
|
|
||||||
onResult(GpsBatchResult.RETRY)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(
|
|
||||||
TAG,
|
|
||||||
"GPS BATCH HTTPS ERROR",
|
|
||||||
e
|
|
||||||
)
|
|
||||||
onResult(GpsBatchResult.RETRY)
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
conn?.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fun flushPendingGps() {
|
|
||||||
httpExecutor.execute {
|
|
||||||
val currentUserId = runBlocking {
|
|
||||||
authSessionStore.userId.first()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentUserId == null) {
|
|
||||||
Log.w(
|
|
||||||
TAG,
|
|
||||||
"GPS BUFFER: no se puede sincronizar; userId no disponible"
|
|
||||||
)
|
|
||||||
return@execute
|
|
||||||
}
|
|
||||||
|
|
||||||
val pending = gpsBufferRepository.getBatch(currentUserId)
|
|
||||||
|
|
||||||
if (pending.isEmpty()) {
|
|
||||||
Log.i(
|
|
||||||
TAG,
|
|
||||||
"GPS BUFFER: no hay puntos pendientes para userId=$currentUserId"
|
|
||||||
)
|
|
||||||
return@execute
|
|
||||||
}
|
|
||||||
|
|
||||||
val locations = pending.map {
|
|
||||||
gpsBufferRepository.toJson(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.i(
|
|
||||||
TAG,
|
|
||||||
"GPS BUFFER: enviando ${locations.size} puntos " +
|
|
||||||
"pendientes para userId=$currentUserId"
|
|
||||||
)
|
|
||||||
|
|
||||||
publishGpsBatch(locations) { result ->
|
|
||||||
when (result) {
|
|
||||||
GpsBatchResult.SENT -> {
|
|
||||||
gpsBufferRepository.deleteSent(pending)
|
|
||||||
Log.i(TAG, "GPS BUFFER: ${pending.size} puntos eliminados despues de HTTP 201")
|
|
||||||
}
|
|
||||||
GpsBatchResult.DISCARD -> {
|
|
||||||
gpsBufferRepository.deleteSent(pending)
|
|
||||||
Log.w(TAG, "GPS BUFFER: ${pending.size} puntos descartados; no se reintentaran")
|
|
||||||
}
|
|
||||||
GpsBatchResult.RETRY -> {
|
|
||||||
Log.w(TAG, "GPS BUFFER: envio fallido; se conservan ${pending.size} puntos")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fun publishTelemetry(payload: JSONObject) {
|
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", serial)
|
.put("deviceId", deviceId)
|
||||||
.put("timestamp", System.currentTimeMillis())
|
.put("timestamp", System.currentTimeMillis())
|
||||||
.put("payload", payload)
|
.put("payload", payload)
|
||||||
httpExecutor.execute {
|
enqueuePublish("m530/$deviceId/telemetry", envelope)
|
||||||
var conn: HttpURLConnection? = null
|
|
||||||
try {
|
|
||||||
val token = runBlocking {
|
|
||||||
authSessionStore.token.first()
|
|
||||||
}?.takeIf { it.isNotBlank() }
|
|
||||||
?: error("No hay sesion JWT activa")
|
|
||||||
|
|
||||||
val url = URL(
|
|
||||||
BackendConfig.API_BASE_URL.trimEnd('/') +
|
|
||||||
"/mobile/v1/devices/telemetry"
|
|
||||||
)
|
|
||||||
|
|
||||||
conn = (url.openConnection() as HttpURLConnection).apply {
|
|
||||||
requestMethod = "POST"
|
|
||||||
connectTimeout = 15_000
|
|
||||||
readTimeout = 30_000
|
|
||||||
doOutput = true
|
|
||||||
setRequestProperty("Content-Type", "application/json")
|
|
||||||
setRequestProperty("Accept", "application/json")
|
|
||||||
setRequestProperty("Authorization", "Bearer $token")
|
|
||||||
setRequestProperty(
|
|
||||||
"X-Device-Serial",
|
|
||||||
serial
|
|
||||||
)
|
|
||||||
setRequestProperty(
|
|
||||||
"X-Device-Time",
|
|
||||||
System.currentTimeMillis().toString()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.outputStream.use {
|
|
||||||
it.write(envelope.toString().toByteArray(Charsets.UTF_8))
|
|
||||||
}
|
|
||||||
|
|
||||||
val status = conn.responseCode
|
|
||||||
|
|
||||||
if (status in 200..299) {
|
|
||||||
Log.i(TAG, "TELEMETRY HTTPS OK: HTTP $status")
|
|
||||||
} else {
|
|
||||||
val errorBody = conn.errorStream
|
|
||||||
?.bufferedReader()
|
|
||||||
?.use { it.readText() }
|
|
||||||
.orEmpty()
|
|
||||||
|
|
||||||
Log.e(TAG, "TELEMETRY HTTPS ERROR: HTTP $status $errorBody")
|
|
||||||
|
|
||||||
if (status == HttpURLConnection.HTTP_FORBIDDEN && isDeviceNotAssigned(errorBody)) {
|
|
||||||
Log.w(TAG, "TELEMETRY: device_not_assigned; cerrando sesion local")
|
|
||||||
invalidateSession("device_not_assigned")
|
|
||||||
} else if (status == HttpURLConnection.HTTP_NOT_FOUND) {
|
|
||||||
Log.w(TAG, "TELEMETRY: HTTP 404; telemetria descartada, sin reintento")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "TELEMETRY HTTPS ERROR", e)
|
|
||||||
} finally {
|
|
||||||
conn?.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun uploadMedia(file: File, kind: String, metadata: JSONObject = JSONObject()) {
|
fun uploadMedia(file: File, kind: String, metadata: JSONObject = JSONObject()) {
|
||||||
|
|
@ -814,16 +117,24 @@ fun flushPendingGps() {
|
||||||
"FAVORITE ultimo media_item guardado: id=$uploadedMediaId"
|
"FAVORITE ultimo media_item guardado: id=$uploadedMediaId"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Log.i(
|
val mediaPayload = JSONObject()
|
||||||
TAG,
|
.put("kind", kind)
|
||||||
"MEDIA HTTPS OK: archivo=${file.name}, tipo=$kind, id=${uploadedMediaId ?: "sin_id"}"
|
.put("fileName", file.name)
|
||||||
)
|
.put("sizeBytes", file.length())
|
||||||
|
.put("localPath", file.absolutePath)
|
||||||
|
.put("server", response)
|
||||||
|
val event = JSONObject()
|
||||||
|
.put("eventId", UUID.randomUUID().toString())
|
||||||
|
.put("deviceId", deviceId)
|
||||||
|
.put("type", "MEDIA_UPLOADED")
|
||||||
|
.put("timestamp", System.currentTimeMillis())
|
||||||
|
.put("payload", mediaPayload)
|
||||||
|
publishNow("m530/$deviceId/media/$kind", event)
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
Log.e(
|
Log.e(TAG, "Upload failed: ${file.name}", t)
|
||||||
TAG,
|
publishNow("m530/$deviceId/events/media_upload_failed", JSONObject()
|
||||||
"MEDIA HTTPS ERROR: archivo=${file.name}, tipo=$kind",
|
.put("deviceId", deviceId).put("timestamp", System.currentTimeMillis())
|
||||||
t
|
.put("fileName", file.name).put("kind", kind).put("error", t.message ?: "unknown"))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -889,7 +200,7 @@ fun flushPendingGps() {
|
||||||
|
|
||||||
setRequestProperty(
|
setRequestProperty(
|
||||||
"X-Device-Serial",
|
"X-Device-Serial",
|
||||||
deviceIdentity.requireSerial()
|
BackendConfig.DEVICE_SERIAL
|
||||||
)
|
)
|
||||||
|
|
||||||
setRequestProperty(
|
setRequestProperty(
|
||||||
|
|
@ -935,7 +246,23 @@ fun flushPendingGps() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
@ -964,8 +291,7 @@ fun flushPendingGps() {
|
||||||
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 {
|
||||||
|
|
||||||
|
|
@ -1015,7 +341,7 @@ fun flushPendingGps() {
|
||||||
|
|
||||||
setRequestProperty(
|
setRequestProperty(
|
||||||
"X-Device-Serial",
|
"X-Device-Serial",
|
||||||
deviceIdentity.requireSerial()
|
BackendConfig.DEVICE_SERIAL
|
||||||
)
|
)
|
||||||
|
|
||||||
setRequestProperty(
|
setRequestProperty(
|
||||||
|
|
@ -1031,7 +357,7 @@ fun flushPendingGps() {
|
||||||
.put("notes", "Boton panico F7")
|
.put("notes", "Boton panico F7")
|
||||||
.put(
|
.put(
|
||||||
"device_serial",
|
"device_serial",
|
||||||
deviceIdentity.requireSerial()
|
BackendConfig.DEVICE_SERIAL
|
||||||
)
|
)
|
||||||
.toString()
|
.toString()
|
||||||
|
|
||||||
|
|
@ -1101,19 +427,6 @@ fun flushPendingGps() {
|
||||||
?: 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,
|
||||||
|
|
@ -1282,7 +595,7 @@ fun flushPendingGps() {
|
||||||
|
|
||||||
setRequestProperty(
|
setRequestProperty(
|
||||||
"X-Device-Serial",
|
"X-Device-Serial",
|
||||||
deviceIdentity.requireSerial()
|
BackendConfig.DEVICE_SERIAL
|
||||||
)
|
)
|
||||||
|
|
||||||
setRequestProperty(
|
setRequestProperty(
|
||||||
|
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
package com.bodycamera.twentyfoulabs.data.backend
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class DeviceIdentity @Inject constructor(
|
|
||||||
@ApplicationContext private val context: Context
|
|
||||||
) {
|
|
||||||
private val preferences = context.getSharedPreferences(
|
|
||||||
"device_identity",
|
|
||||||
Context.MODE_PRIVATE
|
|
||||||
)
|
|
||||||
|
|
||||||
fun configuredSerial(): String? =
|
|
||||||
preferences.getString("device_serial", null)
|
|
||||||
?.trim()
|
|
||||||
?.takeIf { it.isNotEmpty() }
|
|
||||||
|
|
||||||
fun requireSerial(): String =
|
|
||||||
configuredSerial()
|
|
||||||
?: error("El dispositivo no tiene un serial configurado")
|
|
||||||
|
|
||||||
fun configureSerial(serial: String) {
|
|
||||||
val normalized = serial.trim()
|
|
||||||
|
|
||||||
require(Regex("^M530[A-Z0-9]+$").matches(normalized)) {
|
|
||||||
"Formato de serial no válido"
|
|
||||||
}
|
|
||||||
|
|
||||||
check(configuredSerial() == null || configuredSerial() == normalized) {
|
|
||||||
"El dispositivo ya tiene un serial diferente"
|
|
||||||
}
|
|
||||||
|
|
||||||
check(
|
|
||||||
preferences.edit()
|
|
||||||
.putString("device_serial", normalized)
|
|
||||||
.commit()
|
|
||||||
) {
|
|
||||||
"No se pudo guardar el serial"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
package com.bodycamera.twentyfoulabs.data.device
|
package com.bodycamera.twentyfoulabs.data.device
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.location.Location
|
import android.location.Location
|
||||||
import android.location.LocationListener
|
|
||||||
import android.location.LocationManager
|
import android.location.LocationManager
|
||||||
import android.media.MediaRecorder
|
import android.media.MediaRecorder
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
|
|
@ -24,7 +22,6 @@ 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
|
||||||
|
|
@ -37,8 +34,7 @@ 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" }
|
||||||
|
|
||||||
|
|
@ -49,7 +45,6 @@ class M530DeviceController @Inject constructor(
|
||||||
val network: String,
|
val network: String,
|
||||||
val connected: Boolean,
|
val connected: Boolean,
|
||||||
val freeStorageMb: Long,
|
val freeStorageMb: Long,
|
||||||
val storageTotalMb: Long,
|
|
||||||
val timestamp: Long = System.currentTimeMillis()
|
val timestamp: Long = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
data class DeviceInfo(
|
data class DeviceInfo(
|
||||||
|
|
@ -68,35 +63,12 @@ class M530DeviceController @Inject constructor(
|
||||||
private var micFile: File? = null
|
private var micFile: File? = null
|
||||||
private val ioExecutor: Executor get() = ContextCompat.getMainExecutor(context)
|
private val ioExecutor: Executor get() = ContextCompat.getMainExecutor(context)
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var latestGpsLocation: Location? = null
|
|
||||||
|
|
||||||
private val locationManager: LocationManager by lazy {
|
|
||||||
context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
|
||||||
}
|
|
||||||
|
|
||||||
private val gpsLocationListener = LocationListener { location ->
|
|
||||||
latestGpsLocation = location
|
|
||||||
Log.i(
|
|
||||||
TAG,
|
|
||||||
"GPS fix real recibido: accuracy=${location.accuracy}m time=${location.time}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val powerReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == Intent.ACTION_POWER_CONNECTED || intent?.action == Intent.ACTION_POWER_DISCONNECTED) { Log.i(TAG, "Cambio de cargador detectado: ${intent.action}"); logTelemetry() } } }
|
|
||||||
|
|
||||||
init {
|
|
||||||
startLocationUpdates()
|
|
||||||
context.registerReceiver(powerReceiver, IntentFilter().apply { addAction(Intent.ACTION_POWER_CONNECTED); addAction(Intent.ACTION_POWER_DISCONNECTED) })
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fun deviceInfo(): DeviceInfo = DeviceInfo(
|
fun deviceInfo(): DeviceInfo = DeviceInfo(
|
||||||
model = Build.MODEL ?: "M530",
|
model = Build.MODEL ?: "M530",
|
||||||
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 = deviceIdentity.configuredSerial() ?: "Sin configurar"
|
serial = runCatching { Build.getSerial() }.getOrDefault("M530")
|
||||||
)
|
)
|
||||||
|
|
||||||
fun capturePhoto(lifecycleOwner: LifecycleOwner, callback: (Result<File>) -> Unit) {
|
fun capturePhoto(lifecycleOwner: LifecycleOwner, callback: (Result<File>) -> Unit) {
|
||||||
|
|
@ -114,13 +86,7 @@ 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(
|
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))
|
||||||
"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))
|
||||||
|
|
@ -319,9 +285,12 @@ Result.success(file)
|
||||||
micRecorder != null
|
micRecorder != null
|
||||||
fun triggerSos(): SosResult {
|
fun triggerSos(): SosResult {
|
||||||
|
|
||||||
val location = bestLastKnownLocation()?.takeIf {
|
val location =
|
||||||
System.currentTimeMillis() - it.time in -5_000L..300_000L
|
Location("M530").apply {
|
||||||
}
|
latitude = -12.077
|
||||||
|
longitude = -77.092
|
||||||
|
accuracy = 10f
|
||||||
|
}
|
||||||
|
|
||||||
val payload =
|
val payload =
|
||||||
JSONObject()
|
JSONObject()
|
||||||
|
|
@ -340,15 +309,15 @@ Result.success(file)
|
||||||
)
|
)
|
||||||
.put(
|
.put(
|
||||||
"latitude",
|
"latitude",
|
||||||
location?.latitude ?: JSONObject.NULL
|
location.latitude
|
||||||
)
|
)
|
||||||
.put(
|
.put(
|
||||||
"longitude",
|
"longitude",
|
||||||
location?.longitude ?: JSONObject.NULL
|
location.longitude
|
||||||
)
|
)
|
||||||
.put(
|
.put(
|
||||||
"accuracy",
|
"accuracy",
|
||||||
location?.accuracy ?: JSONObject.NULL
|
location.accuracy
|
||||||
)
|
)
|
||||||
|
|
||||||
backend.publish(
|
backend.publish(
|
||||||
|
|
@ -365,22 +334,6 @@ 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 {
|
||||||
|
|
@ -434,8 +387,7 @@ Result.success(file)
|
||||||
else -> "Sin red"
|
else -> "Sin red"
|
||||||
}
|
}
|
||||||
val freeMb = context.filesDir.usableSpace / (1024L * 1024L)
|
val freeMb = context.filesDir.usableSpace / (1024L * 1024L)
|
||||||
val totalMb = context.filesDir.totalSpace / (1024L * 1024L)
|
return TelemetrySnapshot(batteryPercent, charging, networkName, connected, freeMb)
|
||||||
return TelemetrySnapshot(batteryPercent, charging, networkName, connected, freeMb, totalMb)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun evidenceSummary(): EvidenceSummary {
|
fun evidenceSummary(): EvidenceSummary {
|
||||||
|
|
@ -458,134 +410,23 @@ Result.success(file)
|
||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun logGps() {
|
|
||||||
val location = bestLastKnownLocation()?.takeIf {
|
|
||||||
System.currentTimeMillis() - it.time in -5_000L..300_000L
|
|
||||||
}
|
|
||||||
|
|
||||||
if (location == null) {
|
|
||||||
Log.w(TAG, "GPS: sin ubicación reciente; se omite el envÃÂo")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val latitude = location.latitude
|
|
||||||
val longitude = location.longitude
|
|
||||||
|
|
||||||
val recordedAt = java.text.SimpleDateFormat(
|
|
||||||
"yyyy-MM-dd'T'HH:mm:ss'Z'",
|
|
||||||
java.util.Locale.US
|
|
||||||
).apply {
|
|
||||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
|
||||||
}.format(java.util.Date(location.time))
|
|
||||||
|
|
||||||
val payload = JSONObject()
|
|
||||||
.put("latitude", latitude)
|
|
||||||
.put("longitude", longitude)
|
|
||||||
.put("speed", if (location.hasSpeed()) location.speed * 3.6 else JSONObject.NULL)
|
|
||||||
.put("heading", if (location.hasBearing()) location.bearing else JSONObject.NULL)
|
|
||||||
.put("accuracy", location.accuracy)
|
|
||||||
.put("recorded_at", recordedAt)
|
|
||||||
|
|
||||||
backend.publishGps(payload)
|
|
||||||
|
|
||||||
Log.i(TAG, "GPS: envio HTTPS solicitado")
|
|
||||||
}
|
|
||||||
fun logTelemetry(): File {
|
fun logTelemetry(): File {
|
||||||
val t = telemetry()
|
val t = telemetry()
|
||||||
val p = JSONObject().put("batteryPercent", t.batteryPercent).put("charging", t.charging)
|
val p = JSONObject().put("batteryPercent", t.batteryPercent).put("charging", t.charging)
|
||||||
.put("network", t.network).put("connected", t.connected).put("freeStorageMb", t.freeStorageMb).put("storageTotalMb", t.storageTotalMb).put("timestamp", t.timestamp); backend.publishTelemetry(p); return logEvent("TELEMETRY", p)
|
.put("network", t.network).put("connected", t.connected).put("freeStorageMb", t.freeStorageMb).put("timestamp", t.timestamp); backend.publishTelemetry(p); return logEvent("TELEMETRY", p)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun notifyVideoStarted(endpoint: String) { backend.publish("VIDEO_STREAM_STARTED", JSONObject().put("whipUrl", endpoint).put("streamId", com.bodycamera.twentyfoulabs.data.backend.BackendConfig.STREAM_ID)) }
|
fun notifyVideoStarted(endpoint: String) { backend.publish("VIDEO_STREAM_STARTED", JSONObject().put("whipUrl", endpoint).put("streamId", com.bodycamera.twentyfoulabs.data.backend.BackendConfig.STREAM_ID)) }
|
||||||
fun notifyVideoStopped() { backend.publish("VIDEO_STREAM_STOPPED", JSONObject().put("streamId", com.bodycamera.twentyfoulabs.data.backend.BackendConfig.STREAM_ID)) }
|
fun notifyVideoStopped() { backend.publish("VIDEO_STREAM_STOPPED", JSONObject().put("streamId", com.bodycamera.twentyfoulabs.data.backend.BackendConfig.STREAM_ID)) }
|
||||||
fun notifyVideoError(error: String) { backend.publish("VIDEO_STREAM_ERROR", JSONObject().put("error", error).put("streamId", com.bodycamera.twentyfoulabs.data.backend.BackendConfig.STREAM_ID)) }
|
fun notifyVideoError(error: String) { backend.publish("VIDEO_STREAM_ERROR", JSONObject().put("error", error).put("streamId", com.bodycamera.twentyfoulabs.data.backend.BackendConfig.STREAM_ID)) }
|
||||||
|
|
||||||
fun release() {
|
fun release() { try { mediaRecorder?.stop() } catch (_: Throwable) {}; try { mediaRecorder?.release() } catch (_: Throwable) {}; mediaRecorder = null; pttFile = null; try { micRecorder?.stop() } catch (_: Throwable) {}; try { micRecorder?.release() } catch (_: Throwable) {}; micRecorder = null; micFile = null }
|
||||||
try { mediaRecorder?.stop() } catch (_: Throwable) {}
|
|
||||||
try { mediaRecorder?.release() } catch (_: Throwable) {}
|
|
||||||
mediaRecorder = null
|
|
||||||
pttFile = null
|
|
||||||
|
|
||||||
try { micRecorder?.stop() } catch (_: Throwable) {}
|
|
||||||
try { micRecorder?.release() } catch (_: Throwable) {}
|
|
||||||
micRecorder = null
|
|
||||||
micFile = null
|
|
||||||
|
|
||||||
stopLocationUpdates()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startLocationUpdates() {
|
|
||||||
val fineGranted =
|
|
||||||
ContextCompat.checkSelfPermission(
|
|
||||||
context,
|
|
||||||
Manifest.permission.ACCESS_FINE_LOCATION
|
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
|
||||||
val coarseGranted =
|
|
||||||
ContextCompat.checkSelfPermission(
|
|
||||||
context,
|
|
||||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
|
||||||
|
|
||||||
if (!fineGranted && !coarseGranted) {
|
|
||||||
Log.w(TAG, "GPS activo no iniciado: permiso de ubicacion no concedido")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
|
|
||||||
Log.w(TAG, "GPS activo no iniciado: proveedor GPS deshabilitado")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
locationManager.requestLocationUpdates(
|
|
||||||
LocationManager.GPS_PROVIDER,
|
|
||||||
1000L,
|
|
||||||
0f,
|
|
||||||
gpsLocationListener
|
|
||||||
)
|
|
||||||
Log.i(TAG, "GPS activo iniciado")
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.e(TAG, "GPS activo: permiso rechazado", e)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "GPS activo: error iniciando actualizaciones", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stopLocationUpdates() {
|
|
||||||
runCatching {
|
|
||||||
locationManager.removeUpdates(gpsLocationListener)
|
|
||||||
}.onFailure {
|
|
||||||
Log.w(TAG, "GPS activo: error deteniendo actualizaciones", it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bestLastKnownLocation(): Location? {
|
private fun bestLastKnownLocation(): Location? {
|
||||||
val fineGranted =
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED &&
|
||||||
ContextCompat.checkSelfPermission(
|
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) return null
|
||||||
context,
|
val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||||
Manifest.permission.ACCESS_FINE_LOCATION
|
return runCatching { lm.getProviders(true).mapNotNull { p -> runCatching { lm.getLastKnownLocation(p) }.getOrNull() }.maxByOrNull { it.time } }.getOrNull()
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
|
||||||
val coarseGranted =
|
|
||||||
ContextCompat.checkSelfPermission(
|
|
||||||
context,
|
|
||||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
|
||||||
|
|
||||||
if (!fineGranted && !coarseGranted) return null
|
|
||||||
|
|
||||||
val activeFix = latestGpsLocation
|
|
||||||
val providerFix = runCatching {
|
|
||||||
locationManager.getProviders(true)
|
|
||||||
.mapNotNull { provider ->
|
|
||||||
runCatching {
|
|
||||||
locationManager.getLastKnownLocation(provider)
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
.maxByOrNull { it.time }
|
|
||||||
}.getOrNull()
|
|
||||||
|
|
||||||
return listOfNotNull(activeFix, providerFix)
|
|
||||||
.maxByOrNull { it.time }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun summarizePayload(payload: JSONObject?): String {
|
private fun summarizePayload(payload: JSONObject?): String {
|
||||||
|
|
@ -607,12 +448,3 @@ Result.success(file)
|
||||||
}
|
}
|
||||||
private fun timestamp(): String = SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date())
|
private fun timestamp(): String = SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
package com.bodycamera.twentyfoulabs.data.gps
|
|
||||||
|
|
||||||
import org.json.JSONObject
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class GpsBufferRepository @Inject constructor(
|
|
||||||
private val dao: PendingGpsDao
|
|
||||||
) {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val MAX_BUFFER_SIZE = 100
|
|
||||||
private const val MAX_BATCH_SIZE = 100
|
|
||||||
}
|
|
||||||
|
|
||||||
fun save(point: PendingGpsEntity) {
|
|
||||||
dao.insert(point)
|
|
||||||
|
|
||||||
val count = dao.count()
|
|
||||||
if (count > MAX_BUFFER_SIZE) {
|
|
||||||
dao.deleteOldest(count - MAX_BUFFER_SIZE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getBatch(userId: Int): List<PendingGpsEntity> =
|
|
||||||
dao.getOldestForUser(userId, MAX_BATCH_SIZE)
|
|
||||||
|
|
||||||
fun toJson(point: PendingGpsEntity): JSONObject =
|
|
||||||
JSONObject()
|
|
||||||
.put("latitude", point.latitude)
|
|
||||||
.put("longitude", point.longitude)
|
|
||||||
.put("accuracy", point.accuracy)
|
|
||||||
.put(
|
|
||||||
"speed",
|
|
||||||
point.speed ?: JSONObject.NULL
|
|
||||||
)
|
|
||||||
.put(
|
|
||||||
"heading",
|
|
||||||
point.heading ?: JSONObject.NULL
|
|
||||||
)
|
|
||||||
.put("recorded_at", point.recordedAt)
|
|
||||||
|
|
||||||
fun deleteSent(points: List<PendingGpsEntity>) {
|
|
||||||
if (points.isEmpty()) return
|
|
||||||
|
|
||||||
dao.deleteByIds(points.map { it.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
fun count(): Int =
|
|
||||||
dao.count()
|
|
||||||
|
|
||||||
fun hasPending(): Boolean =
|
|
||||||
dao.count() > 0
|
|
||||||
|
|
||||||
suspend fun clear() = withContext(Dispatchers.IO) {
|
|
||||||
dao.deleteAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
package com.bodycamera.twentyfoulabs.data.gps
|
|
||||||
|
|
||||||
import androidx.room.Database
|
|
||||||
import androidx.room.RoomDatabase
|
|
||||||
|
|
||||||
@Database(
|
|
||||||
entities = [PendingGpsEntity::class],
|
|
||||||
version = 1,
|
|
||||||
exportSchema = false
|
|
||||||
)
|
|
||||||
abstract class GpsDatabase : RoomDatabase() {
|
|
||||||
|
|
||||||
abstract fun pendingGpsDao(): PendingGpsDao
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
package com.bodycamera.twentyfoulabs.data.gps
|
|
||||||
|
|
||||||
import androidx.room.Dao
|
|
||||||
import androidx.room.Insert
|
|
||||||
import androidx.room.Query
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
interface PendingGpsDao {
|
|
||||||
|
|
||||||
@Insert
|
|
||||||
fun insert(point: PendingGpsEntity): Long
|
|
||||||
|
|
||||||
// Obtiene como máximo 100 puntos, del más antiguo al más reciente.
|
|
||||||
@Query("SELECT * FROM pending_gps ORDER BY id ASC LIMIT :limit")
|
|
||||||
fun getOldest(limit: Int = 100): List<PendingGpsEntity>
|
|
||||||
@Query("SELECT * FROM pending_gps WHERE userId = :userId ORDER BY id ASC LIMIT :limit")
|
|
||||||
fun getOldestForUser(
|
|
||||||
userId: Int,
|
|
||||||
limit: Int = 100
|
|
||||||
): List<PendingGpsEntity>
|
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM pending_gps")
|
|
||||||
fun count(): Int
|
|
||||||
|
|
||||||
@Query("DELETE FROM pending_gps WHERE id IN (:ids)")
|
|
||||||
fun deleteByIds(ids: List<Long>)
|
|
||||||
|
|
||||||
// Mantiene el buffer limitado a los 100 puntos más recientes.
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
DELETE FROM pending_gps
|
|
||||||
WHERE id IN (
|
|
||||||
SELECT id FROM pending_gps
|
|
||||||
ORDER BY id ASC
|
|
||||||
LIMIT :amount
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
fun deleteOldest(amount: Int)
|
|
||||||
|
|
||||||
@Query("DELETE FROM pending_gps")
|
|
||||||
fun deleteAll()
|
|
||||||
|
|
||||||
@Query("SELECT DISTINCT userId FROM pending_gps")
|
|
||||||
fun getPendingUserIds(): List<Int>
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
package com.bodycamera.twentyfoulabs.data.gps
|
|
||||||
|
|
||||||
import androidx.room.Entity
|
|
||||||
import androidx.room.PrimaryKey
|
|
||||||
|
|
||||||
@Entity(tableName = "pending_gps")
|
|
||||||
data class PendingGpsEntity(
|
|
||||||
@PrimaryKey(autoGenerate = true)
|
|
||||||
val id: Long = 0,
|
|
||||||
|
|
||||||
val userId: Int,
|
|
||||||
|
|
||||||
val latitude: Double,
|
|
||||||
val longitude: Double,
|
|
||||||
val accuracy: Float,
|
|
||||||
|
|
||||||
val speed: Float? = null,
|
|
||||||
val heading: Float? = null,
|
|
||||||
|
|
||||||
// Hora original en la que se obtuvo el fix GPS.
|
|
||||||
// Debe conservarse al reenviar el punto mediante /gps_locations/batch.
|
|
||||||
val recordedAt: String,
|
|
||||||
|
|
||||||
// Momento en que el punto fue almacenado localmente.
|
|
||||||
// Se usa para mantener el orden del buffer.
|
|
||||||
val createdAt: Long = System.currentTimeMillis()
|
|
||||||
)
|
|
||||||
|
|
@ -19,8 +19,8 @@ data class VideoConfiguration(
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class WebRTCServerConfig(
|
data class WebRTCServerConfig(
|
||||||
val serverUrl: String = "172.93.110.24",
|
val serverUrl: String = "192.168.18.57",
|
||||||
val serverPort: Int = 18889,
|
val serverPort: Int = 8889,
|
||||||
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",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
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
|
||||||
|
|
@ -11,9 +10,7 @@ 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,
|
||||||
|
|
@ -49,7 +46,7 @@ class PttApiClient @Inject constructor(
|
||||||
|
|
||||||
setRequestProperty(
|
setRequestProperty(
|
||||||
"X-Device-Serial",
|
"X-Device-Serial",
|
||||||
deviceIdentity.requireSerial()
|
BackendConfig.DEVICE_SERIAL
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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://172.93.110.24:18889"
|
"http://35.224.197.127:8889"
|
||||||
|
|
||||||
private const val STUN_URL =
|
private const val STUN_URL =
|
||||||
"stun:stun.l.google.com:19302"
|
"stun:stun.l.google.com:19302"
|
||||||
|
|
|
||||||
|
|
@ -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.geosatelital.red/cable"
|
"wss://bodycam-prd-web-nefjf74quq-uc.a.run.app/cable"
|
||||||
private const val RECONNECT_DELAY_MS = 3000L
|
private const val RECONNECT_DELAY_MS = 3000L
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,6 @@ 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
|
||||||
|
|
@ -31,39 +28,12 @@ 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
|
||||||
|
|
@ -173,50 +143,48 @@ 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 {
|
||||||
streamMutex.withLock {
|
try {
|
||||||
if (releasing || generation != streamGeneration.get()) return@withLock
|
val server = config.webrtcServer
|
||||||
try {
|
|
||||||
// Nunca iniciar una segunda cámara mientras se limpia la anterior.
|
if (server.serverUrl.isBlank()) {
|
||||||
cleanupStream()
|
callback?.onError("URL del servidor está vacía")
|
||||||
if (releasing || generation != streamGeneration.get()) return@withLock
|
return@launch
|
||||||
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}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -370,8 +338,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")
|
||||||
|
|
||||||
// Un primer frame tardío no puede reactivar una sesión detenida.
|
// Después que la cámara está lista, crear la oferta SDP
|
||||||
if (activeGeneration == streamGeneration.get() && !releasing) createOffer()
|
createOffer()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCameraClosed() {
|
override fun onCameraClosed() {
|
||||||
|
|
@ -399,31 +367,19 @@ class WebRTCManager @Inject constructor(
|
||||||
capturerObserver
|
capturerObserver
|
||||||
)
|
)
|
||||||
|
|
||||||
// Crear y conectar el track antes del primer frame y de la oferta SDP.
|
// Iniciar captura
|
||||||
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -532,68 +488,53 @@ 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 {
|
||||||
streamMutex.withLock {
|
try {
|
||||||
// Si un nuevo inicio legítimo llegó después, no detenerlo.
|
Log.e(TAG, "Stopping streaming")
|
||||||
if (generation != streamGeneration.get()) return@withLock
|
|
||||||
Log.i(TAG, "Stopping streaming generation=$generation")
|
autoStopJob?.cancel()
|
||||||
cleanupStream()
|
autoStopJob = null
|
||||||
|
|
||||||
|
localVideoTrack?.dispose()
|
||||||
|
localVideoTrack = null
|
||||||
|
|
||||||
|
cameraVideoCapturer?.stopCapture()
|
||||||
|
cameraVideoCapturer = null
|
||||||
|
|
||||||
|
videoSource?.dispose()
|
||||||
|
videoSource = null
|
||||||
|
|
||||||
|
surfaceTextureHelper?.dispose()
|
||||||
|
surfaceTextureHelper = null
|
||||||
|
|
||||||
|
peerConnection?.close()
|
||||||
|
peerConnection = null
|
||||||
|
|
||||||
|
signalingClient?.close()
|
||||||
|
signalingClient = null
|
||||||
|
|
||||||
callback?.onStreamStopped()
|
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() {
|
||||||
releasing = true
|
Log.e(TAG, "Releasing WebRTCManager")
|
||||||
streamGeneration.incrementAndGet()
|
stopStreaming()
|
||||||
scope.launch {
|
|
||||||
streamMutex.withLock {
|
peerConnectionFactory?.dispose()
|
||||||
cleanupStream()
|
peerConnectionFactory = null
|
||||||
runCatching { peerConnectionFactory?.dispose() }
|
|
||||||
peerConnectionFactory = null
|
eglBase?.release()
|
||||||
runCatching { eglBase?.release() }
|
eglBase = null
|
||||||
eglBase = null
|
|
||||||
callback = null
|
callback = null
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
package com.bodycamera.twentyfoulabs.di
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.room.Room
|
|
||||||
import com.bodycamera.twentyfoulabs.data.gps.GpsDatabase
|
|
||||||
import com.bodycamera.twentyfoulabs.data.gps.PendingGpsDao
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.Provides
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
object DatabaseModule {
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideGpsDatabase(
|
|
||||||
@ApplicationContext context: Context
|
|
||||||
): GpsDatabase =
|
|
||||||
Room.databaseBuilder(
|
|
||||||
context,
|
|
||||||
GpsDatabase::class.java,
|
|
||||||
"gps_buffer.db"
|
|
||||||
).build()
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
fun providePendingGpsDao(
|
|
||||||
database: GpsDatabase
|
|
||||||
): PendingGpsDao =
|
|
||||||
database.pendingGpsDao()
|
|
||||||
}
|
|
||||||
|
|
@ -5,7 +5,6 @@ import androidx.lifecycle.viewModelScope
|
||||||
import com.bodycamera.twentyfoulabs.data.auth.AuthApiException
|
import com.bodycamera.twentyfoulabs.data.auth.AuthApiException
|
||||||
import com.bodycamera.twentyfoulabs.data.auth.AuthRepository
|
import com.bodycamera.twentyfoulabs.data.auth.AuthRepository
|
||||||
import com.bodycamera.twentyfoulabs.data.auth.AuthSession
|
import com.bodycamera.twentyfoulabs.data.auth.AuthSession
|
||||||
import com.bodycamera.twentyfoulabs.data.auth.AuthSessionStore
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -32,8 +31,7 @@ sealed interface AuthUiState {
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AuthViewModel @Inject constructor(
|
class AuthViewModel @Inject constructor(
|
||||||
private val repository: AuthRepository,
|
private val repository: AuthRepository
|
||||||
private val authSessionStore: AuthSessionStore
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _state =
|
private val _state =
|
||||||
|
|
@ -45,14 +43,6 @@ class AuthViewModel @Inject constructor(
|
||||||
_state.asStateFlow()
|
_state.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
|
||||||
authSessionStore.token.collect { token ->
|
|
||||||
if (token.isNullOrBlank() && _state.value is AuthUiState.Authenticated) {
|
|
||||||
_state.value = AuthUiState.LoggedOut
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
restoreSession()
|
restoreSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,7 +194,7 @@ class AuthViewModel @Inject constructor(
|
||||||
when (e.statusCode) {
|
when (e.statusCode) {
|
||||||
|
|
||||||
401 ->
|
401 ->
|
||||||
"Error 401: ${e.message}"
|
"QR o PIN incorrecto"
|
||||||
|
|
||||||
else ->
|
else ->
|
||||||
e.message
|
e.message
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -51,24 +50,6 @@ 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 {
|
||||||
|
|
@ -225,7 +206,7 @@ fun LoginScreen(
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text =
|
text =
|
||||||
"${BackendConfig.DEVICE_MODEL} - $configuredSerial",
|
"${BackendConfig.DEVICE_MODEL} - ${BackendConfig.DEVICE_SERIAL}",
|
||||||
color = Color(0xFF8FAEC7),
|
color = Color(0xFF8FAEC7),
|
||||||
style =
|
style =
|
||||||
MaterialTheme.typography.labelSmall
|
MaterialTheme.typography.labelSmall
|
||||||
|
|
@ -422,14 +403,6 @@ fun LoginScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
|
||||||
text = if (state is AuthUiState.Error)
|
|
||||||
"ERROR: ${state.message}"
|
|
||||||
else
|
|
||||||
"PRUEBA VISUAL: ZONA DE MENSAJES",
|
|
||||||
color = Color.Yellow,
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|
||||||
|
|
@ -828,75 +801,3 @@ 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -25,9 +25,6 @@ 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)
|
||||||
|
|
@ -88,35 +85,11 @@ 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 = {
|
onVideo = onOpenVideoCamera,
|
||||||
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 },
|
||||||
|
|
@ -209,9 +182,7 @@ 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,
|
if (streamingState is StreamingState.Active) Danger else Accent, !locked, onVideo
|
||||||
!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)
|
||||||
}
|
}
|
||||||
|
|
@ -375,42 +346,3 @@ private fun FullScreenMessage(padding: PaddingValues, text: String, progress: Bo
|
||||||
|
|
||||||
private fun formatMb(mb: Long): String = if (mb >= 1024) String.format(Locale.US, "%.1f GB", mb / 1024.0) else "$mb MB"
|
private fun 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,8 @@ import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.bodycamera.twentyfoulabs.data.model.NetworkConfiguration
|
import com.bodycamera.twentyfoulabs.data.model.NetworkConfiguration
|
||||||
import com.bodycamera.twentyfoulabs.data.device.M530DeviceController
|
import com.bodycamera.twentyfoulabs.data.device.M530DeviceController
|
||||||
import com.bodycamera.twentyfoulabs.data.backend.BackendIntegrationManager
|
|
||||||
import com.bodycamera.twentyfoulabs.data.auth.AuthSessionStore
|
|
||||||
import com.bodycamera.twentyfoulabs.data.model.VideoConfiguration
|
import com.bodycamera.twentyfoulabs.data.model.VideoConfiguration
|
||||||
import com.bodycamera.twentyfoulabs.data.network.NetworkFallbackManager
|
import com.bodycamera.twentyfoulabs.data.network.NetworkFallbackManager
|
||||||
import com.bodycamera.twentyfoulabs.data.network.NetworkMonitor
|
|
||||||
import com.bodycamera.twentyfoulabs.data.repository.AdminLockRepository
|
import com.bodycamera.twentyfoulabs.data.repository.AdminLockRepository
|
||||||
import com.bodycamera.twentyfoulabs.data.repository.NetworkConfigRepository
|
import com.bodycamera.twentyfoulabs.data.repository.NetworkConfigRepository
|
||||||
import com.bodycamera.twentyfoulabs.data.repository.VideoConfigRepository
|
import com.bodycamera.twentyfoulabs.data.repository.VideoConfigRepository
|
||||||
|
|
@ -25,7 +22,6 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -37,15 +33,12 @@ import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class MainViewModel @Inject constructor(
|
class MainViewModel @Inject constructor(
|
||||||
repository: NetworkConfigRepository,
|
repository: NetworkConfigRepository,
|
||||||
networkFallbackManager: NetworkFallbackManager,
|
networkFallbackManager: NetworkFallbackManager,
|
||||||
private val networkMonitor: NetworkMonitor,
|
private val adminLockRepository: AdminLockRepository,
|
||||||
private val adminLockRepository: AdminLockRepository,
|
|
||||||
private val videoConfigRepository: VideoConfigRepository,
|
private val videoConfigRepository: VideoConfigRepository,
|
||||||
private val webrtcManager: WebRTCManager,
|
private val webrtcManager: WebRTCManager,
|
||||||
private val deviceController: M530DeviceController,
|
private val deviceController: M530DeviceController,
|
||||||
private val backendIntegrationManager: BackendIntegrationManager,
|
|
||||||
private val authSessionStore: AuthSessionStore,
|
|
||||||
@ApplicationContext private val appContext: Context
|
@ApplicationContext private val appContext: Context
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
|
@ -100,8 +93,6 @@ 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
|
||||||
|
|
@ -127,11 +118,11 @@ private fun vibrateRecordingStopped() {
|
||||||
|
|
||||||
private var streamingCallback: WebRTCManager.StreamingCallback? = null
|
private var streamingCallback: WebRTCManager.StreamingCallback? = null
|
||||||
|
|
||||||
// Debounce para prevenir multiples presiones rapidas del boton
|
// Debounce para prevenir múltiples presiones rápidas del botón
|
||||||
private var lastButtonPressTime = 0L
|
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 pulsacion de F5 SIEMPRE detenga camara/stream aunque WHIP falle.
|
// para que una segunda pulsación de F5 SIEMPRE detenga cámara/stream aunque WHIP falle.
|
||||||
@Volatile
|
@Volatile
|
||||||
private var streamingRequested = false
|
private var streamingRequested = false
|
||||||
|
|
||||||
|
|
@ -143,133 +134,17 @@ private fun vibrateRecordingStopped() {
|
||||||
val deviceStatus: StateFlow<DeviceStatus> = _deviceStatus.asStateFlow()
|
val deviceStatus: StateFlow<DeviceStatus> = _deviceStatus.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
initializeStreamingCallback()
|
initializeStreamingCallback()
|
||||||
startDeviceStatusLoop()
|
startDeviceStatusLoop()
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
var hadInternet = networkMonitor.hasInternet()
|
|
||||||
var firstEmission = true
|
|
||||||
|
|
||||||
networkMonitor.observeNetworkStatus().collect { status ->
|
|
||||||
val hasInternet = status.hasInternet
|
|
||||||
|
|
||||||
if (hasInternet && (firstEmission || !hadInternet)) {
|
|
||||||
Log.i(
|
|
||||||
TAG,
|
|
||||||
if (firstEmission) {
|
|
||||||
"Internet disponible al iniciar: sincronizando buffer GPS pendiente"
|
|
||||||
} else {
|
|
||||||
"Internet recuperado: sincronizando buffer GPS pendiente"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
backendIntegrationManager.flushPendingGps()
|
|
||||||
}
|
|
||||||
|
|
||||||
hadInternet = hasInternet
|
|
||||||
firstEmission = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var telemetrySettings = BackendIntegrationManager.TelemetrySettings()
|
|
||||||
|
|
||||||
private fun refreshTelemetrySettings() {
|
|
||||||
backendIntegrationManager.fetchTelemetrySettings { result ->
|
|
||||||
result.onSuccess { settings ->
|
|
||||||
telemetrySettings = settings
|
|
||||||
Log.i(
|
|
||||||
TAG,
|
|
||||||
"Cadencias actualizadas: gps=${settings.intervalSeconds}s, " +
|
|
||||||
"sos=${settings.sosIntervalSeconds}s, " +
|
|
||||||
"device=${settings.deviceStatusIntervalSeconds}s"
|
|
||||||
)
|
|
||||||
}.onFailure { error ->
|
|
||||||
Log.w(
|
|
||||||
TAG,
|
|
||||||
"No se pudo actualizar telemetry_settings; se conserva la ultima configuracion",
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var centralSosCadenceActive = false
|
|
||||||
|
|
||||||
private fun refreshSosSupportState() {
|
|
||||||
backendIntegrationManager.fetchSosSupportState { result ->
|
|
||||||
result.onSuccess { state ->
|
|
||||||
val previous = centralSosCadenceActive
|
|
||||||
centralSosCadenceActive = state.isSosCadenceActive
|
|
||||||
if (previous && !centralSosCadenceActive && sosSessionActive.get()) {
|
|
||||||
Log.i(TAG, "SOS cerrado desde central - finalizando sesion SOS local")
|
|
||||||
stopVideoFromUi()
|
|
||||||
}
|
|
||||||
if (previous != centralSosCadenceActive) {
|
|
||||||
Log.i(
|
|
||||||
TAG,
|
|
||||||
"SOS central actualizado: active=$centralSosCadenceActive " +
|
|
||||||
"own=${state.ownSosActive} support=${state.supportRequestActive}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}.onFailure { error ->
|
|
||||||
Log.w(
|
|
||||||
TAG,
|
|
||||||
"No se pudo consultar SOS central; se conserva el ultimo estado",
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startDeviceStatusLoop() {
|
private fun startDeviceStatusLoop() {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
var lastGpsAt = System.currentTimeMillis()
|
var ticks = 0
|
||||||
var lastTelemetryAt = System.currentTimeMillis()
|
|
||||||
var lastSettingsAt = 0L
|
|
||||||
var lastSosSupportAt = 0L
|
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
val token = authSessionStore.token.first()
|
|
||||||
|
|
||||||
if (token.isNullOrBlank()) {
|
|
||||||
delay(1000)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
|
|
||||||
refreshDeviceStatus()
|
refreshDeviceStatus()
|
||||||
|
if (ticks % 3 == 0) deviceController.logTelemetry()
|
||||||
if (lastSettingsAt == 0L || now - lastSettingsAt >= 300_000L) {
|
ticks++
|
||||||
lastSettingsAt = now
|
|
||||||
refreshTelemetrySettings()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastSosSupportAt == 0L || now - lastSosSupportAt >= 30_000L) {
|
|
||||||
lastSosSupportAt = now
|
|
||||||
refreshSosSupportState()
|
|
||||||
}
|
|
||||||
|
|
||||||
val settings = telemetrySettings
|
|
||||||
val gpsIntervalSeconds =
|
|
||||||
if (sosSessionActive.get() || centralSosCadenceActive) {
|
|
||||||
settings.sosIntervalSeconds
|
|
||||||
} else {
|
|
||||||
settings.intervalSeconds
|
|
||||||
}
|
|
||||||
|
|
||||||
if (now - lastGpsAt >= gpsIntervalSeconds * 1000L) {
|
|
||||||
lastGpsAt = now
|
|
||||||
deviceController.logGps()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (now - lastTelemetryAt >= settings.deviceStatusIntervalSeconds * 1000L) {
|
|
||||||
lastTelemetryAt = now
|
|
||||||
deviceController.logTelemetry()
|
|
||||||
}
|
|
||||||
|
|
||||||
delay(5000)
|
delay(5000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -283,92 +158,8 @@ private fun vibrateRecordingStopped() {
|
||||||
_deviceStatus.value = DeviceStatus(telemetry, info, evidence, events)
|
_deviceStatus.value = DeviceStatus(telemetry, info, evidence, events)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val sosGeneration = java.util.concurrent.atomic.AtomicLong(0)
|
fun triggerSosFromUi() {
|
||||||
// Mantener el estado incluso mientras el backend confirma el SOS o WebRTC conecta.
|
deviceController.triggerSos { }
|
||||||
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
|
|
||||||
}
|
|
||||||
// Al activar SOS, reportar ubicacion real inmediatamente.
|
|
||||||
deviceController.logGps()
|
|
||||||
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -390,24 +181,14 @@ val uri = java.net.URI(directWhipUrl)
|
||||||
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")
|
||||||
val startedWhipUrl = activeWhipUrl
|
deviceController.notifyVideoStarted(
|
||||||
if (startedWhipUrl != null) {
|
com.bodycamera.twentyfoulabs.data.backend.BackendConfig.whipUrl
|
||||||
deviceController.notifyVideoStarted(startedWhipUrl)
|
)
|
||||||
} else {
|
|
||||||
Log.w(TAG, "Video iniciado sin dirección WHIP registrada")
|
|
||||||
}
|
|
||||||
|
|
||||||
mainImmediateScope.launch {
|
mainImmediateScope.launch {
|
||||||
if (streamingRequested) {
|
_streamingState.value = StreamingState.Active(600)
|
||||||
_streamingState.value = StreamingState.Active(600)
|
vibrateRecordingStarted()
|
||||||
vibrateRecordingStarted()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -415,7 +196,6 @@ 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
|
||||||
|
|
@ -423,65 +203,37 @@ override fun onStreamStopped() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onConnectionChange(isConnected: Boolean) {
|
||||||
|
Log.e(TAG, "Connection changed: $isConnected, current state: ${_streamingState.value}")
|
||||||
|
mainImmediateScope.launch {
|
||||||
|
_streamingState.value = when (_streamingState.value) {
|
||||||
|
is StreamingState.Connecting -> {
|
||||||
|
// Si estaba conectando y ahora está conectado, pasar a Active
|
||||||
|
if (isConnected) {
|
||||||
override fun onConnectionChange(isConnected: Boolean) {
|
Log.e(TAG, "Connection established, changing to Active")
|
||||||
Log.e(
|
StreamingState.Active(600)
|
||||||
TAG,
|
} else {
|
||||||
"Connection changed: $isConnected, current state: ${_streamingState.value}"
|
_streamingState.value
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
is StreamingState.Active -> {
|
||||||
|
if (!isConnected) {
|
||||||
mainImmediateScope.launch {
|
Log.e(TAG, "Connection lost, changing to Connecting")
|
||||||
if (!streamingRequested) return@launch
|
StreamingState.Connecting
|
||||||
|
} else {
|
||||||
_streamingState.value = when (_streamingState.value) {
|
_streamingState.value
|
||||||
|
}
|
||||||
is StreamingState.Connecting -> {
|
}
|
||||||
if (isConnected) {
|
else -> _streamingState.value
|
||||||
Log.e(
|
}
|
||||||
TAG,
|
|
||||||
"Connection established, changing to Active"
|
|
||||||
)
|
|
||||||
StreamingState.Active(600)
|
|
||||||
} else {
|
|
||||||
_streamingState.value
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is StreamingState.Active -> {
|
|
||||||
if (!isConnected) {
|
|
||||||
Log.e(
|
|
||||||
TAG,
|
|
||||||
"Connection lost, changing to Connecting"
|
|
||||||
)
|
|
||||||
StreamingState.Connecting
|
|
||||||
} else {
|
|
||||||
_streamingState.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> _streamingState.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
override fun onError(error: String) {
|
override fun onError(error: String) {
|
||||||
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
|
||||||
activeWhipUrl = null
|
// Si WHIP/signaling falla, no dejar la cámara capturando indefinidamente.
|
||||||
// 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)
|
||||||
|
|
@ -508,7 +260,7 @@ override fun onConnectionChange(isConnected: Boolean) {
|
||||||
fun onEvent(event: MainUiEvent) {
|
fun onEvent(event: MainUiEvent) {
|
||||||
when (event) {
|
when (event) {
|
||||||
MainUiEvent.RetryLoad -> {
|
MainUiEvent.RetryLoad -> {
|
||||||
// El flow se actualiza automaticamente
|
// El flow se actualiza automáticamente
|
||||||
}
|
}
|
||||||
MainUiEvent.ImportConfigFromFile -> {
|
MainUiEvent.ImportConfigFromFile -> {
|
||||||
// TODO: Implement file picker
|
// TODO: Implement file picker
|
||||||
|
|
@ -526,13 +278,13 @@ override fun onConnectionChange(isConnected: Boolean) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Metodo publico para ser llamado desde el boton fisico de camara (KEYCODE_F5).
|
* Método público para ser llamado desde el botón fÃsico de cámara (KEYCODE_F5).
|
||||||
* Verifica si la app esta bloqueada antes de iniciar/detener streaming.
|
* Verifica si la app está 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, "Boton presionado muy rapido, ignorando (debounce)")
|
Log.e(TAG, "Botón presionado muy rápido, ignorando (debounce)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastButtonPressTime = currentTime
|
lastButtonPressTime = currentTime
|
||||||
|
|
@ -542,7 +294,7 @@ override fun onConnectionChange(isConnected: Boolean) {
|
||||||
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 - boton camara ignorado")
|
Log.e(TAG, "App bloqueada - botón cámara ignorado")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
Log.e(TAG, "App desbloqueada - llamando a toggleStreaming")
|
Log.e(TAG, "App desbloqueada - llamando a toggleStreaming")
|
||||||
|
|
@ -554,57 +306,22 @@ override fun onConnectionChange(isConnected: Boolean) {
|
||||||
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 camara/WebRTC")
|
Log.e(TAG, "F5 STOP solicitado - liberando cámara/WebRTC")
|
||||||
streamingRequested = false
|
streamingRequested = false
|
||||||
webrtcManager.stopStreaming()
|
webrtcManager.stopStreaming()
|
||||||
_streamingState.value = StreamingState.Stopped
|
_streamingState.value = StreamingState.Stopped
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val savedConfig = videoConfigRepository.getConfig()
|
val config = videoConfigRepository.getConfig()
|
||||||
|
if (config == null) {
|
||||||
val config = when {
|
Log.e(TAG, "No hay configuración de video")
|
||||||
savedConfig == null -> VideoConfiguration().also {
|
streamingRequested = false
|
||||||
videoConfigRepository.saveConfig(it)
|
_streamingState.value = StreamingState.Error("No hay configuración de video")
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
if (server.serverUrl.isBlank() ||
|
Log.e(TAG, "F5 START solicitado: ${config.webrtcServer.serverUrl}:${config.webrtcServer.serverPort}")
|
||||||
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 {
|
||||||
|
|
@ -618,18 +335,6 @@ override fun onConnectionChange(isConnected: Boolean) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
||||||
|
|
@ -667,9 +372,3 @@ data class DeviceStatus(
|
||||||
val evidence: M530DeviceController.EvidenceSummary? = null,
|
val evidence: M530DeviceController.EvidenceSummary? = null,
|
||||||
val recentEvents: List<M530DeviceController.EventEntry> = emptyList()
|
val recentEvents: List<M530DeviceController.EventEntry> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@
|
||||||
<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 -->
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue