m530-command-android/app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainActivity.kt

669 lines
22 KiB
Kotlin

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)
}
}
}
}