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

377 lines
14 KiB
Kotlin
Raw Normal View History

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.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.collect
@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
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 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
}
}
}
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() }
}
registerHardwareReceiver()
deviceController.logTelemetry()
setContent {
MainAppTheme { 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 sesión 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 -> {
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.
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 -> {
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)
}
}
private fun handleVideoButton() {
if (!isAuthenticated) {
toast("Inicia sesión 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 handlePhotoButton() {
startActivity(
android.content.Intent(
this,
com.bodycamera.twentyfoulabs.camera.M530CameraActivity::class.java
).addFlags(android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP)
)
}
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)
}
private fun registerHardwareReceiver() {
if (isReceiverRegistered) return
val filter = IntentFilter().apply {
addAction(ACTION_VIDEO_DOWN)
addAction(ACTION_VIDEO_UP)
}
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()
unregisterHardwareReceiver()
}
override fun onDestroy() {
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,
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)
}
}
}
}