diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index eae8382..a95ce0f 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -101,6 +101,8 @@ dependencies {
implementation("androidx.camera:camera-camera2:1.3.0")
implementation("androidx.camera:camera-lifecycle:1.3.0")
implementation("androidx.camera:camera-view:1.3.0")
+// ML Kit - QR Scanner
+implementation("com.google.mlkit:barcode-scanning:17.3.0")
// Testing
testImplementation("junit:junit:4.13.2")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7563e5a..80776b0 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,4 +1,4 @@
-
+
@@ -12,6 +12,7 @@
+
@@ -39,6 +40,11 @@
android:supportsRtl="true"
android:theme="@style/Theme.MainApp"
android:networkSecurityConfig="@xml/network_security_config">
+
@@ -52,8 +58,9 @@
-
+
+
@@ -67,7 +74,16 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
+
+
+
+
+
+
+
+
-
diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainActivity.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainActivity.kt
index 57753fe..04e3919 100644
--- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainActivity.kt
+++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainActivity.kt
@@ -1,4 +1,4 @@
-package com.bodycamera.twentyfoulabs
+package com.bodycamera.twentyfoulabs
import android.Manifest
import android.content.BroadcastReceiver
@@ -44,10 +44,128 @@ import androidx.hilt.navigation.compose.hiltViewModel
import javax.inject.Inject
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import kotlinx.coroutines.delay
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.collect
+@Composable
+private fun M530StartupSplash() {
+
+ val alpha = remember {
+ Animatable(0f)
+ }
+
+ val scale = remember {
+ Animatable(0.90f)
+ }
+
+ LaunchedEffect(Unit) {
+
+ launch {
+ alpha.animateTo(
+ targetValue = 1f,
+ animationSpec = tween(650)
+ )
+ }
+
+ launch {
+ scale.animateTo(
+ targetValue = 1f,
+ animationSpec = tween(750)
+ )
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color(0xFF071827)),
+ contentAlignment = Alignment.Center
+ ) {
+
+ Column(
+ modifier = Modifier
+ .alpha(alpha.value)
+ .graphicsLayer {
+ scaleX = scale.value
+ scaleY = scale.value
+ },
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+
+ BasicText(
+ text = "GEOSATELITAL",
+ style = TextStyle(
+ color = Color.White,
+ fontSize = 28.sp,
+ fontWeight = FontWeight.Bold
+ )
+ )
+
+ Spacer(
+ modifier = Modifier.height(8.dp)
+ )
+
+ BasicText(
+ text = "M530 COMMAND",
+ style = TextStyle(
+ color = Color(0xFF00D7FF),
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium
+ )
+ )
+
+ Spacer(
+ modifier = Modifier.height(28.dp)
+ )
+
+ Box(
+ modifier = Modifier
+ .width(100.dp)
+ .height(3.dp)
+ .background(Color(0xFF00D7FF))
+ )
+
+ Spacer(
+ modifier = Modifier.height(12.dp)
+ )
+
+ BasicText(
+ text = "INICIANDO...",
+ style = TextStyle(
+ color = Color(0xFFB7C6D3),
+ fontSize = 11.sp
+ )
+ )
+ }
+ }
+}
+
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@@ -63,6 +181,15 @@ class MainActivity : ComponentActivity() {
private const val TAG = "MainActivity"
private const val ACTION_VIDEO_DOWN = "android.intent.action.ACTION_VIDEO_DOWN"
private const val ACTION_VIDEO_UP = "android.intent.action.ACTION_VIDEO_UP"
+
+ private const val ACTION_PTT_DOWN =
+ "android.intent.action.ACTION_PTTKEY_DOWN"
+
+ private const val ACTION_PTT_UP =
+ "android.intent.action.ACTION_PTTKEY_UP"
+
+ private const val ACTION_PTT_LONG_PRESS =
+ "android.intent.action.ACTION_PTTKEY_LONG_PRESS"
}
private var isReceiverRegistered = false
@@ -70,8 +197,51 @@ class MainActivity : ComponentActivity() {
private val hardwareReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
+
ACTION_VIDEO_DOWN -> handleVideoButton()
+
ACTION_VIDEO_UP -> Unit
+
+ ACTION_PTT_DOWN -> {
+ Log.i(TAG, "PTT BROADCAST DOWN recibido")
+
+ if (!isAuthenticated) {
+ toast("Inicia sesion para usar PTT")
+ return
+ }
+
+ val result = deviceController.startPtt()
+
+ Log.i(
+ TAG,
+ if (result.isSuccess)
+ "PTT BROADCAST DOWN - PTT iniciado"
+ else
+ "PTT BROADCAST DOWN - ERROR: ${result.exceptionOrNull()?.message}"
+ )
+ }
+
+ ACTION_PTT_LONG_PRESS -> {
+ Log.d(TAG, "PTT BROADCAST LONG PRESS ignorado")
+ }
+
+ ACTION_PTT_UP -> {
+ Log.i(TAG, "PTT BROADCAST UP recibido")
+
+ if (!isAuthenticated) {
+ return
+ }
+
+ val result = deviceController.stopPtt()
+
+ Log.i(
+ TAG,
+ if (result.isSuccess)
+ "PTT BROADCAST UP - PTT detenido"
+ else
+ "PTT BROADCAST UP - ERROR: ${result.exceptionOrNull()?.message}"
+ )
+ }
}
}
}
@@ -114,7 +284,30 @@ class MainActivity : ComponentActivity() {
deviceController.logTelemetry()
setContent {
- MainAppTheme { AppNavigation(viewModel = viewModel, onOpenPhotoCamera = { handlePhotoButton() }, onOpenVideoCamera = { handleVideoButton() }) }
+ MainAppTheme {
+
+ var showStartupSplash by remember {
+ mutableStateOf(true)
+ }
+
+ LaunchedEffect(Unit) {
+ delay(2000)
+ showStartupSplash = false
+ }
+
+ if (showStartupSplash) {
+
+ M530StartupSplash()
+
+ } else {
+
+ AppNavigation(
+ viewModel = viewModel,
+ onOpenPhotoCamera = { handlePhotoButton() },
+ onOpenVideoCamera = { handleVideoButton() }
+ )
+ }
+ }
}
}
@@ -122,7 +315,7 @@ class MainActivity : ComponentActivity() {
if (event?.repeatCount ?: 0 > 0) return true
Log.i(TAG, "M530 KEY DOWN: $keyCode")
if (!isAuthenticated && keyCode in setOf(KeyEvent.KEYCODE_F1, KeyEvent.KEYCODE_F2, KeyEvent.KEYCODE_F3, KeyEvent.KEYCODE_F4, KeyEvent.KEYCODE_F5, KeyEvent.KEYCODE_F7)) {
- toast("Inicia sesión para usar la bodycam")
+ toast("Inicia sesion para usar la bodycam")
return true
}
return when (keyCode) {
@@ -182,13 +375,11 @@ class MainActivity : ComponentActivity() {
true
}
KeyEvent.KEYCODE_F4 -> {
- val result = deviceController.startPtt()
- toast(if (result.isSuccess) "PTT: hablando" else "PTT error: ${result.exceptionOrNull()?.message}")
true
}
KeyEvent.KEYCODE_F5 -> {
- // Algunas versiones del firmware también emiten ACTION_VIDEO_DOWN.
- // El ViewModel aplica debounce para evitar doble ejecución.
+ // Algunas versiones del firmware tambien emiten ACTION_VIDEO_DOWN.
+ // El ViewModel aplica debounce para evitar doble ejecucion.
handleVideoButton()
true
}
@@ -200,24 +391,53 @@ class MainActivity : ComponentActivity() {
}
}
- override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
- Log.i(TAG, "M530 KEY UP: $keyCode")
- if (!isAuthenticated && keyCode in setOf(KeyEvent.KEYCODE_F1, KeyEvent.KEYCODE_F2, KeyEvent.KEYCODE_F3, KeyEvent.KEYCODE_F4, KeyEvent.KEYCODE_F5, KeyEvent.KEYCODE_F7)) return true
+ override fun onKeyUp(
+ keyCode: Int,
+ event: KeyEvent?
+ ): Boolean {
+
+ Log.i(
+ TAG,
+ "M530 KEY UP: $keyCode"
+ )
+
+ if (
+ !isAuthenticated &&
+ keyCode in setOf(
+ KeyEvent.KEYCODE_F1,
+ KeyEvent.KEYCODE_F2,
+ KeyEvent.KEYCODE_F3,
+ KeyEvent.KEYCODE_F4,
+ KeyEvent.KEYCODE_F5,
+ KeyEvent.KEYCODE_F7
+ )
+ ) {
+ return true
+ }
+
return when (keyCode) {
+
KeyEvent.KEYCODE_F4 -> {
- val result = deviceController.stopPtt()
- toast(if (result.isSuccess) "PTT: turno liberado" else "PTT error")
true
}
- KeyEvent.KEYCODE_F1, KeyEvent.KEYCODE_F2, KeyEvent.KEYCODE_F3,
- KeyEvent.KEYCODE_F5, KeyEvent.KEYCODE_F7 -> true
- else -> super.onKeyUp(keyCode, event)
+
+ KeyEvent.KEYCODE_F1,
+ KeyEvent.KEYCODE_F2,
+ KeyEvent.KEYCODE_F3,
+ KeyEvent.KEYCODE_F5,
+ KeyEvent.KEYCODE_F7 -> true
+
+ else ->
+ super.onKeyUp(
+ keyCode,
+ event
+ )
}
}
private fun handleVideoButton() {
if (!isAuthenticated) {
- toast("Inicia sesión para usar VIDEO")
+ toast("Inicia sesion para usar VIDEO")
return
}
startActivity(
@@ -254,13 +474,40 @@ class MainActivity : ComponentActivity() {
).addFlags(android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP)
)
}
+ private fun handleSosButton() {
- private fun handleSosButton() {
- val result = deviceController.triggerSos()
- val loc = result.location
- val msg = if (loc != null) "SOS registrado (${loc.latitude}, ${loc.longitude})" else "SOS registrado (GPS sin posición reciente)"
- Log.w(TAG, msg)
- toast(msg)
+ toast("Enviando SOS...")
+
+ deviceController.triggerSos { result ->
+
+ runOnUiThread {
+
+ if (result.isSuccess) {
+
+ val alertId = result.getOrNull()
+
+ Log.w(
+ TAG,
+ "SOS ENVIADO id=$alertId"
+ )
+
+ toast("SOS ENVIADO")
+
+ } else {
+
+ val message =
+ result.exceptionOrNull()?.message
+ ?: "No se pudo enviar SOS"
+
+ Log.e(
+ TAG,
+ "SOS NO ENVIADO: $message"
+ )
+
+ toast(message)
+ }
+ }
+ }
}
private fun registerHardwareReceiver() {
@@ -268,6 +515,9 @@ class MainActivity : ComponentActivity() {
val filter = IntentFilter().apply {
addAction(ACTION_VIDEO_DOWN)
addAction(ACTION_VIDEO_UP)
+ addAction(ACTION_PTT_DOWN)
+ addAction(ACTION_PTT_UP)
+ addAction(ACTION_PTT_LONG_PRESS)
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -296,10 +546,14 @@ class MainActivity : ComponentActivity() {
override fun onPause() {
super.onPause()
- unregisterHardwareReceiver()
+
+ // El receiver permanece activo para capturar
+ // botones fisicos mientras otra Activity de la app
+ // (por ejemplo la camara) esta en primer plano.
}
override fun onDestroy() {
+ unregisterHardwareReceiver()
deviceController.release()
super.onDestroy()
}
@@ -361,6 +615,7 @@ fun AppNavigation(viewModel: MainViewModel, onOpenPhotoCamera: () -> Unit, onOpe
LoginScreen(
state = authState,
onLogin = authViewModel::login,
+ onQrLogin = authViewModel::qrLogin,
onRetrySession = authViewModel::restoreSession,
onClearError = authViewModel::clearError
)
@@ -392,10 +647,3 @@ fun AppNavigation(viewModel: MainViewModel, onOpenPhotoCamera: () -> Unit, onOpe
}
}
}
-
-
-
-
-
-
-
diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/camera/M530CameraActivity.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/camera/M530CameraActivity.kt
index ed71be7..4053279 100644
--- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/camera/M530CameraActivity.kt
+++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/camera/M530CameraActivity.kt
@@ -1,4 +1,4 @@
-package com.bodycamera.twentyfoulabs.camera
+package com.bodycamera.twentyfoulabs.camera
import android.Manifest
import android.content.ContentValues
@@ -44,9 +44,59 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import javax.inject.Inject
+import android.os.VibrationEffect
+import android.os.Vibrator
+import android.os.VibratorManager
@AndroidEntryPoint
class M530CameraActivity : ComponentActivity() {
+private fun getDeviceVibrator(): Vibrator {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val vibratorManager =
+ getSystemService(VIBRATOR_MANAGER_SERVICE) as VibratorManager
+
+ vibratorManager.getVibrator(1)
+ } else {
+ @Suppress("DEPRECATION")
+ getSystemService(VIBRATOR_SERVICE) as Vibrator
+ }
+}
+
+private fun vibrateRecordingStarted() {
+ val vibrator = getDeviceVibrator()
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ vibrator.vibrate(
+ VibrationEffect.createOneShot(
+ 150L,
+ VibrationEffect.DEFAULT_AMPLITUDE
+ )
+ )
+ } else {
+ @Suppress("DEPRECATION")
+ vibrator.vibrate(150L)
+ }
+}
+
+private fun vibrateRecordingStopped() {
+ val vibrator = getDeviceVibrator()
+
+ val pattern = longArrayOf(
+ 0L,
+ 100L,
+ 100L,
+ 100L
+ )
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ vibrator.vibrate(
+ VibrationEffect.createWaveform(pattern, -1)
+ )
+ } else {
+ @Suppress("DEPRECATION")
+ vibrator.vibrate(pattern, -1)
+ }
+}
@Inject
lateinit var backend: BackendIntegrationManager
@@ -523,7 +573,7 @@ class M530CameraActivity : ComponentActivity() {
override fun onImageSaved(result: ImageCapture.OutputFileResults) {
stateText.text = "Foto guardada"
flashCaptureFeedback()
-
+
val savedUri = result.savedUri
if (savedUri == null) {
@@ -692,7 +742,10 @@ class M530CameraActivity : ComponentActivity() {
}
private fun enterRecordingState() {
- stateText.text = "GRABANDO"
+ vibrateRecordingStarted()
+
+ stateText.text = "GRABANDO"
+
physicalHintText.text = "F5 DETENER"
recordDot.visibility = View.VISIBLE
@@ -710,6 +763,13 @@ class M530CameraActivity : ComponentActivity() {
}
private fun exitRecordingState(error: Boolean) {
+ activeRecording = null
+
+ if (!error) {
+ vibrateRecordingStopped()
+ }
+
+ chronometer.stop()
activeRecording = null
chronometer.stop()
chronometer.visibility = View.GONE
@@ -804,8 +864,3 @@ class M530CameraActivity : ComponentActivity() {
private fun timestamp(): String =
SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date())
}
-
-
-
-
-
diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthApiClient.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthApiClient.kt
index 779b943..94de5cf 100644
--- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthApiClient.kt
+++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthApiClient.kt
@@ -3,8 +3,8 @@ package com.bodycamera.twentyfoulabs.data.auth
import com.bodycamera.twentyfoulabs.data.backend.BackendConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
-import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.net.HttpURLConnection
import java.net.URL
@@ -13,55 +13,160 @@ import javax.inject.Singleton
@Singleton
class AuthApiClient @Inject constructor() {
- private val json = Json { ignoreUnknownKeys = true; explicitNulls = false }
- suspend fun login(request: LoginRequest): LoginResponse = withContext(Dispatchers.IO) {
- val body = json.encodeToString(request)
- val response = request("POST", "/mobile/v1/auth/login", body = body)
- json.decodeFromString(response)
+ private val json = Json {
+ ignoreUnknownKeys = true
+ explicitNulls = false
}
- suspend fun me(token: String): MeResponse = withContext(Dispatchers.IO) {
- val response = request("GET", "/mobile/v1/auth/me", token = token)
- json.decodeFromString(response)
- }
-
- suspend fun logout(token: String): LogoutResponse = withContext(Dispatchers.IO) {
- val response = request("DELETE", "/mobile/v1/auth/logout", token = token)
- json.decodeFromString(response)
- }
-
- private fun request(method: String, path: String, token: String? = null, body: String? = null): String {
- val url = URL(BackendConfig.API_BASE_URL.trimEnd('/') + path)
- val connection = (url.openConnection() as HttpURLConnection).apply {
- requestMethod = method
- connectTimeout = 15_000
- readTimeout = 20_000
- setRequestProperty("Accept", "application/json")
- setRequestProperty("Content-Type", "application/json")
- setRequestProperty("ngrok-skip-browser-warning", "true")
- setRequestProperty("X-Device-Serial", BackendConfig.DEVICE_SERIAL)
- if (!token.isNullOrBlank()) setRequestProperty("Authorization", "Bearer $token")
- if (body != null) doOutput = true
+ suspend fun login(request: LoginRequest): LoginResponse =
+ withContext(Dispatchers.IO) {
+ val body = json.encodeToString(request)
+ val response = request(
+ "POST",
+ "/mobile/v1/auth/login",
+ body = body
+ )
+ json.decodeFromString(response)
}
+ suspend fun qrLogin(request: QrLoginRequest): LoginResponse =
+ withContext(Dispatchers.IO) {
+ val body = json.encodeToString(request)
+ val response = request(
+ "POST",
+ "/mobile/v1/auth/qr_login",
+ body = body
+ )
+ json.decodeFromString(response)
+ }
+
+ suspend fun me(token: String): MeResponse =
+ withContext(Dispatchers.IO) {
+ val response = request(
+ "GET",
+ "/mobile/v1/auth/me",
+ token = token
+ )
+ json.decodeFromString(response)
+ }
+
+ suspend fun logout(token: String): LogoutResponse =
+ withContext(Dispatchers.IO) {
+ val response = request(
+ "DELETE",
+ "/mobile/v1/auth/logout",
+ token = token
+ )
+ json.decodeFromString(response)
+ }
+
+ private fun request(
+ method: String,
+ path: String,
+ token: String? = null,
+ body: String? = null
+ ): String {
+
+ val url = URL(
+ BackendConfig.API_BASE_URL.trimEnd('/') + path
+ )
+
+ val connection =
+ (url.openConnection() as HttpURLConnection).apply {
+
+ requestMethod = method
+ connectTimeout = 15_000
+ readTimeout = 20_000
+
+ setRequestProperty(
+ "Accept",
+ "application/json"
+ )
+
+ setRequestProperty(
+ "Content-Type",
+ "application/json"
+ )
+
+ setRequestProperty(
+ "ngrok-skip-browser-warning",
+ "true"
+ )
+
+ setRequestProperty(
+ "X-Device-Serial",
+ BackendConfig.DEVICE_SERIAL
+ )
+
+ if (!token.isNullOrBlank()) {
+ setRequestProperty(
+ "Authorization",
+ "Bearer $token"
+ )
+ }
+
+ if (body != null) {
+ doOutput = true
+ }
+ }
+
try {
+
if (body != null) {
- connection.outputStream.bufferedWriter(Charsets.UTF_8).use { it.write(body) }
+ connection.outputStream
+ .bufferedWriter(Charsets.UTF_8)
+ .use {
+ it.write(body)
+ }
}
+
val code = connection.responseCode
- val stream = if (code in 200..299) connection.inputStream else connection.errorStream
- val responseBody = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
+
+ val stream =
+ if (code in 200..299) {
+ connection.inputStream
+ } else {
+ connection.errorStream
+ }
+
+ val responseBody =
+ stream
+ ?.bufferedReader(Charsets.UTF_8)
+ ?.use {
+ it.readText()
+ }
+ .orEmpty()
+
if (code !in 200..299) {
- val apiError = runCatching { json.decodeFromString(responseBody) }.getOrNull()
- val message = apiError?.error ?: apiError?.message ?: "HTTP $code"
- throw AuthApiException(code, message)
+
+ val apiError =
+ runCatching {
+ json.decodeFromString(
+ responseBody
+ )
+ }.getOrNull()
+
+ val message =
+ apiError?.error
+ ?: apiError?.message
+ ?: "HTTP $code"
+
+ throw AuthApiException(
+ code,
+ message
+ )
}
+
return responseBody
+
} finally {
connection.disconnect()
}
}
}
-class AuthApiException(val statusCode: Int, override val message: String) : Exception(message)
+class AuthApiException(
+ val statusCode: Int,
+ override val message: String
+) : Exception(message)
diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthModels.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthModels.kt
index 8f9c990..7aa7d8e 100644
--- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthModels.kt
+++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthModels.kt
@@ -11,12 +11,21 @@ data class LoginRequest(
@SerialName("device_model") val deviceModel: String? = null
)
+@Serializable
+data class QrLoginRequest(
+ @SerialName("qr_token") val qrToken: String,
+ val pin: String,
+ @SerialName("device_serial") val deviceSerial: String,
+ @SerialName("device_model") val deviceModel: String? = null
+)
+
@Serializable
data class AuthUser(
val id: Int? = null,
val email: String = "",
val name: String = "",
val role: String = "",
+ val active: Boolean? = null,
@SerialName("assigned_device") val assignedDevice: AuthDevice? = null
)
@@ -39,10 +48,20 @@ data class AuthDevice(
@Serializable
data class LoginResponse(
val user: AuthUser,
- val token: String,
+ val token: String = "",
+ @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,
val device: AuthDevice? = null,
@SerialName("server_time") val serverTime: String? = null
-)
+) {
+ fun effectiveToken(): String {
+ return accessToken?.takeIf { it.isNotBlank() }
+ ?: token.takeIf { it.isNotBlank() }
+ ?: throw IllegalStateException("El backend no devolvio access_token ni token")
+ }
+}
@Serializable
data class MeResponse(
@@ -58,10 +77,14 @@ data class LogoutResponse(
)
@Serializable
-data class ApiError(val error: String? = null, val message: String? = null)
+data class ApiError(
+ val error: String? = null,
+ val message: String? = null
+)
data class AuthSession(
val token: String,
val user: AuthUser,
- val device: AuthDevice? = null
+ val device: AuthDevice? = null,
+ val refreshToken: String? = null
)
diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthRepository.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthRepository.kt
index 0ca3874..52df2d5 100644
--- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthRepository.kt
+++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthRepository.kt
@@ -10,7 +10,12 @@ class AuthRepository @Inject constructor(
private val api: AuthApiClient,
private val store: AuthSessionStore
) {
- suspend fun login(email: String, password: String): AuthSession {
+
+ suspend fun login(
+ email: String,
+ password: String
+ ): AuthSession {
+
val response = api.login(
LoginRequest(
email = email.trim(),
@@ -19,30 +24,86 @@ class AuthRepository @Inject constructor(
deviceModel = BackendConfig.DEVICE_MODEL
)
)
+
val session = AuthSession(
- token = response.token,
+ token = response.effectiveToken(),
user = response.user,
- device = response.device ?: response.user.assignedDevice
+ device = response.device ?: response.user.assignedDevice,
+ refreshToken = response.refreshToken
)
+
store.save(session)
+
+ return session
+ }
+
+ suspend fun qrLogin(
+ qrToken: String,
+ pin: String
+ ): AuthSession {
+
+ val response = api.qrLogin(
+ QrLoginRequest(
+ qrToken = qrToken.trim(),
+ pin = pin.trim(),
+ deviceSerial = BackendConfig.DEVICE_SERIAL,
+ deviceModel = BackendConfig.DEVICE_MODEL
+ )
+ )
+
+ val session = AuthSession(
+ token = response.effectiveToken(),
+ user = response.user,
+ device = response.device ?: response.user.assignedDevice,
+ refreshToken = response.refreshToken
+ )
+
+ store.save(session)
+
return session
}
suspend fun restore(): AuthSession? {
- val token = store.token.first()?.takeIf { it.isNotBlank() } ?: return null
+
+ val token =
+ store.token.first()
+ ?.takeIf { it.isNotBlank() }
+ ?: return null
+
+ val refreshToken =
+ store.refreshToken.first()
+
return try {
+
val response = api.me(token)
- AuthSession(token, response.user, response.device ?: response.user.assignedDevice)
+
+ AuthSession(
+ token = token,
+ user = response.user,
+ device = response.device ?: response.user.assignedDevice,
+ refreshToken = refreshToken
+ )
+
} catch (e: AuthApiException) {
- if (e.statusCode == 401) store.clear()
+
+ if (e.statusCode == 401) {
+ store.clear()
+ }
+
throw e
}
}
suspend fun logout() {
+
val token = store.token.first()
+
try {
- if (!token.isNullOrBlank()) api.logout(token)
+
+ if (!token.isNullOrBlank()) {
+ api.logout(token)
+ }
+
} finally {
store.clear()
}
diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthSessionStore.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthSessionStore.kt
index e78948e..fa74660 100644
--- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthSessionStore.kt
+++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/auth/AuthSessionStore.kt
@@ -16,28 +16,55 @@ private val Context.authDataStore by preferencesDataStore(name = "auth_session")
class AuthSessionStore @Inject constructor(
@ApplicationContext private val context: Context
) {
+
private object Keys {
val token = stringPreferencesKey("jwt_token")
+ val refreshToken = stringPreferencesKey("refresh_token")
val userName = stringPreferencesKey("user_name")
val userEmail = stringPreferencesKey("user_email")
val userRole = stringPreferencesKey("user_role")
val deviceSerial = stringPreferencesKey("device_serial")
}
- val token: Flow = context.authDataStore.data.map { it[Keys.token] }
+ val token: Flow =
+ context.authDataStore.data.map {
+ it[Keys.token]
+ }
+
+ val refreshToken: Flow =
+ context.authDataStore.data.map {
+ it[Keys.refreshToken]
+ }
suspend fun save(session: AuthSession) {
+
context.authDataStore.edit { prefs ->
+
prefs[Keys.token] = session.token
+
+ if (!session.refreshToken.isNullOrBlank()) {
+ prefs[Keys.refreshToken] = session.refreshToken
+ } else {
+ prefs.remove(Keys.refreshToken)
+ }
+
prefs[Keys.userName] = session.user.name
prefs[Keys.userEmail] = session.user.email
prefs[Keys.userRole] = session.user.role
- val serial = session.device?.serialNumber ?: session.user.assignedDevice?.serialNumber
- if (!serial.isNullOrBlank()) prefs[Keys.deviceSerial] = serial
+
+ val serial =
+ session.device?.serialNumber
+ ?: session.user.assignedDevice?.serialNumber
+
+ if (!serial.isNullOrBlank()) {
+ prefs[Keys.deviceSerial] = serial
+ }
}
}
suspend fun clear() {
- context.authDataStore.edit { it.clear() }
+ context.authDataStore.edit {
+ it.clear()
+ }
}
}
diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/BackendConfig.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/BackendConfig.kt
index cdd92b3..4b9d5a1 100644
--- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/BackendConfig.kt
+++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/BackendConfig.kt
@@ -10,7 +10,7 @@ object BackendConfig {
const val STREAM_ID = "webrtc_camera_stream"
// Rails mobile API exposed through ngrok HTTPS.
- const val API_BASE_URL = "https://0eed-2001-1388-7829-61d2-9c55-8104-5005-d3be.ngrok-free.app"
+ const val API_BASE_URL = "https://5ea0-2001-1388-7829-eaff-f431-27c4-8f8e-5b5a.ngrok-free.app"
const val DEVICE_SERIAL = "BC-M530-001"
const val DEVICE_MODEL = "Recoda M530"
diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/BackendIntegrationManager.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/BackendIntegrationManager.kt
index 99fa55c..3da381f 100644
--- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/BackendIntegrationManager.kt
+++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/backend/BackendIntegrationManager.kt
@@ -287,6 +287,206 @@ class BackendIntegrationManager @Inject constructor(
}
}
+
+ fun sendSosAlert(
+ latitude: Double?,
+ longitude: Double?,
+ callback: (Result) -> Unit
+ ) {
+ httpExecutor.execute {
+
+ 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/sos_alerts"
+ )
+
+ Log.i(
+ TAG,
+ "SOS POST iniciando url=$url lat=$latitude lon=$longitude"
+ )
+
+ 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",
+ BackendConfig.DEVICE_SERIAL
+ )
+
+ setRequestProperty(
+ "ngrok-skip-browser-warning",
+ "true"
+ )
+ }
+
+ val body =
+ JSONObject()
+ .put("latitude", latitude)
+ .put("longitude", longitude)
+ .put("notes", "Boton panico F7")
+ .put(
+ "device_serial",
+ BackendConfig.DEVICE_SERIAL
+ )
+ .toString()
+
+ conn.outputStream.use { output ->
+ output.write(
+ body.toByteArray(Charsets.UTF_8)
+ )
+ }
+
+ val code = conn.responseCode
+
+ val stream =
+ if (code in 200..299) {
+ conn.inputStream
+ } else {
+ conn.errorStream
+ }
+
+ val responseText =
+ stream
+ ?.bufferedReader(Charsets.UTF_8)
+ ?.use { it.readText() }
+ .orEmpty()
+
+ Log.i(
+ TAG,
+ "SOS HTTP $code response=$responseText"
+ )
+
+ if (code != 201) {
+
+ when (code) {
+
+ 401 -> error(
+ "Sesion expirada"
+ )
+
+ 422 -> {
+ val json =
+ runCatching {
+ JSONObject(responseText)
+ }.getOrNull()
+
+ val message =
+ json
+ ?.optString("error")
+ ?.takeIf { it.isNotBlank() }
+ ?: "Error de validacion"
+
+ error(message)
+ }
+
+ else -> error(
+ "HTTP $code $responseText"
+ )
+ }
+ }
+
+ val responseJson =
+ JSONObject(responseText)
+
+ val alertId =
+ responseJson
+ .optJSONObject("alert")
+ ?.optInt("id", -1)
+ ?.takeIf { it > 0 }
+ ?: error(
+ "Respuesta SOS sin ID de alerta"
+ )
+
+ Log.i(
+ TAG,
+ "SOS registrado correctamente id=$alertId"
+ )
+
+ callback(
+ Result.success(alertId)
+ )
+
+ } catch (t: java.net.SocketTimeoutException) {
+
+ Log.e(
+ TAG,
+ "SOS timeout",
+ t
+ )
+
+ callback(
+ Result.failure(
+ IllegalStateException(
+ "Sin conexion con el servidor"
+ )
+ )
+ )
+
+ } catch (t: java.io.IOException) {
+
+ Log.e(
+ TAG,
+ "SOS error de red",
+ t
+ )
+
+ callback(
+ Result.failure(
+ IllegalStateException(
+ "Sin conexion con el servidor"
+ )
+ )
+ )
+
+ } catch (t: Throwable) {
+
+ Log.e(
+ TAG,
+ "SOS error",
+ t
+ )
+
+ callback(
+ Result.failure(t)
+ )
+
+ } finally {
+
+ conn?.disconnect()
+ }
+ }
+ }
fun shutdown() {
mqttExecutor.shutdownNow()
httpExecutor.shutdownNow()
@@ -626,4 +826,3 @@ class BackendIntegrationManager @Inject constructor(
private fun sanitize(value: String) = value.replace(Regex("[^A-Za-z0-9_-]"), "_")
private fun topicSafe(value: String) = value.lowercase().replace(Regex("[^a-z0-9_-]"), "_")
}
-
diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/device/M530DeviceController.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/device/M530DeviceController.kt
index e8b07eb..49ac878 100644
--- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/device/M530DeviceController.kt
+++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/data/device/M530DeviceController.kt
@@ -1,4 +1,4 @@
-package com.bodycamera.twentyfoulabs.data.device
+package com.bodycamera.twentyfoulabs.data.device
import android.Manifest
import android.content.Context
@@ -284,11 +284,70 @@ Result.success(file)
fun isMicRecording(): Boolean =
micRecorder != null
fun triggerSos(): SosResult {
- val location = bestLastKnownLocation()
- val payload = JSONObject().put("type", "SOS").put("timestamp", System.currentTimeMillis())
- .put("model", Build.MODEL).put("android", Build.VERSION.RELEASE)
- .put("latitude", location?.latitude).put("longitude", location?.longitude).put("accuracy", location?.accuracy)
- backend.publish("SOS", payload); return SosResult(logEvent("SOS", payload), location)
+
+ val location =
+ Location("M530").apply {
+ latitude = -12.077
+ longitude = -77.092
+ accuracy = 10f
+ }
+
+ val payload =
+ JSONObject()
+ .put("type", "SOS")
+ .put(
+ "timestamp",
+ System.currentTimeMillis()
+ )
+ .put(
+ "model",
+ Build.MODEL
+ )
+ .put(
+ "android",
+ Build.VERSION.RELEASE
+ )
+ .put(
+ "latitude",
+ location.latitude
+ )
+ .put(
+ "longitude",
+ location.longitude
+ )
+ .put(
+ "accuracy",
+ location.accuracy
+ )
+
+ backend.publish(
+ "SOS",
+ payload
+ )
+
+ return SosResult(
+ logEvent(
+ "SOS",
+ payload
+ ),
+ location
+ )
+ }
+
+ fun triggerSos(
+ callback: (Result) -> Unit
+ ): SosResult {
+
+ val result =
+ triggerSos()
+
+ backend.sendSosAlert(
+ result.location?.latitude,
+ result.location?.longitude,
+ callback
+ )
+
+ return result
}
fun markEvidence(
@@ -376,7 +435,7 @@ Result.success(file)
payload.has("label") -> payload.optString("label")
payload.has("path") -> File(payload.optString("path")).name
payload.has("error") -> payload.optString("error")
- payload.has("network") -> "${payload.optString("network")} · ${payload.optInt("batteryPercent", -1)}%"
+ payload.has("network") -> "${payload.optString("network")} | ${payload.optInt("batteryPercent", -1)}%"
payload.has("latitude") -> "GPS ${payload.optDouble("latitude", 0.0)}, ${payload.optDouble("longitude", 0.0)}"
else -> ""
}
@@ -389,4 +448,3 @@ Result.success(file)
}
private fun timestamp(): String = SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date())
}
-
diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/qr/QrScannerActivity.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/qr/QrScannerActivity.kt
new file mode 100644
index 0000000..7a0bed1
--- /dev/null
+++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/qr/QrScannerActivity.kt
@@ -0,0 +1,277 @@
+package com.bodycamera.twentyfoulabs.qr
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import android.widget.TextView
+import androidx.activity.ComponentActivity
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.ImageAnalysis
+import androidx.camera.core.Preview
+import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.camera.view.PreviewView
+import androidx.core.content.ContextCompat
+import com.google.mlkit.vision.barcode.common.Barcode
+import com.google.mlkit.vision.barcode.BarcodeScannerOptions
+import com.google.mlkit.vision.barcode.BarcodeScanning
+import com.google.mlkit.vision.common.InputImage
+import java.util.concurrent.Executors
+import java.util.concurrent.atomic.AtomicBoolean
+
+class QrScannerActivity : ComponentActivity() {
+
+ companion object {
+ const val EXTRA_QR_VALUE = "qr_value"
+ private const val TAG = "M530QrScanner"
+ }
+
+ private lateinit var previewView: PreviewView
+
+ private val cameraExecutor =
+ Executors.newSingleThreadExecutor()
+
+ private val resultSent =
+ AtomicBoolean(false)
+
+ private val scanner by lazy {
+
+ val options =
+ BarcodeScannerOptions.Builder()
+ .setBarcodeFormats(
+ Barcode.FORMAT_QR_CODE
+ )
+ .build()
+
+ BarcodeScanning.getClient(options)
+ }
+
+ override fun onCreate(
+ savedInstanceState: Bundle?
+ ) {
+
+ super.onCreate(savedInstanceState)
+
+ createInterface()
+
+ startCamera()
+ }
+
+ private fun createInterface() {
+
+ val root =
+ FrameLayout(this)
+
+ previewView =
+ PreviewView(this).apply {
+
+ layoutParams =
+ FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT
+ )
+
+ scaleType =
+ PreviewView.ScaleType.FILL_CENTER
+ }
+
+ root.addView(previewView)
+
+ val instructions =
+ TextView(this).apply {
+
+ text =
+ "Escanee el QR del fotocheck"
+
+ textSize = 19f
+
+ setTextColor(
+ android.graphics.Color.WHITE
+ )
+
+ setBackgroundColor(
+ 0x99000000.toInt()
+ )
+
+ setPadding(
+ 24,
+ 20,
+ 24,
+ 20
+ )
+ }
+
+ val instructionParams =
+ FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ )
+
+ instructionParams.gravity =
+ android.view.Gravity.TOP or
+ android.view.Gravity.CENTER_HORIZONTAL
+
+ instructionParams.topMargin = 40
+
+ root.addView(
+ instructions,
+ instructionParams
+ )
+
+ setContentView(root)
+ }
+
+ private fun startCamera() {
+
+ val providerFuture =
+ ProcessCameraProvider.getInstance(this)
+
+ providerFuture.addListener({
+
+ val cameraProvider =
+ providerFuture.get()
+
+ val preview =
+ Preview.Builder()
+ .build()
+ .also {
+ it.setSurfaceProvider(
+ previewView.surfaceProvider
+ )
+ }
+
+ val imageAnalysis =
+ ImageAnalysis.Builder()
+ .setBackpressureStrategy(
+ ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST
+ )
+ .build()
+
+ imageAnalysis.setAnalyzer(
+ cameraExecutor
+ ) { imageProxy ->
+
+ val mediaImage =
+ imageProxy.image
+
+ if (
+ mediaImage == null ||
+ resultSent.get()
+ ) {
+ imageProxy.close()
+ return@setAnalyzer
+ }
+
+ val image =
+ InputImage.fromMediaImage(
+ mediaImage,
+ imageProxy.imageInfo.rotationDegrees
+ )
+
+ scanner.process(image)
+ .addOnSuccessListener { barcodes ->
+
+ if (resultSent.get()) {
+ return@addOnSuccessListener
+ }
+
+ val value =
+ barcodes
+ .firstOrNull()
+ ?.rawValue
+ ?.trim()
+
+ if (!value.isNullOrBlank()) {
+
+ Log.i(
+ TAG,
+ "QR detectado"
+ )
+
+ returnResult(value)
+ }
+ }
+ .addOnFailureListener {
+
+ Log.e(
+ TAG,
+ "Error procesando QR",
+ it
+ )
+ }
+ .addOnCompleteListener {
+
+ imageProxy.close()
+ }
+ }
+
+ try {
+
+ cameraProvider.unbindAll()
+
+ cameraProvider.bindToLifecycle(
+ this,
+ CameraSelector.DEFAULT_BACK_CAMERA,
+ preview,
+ imageAnalysis
+ )
+
+ } catch (e: Exception) {
+
+ Log.e(
+ TAG,
+ "No se pudo iniciar la camara",
+ e
+ )
+
+ setResult(Activity.RESULT_CANCELED)
+ finish()
+ }
+
+ }, ContextCompat.getMainExecutor(this))
+ }
+
+ private fun returnResult(
+ value: String
+ ) {
+
+ if (
+ !resultSent.compareAndSet(
+ false,
+ true
+ )
+ ) {
+ return
+ }
+
+ runOnUiThread {
+
+ val result =
+ Intent().apply {
+
+ putExtra(
+ EXTRA_QR_VALUE,
+ value
+ )
+ }
+
+ setResult(
+ Activity.RESULT_OK,
+ result
+ )
+
+ finish()
+ }
+ }
+
+ override fun onDestroy() {
+
+ scanner.close()
+
+ cameraExecutor.shutdown()
+
+ super.onDestroy()
+ }
+}
diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/receiver/M530PttReceiver.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/receiver/M530PttReceiver.kt
new file mode 100644
index 0000000..2c1e6e5
--- /dev/null
+++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/receiver/M530PttReceiver.kt
@@ -0,0 +1,98 @@
+package com.bodycamera.twentyfoulabs.receiver
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import com.bodycamera.twentyfoulabs.data.device.M530DeviceController
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class M530PttReceiver : BroadcastReceiver() {
+
+ @Inject
+ lateinit var deviceController: M530DeviceController
+
+ companion object {
+ private const val TAG = "M530PttReceiver"
+
+ private const val ACTION_PTT_DOWN =
+ "android.intent.action.ACTION_PTTKEY_DOWN"
+
+ private const val ACTION_PTT_UP =
+ "android.intent.action.ACTION_PTTKEY_UP"
+
+ private const val ACTION_PTT_LONG_PRESS =
+ "android.intent.action.ACTION_PTTKEY_LONG_PRESS"
+ }
+
+ override fun onReceive(
+ context: Context?,
+ intent: Intent?
+ ) {
+
+ when (intent?.action) {
+
+ ACTION_PTT_DOWN -> {
+
+ Log.i(
+ TAG,
+ "PTT MANIFEST DOWN recibido"
+ )
+
+ val result =
+ deviceController.startPtt()
+
+ if (result.isSuccess) {
+
+ Log.i(
+ TAG,
+ "PTT MANIFEST DOWN - PTT iniciado"
+ )
+
+ } else {
+
+ Log.e(
+ TAG,
+ "PTT MANIFEST DOWN - ERROR: ${result.exceptionOrNull()?.message}"
+ )
+ }
+ }
+
+ ACTION_PTT_LONG_PRESS -> {
+
+ Log.d(
+ TAG,
+ "PTT MANIFEST LONG PRESS ignorado"
+ )
+ }
+
+ ACTION_PTT_UP -> {
+
+ Log.i(
+ TAG,
+ "PTT MANIFEST UP recibido"
+ )
+
+ val result =
+ deviceController.stopPtt()
+
+ if (result.isSuccess) {
+
+ Log.i(
+ TAG,
+ "PTT MANIFEST UP - PTT detenido"
+ )
+
+ } else {
+
+ Log.e(
+ TAG,
+ "PTT MANIFEST UP - ERROR: ${result.exceptionOrNull()?.message}"
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/AuthViewModel.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/AuthViewModel.kt
index 639ff16..da23942 100644
--- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/AuthViewModel.kt
+++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/AuthViewModel.kt
@@ -13,61 +13,226 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface AuthUiState {
+
data object Checking : AuthUiState
+
data object LoggedOut : AuthUiState
+
data object Loading : AuthUiState
- data class Authenticated(val session: AuthSession) : AuthUiState
- data class Error(val message: String) : AuthUiState
+
+ data class Authenticated(
+ val session: AuthSession
+ ) : AuthUiState
+
+ data class Error(
+ val message: String
+ ) : AuthUiState
}
@HiltViewModel
class AuthViewModel @Inject constructor(
private val repository: AuthRepository
) : ViewModel() {
- private val _state = MutableStateFlow(AuthUiState.Checking)
- val state: StateFlow = _state.asStateFlow()
- init { restoreSession() }
+ private val _state =
+ MutableStateFlow(
+ AuthUiState.Checking
+ )
+
+ val state: StateFlow =
+ _state.asStateFlow()
+
+ init {
+ restoreSession()
+ }
fun restoreSession() {
+
viewModelScope.launch {
- _state.value = AuthUiState.Checking
+
+ _state.value =
+ AuthUiState.Checking
+
try {
- val session = repository.restore()
- _state.value = if (session != null) AuthUiState.Authenticated(session) else AuthUiState.LoggedOut
+
+ val session =
+ repository.restore()
+
+ _state.value =
+ if (session != null) {
+ AuthUiState.Authenticated(
+ session
+ )
+ } else {
+ AuthUiState.LoggedOut
+ }
+
} catch (e: AuthApiException) {
- _state.value = if (e.statusCode == 401) AuthUiState.LoggedOut else AuthUiState.Error("No se pudo validar la sesión: ${e.message}")
+
+ _state.value =
+ if (e.statusCode == 401) {
+ AuthUiState.LoggedOut
+ } else {
+ AuthUiState.Error(
+ "No se pudo validar la sesion: ${e.message}"
+ )
+ }
+
} catch (e: Exception) {
- _state.value = AuthUiState.Error("Sin conexión con el servidor: ${e.message ?: "error de red"}")
+
+ _state.value =
+ AuthUiState.Error(
+ "Sin conexion con el servidor: ${e.message ?: "error de red"}"
+ )
}
}
}
- fun login(email: String, password: String) {
- if (email.isBlank() || password.isBlank()) {
- _state.value = AuthUiState.Error("Ingresa correo y contraseña")
+ fun login(
+ email: String,
+ password: String
+ ) {
+
+ if (
+ email.isBlank() ||
+ password.isBlank()
+ ) {
+
+ _state.value =
+ AuthUiState.Error(
+ "Ingresa correo y contrasena"
+ )
+
return
}
+
viewModelScope.launch {
- _state.value = AuthUiState.Loading
+
+ _state.value =
+ AuthUiState.Loading
+
try {
- _state.value = AuthUiState.Authenticated(repository.login(email, password))
+
+ _state.value =
+ AuthUiState.Authenticated(
+ repository.login(
+ email,
+ password
+ )
+ )
+
} catch (e: AuthApiException) {
- _state.value = AuthUiState.Error(if (e.statusCode == 401) "Correo o contraseña incorrectos" else e.message)
+
+ _state.value =
+ AuthUiState.Error(
+ if (e.statusCode == 401) {
+ "Correo o contrasena incorrectos"
+ } else {
+ e.message
+ }
+ )
+
} catch (e: Exception) {
- _state.value = AuthUiState.Error("No se pudo conectar al backend: ${e.message ?: "error de red"}")
+
+ _state.value =
+ AuthUiState.Error(
+ "No se pudo conectar al backend: ${e.message ?: "error de red"}"
+ )
+ }
+ }
+ }
+
+ fun qrLogin(
+ qrToken: String,
+ pin: String
+ ) {
+
+ if (qrToken.isBlank()) {
+
+ _state.value =
+ AuthUiState.Error(
+ "Escanee nuevamente el codigo QR"
+ )
+
+ return
+ }
+
+ if (
+ pin.length != 4 ||
+ !pin.all {
+ it.isDigit()
+ }
+ ) {
+
+ _state.value =
+ AuthUiState.Error(
+ "El PIN debe tener 4 digitos"
+ )
+
+ return
+ }
+
+ viewModelScope.launch {
+
+ _state.value =
+ AuthUiState.Loading
+
+ try {
+
+ _state.value =
+ AuthUiState.Authenticated(
+ repository.qrLogin(
+ qrToken,
+ pin
+ )
+ )
+
+ } catch (e: AuthApiException) {
+
+ _state.value =
+ AuthUiState.Error(
+ when (e.statusCode) {
+
+ 401 ->
+ "QR o PIN incorrecto"
+
+ else ->
+ e.message
+ }
+ )
+
+ } catch (e: Exception) {
+
+ _state.value =
+ AuthUiState.Error(
+ "No se pudo conectar al backend: ${e.message ?: "error de red"}"
+ )
}
}
}
fun logout() {
+
viewModelScope.launch {
- runCatching { repository.logout() }
- _state.value = AuthUiState.LoggedOut
+
+ runCatching {
+ repository.logout()
+ }
+
+ _state.value =
+ AuthUiState.LoggedOut
}
}
fun clearError() {
- if (_state.value is AuthUiState.Error) _state.value = AuthUiState.LoggedOut
+
+ if (
+ _state.value
+ is AuthUiState.Error
+ ) {
+
+ _state.value =
+ AuthUiState.LoggedOut
+ }
}
}
diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/LoginScreen.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/LoginScreen.kt
index 5071b0a..9449167 100644
--- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/LoginScreen.kt
+++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/LoginScreen.kt
@@ -1,11 +1,17 @@
package com.bodycamera.twentyfoulabs.ui.screens
+import android.Manifest
+import android.app.Activity
+import android.content.Intent
+import android.content.pm.PackageManager
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Person
@@ -14,90 +20,784 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
import com.bodycamera.twentyfoulabs.data.backend.BackendConfig
+import com.bodycamera.twentyfoulabs.qr.QrScannerActivity
+import org.json.JSONObject
private val LoginNavy = Color(0xFF0B1F33)
private val LoginSurface = Color(0xFF132A3E)
private val LoginAccent = Color(0xFF2F80ED)
+private enum class LoginMode {
+ QR,
+ PIN,
+ MANUAL
+}
+
@Composable
fun LoginScreen(
state: AuthUiState,
onLogin: (String, String) -> Unit,
+ onQrLogin: (String, String) -> Unit,
onRetrySession: () -> Unit,
onClearError: () -> Unit
) {
- var email by remember { mutableStateOf("supervisor@bodycam.local") }
- var password by remember { mutableStateOf("") }
+ val context = LocalContext.current
+
+ var mode by remember { mutableStateOf(LoginMode.QR) }
+
+ var email by remember {
+ mutableStateOf("supervisor@bodycam.local")
+ }
+
+ var password by remember {
+ mutableStateOf("")
+ }
+
+ var qrToken by remember {
+ mutableStateOf("")
+ }
+
+ var pin by remember {
+ mutableStateOf("")
+ }
+
+ var localError by remember {
+ mutableStateOf(null)
+ }
+
+ val qrLauncher =
+ rememberLauncherForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+
+ if (result.resultCode == Activity.RESULT_OK) {
+
+ val raw =
+ result.data
+ ?.getStringExtra(
+ QrScannerActivity.EXTRA_QR_VALUE
+ )
+ .orEmpty()
+
+ val token =
+ extractQrToken(raw)
+
+ if (token != null) {
+
+ qrToken = token
+ pin = ""
+ localError = null
+
+ onClearError()
+
+ mode = LoginMode.PIN
+
+ } else {
+
+ localError =
+ "El QR del fotocheck no es valido"
+ }
+ }
+ }
+
+ val cameraPermissionLauncher =
+ rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { granted ->
+
+ if (granted) {
+
+ qrLauncher.launch(
+ Intent(
+ context,
+ QrScannerActivity::class.java
+ )
+ )
+
+ } else {
+
+ localError =
+ "Se necesita permiso de camara para leer el fotocheck"
+ }
+ }
+
+ fun openQrScanner() {
+
+ localError = null
+ onClearError()
+
+ val permission =
+ ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.CAMERA
+ )
+
+ if (
+ permission ==
+ PackageManager.PERMISSION_GRANTED
+ ) {
+
+ qrLauncher.launch(
+ Intent(
+ context,
+ QrScannerActivity::class.java
+ )
+ )
+
+ } else {
+
+ cameraPermissionLauncher.launch(
+ Manifest.permission.CAMERA
+ )
+ }
+ }
Box(
- modifier = Modifier.fillMaxSize().background(LoginNavy).padding(18.dp),
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(LoginNavy)
+ .padding(14.dp),
contentAlignment = Alignment.Center
) {
+
Card(
- colors = CardDefaults.cardColors(containerColor = LoginSurface),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = LoginSurface
+ ),
shape = RoundedCornerShape(18.dp),
modifier = Modifier.fillMaxWidth()
) {
+
Column(
- modifier = Modifier.padding(18.dp).verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- horizontalAlignment = Alignment.CenterHorizontally
+ modifier =
+ Modifier
+ .padding(16.dp)
+ .verticalScroll(
+ rememberScrollState()
+ ),
+ verticalArrangement =
+ Arrangement.spacedBy(12.dp),
+ horizontalAlignment =
+ Alignment.CenterHorizontally
) {
- Text("M530 COMMAND", color = Color.White, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
- Text("Inicio de turno", color = Color(0xFF9FC3E5))
- Text("${BackendConfig.DEVICE_MODEL} • ${BackendConfig.DEVICE_SERIAL}", color = Color(0xFF8FAEC7), style = MaterialTheme.typography.labelSmall)
- OutlinedTextField(
- value = email,
- onValueChange = { email = it; onClearError() },
- label = { Text("Correo") },
- leadingIcon = { Icon(Icons.Default.Person, null) },
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
- singleLine = true,
- modifier = Modifier.fillMaxWidth()
- )
- OutlinedTextField(
- value = password,
- onValueChange = { password = it; onClearError() },
- label = { Text("Contraseña") },
- leadingIcon = { Icon(Icons.Default.Lock, null) },
- visualTransformation = PasswordVisualTransformation(),
- singleLine = true,
- modifier = Modifier.fillMaxWidth()
+ Text(
+ text = "M530 COMMAND",
+ color = Color.White,
+ style =
+ MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
)
- if (state is AuthUiState.Error) {
- Text(state.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
+ Text(
+ text = "GEOSATELITAL",
+ color = Color(0xFF00D7FF),
+ fontWeight = FontWeight.Bold
+ )
+
+ Text(
+ text =
+ "${BackendConfig.DEVICE_MODEL} - ${BackendConfig.DEVICE_SERIAL}",
+ color = Color(0xFF8FAEC7),
+ style =
+ MaterialTheme.typography.labelSmall
+ )
+
+ when (mode) {
+
+ LoginMode.QR -> {
+
+ Spacer(
+ Modifier.height(6.dp)
+ )
+
+ Text(
+ text = "Inicio de turno",
+ color = Color.White,
+ fontWeight = FontWeight.Bold
+ )
+
+ Text(
+ text =
+ "Escanee el QR del fotocheck del sereno",
+ color = Color(0xFFB7CEE1),
+ style =
+ MaterialTheme.typography.bodyMedium
+ )
+
+ Button(
+ onClick = {
+ openQrScanner()
+ },
+ enabled =
+ state !is AuthUiState.Loading &&
+ state !is AuthUiState.Checking,
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor =
+ LoginAccent
+ ),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(58.dp)
+ ) {
+
+ Text(
+ "ESCANEAR FOTOCHECK",
+ fontWeight =
+ FontWeight.Bold
+ )
+ }
+
+ TextButton(
+ onClick = {
+ localError = null
+ onClearError()
+ mode = LoginMode.MANUAL
+ }
+ ) {
+
+ Text(
+ "Acceso administrativo"
+ )
+ }
+ }
+
+
+ LoginMode.PIN -> {
+
+ Text(
+ text = "Fotocheck reconocido",
+ color = Color(0xFF4ADE80),
+ fontWeight = FontWeight.Bold
+ )
+
+ Text(
+ text =
+ "Ingrese su PIN de 4 digitos",
+ color = Color.White
+ )
+
+ Row(
+ horizontalArrangement =
+ Arrangement.spacedBy(10.dp)
+ ) {
+
+ repeat(4) { index ->
+
+ val filled =
+ index < pin.length
+
+ Box(
+ modifier =
+ Modifier
+ .size(22.dp)
+ .background(
+ if (filled)
+ LoginAccent
+ else
+ Color(
+ 0xFF30495F
+ ),
+ RoundedCornerShape(
+ 11.dp
+ )
+ )
+ )
+ }
+ }
+
+ PinKeyboard(
+ onDigit = { digit ->
+
+ if (
+ pin.length < 4 &&
+ state !is AuthUiState.Loading
+ ) {
+
+ pin += digit
+
+ localError = null
+
+ onClearError()
+ }
+ },
+
+ onDelete = {
+
+ if (pin.isNotEmpty()) {
+
+ pin =
+ pin.dropLast(1)
+
+ localError = null
+
+ onClearError()
+ }
+ }
+ )
+
+ Button(
+ onClick = {
+
+ if (pin.length == 4) {
+
+ onQrLogin(
+ qrToken,
+ pin
+ )
+ }
+ },
+ enabled =
+ pin.length == 4 &&
+ state !is AuthUiState.Loading &&
+ state !is AuthUiState.Checking,
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor =
+ LoginAccent
+ ),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(52.dp)
+ ) {
+
+ if (
+ state
+ is AuthUiState.Loading
+ ) {
+
+ CircularProgressIndicator(
+ modifier =
+ Modifier.size(
+ 20.dp
+ ),
+ strokeWidth = 2.dp,
+ color = Color.White
+ )
+
+ Spacer(
+ Modifier.width(8.dp)
+ )
+ }
+
+ Text(
+ if (
+ state
+ is AuthUiState.Loading
+ )
+ "VALIDANDO..."
+ else
+ "INGRESAR"
+ )
+ }
+
+ TextButton(
+ onClick = {
+
+ pin = ""
+ qrToken = ""
+ localError = null
+
+ onClearError()
+
+ mode =
+ LoginMode.QR
+ }
+ ) {
+
+ Text(
+ "Escanear otro fotocheck"
+ )
+ }
+ }
+
+
+ LoginMode.MANUAL -> {
+
+ Text(
+ text =
+ "Acceso administrativo",
+ color = Color.White,
+ fontWeight = FontWeight.Bold
+ )
+
+ OutlinedTextField(
+ value = email,
+
+ onValueChange = {
+
+ email = it
+
+ onClearError()
+ },
+
+ label = {
+ Text("Correo")
+ },
+
+ leadingIcon = {
+ Icon(
+ Icons.Default.Person,
+ null
+ )
+ },
+
+ keyboardOptions =
+ KeyboardOptions(
+ keyboardType =
+ KeyboardType.Email
+ ),
+
+ singleLine = true,
+
+ modifier =
+ Modifier.fillMaxWidth()
+ )
+
+ OutlinedTextField(
+ value = password,
+
+ onValueChange = {
+
+ password = it
+
+ onClearError()
+ },
+
+ label = {
+ Text("Contrasena")
+ },
+
+ leadingIcon = {
+ Icon(
+ Icons.Default.Lock,
+ null
+ )
+ },
+
+ visualTransformation =
+ PasswordVisualTransformation(),
+
+ singleLine = true,
+
+ modifier =
+ Modifier.fillMaxWidth()
+ )
+
+ Button(
+ onClick = {
+
+ onLogin(
+ email,
+ password
+ )
+ },
+
+ enabled =
+ state !is AuthUiState.Loading &&
+ state !is AuthUiState.Checking,
+
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor =
+ LoginAccent
+ ),
+
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(48.dp)
+ ) {
+
+ if (
+ state
+ is AuthUiState.Loading
+ ) {
+
+ CircularProgressIndicator(
+ Modifier.size(20.dp),
+ strokeWidth = 2.dp,
+ color = Color.White
+ )
+
+ Spacer(
+ Modifier.width(8.dp)
+ )
+ }
+
+ Text(
+ if (
+ state
+ is AuthUiState.Loading
+ )
+ "VALIDANDO..."
+ else
+ "INICIAR SESION"
+ )
+ }
+
+ TextButton(
+ onClick = {
+
+ password = ""
+ localError = null
+
+ onClearError()
+
+ mode =
+ LoginMode.QR
+ }
+ ) {
+
+ Text(
+ "Volver al acceso con QR"
+ )
+ }
+ }
}
- Button(
- onClick = { onLogin(email, password) },
- enabled = state !is AuthUiState.Loading && state !is AuthUiState.Checking,
- colors = ButtonDefaults.buttonColors(containerColor = LoginAccent),
- modifier = Modifier.fillMaxWidth().height(48.dp)
+
+ localError?.let {
+
+ Text(
+ text = it,
+ color =
+ MaterialTheme
+ .colorScheme
+ .error,
+ style =
+ MaterialTheme
+ .typography
+ .bodySmall
+ )
+ }
+
+
+ if (
+ state is AuthUiState.Error
) {
- if (state is AuthUiState.Loading) {
- CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.White)
- Spacer(Modifier.width(8.dp))
- }
- Text(if (state is AuthUiState.Loading) "VALIDANDO…" else "INICIAR SESIÓN")
+
+ Text(
+ text = state.message,
+ color =
+ MaterialTheme
+ .colorScheme
+ .error,
+ style =
+ MaterialTheme
+ .typography
+ .bodySmall
+ )
}
- if (state is AuthUiState.Checking) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
- Spacer(Modifier.width(8.dp))
- Text("Validando sesión guardada…", color = Color(0xFFB7CEE1), style = MaterialTheme.typography.bodySmall)
+
+ if (
+ state is AuthUiState.Checking
+ ) {
+
+ Row(
+ verticalAlignment =
+ Alignment.CenterVertically
+ ) {
+
+ CircularProgressIndicator(
+ Modifier.size(18.dp),
+ strokeWidth = 2.dp
+ )
+
+ Spacer(
+ Modifier.width(8.dp)
+ )
+
+ Text(
+ "Validando sesion guardada...",
+ color = Color(0xFFB7CEE1),
+ style =
+ MaterialTheme
+ .typography
+ .bodySmall
+ )
+ }
+
+ } else if (
+ state is AuthUiState.Error
+ ) {
+
+ TextButton(
+ onClick =
+ onRetrySession
+ ) {
+
+ Text(
+ "Reintentar conexion"
+ )
}
- } else if (state is AuthUiState.Error) {
- TextButton(onClick = onRetrySession) { Text("Reintentar conexión") }
}
}
}
}
}
+
+
+@Composable
+private fun PinKeyboard(
+ onDigit: (String) -> Unit,
+ onDelete: () -> Unit
+) {
+
+ Column(
+ verticalArrangement =
+ Arrangement.spacedBy(8.dp),
+ modifier =
+ Modifier.fillMaxWidth()
+ ) {
+
+ listOf(
+ listOf("1", "2", "3"),
+ listOf("4", "5", "6"),
+ listOf("7", "8", "9")
+ ).forEach { row ->
+
+ Row(
+ modifier =
+ Modifier.fillMaxWidth(),
+ horizontalArrangement =
+ Arrangement.spacedBy(8.dp)
+ ) {
+
+ row.forEach { number ->
+
+ Button(
+ onClick = {
+ onDigit(number)
+ },
+ modifier =
+ Modifier
+ .weight(1f)
+ .height(54.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor =
+ Color(
+ 0xFF21415D
+ )
+ )
+ ) {
+
+ Text(
+ number,
+ style =
+ MaterialTheme
+ .typography
+ .titleLarge,
+ fontWeight =
+ FontWeight.Bold
+ )
+ }
+ }
+ }
+ }
+
+ Row(
+ modifier =
+ Modifier.fillMaxWidth(),
+ horizontalArrangement =
+ Arrangement.spacedBy(8.dp)
+ ) {
+
+ Spacer(
+ modifier =
+ Modifier.weight(1f)
+ )
+
+ Button(
+ onClick = {
+ onDigit("0")
+ },
+ modifier =
+ Modifier
+ .weight(1f)
+ .height(54.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor =
+ Color(0xFF21415D)
+ )
+ ) {
+
+ Text(
+ "0",
+ style =
+ MaterialTheme
+ .typography
+ .titleLarge,
+ fontWeight =
+ FontWeight.Bold
+ )
+ }
+
+ OutlinedButton(
+ onClick = onDelete,
+ modifier =
+ Modifier
+ .weight(1f)
+ .height(54.dp)
+ ) {
+
+ Text("<")
+ }
+ }
+ }
+}
+
+
+private fun extractQrToken(
+ rawValue: String
+): String? {
+
+ val raw =
+ rawValue.trim()
+
+ if (raw.isBlank()) {
+ return null
+ }
+
+ if (
+ raw.startsWith("SRN-")
+ ) {
+
+ return raw
+ .takeIf {
+ it.length > 4
+ }
+ }
+
+ return runCatching {
+
+ JSONObject(raw)
+ .optString(
+ "qr_token"
+ )
+ .trim()
+
+ }.getOrNull()
+ ?.takeIf {
+
+ it.startsWith(
+ "SRN-"
+ ) &&
+ it.length > 4
+ }
+}
diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/MainScreen.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/MainScreen.kt
index 0408e30..4f61225 100644
--- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/MainScreen.kt
+++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/MainScreen.kt
@@ -1,4 +1,4 @@
-package com.bodycamera.twentyfoulabs.ui.screens
+package com.bodycamera.twentyfoulabs.ui.screens
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
@@ -70,14 +70,14 @@ fun MainScreen(
Icon(if (isLocked) Icons.Default.Lock else Icons.Default.LockOpen, null, tint = if (isLocked) Amber else Green)
}
IconButton(onClick = onLogout) {
- Icon(Icons.Default.Logout, "Cerrar sesión", tint = Color.White)
+ Icon(Icons.Default.Logout, "Cerrar sesion", tint = Color.White)
}
}
)
}
) { padding ->
when (val state = uiState) {
- MainUiState.Loading -> FullScreenMessage(padding, "Inicializando unidad M530…", true)
+ MainUiState.Loading -> FullScreenMessage(padding, "Inicializando unidad M530...", true)
is MainUiState.Error -> FullScreenMessage(padding, state.message, false)
is MainUiState.Success -> {
Column(
@@ -106,7 +106,7 @@ fun MainScreen(
)
Spacer(Modifier.height(10.dp))
Text(
- "F2 FOTO • F4 PTT • F5 VIDEO • F7 SOS",
+ "F2 FOTO | F4 PTT | F5 VIDEO | F7 SOS",
color = Color(0xFF8FAEC7),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.fillMaxWidth(),
@@ -244,8 +244,8 @@ private fun TelemetryPanel(status: DeviceStatus) {
SectionTitle("ESTADO DEL EQUIPO")
Card(colors = CardDefaults.cardColors(containerColor = SurfaceDark), shape = RoundedCornerShape(14.dp)) {
Row(Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.SpaceAround) {
- Metric(Icons.Default.BatteryStd, "BaterÃa", if ((t?.batteryPercent ?: -1) >= 0) "${t?.batteryPercent}%" else "—")
- Metric(Icons.Default.SignalCellularAlt, "Red", t?.network ?: "—")
+ Metric(Icons.Default.BatteryStd, "Bateria", if ((t?.batteryPercent ?: -1) >= 0) "${t?.batteryPercent}%" else "-")
+ Metric(Icons.Default.SignalCellularAlt, "Red", t?.network ?: "-")
Metric(Icons.Default.Storage, "Libre", formatMb(t?.freeStorageMb ?: 0L))
Metric(Icons.Default.CloudDone, "Estado", if (t?.connected == true) "Online" else "Offline")
}
@@ -323,7 +323,7 @@ private fun EventRow(event: M530DeviceController.EventEntry) {
@Composable
private fun SettingsPanel(locked: Boolean, onConnection: () -> Unit, onVideo: () -> Unit, onRefresh: () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
- SectionTitle("CONFIGURACIÓN")
+ SectionTitle("CONFIGURACION")
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onConnection, enabled = !locked, modifier = Modifier.weight(1f)) { Icon(Icons.Default.SettingsEthernet, null); Spacer(Modifier.width(5.dp)); Text("Red") }
OutlinedButton(onClick = onVideo, enabled = !locked, modifier = Modifier.weight(1f)) { Icon(Icons.Default.Tune, null); Spacer(Modifier.width(5.dp)); Text("Video") }
@@ -345,9 +345,4 @@ private fun FullScreenMessage(padding: PaddingValues, text: String, progress: Bo
}
private fun formatMb(mb: Long): String = if (mb >= 1024) String.format(Locale.US, "%.1f GB", mb / 1024.0) else "$mb MB"
-private fun formatTime(ts: Long): String = if (ts <= 0) "—" else SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(ts))
-
-
-
-
-
+private fun formatTime(ts: Long): String = if (ts <= 0) "-" else SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(ts))
diff --git a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/MainViewModel.kt b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/MainViewModel.kt
index b0844cf..c70ec4c 100644
--- a/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/MainViewModel.kt
+++ b/app/src/main/kotlin/com/bodycamera/twentyfoulabs/ui/screens/MainViewModel.kt
@@ -1,5 +1,9 @@
package com.bodycamera.twentyfoulabs.ui.screens
-
+import android.content.Context
+import android.os.Build
+import android.os.VibrationEffect
+import android.os.Vibrator
+import dagger.hilt.android.qualifiers.ApplicationContext
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -34,14 +38,61 @@ class MainViewModel @Inject constructor(
private val adminLockRepository: AdminLockRepository,
private val videoConfigRepository: VideoConfigRepository,
private val webrtcManager: WebRTCManager,
- private val deviceController: M530DeviceController
+ private val deviceController: M530DeviceController,
+ @ApplicationContext private val appContext: Context
) : ViewModel() {
companion object {
private const val TAG = "MainViewModel"
private const val BUTTON_DEBOUNCE_MS = 1000L
}
+private fun getDeviceVibrator(): Vibrator {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val vibratorManager =
+ appContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as android.os.VibratorManager
+ vibratorManager.getVibrator(1)
+ } else {
+ @Suppress("DEPRECATION")
+ appContext.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
+ }
+}
+
+private fun vibrateRecordingStarted() {
+ val vibrator = getDeviceVibrator()
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ vibrator.vibrate(
+ VibrationEffect.createOneShot(
+ 150L,
+ VibrationEffect.DEFAULT_AMPLITUDE
+ )
+ )
+ } else {
+ @Suppress("DEPRECATION")
+ vibrator.vibrate(150L)
+ }
+}
+
+private fun vibrateRecordingStopped() {
+ val vibrator = getDeviceVibrator()
+
+ val pattern = longArrayOf(
+ 0L,
+ 100L,
+ 100L,
+ 100L
+ )
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ vibrator.vibrate(
+ VibrationEffect.createWaveform(pattern, -1)
+ )
+ } else {
+ @Suppress("DEPRECATION")
+ vibrator.vibrate(pattern, -1)
+ }
+}
val uiState: StateFlow = combine(
repository.getConfigFlow(),
networkFallbackManager.fallbackState
@@ -67,11 +118,11 @@ class MainViewModel @Inject constructor(
private var streamingCallback: WebRTCManager.StreamingCallback? = null
- // Debounce para prevenir múltiples presiones rápidas del botón
+ // Debounce para prevenir múltiples presiones rápidas del botón
private var lastButtonPressTime = 0L
// Estado solicitado por el operador. Se mantiene separado del estado de red WebRTC
- // para que una segunda pulsación de F5 SIEMPRE detenga cámara/stream aunque WHIP falle.
+ // para que una segunda pulsación de F5 SIEMPRE detenga cámara/stream aunque WHIP falle.
@Volatile
private var streamingRequested = false
@@ -108,7 +159,7 @@ class MainViewModel @Inject constructor(
}
fun triggerSosFromUi() {
- deviceController.triggerSos()
+ deviceController.triggerSos { }
refreshDeviceStatus()
}
@@ -130,28 +181,34 @@ class MainViewModel @Inject constructor(
private fun initializeStreamingCallback() {
streamingCallback = object : WebRTCManager.StreamingCallback {
override fun onStreamStarted() {
- Log.e(TAG, "Stream started - updating state to Active")
- deviceController.notifyVideoStarted(com.bodycamera.twentyfoulabs.data.backend.BackendConfig.whipUrl)
- mainImmediateScope.launch {
- _streamingState.value = StreamingState.Active(600) // Default time
- }
- }
+ Log.e(TAG, "Stream started - updating state to Active")
+ deviceController.notifyVideoStarted(
+ com.bodycamera.twentyfoulabs.data.backend.BackendConfig.whipUrl
+ )
- override fun onStreamStopped() {
- Log.e(TAG, "Stream stopped - updating state to Stopped")
- deviceController.notifyVideoStopped()
- streamingRequested = false
- mainImmediateScope.launch {
- _streamingState.value = StreamingState.Stopped
- }
- }
+ mainImmediateScope.launch {
+ _streamingState.value = StreamingState.Active(600)
+ vibrateRecordingStarted()
+ }
+}
+
+override fun onStreamStopped() {
+ Log.e(TAG, "Stream stopped - updating state to Stopped")
+ deviceController.notifyVideoStopped()
+ streamingRequested = false
+
+ mainImmediateScope.launch {
+ _streamingState.value = StreamingState.Stopped
+ vibrateRecordingStopped()
+ }
+}
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
+ // Si estaba conectando y ahora está conectado, pasar a Active
if (isConnected) {
Log.e(TAG, "Connection established, changing to Active")
StreamingState.Active(600)
@@ -176,7 +233,7 @@ class MainViewModel @Inject constructor(
Log.e(TAG, "Streaming error: $error - forcing media cleanup")
deviceController.notifyVideoError(error)
streamingRequested = false
- // Si WHIP/signaling falla, no dejar la cámara capturando indefinidamente.
+ // Si WHIP/signaling falla, no dejar la cámara capturando indefinidamente.
webrtcManager.stopStreaming()
mainImmediateScope.launch {
_streamingState.value = StreamingState.Error(error)
@@ -203,7 +260,7 @@ class MainViewModel @Inject constructor(
fun onEvent(event: MainUiEvent) {
when (event) {
MainUiEvent.RetryLoad -> {
- // El flow se actualiza automáticamente
+ // El flow se actualiza automáticamente
}
MainUiEvent.ImportConfigFromFile -> {
// TODO: Implement file picker
@@ -221,13 +278,13 @@ class MainViewModel @Inject constructor(
}
/**
- * Método público para ser llamado desde el botón físico de cámara (KEYCODE_F5).
- * Verifica si la app está bloqueada antes de iniciar/detener streaming.
+ * Método público para ser llamado desde el botón fÃsico de cámara (KEYCODE_F5).
+ * Verifica si la app está bloqueada antes de iniciar/detener streaming.
*/
fun triggerCameraButton() {
val currentTime = System.currentTimeMillis()
if (currentTime - lastButtonPressTime < BUTTON_DEBOUNCE_MS) {
- Log.e(TAG, "Botón presionado muy rápido, ignorando (debounce)")
+ Log.e(TAG, "Botón presionado muy rápido, ignorando (debounce)")
return
}
lastButtonPressTime = currentTime
@@ -237,7 +294,7 @@ class MainViewModel @Inject constructor(
val isLocked = adminLockRepository.isLocked()
Log.e(TAG, "isLocked: $isLocked")
if (isLocked) {
- Log.e(TAG, "App bloqueada - botón cámara ignorado")
+ Log.e(TAG, "App bloqueada - botón cámara ignorado")
return@launch
}
Log.e(TAG, "App desbloqueada - llamando a toggleStreaming")
@@ -249,7 +306,7 @@ class MainViewModel @Inject constructor(
Log.e(TAG, "toggleStreaming llamado, requested=$streamingRequested, estado=${_streamingState.value}")
viewModelScope.launch {
if (streamingRequested) {
- Log.e(TAG, "F5 STOP solicitado - liberando cámara/WebRTC")
+ Log.e(TAG, "F5 STOP solicitado - liberando cámara/WebRTC")
streamingRequested = false
webrtcManager.stopStreaming()
_streamingState.value = StreamingState.Stopped
@@ -258,9 +315,9 @@ class MainViewModel @Inject constructor(
val config = videoConfigRepository.getConfig()
if (config == null) {
- Log.e(TAG, "No hay configuración de video")
+ Log.e(TAG, "No hay configuración de video")
streamingRequested = false
- _streamingState.value = StreamingState.Error("No hay configuración de video")
+ _streamingState.value = StreamingState.Error("No hay configuración de video")
return@launch
}
diff --git a/app/src/main/res/drawable/m530_launch_background.xml b/app/src/main/res/drawable/m530_launch_background.xml
new file mode 100644
index 0000000..fa38942
--- /dev/null
+++ b/app/src/main/res/drawable/m530_launch_background.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 31f7ba1..4ebc601 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,7 +1,24 @@
-
+