package com.bodycamera.twentyfoulabs import android.Manifest import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.util.Log import android.view.KeyEvent import android.view.View import android.view.WindowManager import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.core.content.ContextCompat import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.bodycamera.twentyfoulabs.data.device.M530DeviceController import com.bodycamera.twentyfoulabs.data.auth.AuthSessionStore import com.bodycamera.twentyfoulabs.data.ptt.PttManager import com.bodycamera.twentyfoulabs.navigation.LocalNavigationHandler import com.bodycamera.twentyfoulabs.navigation.rememberNavigationHandler import com.bodycamera.twentyfoulabs.ui.screens.ConnectionSettingsScreen import com.bodycamera.twentyfoulabs.ui.screens.ConnectionSettingsViewModel import com.bodycamera.twentyfoulabs.ui.screens.MainScreen import com.bodycamera.twentyfoulabs.ui.screens.MainViewModel import com.bodycamera.twentyfoulabs.ui.screens.AuthViewModel import com.bodycamera.twentyfoulabs.ui.screens.AuthUiState import com.bodycamera.twentyfoulabs.ui.screens.LoginScreen import com.bodycamera.twentyfoulabs.ui.screens.VideoConfigScreen import com.bodycamera.twentyfoulabs.ui.screens.VideoConfigViewModel import com.bodycamera.twentyfoulabs.ui.theme.MainAppTheme import dagger.hilt.android.AndroidEntryPoint 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() { private val viewModel: MainViewModel by viewModels() @Inject lateinit var deviceController: M530DeviceController @Inject lateinit var authSessionStore: AuthSessionStore @Inject lateinit var pttManager: PttManager @Volatile private var isAuthenticated = false private var frontPreviewOpened = false companion object { 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 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 accepted = pttManager.pressToTalk() Log.i(TAG, if (accepted) "PTT LIVE DOWN - solicitud enviada" else "PTT LIVE DOWN - PTT no preparado") } 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 } pttManager.releaseTalk() Log.i(TAG, "PTT LIVE UP - transmision liberada") } } } } private val permissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { permissions -> val denied = permissions.filterValues { !it }.keys if (denied.isNotEmpty()) { Toast.makeText(this, "Permisos pendientes: ${denied.joinToString()}", Toast.LENGTH_LONG).show() } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Log.i(TAG, "M530 Controller onCreate") window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) window.setFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) requestPermissions() pttManager.initialize( object : PttManager.Listener { override fun onPttReady() = Unit override fun onPttDisconnected() = Unit override fun onTransmitChanged(transmitting: Boolean) = Unit override fun onChannelBusy(holderName: String?) = Unit override fun onError(message: String) = Unit } ) onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() = Unit }) lifecycleScope.launch { authSessionStore.token.collect { token -> isAuthenticated = !token.isNullOrBlank() if (!token.isNullOrBlank()) { pttManager.start(token) } else { pttManager.stop() } if (false && isAuthenticated && !frontPreviewOpened) { frontPreviewOpened = true runOnUiThread { openFrontPreview() } } } } registerHardwareReceiver() deviceController.logTelemetry() setContent { MainAppTheme { var showStartupSplash by remember { mutableStateOf(true) } LaunchedEffect(Unit) { delay(2000) showStartupSplash = false } if (showStartupSplash) { M530StartupSplash() } else { AppNavigation( viewModel = viewModel, onOpenPhotoCamera = { handlePhotoButton() }, onOpenVideoCamera = { handleVideoButton() } ) } } } } override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { 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 sesion para usar la bodycam") return true } return when (keyCode) { KeyEvent.KEYCODE_F1 -> { toast("Marcando evidencia como favorita...") deviceController.markEvidence( "F1_STAR" ) { result -> runOnUiThread { if (result.isSuccess) { val mediaId = result.getOrNull() toast( "Evidencia favorita registrada (ID $mediaId)" ) } else { toast( result.exceptionOrNull()?.message ?: "No se pudo marcar favorito" ) } } } true } KeyEvent.KEYCODE_F2 -> { handlePhotoButton() true } KeyEvent.KEYCODE_F3 -> { if (deviceController.isMicRecording()) { val result = deviceController.stopMicRecording() toast( if (result.isSuccess) "MIC: grabacion guardada" else "MIC error: ${result.exceptionOrNull()?.message}" ) } else { val result = deviceController.startMicRecording() toast( if (result.isSuccess) "MIC: grabando" else "MIC error: ${result.exceptionOrNull()?.message}" ) } true } KeyEvent.KEYCODE_F4 -> { true } KeyEvent.KEYCODE_F5 -> { // Algunas versiones del firmware tambien emiten ACTION_VIDEO_DOWN. // El ViewModel aplica debounce para evitar doble ejecucion. handleVideoButton() true } KeyEvent.KEYCODE_F7 -> { handleSosButton() true } else -> super.onKeyDown(keyCode, event) } } 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 -> { true } 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 sesion para usar VIDEO") return } startActivity( android.content.Intent( this, com.bodycamera.twentyfoulabs.camera.M530CameraActivity::class.java ).putExtra( com.bodycamera.twentyfoulabs.camera.M530CameraActivity.EXTRA_MODE, com.bodycamera.twentyfoulabs.camera.M530CameraActivity.MODE_VIDEO ).addFlags(android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP) ) } private fun openFrontPreview() { if (!isAuthenticated) return startActivity( android.content.Intent( this, com.bodycamera.twentyfoulabs.camera.M530CameraActivity::class.java ).putExtra( com.bodycamera.twentyfoulabs.camera.M530CameraActivity.EXTRA_MODE, com.bodycamera.twentyfoulabs.camera.M530CameraActivity.MODE_FRONT_PREVIEW ).addFlags(android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP) ) } private fun handlePhotoButton() { startActivity( android.content.Intent( this, com.bodycamera.twentyfoulabs.camera.M530CameraActivity::class.java ).addFlags(android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP) ) } private fun handleSosButton() { // F7 alterna SOS/DETENER. La misma orden de parada que usa la pantalla. if (viewModel.isSosSessionActive()) { Log.i(TAG, "F7 DETENER SOS: solicitando cierre de WebRTC") viewModel.stopVideoFromUi() toast("Deteniendo video SOS...") return } Log.i(TAG, "F7 INICIAR SOS") toast("Enviando SOS...") viewModel.triggerSosFromUi { 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() { if (isReceiverRegistered) return 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) { registerReceiver(hardwareReceiver, filter, RECEIVER_EXPORTED) } else { @Suppress("DEPRECATION") registerReceiver(hardwareReceiver, filter) } isReceiverRegistered = true } catch (t: Throwable) { Log.e(TAG, "No se pudo registrar receiver", t) } } private fun unregisterHardwareReceiver() { if (!isReceiverRegistered) return try { unregisterReceiver(hardwareReceiver) } catch (_: Throwable) {} isReceiverRegistered = false } override fun onResume() { super.onResume() hideSystemUI() registerHardwareReceiver() } override fun onPause() { super.onPause() // 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() } override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) if (hasFocus) hideSystemUI() } private fun hideSystemUI() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { window.setDecorFitsSystemWindows(false) window.insetsController?.let { controller -> controller.hide(android.view.WindowInsets.Type.statusBars()) controller.hide(android.view.WindowInsets.Type.navigationBars()) controller.systemBarsBehavior = android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } } else { @Suppress("DEPRECATION") window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION } } private fun requestPermissions() { val requested = listOf( Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO ).filter { ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED } if (requested.isNotEmpty()) permissionLauncher.launch(requested.toTypedArray()) } private fun toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_SHORT).show() } @Composable fun AppNavigation(viewModel: MainViewModel, onOpenPhotoCamera: () -> Unit, onOpenVideoCamera: () -> Unit) { val navController = rememberNavController() val navigationHandler = rememberNavigationHandler(navController) val authViewModel: AuthViewModel = hiltViewModel() val authState by authViewModel.state.collectAsState() LaunchedEffect(authState) { if (authState is AuthUiState.Authenticated && navController.currentDestination?.route != "main") { navController.navigate("main") { popUpTo("login") { inclusive = true } } } } CompositionLocalProvider(LocalNavigationHandler provides navigationHandler) { NavHost( navController = navController, startDestination = "login" ) { composable("login") { LoginScreen( state = authState, onLogin = authViewModel::login, onQrLogin = authViewModel::qrLogin, onRetrySession = authViewModel::restoreSession, onClearError = authViewModel::clearError ) } composable("main") { MainScreen( viewModel = viewModel, onNavigateToSettings = { navController.navigate("connection_settings") }, onNavigateToVideoConfig = { navController.navigate("video_config") }, onOpenPhotoCamera = onOpenPhotoCamera, onOpenVideoCamera = onOpenVideoCamera, onLogout = { // Liberar WebRTC antes de mostrar nuevamente el lector del fotocheck. viewModel.stopVideoFromUi() authViewModel.logout() navController.navigate("login") { popUpTo("main") { inclusive = true } } } ) } composable("connection_settings") { val settingsViewModel: ConnectionSettingsViewModel = hiltViewModel() ConnectionSettingsScreen(viewModel = settingsViewModel) } composable("video_config") { val videoConfigViewModel: VideoConfigViewModel = hiltViewModel() VideoConfigScreen(viewModel = videoConfigViewModel) } } } }