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