Initial version M530 COMMAND - photo video audio backend integration
This commit is contained in:
commit
5be04b0217
72 changed files with 8764 additions and 0 deletions
376
app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainActivity.kt
Normal file
376
app/src/main/kotlin/com/bodycamera/twentyfoulabs/MainActivity.kt
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue