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