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.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 @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 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}" ) } } } } 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() onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() = Unit }) lifecycleScope.launch { authSessionStore.token.collect { token -> isAuthenticated = !token.isNullOrBlank() if (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() { 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() { 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 = { 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) } } } }