Compare commits
2 commits
51ccd80070
...
1b130a9ed8
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b130a9ed8 | |||
| 41b81c972a |
15 changed files with 1269 additions and 88 deletions
|
|
@ -80,6 +80,11 @@ 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")
|
||||||
|
|
||||||
|
|
@ -118,3 +123,5 @@ implementation("com.google.mlkit:barcode-scanning:17.3.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -613,8 +613,18 @@ fun AppNavigation(viewModel: MainViewModel, onOpenPhotoCamera: () -> Unit, onOpe
|
||||||
val authState by authViewModel.state.collectAsState()
|
val authState by authViewModel.state.collectAsState()
|
||||||
|
|
||||||
LaunchedEffect(authState) {
|
LaunchedEffect(authState) {
|
||||||
if (authState is AuthUiState.Authenticated && navController.currentDestination?.route != "main") {
|
when (authState) {
|
||||||
navController.navigate("main") { popUpTo("login") { inclusive = true } }
|
is AuthUiState.Authenticated -> {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
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.backend.DeviceIdentity
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -44,6 +46,18 @@ 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(
|
||||||
|
|
@ -173,3 +187,9 @@ class AuthApiException(
|
||||||
val statusCode: Int,
|
val statusCode: Int,
|
||||||
override val message: String
|
override val message: String
|
||||||
) : Exception(message)
|
) : Exception(message)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,11 @@ 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,
|
||||||
|
|
@ -63,6 +68,15 @@ 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,
|
||||||
|
|
@ -88,3 +102,7 @@ data class AuthSession(
|
||||||
val device: AuthDevice? = null,
|
val device: AuthDevice? = null,
|
||||||
val refreshToken: String? = null
|
val refreshToken: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
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.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
|
||||||
|
|
@ -10,7 +13,8 @@ import javax.inject.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 deviceIdentity: DeviceIdentity,
|
||||||
|
private val gpsBufferRepository: GpsBufferRepository
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun login(
|
suspend fun login(
|
||||||
|
|
@ -34,8 +38,28 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,6 +84,15 @@ 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
|
||||||
|
|
@ -77,37 +110,73 @@ class AuthRepository @Inject constructor(
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
|
|
||||||
val response = api.me(token)
|
|
||||||
|
|
||||||
AuthSession(
|
val response = api.me(token)
|
||||||
token = token,
|
|
||||||
user = response.user,
|
val session = AuthSession(
|
||||||
device = response.device ?: response.user.assignedDevice,
|
token = token,
|
||||||
refreshToken = refreshToken
|
user = response.user,
|
||||||
)
|
device = response.device ?: response.user.assignedDevice,
|
||||||
|
refreshToken = refreshToken
|
||||||
|
)
|
||||||
|
|
||||||
|
store.save(session)
|
||||||
|
|
||||||
|
session
|
||||||
|
|
||||||
} catch (e: AuthApiException) {
|
} catch (e: AuthApiException) {
|
||||||
|
|
||||||
if (e.statusCode == 401) {
|
if (e.statusCode == 401 && !refreshToken.isNullOrBlank()) {
|
||||||
|
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()
|
||||||
|
|
||||||
try {
|
// Cerrar la sesion local inmediatamente.
|
||||||
|
store.clear()
|
||||||
|
|
||||||
if (!token.isNullOrBlank()) {
|
// Descartar GPS pendientes del sereno anterior.
|
||||||
api.logout(token)
|
gpsBufferRepository.clear()
|
||||||
}
|
|
||||||
|
|
||||||
} finally {
|
// Notificar al backend sin impedir el logout local si no hay Internet.
|
||||||
store.clear()
|
if (!token.isNullOrBlank()) {
|
||||||
|
runCatching {
|
||||||
|
api.logout(token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ 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")
|
||||||
|
|
@ -35,12 +36,19 @@ class AuthSessionStore @Inject constructor(
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,14 @@ 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 deviceIdentity: DeviceIdentity,
|
||||||
|
private val gpsBufferRepository: GpsBufferRepository
|
||||||
) {
|
) {
|
||||||
companion object { private const val TAG = "BackendIntegration" }
|
companion object { private const val TAG = "BackendIntegration" }
|
||||||
|
|
||||||
|
|
@ -33,6 +35,29 @@ class BackendIntegrationManager @Inject constructor(
|
||||||
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 {
|
||||||
|
|
@ -91,7 +116,15 @@ class BackendIntegrationManager @Inject constructor(
|
||||||
enqueuePublish(topic, envelope)
|
enqueuePublish(topic, envelope)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun publishGps(payload: JSONObject) {
|
data class TelemetrySettings(
|
||||||
|
val intervalSeconds: Int = 300,
|
||||||
|
val sosIntervalSeconds: Int = 30,
|
||||||
|
val deviceStatusIntervalSeconds: Int = 300
|
||||||
|
)
|
||||||
|
|
||||||
|
fun fetchTelemetrySettings(
|
||||||
|
onResult: (Result<TelemetrySettings>) -> Unit
|
||||||
|
) {
|
||||||
httpExecutor.execute {
|
httpExecutor.execute {
|
||||||
var conn: HttpURLConnection? = null
|
var conn: HttpURLConnection? = null
|
||||||
try {
|
try {
|
||||||
|
|
@ -100,46 +133,595 @@ class BackendIntegrationManager @Inject constructor(
|
||||||
}?.takeIf { it.isNotBlank() }
|
}?.takeIf { it.isNotBlank() }
|
||||||
?: error("No hay sesion JWT activa")
|
?: error("No hay sesion JWT activa")
|
||||||
|
|
||||||
|
val serial = deviceIdentity.requireSerial()
|
||||||
val url = URL(
|
val url = URL(
|
||||||
BackendConfig.API_BASE_URL.trimEnd('/') +
|
BackendConfig.API_BASE_URL.trimEnd('/') +
|
||||||
"/mobile/v1/gps_locations"
|
"/mobile/v1/telemetry_settings"
|
||||||
)
|
)
|
||||||
|
|
||||||
conn = (url.openConnection() as HttpURLConnection).apply {
|
conn = (url.openConnection() as HttpURLConnection).apply {
|
||||||
requestMethod = "POST"
|
requestMethod = "GET"
|
||||||
connectTimeout = 15_000
|
connectTimeout = 15_000
|
||||||
readTimeout = 30_000
|
readTimeout = 30_000
|
||||||
doOutput = true
|
|
||||||
setRequestProperty("Content-Type", "application/json")
|
|
||||||
setRequestProperty("Accept", "application/json")
|
setRequestProperty("Accept", "application/json")
|
||||||
setRequestProperty("Authorization", "Bearer $token")
|
setRequestProperty("Authorization", "Bearer $token")
|
||||||
|
setRequestProperty("X-Device-Serial", serial)
|
||||||
setRequestProperty(
|
setRequestProperty(
|
||||||
"X-Device-Serial",
|
"X-Device-Time",
|
||||||
deviceIdentity.requireSerial()
|
System.currentTimeMillis().toString()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
conn.outputStream.use {
|
|
||||||
it.write(payload.toString().toByteArray(Charsets.UTF_8))
|
|
||||||
}
|
|
||||||
|
|
||||||
val status = conn.responseCode
|
val status = conn.responseCode
|
||||||
if (status in 200..299) {
|
if (status in 200..299) {
|
||||||
Log.i(TAG, "GPS HTTPS OK: HTTP $status")
|
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 {
|
} else {
|
||||||
val errorBody = conn.errorStream
|
val errorBody = conn.errorStream
|
||||||
?.bufferedReader()
|
?.bufferedReader()
|
||||||
?.use { it.readText() }
|
?.use { it.readText() }
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
Log.e(TAG, "GPS HTTPS ERROR: HTTP $status $errorBody")
|
val error = IllegalStateException(
|
||||||
|
"Telemetry settings HTTP $status $errorBody"
|
||||||
|
)
|
||||||
|
Log.e(TAG, error.message.orEmpty())
|
||||||
|
onResult(Result.failure(error))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "GPS HTTPS ERROR", e)
|
Log.e(TAG, "Telemetry settings ERROR", e)
|
||||||
|
onResult(Result.failure(e))
|
||||||
} finally {
|
} finally {
|
||||||
conn?.disconnect()
|
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()
|
val serial = deviceIdentity.configuredSerial()
|
||||||
if (serial == null) {
|
if (serial == null) {
|
||||||
|
|
@ -174,7 +756,11 @@ class BackendIntegrationManager @Inject constructor(
|
||||||
setRequestProperty("Authorization", "Bearer $token")
|
setRequestProperty("Authorization", "Bearer $token")
|
||||||
setRequestProperty(
|
setRequestProperty(
|
||||||
"X-Device-Serial",
|
"X-Device-Serial",
|
||||||
deviceIdentity.requireSerial()
|
serial
|
||||||
|
)
|
||||||
|
setRequestProperty(
|
||||||
|
"X-Device-Time",
|
||||||
|
System.currentTimeMillis().toString()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,6 +779,13 @@ class BackendIntegrationManager @Inject constructor(
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
|
|
||||||
Log.e(TAG, "TELEMETRY HTTPS ERROR: HTTP $status $errorBody")
|
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) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "TELEMETRY HTTPS ERROR", e)
|
Log.e(TAG, "TELEMETRY HTTPS ERROR", e)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
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
|
||||||
|
|
@ -47,6 +49,7 @@ 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(
|
||||||
|
|
@ -65,6 +68,29 @@ 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",
|
||||||
|
|
@ -294,12 +320,7 @@ Result.success(file)
|
||||||
fun triggerSos(): SosResult {
|
fun triggerSos(): SosResult {
|
||||||
|
|
||||||
val location = bestLastKnownLocation()?.takeIf {
|
val location = bestLastKnownLocation()?.takeIf {
|
||||||
System.currentTimeMillis() - it.time in 0..300_000L
|
System.currentTimeMillis() - it.time in -5_000L..300_000L
|
||||||
} ?: Location("simulated").apply {
|
|
||||||
latitude = -12.077
|
|
||||||
longitude = -77.092
|
|
||||||
accuracy = 10f
|
|
||||||
time = System.currentTimeMillis()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val payload =
|
val payload =
|
||||||
|
|
@ -413,7 +434,8 @@ Result.success(file)
|
||||||
else -> "Sin red"
|
else -> "Sin red"
|
||||||
}
|
}
|
||||||
val freeMb = context.filesDir.usableSpace / (1024L * 1024L)
|
val freeMb = context.filesDir.usableSpace / (1024L * 1024L)
|
||||||
return TelemetrySnapshot(batteryPercent, charging, networkName, connected, freeMb)
|
val totalMb = context.filesDir.totalSpace / (1024L * 1024L)
|
||||||
|
return TelemetrySnapshot(batteryPercent, charging, networkName, connected, freeMb, totalMb)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun evidenceSummary(): EvidenceSummary {
|
fun evidenceSummary(): EvidenceSummary {
|
||||||
|
|
@ -438,11 +460,11 @@ Result.success(file)
|
||||||
|
|
||||||
fun logGps() {
|
fun logGps() {
|
||||||
val location = bestLastKnownLocation()?.takeIf {
|
val location = bestLastKnownLocation()?.takeIf {
|
||||||
System.currentTimeMillis() - it.time in 0..300_000L
|
System.currentTimeMillis() - it.time in -5_000L..300_000L
|
||||||
}
|
}
|
||||||
|
|
||||||
if (location == null) {
|
if (location == null) {
|
||||||
Log.w(TAG, "GPS: sin ubicación reciente; se omite el envÃo")
|
Log.w(TAG, "GPS: sin ubicación reciente; se omite el envÃÂo")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -454,12 +476,12 @@ Result.success(file)
|
||||||
java.util.Locale.US
|
java.util.Locale.US
|
||||||
).apply {
|
).apply {
|
||||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||||
}.format(java.util.Date())
|
}.format(java.util.Date(location.time))
|
||||||
|
|
||||||
val payload = JSONObject()
|
val payload = JSONObject()
|
||||||
.put("latitude", latitude)
|
.put("latitude", latitude)
|
||||||
.put("longitude", longitude)
|
.put("longitude", longitude)
|
||||||
.put("speed", if (location.hasSpeed()) location.speed else JSONObject.NULL)
|
.put("speed", if (location.hasSpeed()) location.speed * 3.6 else JSONObject.NULL)
|
||||||
.put("heading", if (location.hasBearing()) location.bearing else JSONObject.NULL)
|
.put("heading", if (location.hasBearing()) location.bearing else JSONObject.NULL)
|
||||||
.put("accuracy", location.accuracy)
|
.put("accuracy", location.accuracy)
|
||||||
.put("recorded_at", recordedAt)
|
.put("recorded_at", recordedAt)
|
||||||
|
|
@ -471,20 +493,99 @@ Result.success(file)
|
||||||
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("timestamp", t.timestamp); backend.publishTelemetry(p); return logEvent("TELEMETRY", p)
|
.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)
|
||||||
}
|
}
|
||||||
|
|
||||||
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() { 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 }
|
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
|
||||||
|
|
||||||
|
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? {
|
||||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED &&
|
val fineGranted =
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) return null
|
ContextCompat.checkSelfPermission(
|
||||||
val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
context,
|
||||||
return runCatching { lm.getProviders(true).mapNotNull { p -> runCatching { lm.getLastKnownLocation(p) }.getOrNull() }.maxByOrNull { it.time } }.getOrNull()
|
Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
|
) == 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 {
|
||||||
|
|
@ -506,3 +607,12 @@ 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())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
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,6 +5,7 @@ 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
|
||||||
|
|
@ -31,7 +32,8 @@ 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 =
|
||||||
|
|
@ -43,6 +45,14 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,11 @@ 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
|
||||||
|
|
@ -22,6 +25,7 @@ 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
|
||||||
|
|
@ -33,12 +37,15 @@ import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class MainViewModel @Inject constructor(
|
class MainViewModel @Inject constructor(
|
||||||
repository: NetworkConfigRepository,
|
repository: NetworkConfigRepository,
|
||||||
networkFallbackManager: NetworkFallbackManager,
|
networkFallbackManager: NetworkFallbackManager,
|
||||||
private val adminLockRepository: AdminLockRepository,
|
private val networkMonitor: NetworkMonitor,
|
||||||
|
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() {
|
||||||
|
|
||||||
|
|
@ -136,20 +143,133 @@ 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 ticks = 0
|
var lastGpsAt = System.currentTimeMillis()
|
||||||
|
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 > 0 && ticks % 60 == 0) {
|
|
||||||
deviceController.logTelemetry()
|
if (lastSettingsAt == 0L || now - lastSettingsAt >= 300_000L) {
|
||||||
|
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()
|
deviceController.logGps()
|
||||||
}
|
}
|
||||||
ticks++
|
|
||||||
|
if (now - lastTelemetryAt >= settings.deviceStatusIntervalSeconds * 1000L) {
|
||||||
|
lastTelemetryAt = now
|
||||||
|
deviceController.logTelemetry()
|
||||||
|
}
|
||||||
|
|
||||||
delay(5000)
|
delay(5000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -177,6 +297,8 @@ private fun vibrateRecordingStopped() {
|
||||||
Log.w(TAG, "SOS ya activo o conectando: no generar otra alerta")
|
Log.w(TAG, "SOS ya activo o conectando: no generar otra alerta")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Al activar SOS, reportar ubicacion real inmediatamente.
|
||||||
|
deviceController.logGps()
|
||||||
if (!sosInProgress.compareAndSet(false, true)) {
|
if (!sosInProgress.compareAndSet(false, true)) {
|
||||||
sosSessionActive.set(false)
|
sosSessionActive.set(false)
|
||||||
Log.w(TAG, "SOS ya en proceso")
|
Log.w(TAG, "SOS ya en proceso")
|
||||||
|
|
@ -301,33 +423,59 @@ override fun onStreamStopped() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onConnectionChange(isConnected: Boolean) {
|
|
||||||
Log.e(TAG, "Connection changed: $isConnected, current state: ${_streamingState.value}")
|
|
||||||
mainImmediateScope.launch {
|
|
||||||
if (!streamingRequested) return@launch
|
|
||||||
_streamingState.value = when (_streamingState.value) {
|
|
||||||
is StreamingState.Connecting -> {
|
|
||||||
// Si estaba conectando y ahora esta conectado, pasar a Active
|
|
||||||
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)
|
||||||
|
|
@ -519,3 +667,9 @@ 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()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue