feat: integrar autenticacion QR, sesion y mejoras M530
This commit is contained in:
parent
9deeb40217
commit
0d87c0e4d9
19 changed files with 2322 additions and 212 deletions
|
|
@ -101,6 +101,8 @@ dependencies {
|
||||||
implementation("androidx.camera:camera-camera2:1.3.0")
|
implementation("androidx.camera:camera-camera2:1.3.0")
|
||||||
implementation("androidx.camera:camera-lifecycle:1.3.0")
|
implementation("androidx.camera:camera-lifecycle:1.3.0")
|
||||||
implementation("androidx.camera:camera-view: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
|
// Testing
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<!-- Permisos de red -->
|
<!-- Permisos de red -->
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
|
||||||
|
|
||||||
<!-- Permisos para cámara y streaming -->
|
<!-- Permisos para cámara y streaming -->
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
@ -39,6 +40,11 @@
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.MainApp"
|
android:theme="@style/Theme.MainApp"
|
||||||
android:networkSecurityConfig="@xml/network_security_config">
|
android:networkSecurityConfig="@xml/network_security_config">
|
||||||
|
<activity
|
||||||
|
android:name=".qr.QrScannerActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
|
android:theme="@style/Theme.MainApp" />
|
||||||
|
|
||||||
<activity android:name=".camera.M530CameraActivity" android:exported="false" android:screenOrientation="portrait" android:theme="@style/Theme.MainApp" />
|
<activity android:name=".camera.M530CameraActivity" android:exported="false" android:screenOrientation="portrait" android:theme="@style/Theme.MainApp" />
|
||||||
|
|
||||||
|
|
@ -52,8 +58,9 @@
|
||||||
<!-- Intent filter para launcher principal -->
|
<!-- Intent filter para launcher principal -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.HOME" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
|
@ -67,7 +74,16 @@
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/file_paths" />
|
android:resource="@xml/file_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".receiver.M530PttReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.ACTION_PTTKEY_DOWN" />
|
||||||
|
<action android:name="android.intent.action.ACTION_PTTKEY_LONG_PRESS" />
|
||||||
|
<action android:name="android.intent.action.ACTION_PTTKEY_UP" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.bodycamera.twentyfoulabs
|
package com.bodycamera.twentyfoulabs
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
|
|
@ -44,10 +44,128 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
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 androidx.lifecycle.lifecycleScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.flow.collect
|
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
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
|
@ -63,6 +181,15 @@ class MainActivity : ComponentActivity() {
|
||||||
private const val TAG = "MainActivity"
|
private const val TAG = "MainActivity"
|
||||||
private const val ACTION_VIDEO_DOWN = "android.intent.action.ACTION_VIDEO_DOWN"
|
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_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
|
private var isReceiverRegistered = false
|
||||||
|
|
@ -70,8 +197,51 @@ class MainActivity : ComponentActivity() {
|
||||||
private val hardwareReceiver = object : BroadcastReceiver() {
|
private val hardwareReceiver = object : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
|
|
||||||
ACTION_VIDEO_DOWN -> handleVideoButton()
|
ACTION_VIDEO_DOWN -> handleVideoButton()
|
||||||
|
|
||||||
ACTION_VIDEO_UP -> Unit
|
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()
|
deviceController.logTelemetry()
|
||||||
|
|
||||||
setContent {
|
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
|
if (event?.repeatCount ?: 0 > 0) return true
|
||||||
Log.i(TAG, "M530 KEY DOWN: $keyCode")
|
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)) {
|
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 true
|
||||||
}
|
}
|
||||||
return when (keyCode) {
|
return when (keyCode) {
|
||||||
|
|
@ -182,13 +375,11 @@ class MainActivity : ComponentActivity() {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_F4 -> {
|
KeyEvent.KEYCODE_F4 -> {
|
||||||
val result = deviceController.startPtt()
|
|
||||||
toast(if (result.isSuccess) "PTT: hablando" else "PTT error: ${result.exceptionOrNull()?.message}")
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_F5 -> {
|
KeyEvent.KEYCODE_F5 -> {
|
||||||
// Algunas versiones del firmware también emiten ACTION_VIDEO_DOWN.
|
// Algunas versiones del firmware tambien emiten ACTION_VIDEO_DOWN.
|
||||||
// El ViewModel aplica debounce para evitar doble ejecución.
|
// El ViewModel aplica debounce para evitar doble ejecucion.
|
||||||
handleVideoButton()
|
handleVideoButton()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
@ -200,24 +391,53 @@ class MainActivity : ComponentActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
|
override fun onKeyUp(
|
||||||
Log.i(TAG, "M530 KEY UP: $keyCode")
|
keyCode: Int,
|
||||||
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
|
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) {
|
return when (keyCode) {
|
||||||
|
|
||||||
KeyEvent.KEYCODE_F4 -> {
|
KeyEvent.KEYCODE_F4 -> {
|
||||||
val result = deviceController.stopPtt()
|
|
||||||
toast(if (result.isSuccess) "PTT: turno liberado" else "PTT error")
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_F1, KeyEvent.KEYCODE_F2, KeyEvent.KEYCODE_F3,
|
|
||||||
KeyEvent.KEYCODE_F5, KeyEvent.KEYCODE_F7 -> true
|
KeyEvent.KEYCODE_F1,
|
||||||
else -> super.onKeyUp(keyCode, event)
|
KeyEvent.KEYCODE_F2,
|
||||||
|
KeyEvent.KEYCODE_F3,
|
||||||
|
KeyEvent.KEYCODE_F5,
|
||||||
|
KeyEvent.KEYCODE_F7 -> true
|
||||||
|
|
||||||
|
else ->
|
||||||
|
super.onKeyUp(
|
||||||
|
keyCode,
|
||||||
|
event
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleVideoButton() {
|
private fun handleVideoButton() {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
toast("Inicia sesión para usar VIDEO")
|
toast("Inicia sesion para usar VIDEO")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
startActivity(
|
startActivity(
|
||||||
|
|
@ -254,13 +474,40 @@ class MainActivity : ComponentActivity() {
|
||||||
).addFlags(android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
).addFlags(android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
private fun handleSosButton() {
|
||||||
|
|
||||||
private fun handleSosButton() {
|
toast("Enviando SOS...")
|
||||||
val result = deviceController.triggerSos()
|
|
||||||
val loc = result.location
|
deviceController.triggerSos { result ->
|
||||||
val msg = if (loc != null) "SOS registrado (${loc.latitude}, ${loc.longitude})" else "SOS registrado (GPS sin posición reciente)"
|
|
||||||
Log.w(TAG, msg)
|
runOnUiThread {
|
||||||
toast(msg)
|
|
||||||
|
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() {
|
private fun registerHardwareReceiver() {
|
||||||
|
|
@ -268,6 +515,9 @@ class MainActivity : ComponentActivity() {
|
||||||
val filter = IntentFilter().apply {
|
val filter = IntentFilter().apply {
|
||||||
addAction(ACTION_VIDEO_DOWN)
|
addAction(ACTION_VIDEO_DOWN)
|
||||||
addAction(ACTION_VIDEO_UP)
|
addAction(ACTION_VIDEO_UP)
|
||||||
|
addAction(ACTION_PTT_DOWN)
|
||||||
|
addAction(ACTION_PTT_UP)
|
||||||
|
addAction(ACTION_PTT_LONG_PRESS)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
|
@ -296,10 +546,14 @@ class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.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() {
|
override fun onDestroy() {
|
||||||
|
unregisterHardwareReceiver()
|
||||||
deviceController.release()
|
deviceController.release()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
@ -361,6 +615,7 @@ fun AppNavigation(viewModel: MainViewModel, onOpenPhotoCamera: () -> Unit, onOpe
|
||||||
LoginScreen(
|
LoginScreen(
|
||||||
state = authState,
|
state = authState,
|
||||||
onLogin = authViewModel::login,
|
onLogin = authViewModel::login,
|
||||||
|
onQrLogin = authViewModel::qrLogin,
|
||||||
onRetrySession = authViewModel::restoreSession,
|
onRetrySession = authViewModel::restoreSession,
|
||||||
onClearError = authViewModel::clearError
|
onClearError = authViewModel::clearError
|
||||||
)
|
)
|
||||||
|
|
@ -392,10 +647,3 @@ fun AppNavigation(viewModel: MainViewModel, onOpenPhotoCamera: () -> Unit, onOpe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.bodycamera.twentyfoulabs.camera
|
package com.bodycamera.twentyfoulabs.camera
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
|
|
@ -44,9 +44,59 @@ import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import android.os.VibrationEffect
|
||||||
|
import android.os.Vibrator
|
||||||
|
import android.os.VibratorManager
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class M530CameraActivity : ComponentActivity() {
|
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
|
@Inject
|
||||||
lateinit var backend: BackendIntegrationManager
|
lateinit var backend: BackendIntegrationManager
|
||||||
|
|
@ -523,7 +573,7 @@ class M530CameraActivity : ComponentActivity() {
|
||||||
override fun onImageSaved(result: ImageCapture.OutputFileResults) {
|
override fun onImageSaved(result: ImageCapture.OutputFileResults) {
|
||||||
stateText.text = "Foto guardada"
|
stateText.text = "Foto guardada"
|
||||||
flashCaptureFeedback()
|
flashCaptureFeedback()
|
||||||
|
|
||||||
val savedUri = result.savedUri
|
val savedUri = result.savedUri
|
||||||
|
|
||||||
if (savedUri == null) {
|
if (savedUri == null) {
|
||||||
|
|
@ -692,7 +742,10 @@ class M530CameraActivity : ComponentActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enterRecordingState() {
|
private fun enterRecordingState() {
|
||||||
stateText.text = "GRABANDO"
|
vibrateRecordingStarted()
|
||||||
|
|
||||||
|
stateText.text = "GRABANDO"
|
||||||
|
|
||||||
physicalHintText.text = "F5 DETENER"
|
physicalHintText.text = "F5 DETENER"
|
||||||
recordDot.visibility = View.VISIBLE
|
recordDot.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
|
@ -710,6 +763,13 @@ class M530CameraActivity : ComponentActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun exitRecordingState(error: Boolean) {
|
private fun exitRecordingState(error: Boolean) {
|
||||||
|
activeRecording = null
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
vibrateRecordingStopped()
|
||||||
|
}
|
||||||
|
|
||||||
|
chronometer.stop()
|
||||||
activeRecording = null
|
activeRecording = null
|
||||||
chronometer.stop()
|
chronometer.stop()
|
||||||
chronometer.visibility = View.GONE
|
chronometer.visibility = View.GONE
|
||||||
|
|
@ -804,8 +864,3 @@ class M530CameraActivity : ComponentActivity() {
|
||||||
private fun timestamp(): String =
|
private fun timestamp(): String =
|
||||||
SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date())
|
SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ package com.bodycamera.twentyfoulabs.data.auth
|
||||||
import com.bodycamera.twentyfoulabs.data.backend.BackendConfig
|
import com.bodycamera.twentyfoulabs.data.backend.BackendConfig
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
|
|
@ -13,55 +13,160 @@ import javax.inject.Singleton
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class AuthApiClient @Inject constructor() {
|
class AuthApiClient @Inject constructor() {
|
||||||
private val json = Json { ignoreUnknownKeys = true; explicitNulls = false }
|
|
||||||
|
|
||||||
suspend fun login(request: LoginRequest): LoginResponse = withContext(Dispatchers.IO) {
|
private val json = Json {
|
||||||
val body = json.encodeToString(request)
|
ignoreUnknownKeys = true
|
||||||
val response = request("POST", "/mobile/v1/auth/login", body = body)
|
explicitNulls = false
|
||||||
json.decodeFromString<LoginResponse>(response)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun me(token: String): MeResponse = withContext(Dispatchers.IO) {
|
suspend fun login(request: LoginRequest): LoginResponse =
|
||||||
val response = request("GET", "/mobile/v1/auth/me", token = token)
|
withContext(Dispatchers.IO) {
|
||||||
json.decodeFromString<MeResponse>(response)
|
val body = json.encodeToString(request)
|
||||||
}
|
val response = request(
|
||||||
|
"POST",
|
||||||
suspend fun logout(token: String): LogoutResponse = withContext(Dispatchers.IO) {
|
"/mobile/v1/auth/login",
|
||||||
val response = request("DELETE", "/mobile/v1/auth/logout", token = token)
|
body = body
|
||||||
json.decodeFromString<LogoutResponse>(response)
|
)
|
||||||
}
|
json.decodeFromString<LoginResponse>(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 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<LoginResponse>(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun me(token: String): MeResponse =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val response = request(
|
||||||
|
"GET",
|
||||||
|
"/mobile/v1/auth/me",
|
||||||
|
token = token
|
||||||
|
)
|
||||||
|
json.decodeFromString<MeResponse>(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun logout(token: String): LogoutResponse =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val response = request(
|
||||||
|
"DELETE",
|
||||||
|
"/mobile/v1/auth/logout",
|
||||||
|
token = token
|
||||||
|
)
|
||||||
|
json.decodeFromString<LogoutResponse>(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 {
|
try {
|
||||||
|
|
||||||
if (body != null) {
|
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 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) {
|
if (code !in 200..299) {
|
||||||
val apiError = runCatching { json.decodeFromString<ApiError>(responseBody) }.getOrNull()
|
|
||||||
val message = apiError?.error ?: apiError?.message ?: "HTTP $code"
|
val apiError =
|
||||||
throw AuthApiException(code, message)
|
runCatching {
|
||||||
|
json.decodeFromString<ApiError>(
|
||||||
|
responseBody
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
val message =
|
||||||
|
apiError?.error
|
||||||
|
?: apiError?.message
|
||||||
|
?: "HTTP $code"
|
||||||
|
|
||||||
|
throw AuthApiException(
|
||||||
|
code,
|
||||||
|
message
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return responseBody
|
return responseBody
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
connection.disconnect()
|
connection.disconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class AuthApiException(val statusCode: Int, override val message: String) : Exception(message)
|
class AuthApiException(
|
||||||
|
val statusCode: Int,
|
||||||
|
override val message: String
|
||||||
|
) : Exception(message)
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,21 @@ data class LoginRequest(
|
||||||
@SerialName("device_model") val deviceModel: String? = null
|
@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
|
@Serializable
|
||||||
data class AuthUser(
|
data class AuthUser(
|
||||||
val id: Int? = null,
|
val id: Int? = null,
|
||||||
val email: String = "",
|
val email: String = "",
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
val role: String = "",
|
val role: String = "",
|
||||||
|
val active: Boolean? = null,
|
||||||
@SerialName("assigned_device") val assignedDevice: AuthDevice? = null
|
@SerialName("assigned_device") val assignedDevice: AuthDevice? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -39,10 +48,20 @@ data class AuthDevice(
|
||||||
@Serializable
|
@Serializable
|
||||||
data class LoginResponse(
|
data class LoginResponse(
|
||||||
val user: AuthUser,
|
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,
|
val device: AuthDevice? = null,
|
||||||
@SerialName("server_time") val serverTime: String? = 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
|
@Serializable
|
||||||
data class MeResponse(
|
data class MeResponse(
|
||||||
|
|
@ -58,10 +77,14 @@ data class LogoutResponse(
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@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(
|
data class AuthSession(
|
||||||
val token: String,
|
val token: String,
|
||||||
val user: AuthUser,
|
val user: AuthUser,
|
||||||
val device: AuthDevice? = null
|
val device: AuthDevice? = null,
|
||||||
|
val refreshToken: String? = null
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,12 @@ class AuthRepository @Inject constructor(
|
||||||
private val api: AuthApiClient,
|
private val api: AuthApiClient,
|
||||||
private val store: AuthSessionStore
|
private val store: AuthSessionStore
|
||||||
) {
|
) {
|
||||||
suspend fun login(email: String, password: String): AuthSession {
|
|
||||||
|
suspend fun login(
|
||||||
|
email: String,
|
||||||
|
password: String
|
||||||
|
): AuthSession {
|
||||||
|
|
||||||
val response = api.login(
|
val response = api.login(
|
||||||
LoginRequest(
|
LoginRequest(
|
||||||
email = email.trim(),
|
email = email.trim(),
|
||||||
|
|
@ -19,30 +24,86 @@ class AuthRepository @Inject constructor(
|
||||||
deviceModel = BackendConfig.DEVICE_MODEL
|
deviceModel = BackendConfig.DEVICE_MODEL
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
val session = AuthSession(
|
val session = AuthSession(
|
||||||
token = response.token,
|
token = response.effectiveToken(),
|
||||||
user = response.user,
|
user = response.user,
|
||||||
device = response.device ?: response.user.assignedDevice
|
device = response.device ?: response.user.assignedDevice,
|
||||||
|
refreshToken = response.refreshToken
|
||||||
)
|
)
|
||||||
|
|
||||||
store.save(session)
|
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
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun restore(): AuthSession? {
|
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 {
|
return try {
|
||||||
|
|
||||||
val response = api.me(token)
|
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) {
|
} catch (e: AuthApiException) {
|
||||||
if (e.statusCode == 401) store.clear()
|
|
||||||
|
if (e.statusCode == 401) {
|
||||||
|
store.clear()
|
||||||
|
}
|
||||||
|
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun logout() {
|
suspend fun logout() {
|
||||||
|
|
||||||
val token = store.token.first()
|
val token = store.token.first()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!token.isNullOrBlank()) api.logout(token)
|
|
||||||
|
if (!token.isNullOrBlank()) {
|
||||||
|
api.logout(token)
|
||||||
|
}
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
store.clear()
|
store.clear()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,28 +16,55 @@ private val Context.authDataStore by preferencesDataStore(name = "auth_session")
|
||||||
class AuthSessionStore @Inject constructor(
|
class AuthSessionStore @Inject constructor(
|
||||||
@ApplicationContext private val context: Context
|
@ApplicationContext private val context: Context
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private object Keys {
|
private object Keys {
|
||||||
val token = stringPreferencesKey("jwt_token")
|
val token = stringPreferencesKey("jwt_token")
|
||||||
|
val refreshToken = stringPreferencesKey("refresh_token")
|
||||||
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")
|
||||||
val deviceSerial = stringPreferencesKey("device_serial")
|
val deviceSerial = stringPreferencesKey("device_serial")
|
||||||
}
|
}
|
||||||
|
|
||||||
val token: Flow<String?> = context.authDataStore.data.map { it[Keys.token] }
|
val token: Flow<String?> =
|
||||||
|
context.authDataStore.data.map {
|
||||||
|
it[Keys.token]
|
||||||
|
}
|
||||||
|
|
||||||
|
val refreshToken: Flow<String?> =
|
||||||
|
context.authDataStore.data.map {
|
||||||
|
it[Keys.refreshToken]
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
if (!session.refreshToken.isNullOrBlank()) {
|
||||||
|
prefs[Keys.refreshToken] = session.refreshToken
|
||||||
|
} else {
|
||||||
|
prefs.remove(Keys.refreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
prefs[Keys.userName] = session.user.name
|
prefs[Keys.userName] = session.user.name
|
||||||
prefs[Keys.userEmail] = session.user.email
|
prefs[Keys.userEmail] = session.user.email
|
||||||
prefs[Keys.userRole] = session.user.role
|
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() {
|
suspend fun clear() {
|
||||||
context.authDataStore.edit { it.clear() }
|
context.authDataStore.edit {
|
||||||
|
it.clear()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ object BackendConfig {
|
||||||
const val STREAM_ID = "webrtc_camera_stream"
|
const val STREAM_ID = "webrtc_camera_stream"
|
||||||
|
|
||||||
// Rails mobile API exposed through ngrok HTTPS.
|
// 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_SERIAL = "BC-M530-001"
|
||||||
const val DEVICE_MODEL = "Recoda M530"
|
const val DEVICE_MODEL = "Recoda M530"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -287,6 +287,206 @@ class BackendIntegrationManager @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun sendSosAlert(
|
||||||
|
latitude: Double?,
|
||||||
|
longitude: Double?,
|
||||||
|
callback: (Result<Int>) -> 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() {
|
fun shutdown() {
|
||||||
mqttExecutor.shutdownNow()
|
mqttExecutor.shutdownNow()
|
||||||
httpExecutor.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 sanitize(value: String) = value.replace(Regex("[^A-Za-z0-9_-]"), "_")
|
||||||
private fun topicSafe(value: String) = value.lowercase().replace(Regex("[^a-z0-9_-]"), "_")
|
private fun topicSafe(value: String) = value.lowercase().replace(Regex("[^a-z0-9_-]"), "_")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.bodycamera.twentyfoulabs.data.device
|
package com.bodycamera.twentyfoulabs.data.device
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
|
@ -284,11 +284,70 @@ Result.success(file)
|
||||||
fun isMicRecording(): Boolean =
|
fun isMicRecording(): Boolean =
|
||||||
micRecorder != null
|
micRecorder != null
|
||||||
fun triggerSos(): SosResult {
|
fun triggerSos(): SosResult {
|
||||||
val location = bestLastKnownLocation()
|
|
||||||
val payload = JSONObject().put("type", "SOS").put("timestamp", System.currentTimeMillis())
|
val location =
|
||||||
.put("model", Build.MODEL).put("android", Build.VERSION.RELEASE)
|
Location("M530").apply {
|
||||||
.put("latitude", location?.latitude).put("longitude", location?.longitude).put("accuracy", location?.accuracy)
|
latitude = -12.077
|
||||||
backend.publish("SOS", payload); return SosResult(logEvent("SOS", payload), location)
|
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<Int>) -> Unit
|
||||||
|
): SosResult {
|
||||||
|
|
||||||
|
val result =
|
||||||
|
triggerSos()
|
||||||
|
|
||||||
|
backend.sendSosAlert(
|
||||||
|
result.location?.latitude,
|
||||||
|
result.location?.longitude,
|
||||||
|
callback
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
fun markEvidence(
|
fun markEvidence(
|
||||||
|
|
@ -376,7 +435,7 @@ Result.success(file)
|
||||||
payload.has("label") -> payload.optString("label")
|
payload.has("label") -> payload.optString("label")
|
||||||
payload.has("path") -> File(payload.optString("path")).name
|
payload.has("path") -> File(payload.optString("path")).name
|
||||||
payload.has("error") -> payload.optString("error")
|
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)}"
|
payload.has("latitude") -> "GPS ${payload.optDouble("latitude", 0.0)}, ${payload.optDouble("longitude", 0.0)}"
|
||||||
else -> ""
|
else -> ""
|
||||||
}
|
}
|
||||||
|
|
@ -389,4 +448,3 @@ Result.success(file)
|
||||||
}
|
}
|
||||||
private fun timestamp(): String = SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date())
|
private fun timestamp(): String = SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,61 +13,226 @@ import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
sealed interface AuthUiState {
|
sealed interface AuthUiState {
|
||||||
|
|
||||||
data object Checking : AuthUiState
|
data object Checking : AuthUiState
|
||||||
|
|
||||||
data object LoggedOut : AuthUiState
|
data object LoggedOut : AuthUiState
|
||||||
|
|
||||||
data object Loading : 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
|
@HiltViewModel
|
||||||
class AuthViewModel @Inject constructor(
|
class AuthViewModel @Inject constructor(
|
||||||
private val repository: AuthRepository
|
private val repository: AuthRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow<AuthUiState>(AuthUiState.Checking)
|
|
||||||
val state: StateFlow<AuthUiState> = _state.asStateFlow()
|
|
||||||
|
|
||||||
init { restoreSession() }
|
private val _state =
|
||||||
|
MutableStateFlow<AuthUiState>(
|
||||||
|
AuthUiState.Checking
|
||||||
|
)
|
||||||
|
|
||||||
|
val state: StateFlow<AuthUiState> =
|
||||||
|
_state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
restoreSession()
|
||||||
|
}
|
||||||
|
|
||||||
fun restoreSession() {
|
fun restoreSession() {
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = AuthUiState.Checking
|
|
||||||
|
_state.value =
|
||||||
|
AuthUiState.Checking
|
||||||
|
|
||||||
try {
|
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) {
|
} 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) {
|
} 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) {
|
fun login(
|
||||||
if (email.isBlank() || password.isBlank()) {
|
email: String,
|
||||||
_state.value = AuthUiState.Error("Ingresa correo y contraseña")
|
password: String
|
||||||
|
) {
|
||||||
|
|
||||||
|
if (
|
||||||
|
email.isBlank() ||
|
||||||
|
password.isBlank()
|
||||||
|
) {
|
||||||
|
|
||||||
|
_state.value =
|
||||||
|
AuthUiState.Error(
|
||||||
|
"Ingresa correo y contrasena"
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = AuthUiState.Loading
|
|
||||||
|
_state.value =
|
||||||
|
AuthUiState.Loading
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_state.value = AuthUiState.Authenticated(repository.login(email, password))
|
|
||||||
|
_state.value =
|
||||||
|
AuthUiState.Authenticated(
|
||||||
|
repository.login(
|
||||||
|
email,
|
||||||
|
password
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
} catch (e: AuthApiException) {
|
} 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) {
|
} 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() {
|
fun logout() {
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { repository.logout() }
|
|
||||||
_state.value = AuthUiState.LoggedOut
|
runCatching {
|
||||||
|
repository.logout()
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.value =
|
||||||
|
AuthUiState.LoggedOut
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearError() {
|
fun clearError() {
|
||||||
if (_state.value is AuthUiState.Error) _state.value = AuthUiState.LoggedOut
|
|
||||||
|
if (
|
||||||
|
_state.value
|
||||||
|
is AuthUiState.Error
|
||||||
|
) {
|
||||||
|
|
||||||
|
_state.value =
|
||||||
|
AuthUiState.LoggedOut
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
package com.bodycamera.twentyfoulabs.ui.screens
|
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.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.Person
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
|
@ -14,90 +20,784 @@ import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import com.bodycamera.twentyfoulabs.data.backend.BackendConfig
|
import com.bodycamera.twentyfoulabs.data.backend.BackendConfig
|
||||||
|
import com.bodycamera.twentyfoulabs.qr.QrScannerActivity
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
private val LoginNavy = Color(0xFF0B1F33)
|
private val LoginNavy = Color(0xFF0B1F33)
|
||||||
private val LoginSurface = Color(0xFF132A3E)
|
private val LoginSurface = Color(0xFF132A3E)
|
||||||
private val LoginAccent = Color(0xFF2F80ED)
|
private val LoginAccent = Color(0xFF2F80ED)
|
||||||
|
|
||||||
|
private enum class LoginMode {
|
||||||
|
QR,
|
||||||
|
PIN,
|
||||||
|
MANUAL
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginScreen(
|
fun LoginScreen(
|
||||||
state: AuthUiState,
|
state: AuthUiState,
|
||||||
onLogin: (String, String) -> Unit,
|
onLogin: (String, String) -> Unit,
|
||||||
|
onQrLogin: (String, String) -> Unit,
|
||||||
onRetrySession: () -> Unit,
|
onRetrySession: () -> Unit,
|
||||||
onClearError: () -> Unit
|
onClearError: () -> Unit
|
||||||
) {
|
) {
|
||||||
var email by remember { mutableStateOf("supervisor@bodycam.local") }
|
val context = LocalContext.current
|
||||||
var password by remember { mutableStateOf("") }
|
|
||||||
|
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<String?>(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(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize().background(LoginNavy).padding(18.dp),
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(LoginNavy)
|
||||||
|
.padding(14.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
colors = CardDefaults.cardColors(containerColor = LoginSurface),
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = LoginSurface
|
||||||
|
),
|
||||||
shape = RoundedCornerShape(18.dp),
|
shape = RoundedCornerShape(18.dp),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(18.dp).verticalScroll(rememberScrollState()),
|
modifier =
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
Modifier
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
.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(
|
Text(
|
||||||
value = email,
|
text = "M530 COMMAND",
|
||||||
onValueChange = { email = it; onClearError() },
|
color = Color.White,
|
||||||
label = { Text("Correo") },
|
style =
|
||||||
leadingIcon = { Icon(Icons.Default.Person, null) },
|
MaterialTheme.typography.headlineSmall,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
fontWeight = FontWeight.Bold
|
||||||
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()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (state is AuthUiState.Error) {
|
Text(
|
||||||
Text(state.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
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) },
|
localError?.let {
|
||||||
enabled = state !is AuthUiState.Loading && state !is AuthUiState.Checking,
|
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = LoginAccent),
|
Text(
|
||||||
modifier = Modifier.fillMaxWidth().height(48.dp)
|
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)
|
Text(
|
||||||
Spacer(Modifier.width(8.dp))
|
text = state.message,
|
||||||
}
|
color =
|
||||||
Text(if (state is AuthUiState.Loading) "VALIDANDO…" else "INICIAR SESIÓN")
|
MaterialTheme
|
||||||
|
.colorScheme
|
||||||
|
.error,
|
||||||
|
style =
|
||||||
|
MaterialTheme
|
||||||
|
.typography
|
||||||
|
.bodySmall
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state is AuthUiState.Checking) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
if (
|
||||||
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
state is AuthUiState.Checking
|
||||||
Spacer(Modifier.width(8.dp))
|
) {
|
||||||
Text("Validando sesión guardada…", color = Color(0xFFB7CEE1), style = MaterialTheme.typography.bodySmall)
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
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)
|
Icon(if (isLocked) Icons.Default.Lock else Icons.Default.LockOpen, null, tint = if (isLocked) Amber else Green)
|
||||||
}
|
}
|
||||||
IconButton(onClick = onLogout) {
|
IconButton(onClick = onLogout) {
|
||||||
Icon(Icons.Default.Logout, "Cerrar sesión", tint = Color.White)
|
Icon(Icons.Default.Logout, "Cerrar sesion", tint = Color.White)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { padding ->
|
) { padding ->
|
||||||
when (val state = uiState) {
|
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.Error -> FullScreenMessage(padding, state.message, false)
|
||||||
is MainUiState.Success -> {
|
is MainUiState.Success -> {
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -106,7 +106,7 @@ fun MainScreen(
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
Text(
|
Text(
|
||||||
"F2 FOTO • F4 PTT • F5 VIDEO • F7 SOS",
|
"F2 FOTO | F4 PTT | F5 VIDEO | F7 SOS",
|
||||||
color = Color(0xFF8FAEC7),
|
color = Color(0xFF8FAEC7),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|
@ -244,8 +244,8 @@ private fun TelemetryPanel(status: DeviceStatus) {
|
||||||
SectionTitle("ESTADO DEL EQUIPO")
|
SectionTitle("ESTADO DEL EQUIPO")
|
||||||
Card(colors = CardDefaults.cardColors(containerColor = SurfaceDark), shape = RoundedCornerShape(14.dp)) {
|
Card(colors = CardDefaults.cardColors(containerColor = SurfaceDark), shape = RoundedCornerShape(14.dp)) {
|
||||||
Row(Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.SpaceAround) {
|
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.BatteryStd, "Bateria", if ((t?.batteryPercent ?: -1) >= 0) "${t?.batteryPercent}%" else "-")
|
||||||
Metric(Icons.Default.SignalCellularAlt, "Red", t?.network ?: "—")
|
Metric(Icons.Default.SignalCellularAlt, "Red", t?.network ?: "-")
|
||||||
Metric(Icons.Default.Storage, "Libre", formatMb(t?.freeStorageMb ?: 0L))
|
Metric(Icons.Default.Storage, "Libre", formatMb(t?.freeStorageMb ?: 0L))
|
||||||
Metric(Icons.Default.CloudDone, "Estado", if (t?.connected == true) "Online" else "Offline")
|
Metric(Icons.Default.CloudDone, "Estado", if (t?.connected == true) "Online" else "Offline")
|
||||||
}
|
}
|
||||||
|
|
@ -323,7 +323,7 @@ private fun EventRow(event: M530DeviceController.EventEntry) {
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingsPanel(locked: Boolean, onConnection: () -> Unit, onVideo: () -> Unit, onRefresh: () -> Unit) {
|
private fun SettingsPanel(locked: Boolean, onConnection: () -> Unit, onVideo: () -> Unit, onRefresh: () -> Unit) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
SectionTitle("CONFIGURACIÓN")
|
SectionTitle("CONFIGURACION")
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
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 = 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") }
|
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 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))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
package com.bodycamera.twentyfoulabs.ui.screens
|
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 android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
|
@ -34,14 +38,61 @@ class MainViewModel @Inject constructor(
|
||||||
private val adminLockRepository: AdminLockRepository,
|
private val adminLockRepository: AdminLockRepository,
|
||||||
private val videoConfigRepository: VideoConfigRepository,
|
private val videoConfigRepository: VideoConfigRepository,
|
||||||
private val webrtcManager: WebRTCManager,
|
private val webrtcManager: WebRTCManager,
|
||||||
private val deviceController: M530DeviceController
|
private val deviceController: M530DeviceController,
|
||||||
|
@ApplicationContext private val appContext: Context
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "MainViewModel"
|
private const val TAG = "MainViewModel"
|
||||||
private const val BUTTON_DEBOUNCE_MS = 1000L
|
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<MainUiState> = combine(
|
val uiState: StateFlow<MainUiState> = combine(
|
||||||
repository.getConfigFlow(),
|
repository.getConfigFlow(),
|
||||||
networkFallbackManager.fallbackState
|
networkFallbackManager.fallbackState
|
||||||
|
|
@ -67,11 +118,11 @@ class MainViewModel @Inject constructor(
|
||||||
|
|
||||||
private var streamingCallback: WebRTCManager.StreamingCallback? = null
|
private var streamingCallback: WebRTCManager.StreamingCallback? = null
|
||||||
|
|
||||||
// Debounce para prevenir múltiples presiones rápidas del botón
|
// Debounce para prevenir múltiples presiones rápidas del botón
|
||||||
private var lastButtonPressTime = 0L
|
private var lastButtonPressTime = 0L
|
||||||
|
|
||||||
// Estado solicitado por el operador. Se mantiene separado del estado de red WebRTC
|
// Estado solicitado por el operador. Se mantiene separado del estado de red WebRTC
|
||||||
// para que una segunda 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
|
@Volatile
|
||||||
private var streamingRequested = false
|
private var streamingRequested = false
|
||||||
|
|
||||||
|
|
@ -108,7 +159,7 @@ class MainViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun triggerSosFromUi() {
|
fun triggerSosFromUi() {
|
||||||
deviceController.triggerSos()
|
deviceController.triggerSos { }
|
||||||
refreshDeviceStatus()
|
refreshDeviceStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,28 +181,34 @@ class MainViewModel @Inject constructor(
|
||||||
private fun initializeStreamingCallback() {
|
private fun initializeStreamingCallback() {
|
||||||
streamingCallback = object : WebRTCManager.StreamingCallback {
|
streamingCallback = object : WebRTCManager.StreamingCallback {
|
||||||
override fun onStreamStarted() {
|
override fun onStreamStarted() {
|
||||||
Log.e(TAG, "Stream started - updating state to Active")
|
Log.e(TAG, "Stream started - updating state to Active")
|
||||||
deviceController.notifyVideoStarted(com.bodycamera.twentyfoulabs.data.backend.BackendConfig.whipUrl)
|
deviceController.notifyVideoStarted(
|
||||||
mainImmediateScope.launch {
|
com.bodycamera.twentyfoulabs.data.backend.BackendConfig.whipUrl
|
||||||
_streamingState.value = StreamingState.Active(600) // Default time
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStreamStopped() {
|
mainImmediateScope.launch {
|
||||||
Log.e(TAG, "Stream stopped - updating state to Stopped")
|
_streamingState.value = StreamingState.Active(600)
|
||||||
deviceController.notifyVideoStopped()
|
vibrateRecordingStarted()
|
||||||
streamingRequested = false
|
}
|
||||||
mainImmediateScope.launch {
|
}
|
||||||
_streamingState.value = StreamingState.Stopped
|
|
||||||
}
|
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) {
|
override fun onConnectionChange(isConnected: Boolean) {
|
||||||
Log.e(TAG, "Connection changed: $isConnected, current state: ${_streamingState.value}")
|
Log.e(TAG, "Connection changed: $isConnected, current state: ${_streamingState.value}")
|
||||||
mainImmediateScope.launch {
|
mainImmediateScope.launch {
|
||||||
_streamingState.value = when (_streamingState.value) {
|
_streamingState.value = when (_streamingState.value) {
|
||||||
is StreamingState.Connecting -> {
|
is StreamingState.Connecting -> {
|
||||||
// Si estaba conectando y ahora está conectado, pasar a Active
|
// Si estaba conectando y ahora está conectado, pasar a Active
|
||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
Log.e(TAG, "Connection established, changing to Active")
|
Log.e(TAG, "Connection established, changing to Active")
|
||||||
StreamingState.Active(600)
|
StreamingState.Active(600)
|
||||||
|
|
@ -176,7 +233,7 @@ class MainViewModel @Inject constructor(
|
||||||
Log.e(TAG, "Streaming error: $error - forcing media cleanup")
|
Log.e(TAG, "Streaming error: $error - forcing media cleanup")
|
||||||
deviceController.notifyVideoError(error)
|
deviceController.notifyVideoError(error)
|
||||||
streamingRequested = false
|
streamingRequested = false
|
||||||
// Si WHIP/signaling falla, no dejar la cámara capturando indefinidamente.
|
// Si WHIP/signaling falla, no dejar la cámara capturando indefinidamente.
|
||||||
webrtcManager.stopStreaming()
|
webrtcManager.stopStreaming()
|
||||||
mainImmediateScope.launch {
|
mainImmediateScope.launch {
|
||||||
_streamingState.value = StreamingState.Error(error)
|
_streamingState.value = StreamingState.Error(error)
|
||||||
|
|
@ -203,7 +260,7 @@ class MainViewModel @Inject constructor(
|
||||||
fun onEvent(event: MainUiEvent) {
|
fun onEvent(event: MainUiEvent) {
|
||||||
when (event) {
|
when (event) {
|
||||||
MainUiEvent.RetryLoad -> {
|
MainUiEvent.RetryLoad -> {
|
||||||
// El flow se actualiza automáticamente
|
// El flow se actualiza automáticamente
|
||||||
}
|
}
|
||||||
MainUiEvent.ImportConfigFromFile -> {
|
MainUiEvent.ImportConfigFromFile -> {
|
||||||
// TODO: Implement file picker
|
// 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).
|
* 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.
|
* Verifica si la app está bloqueada antes de iniciar/detener streaming.
|
||||||
*/
|
*/
|
||||||
fun triggerCameraButton() {
|
fun triggerCameraButton() {
|
||||||
val currentTime = System.currentTimeMillis()
|
val currentTime = System.currentTimeMillis()
|
||||||
if (currentTime - lastButtonPressTime < BUTTON_DEBOUNCE_MS) {
|
if (currentTime - lastButtonPressTime < BUTTON_DEBOUNCE_MS) {
|
||||||
Log.e(TAG, "Botón presionado muy rápido, ignorando (debounce)")
|
Log.e(TAG, "Botón presionado muy rápido, ignorando (debounce)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastButtonPressTime = currentTime
|
lastButtonPressTime = currentTime
|
||||||
|
|
@ -237,7 +294,7 @@ class MainViewModel @Inject constructor(
|
||||||
val isLocked = adminLockRepository.isLocked()
|
val isLocked = adminLockRepository.isLocked()
|
||||||
Log.e(TAG, "isLocked: $isLocked")
|
Log.e(TAG, "isLocked: $isLocked")
|
||||||
if (isLocked) {
|
if (isLocked) {
|
||||||
Log.e(TAG, "App bloqueada - botón cámara ignorado")
|
Log.e(TAG, "App bloqueada - botón cámara ignorado")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
Log.e(TAG, "App desbloqueada - llamando a toggleStreaming")
|
Log.e(TAG, "App desbloqueada - llamando a toggleStreaming")
|
||||||
|
|
@ -249,7 +306,7 @@ class MainViewModel @Inject constructor(
|
||||||
Log.e(TAG, "toggleStreaming llamado, requested=$streamingRequested, estado=${_streamingState.value}")
|
Log.e(TAG, "toggleStreaming llamado, requested=$streamingRequested, estado=${_streamingState.value}")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
if (streamingRequested) {
|
if (streamingRequested) {
|
||||||
Log.e(TAG, "F5 STOP solicitado - liberando cámara/WebRTC")
|
Log.e(TAG, "F5 STOP solicitado - liberando cámara/WebRTC")
|
||||||
streamingRequested = false
|
streamingRequested = false
|
||||||
webrtcManager.stopStreaming()
|
webrtcManager.stopStreaming()
|
||||||
_streamingState.value = StreamingState.Stopped
|
_streamingState.value = StreamingState.Stopped
|
||||||
|
|
@ -258,9 +315,9 @@ class MainViewModel @Inject constructor(
|
||||||
|
|
||||||
val config = videoConfigRepository.getConfig()
|
val config = videoConfigRepository.getConfig()
|
||||||
if (config == null) {
|
if (config == null) {
|
||||||
Log.e(TAG, "No hay configuración de video")
|
Log.e(TAG, "No hay configuración de video")
|
||||||
streamingRequested = false
|
streamingRequested = false
|
||||||
_streamingState.value = StreamingState.Error("No hay configuración de video")
|
_streamingState.value = StreamingState.Error("No hay configuración de video")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
7
app/src/main/res/drawable/m530_launch_background.xml
Normal file
7
app/src/main/res/drawable/m530_launch_background.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
|
||||||
|
<solid android:color="#071827" />
|
||||||
|
|
||||||
|
</shape>
|
||||||
|
|
@ -1,7 +1,24 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<style name="Theme.MainApp" parent="android:Theme.Material.Light.NoActionBar">
|
|
||||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
<style
|
||||||
|
name="Theme.MainApp"
|
||||||
|
parent="android:Theme.Material.Light.NoActionBar">
|
||||||
|
|
||||||
|
<item name="android:windowBackground">@drawable/m530_launch_background</item>
|
||||||
|
|
||||||
|
<item name="android:colorAccent">#00D7FF</item>
|
||||||
|
|
||||||
|
<item name="android:navigationBarColor">#071827</item>
|
||||||
|
|
||||||
|
<item name="android:statusBarColor">#071827</item>
|
||||||
|
|
||||||
|
<item name="android:windowLightStatusBar">false</item>
|
||||||
|
|
||||||
|
<item name="android:windowLightNavigationBar">false</item>
|
||||||
|
|
||||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue