refactor: replace Service-based sensor collection with direct Fragment sensor registration
Build APK / build (push) Failing after 4s
Build APK / build (push) Failing after 4s
Root cause analysis: the persistent crash on startup was caused by the Service + startForeground lifecycle complexity: 1. BIND_AUTO_CREATE creates Service in bound-only state 2. startForeground requires a started state (Android 12+) 3. Multiple permission checks and lifecycle edge cases Fix: Removed DataCollectionService dependency from DashboardFragment. The Fragment now registers SensorManager directly in onResume and unregisters in onPause. This: - Eliminates all Service lifecycle issues - No foreground notification required - No binding/startService ordering problems - Simpler and more reliable lifecycle management - Recording still uses DataRepository for file I/O Also added global uncaught exception handler in SensToolsApp that writes crash stack traces to Downloads/sensTools_crash.txt.
This commit is contained in:
@@ -1,16 +1,42 @@
|
||||
package com.senstools
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Environment
|
||||
import java.io.File
|
||||
import java.io.FileWriter
|
||||
|
||||
class SensToolsApp : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
|
||||
// Global crash handler - writes to Downloads/sensTools_crash.txt
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
try {
|
||||
val dir = Environment.getExternalStoragePublicDirectory(
|
||||
Environment.DIRECTORY_DOWNLOADS
|
||||
)
|
||||
dir.mkdirs()
|
||||
val crashFile = File(dir, "sensTools_crash.txt")
|
||||
FileWriter(crashFile, false).use { writer ->
|
||||
writer.appendLine("=== CRASH ===")
|
||||
writer.appendLine("Time: ${System.currentTimeMillis()}")
|
||||
writer.appendLine("Thread: ${thread.name}")
|
||||
writer.appendLine("Exception: ${throwable.javaClass.name}: ${throwable.message}")
|
||||
throwable.stackTrace?.forEach { writer.appendLine(" at $it") }
|
||||
writer.appendLine()
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Also call the default handler so the app still crashes normally
|
||||
prevHandler?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var instance: SensToolsApp
|
||||
private set
|
||||
private val prevHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
+194
-152
@@ -1,88 +1,68 @@
|
||||
package com.senstools.presentation.ui.dashboard
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.senstools.data.datasource.DataCollectionService
|
||||
import com.senstools.databinding.FragmentDashboardBinding
|
||||
import com.senstools.domain.ekf.EKFAttitude
|
||||
import com.senstools.domain.model.AttitudeData
|
||||
import com.senstools.domain.model.GPSData
|
||||
import com.senstools.domain.model.SensorData
|
||||
import com.senstools.domain.repository.DataRepository
|
||||
import com.senstools.presentation.ui.charts.AccelChartManager
|
||||
import com.senstools.presentation.ui.charts.GyroChartManager
|
||||
import com.senstools.presentation.ui.charts.MagnetometerBarManager
|
||||
import com.senstools.presentation.viewmodel.DashboardViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class DashboardFragment : Fragment() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DashboardFragment"
|
||||
}
|
||||
class DashboardFragment : Fragment(), SensorEventListener, LocationListener {
|
||||
|
||||
private var _binding: FragmentDashboardBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val viewModel: DashboardViewModel by viewModels()
|
||||
|
||||
private var collectionService: DataCollectionService? = null
|
||||
private var serviceBound = false
|
||||
private var sensorManager: SensorManager? = null
|
||||
private var locationManager: LocationManager? = null
|
||||
|
||||
// Sensor data holders
|
||||
private var lastAccel = floatArrayOf(0f, 0f, 0f)
|
||||
private var lastGyro = floatArrayOf(0f, 0f, 0f)
|
||||
private var lastMag = floatArrayOf(0f, 0f, 0f)
|
||||
private var lastPressureAlt = 0f
|
||||
private var lastTemp = 0f
|
||||
private var lastHumidity = 0f
|
||||
private var lastTimestamp = 0L
|
||||
|
||||
private var recordingFile: java.io.File? = null
|
||||
private var isRecording = false
|
||||
|
||||
private lateinit var repository: DataRepository
|
||||
private lateinit var ekfAttitude: EKFAttitude
|
||||
|
||||
// Chart managers
|
||||
private lateinit var accelChartManager: AccelChartManager
|
||||
private lateinit var gyroChartManager: GyroChartManager
|
||||
private lateinit var magnetometerManager: MagnetometerBarManager
|
||||
|
||||
private var ekfInitialized = false
|
||||
private var sensorsRegistered = false
|
||||
|
||||
private val connection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
Log.d(TAG, "onServiceConnected")
|
||||
val binder = service as DataCollectionService.LocalBinder
|
||||
collectionService = binder.getService()
|
||||
serviceBound = true
|
||||
|
||||
// Start the service so startForeground is legal on Android 12+
|
||||
try {
|
||||
val ctx = requireContext().applicationContext
|
||||
ctx.startService(Intent(ctx, DataCollectionService::class.java))
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "startService failed, trying direct collection", e)
|
||||
}
|
||||
|
||||
// Start collection
|
||||
try {
|
||||
collectionService?.startCollection()
|
||||
Log.d(TAG, "startCollection OK")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "startCollection failed", e)
|
||||
}
|
||||
|
||||
// Observe service flows
|
||||
observeServiceData()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
collectionService = null
|
||||
serviceBound = false
|
||||
}
|
||||
}
|
||||
private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
|
||||
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
@@ -99,27 +79,24 @@ class DashboardFragment : Fragment() {
|
||||
repository = DataRepository(requireContext())
|
||||
ekfAttitude = EKFAttitude()
|
||||
|
||||
sensorManager = requireContext().getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||
locationManager = requireContext().getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
|
||||
setupCharts()
|
||||
setupObservers()
|
||||
setupListeners()
|
||||
bindService()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (!serviceBound) {
|
||||
bindService()
|
||||
}
|
||||
registerSensors()
|
||||
registerLocation()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
if (serviceBound) {
|
||||
try {
|
||||
requireContext().unbindService(connection)
|
||||
} catch (_: Exception) { }
|
||||
serviceBound = false
|
||||
}
|
||||
unregisterSensors()
|
||||
unregisterLocation()
|
||||
}
|
||||
|
||||
private fun setupCharts() {
|
||||
@@ -128,74 +105,155 @@ class DashboardFragment : Fragment() {
|
||||
magnetometerManager = MagnetometerBarManager(binding.chartMagnetometer)
|
||||
}
|
||||
|
||||
private fun bindService() {
|
||||
private fun registerSensors() {
|
||||
if (sensorsRegistered) return
|
||||
val sm = sensorManager ?: return
|
||||
|
||||
sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)?.let {
|
||||
sm.registerListener(this, it, SensorManager.SENSOR_DELAY_GAME)
|
||||
}
|
||||
sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE)?.let {
|
||||
sm.registerListener(this, it, SensorManager.SENSOR_DELAY_GAME)
|
||||
}
|
||||
sm.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)?.let {
|
||||
sm.registerListener(this, it, SensorManager.SENSOR_DELAY_GAME)
|
||||
}
|
||||
sm.getDefaultSensor(Sensor.TYPE_PRESSURE)?.let {
|
||||
sm.registerListener(this, it, SensorManager.SENSOR_DELAY_GAME)
|
||||
}
|
||||
sm.getDefaultSensor(Sensor.TYPE_AMBIENT_TEMPERATURE)?.let {
|
||||
sm.registerListener(this, it, SensorManager.SENSOR_DELAY_GAME)
|
||||
}
|
||||
sm.getDefaultSensor(Sensor.TYPE_RELATIVE_HUMIDITY)?.let {
|
||||
sm.registerListener(this, it, SensorManager.SENSOR_DELAY_GAME)
|
||||
}
|
||||
|
||||
sensorsRegistered = true
|
||||
}
|
||||
|
||||
private fun unregisterSensors() {
|
||||
sensorManager?.unregisterListener(this)
|
||||
sensorsRegistered = false
|
||||
}
|
||||
|
||||
private fun registerLocation() {
|
||||
val lm = locationManager ?: return
|
||||
try {
|
||||
val intent = Intent(requireContext(), DataCollectionService::class.java)
|
||||
requireContext().bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "bindService failed", e)
|
||||
if (requireContext().checkSelfPermission(
|
||||
android.Manifest.permission.ACCESS_FINE_LOCATION
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
lm.requestLocationUpdates(
|
||||
LocationManager.GPS_PROVIDER,
|
||||
200L, 0f, this, null
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
private fun unregisterLocation() {
|
||||
try {
|
||||
locationManager?.removeUpdates(this)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
// SensorEventListener
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
lastTimestamp = timestamp
|
||||
|
||||
when (event.sensor.type) {
|
||||
Sensor.TYPE_ACCELEROMETER -> lastAccel = event.values.clone()
|
||||
Sensor.TYPE_GYROSCOPE -> lastGyro = event.values.clone()
|
||||
Sensor.TYPE_MAGNETIC_FIELD -> lastMag = event.values.clone()
|
||||
Sensor.TYPE_PRESSURE -> {
|
||||
lastPressureAlt = SensorManager.getAltitude(
|
||||
SensorManager.PRESSURE_STANDARD_ATMOSPHERE,
|
||||
event.values[0]
|
||||
)
|
||||
}
|
||||
Sensor.TYPE_AMBIENT_TEMPERATURE -> lastTemp = event.values[0]
|
||||
Sensor.TYPE_RELATIVE_HUMIDITY -> lastHumidity = event.values[0]
|
||||
}
|
||||
|
||||
val sensorData = SensorData(
|
||||
timestamp = timestamp,
|
||||
accelerometer = lastAccel.clone(),
|
||||
gyroscope = lastGyro.clone(),
|
||||
magnetometer = lastMag.clone(),
|
||||
pressureAltitude = lastPressureAlt,
|
||||
temperature = lastTemp,
|
||||
humidity = lastHumidity,
|
||||
time = timeFormat.format(Date(timestamp)),
|
||||
date = dateFormat.format(Date(timestamp))
|
||||
)
|
||||
viewModel.updateSensorData(sensorData)
|
||||
|
||||
updateEKF(sensorData)
|
||||
updateCharts(sensorData)
|
||||
|
||||
if (isRecording && recordingFile != null) {
|
||||
repository.appendSensorData(sensorData, recordingFile!!)
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeServiceData() {
|
||||
val service = collectionService ?: return
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) { }
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
launch {
|
||||
service.currentGPSData.collect { gps ->
|
||||
viewModel.updateGPSData(gps)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
service.currentSensorData.collect { sensor ->
|
||||
viewModel.updateSensorData(sensor)
|
||||
// LocationListener
|
||||
override fun onLocationChanged(location: Location) {
|
||||
val now = System.currentTimeMillis()
|
||||
val gps = GPSData(
|
||||
timestamp = now,
|
||||
longitude = location.longitude,
|
||||
latitude = location.latitude,
|
||||
altitude = location.altitude,
|
||||
speed = location.speed,
|
||||
bearing = location.bearing,
|
||||
satellites = 0,
|
||||
time = timeFormat.format(Date(now)),
|
||||
date = dateFormat.format(Date(now))
|
||||
)
|
||||
viewModel.updateGPSData(gps)
|
||||
|
||||
if (!ekfInitialized) {
|
||||
ekfAttitude.initializeFromSensors(
|
||||
sensor.accelerometer,
|
||||
sensor.magnetometer
|
||||
)
|
||||
ekfInitialized = true
|
||||
}
|
||||
|
||||
ekfAttitude.predict(
|
||||
sensor.gyroscope,
|
||||
sensor.timestamp
|
||||
)
|
||||
ekfAttitude.update(
|
||||
sensor.accelerometer,
|
||||
sensor.magnetometer
|
||||
)
|
||||
|
||||
val attitude = ekfAttitude.getAttitude()
|
||||
viewModel.updateAttitudeData(attitude)
|
||||
|
||||
accelChartManager.addData(
|
||||
sensor.accelerometer[0],
|
||||
sensor.accelerometer[1],
|
||||
sensor.accelerometer[2]
|
||||
)
|
||||
gyroChartManager.addData(
|
||||
sensor.gyroscope[0],
|
||||
sensor.gyroscope[1],
|
||||
sensor.gyroscope[2]
|
||||
)
|
||||
magnetometerManager.updateData(
|
||||
sensor.magnetometer[0],
|
||||
sensor.magnetometer[1],
|
||||
sensor.magnetometer[2]
|
||||
)
|
||||
|
||||
if (viewModel.isRecording.value == true && recordingFile != null) {
|
||||
repository.appendSensorData(sensor, recordingFile!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isRecording && recordingFile != null) {
|
||||
repository.appendGPSData(gps, recordingFile!!)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onProviderEnabled(provider: String) { }
|
||||
override fun onProviderDisabled(provider: String) { }
|
||||
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) { }
|
||||
|
||||
private fun updateEKF(sensor: SensorData) {
|
||||
if (!ekfInitialized) {
|
||||
ekfAttitude.initializeFromSensors(sensor.accelerometer, sensor.magnetometer)
|
||||
ekfInitialized = true
|
||||
} else {
|
||||
ekfAttitude.predict(sensor.gyroscope, sensor.timestamp)
|
||||
ekfAttitude.update(sensor.accelerometer, sensor.magnetometer)
|
||||
}
|
||||
val attitude = ekfAttitude.getAttitude()
|
||||
viewModel.updateAttitudeData(attitude)
|
||||
}
|
||||
|
||||
private fun updateCharts(sensor: SensorData) {
|
||||
accelChartManager.addData(
|
||||
sensor.accelerometer[0],
|
||||
sensor.accelerometer[1],
|
||||
sensor.accelerometer[2]
|
||||
)
|
||||
gyroChartManager.addData(
|
||||
sensor.gyroscope[0],
|
||||
sensor.gyroscope[1],
|
||||
sensor.gyroscope[2]
|
||||
)
|
||||
magnetometerManager.updateData(
|
||||
sensor.magnetometer[0],
|
||||
sensor.magnetometer[1],
|
||||
sensor.magnetometer[2]
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupObservers() {
|
||||
viewModel.gpsData.observe(viewLifecycleOwner) { gps ->
|
||||
binding.apply {
|
||||
@@ -205,10 +263,6 @@ class DashboardFragment : Fragment() {
|
||||
tvSpeed.text = String.format("%.2f m/s", gps.speed)
|
||||
tvBearing.text = String.format("%.2f°", gps.bearing)
|
||||
tvSatellites.text = gps.satellites.toString()
|
||||
|
||||
if (viewModel.isRecording.value == true && recordingFile != null) {
|
||||
repository.appendGPSData(gps, recordingFile!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,45 +291,33 @@ class DashboardFragment : Fragment() {
|
||||
tvYaw.text = String.format("%.2f°", attitude.yaw)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.isRecording.observe(viewLifecycleOwner) { isRecording ->
|
||||
binding.apply {
|
||||
btnRecord.isEnabled = !isRecording
|
||||
btnStopRecording.isEnabled = isRecording
|
||||
tvRecordingStatus.text = if (isRecording) "Recording..." else "Idle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
binding.btnRecord.setOnClickListener { startRecording() }
|
||||
binding.btnStopRecording.setOnClickListener { stopRecording() }
|
||||
}
|
||||
|
||||
private fun startRecording() {
|
||||
if (collectionService != null) {
|
||||
binding.btnRecord.setOnClickListener {
|
||||
isRecording = true
|
||||
viewModel.startRecording()
|
||||
recordingFile = repository.createRecordingFile()
|
||||
binding.btnRecord.isEnabled = false
|
||||
binding.btnStopRecording.isEnabled = true
|
||||
binding.tvRecordingStatus.text = "Recording..."
|
||||
Toast.makeText(requireContext(), "Recording started", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(requireContext(), "Service not ready", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRecording() {
|
||||
viewModel.stopRecording()
|
||||
recordingFile = null
|
||||
Toast.makeText(requireContext(), "Recording saved", Toast.LENGTH_SHORT).show()
|
||||
binding.btnStopRecording.setOnClickListener {
|
||||
isRecording = false
|
||||
viewModel.stopRecording()
|
||||
recordingFile = null
|
||||
binding.btnRecord.isEnabled = true
|
||||
binding.btnStopRecording.isEnabled = false
|
||||
binding.tvRecordingStatus.text = "Idle"
|
||||
Toast.makeText(requireContext(), "Recording saved", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
if (serviceBound) {
|
||||
try {
|
||||
requireContext().unbindService(connection)
|
||||
} catch (_: Exception) { }
|
||||
serviceBound = false
|
||||
}
|
||||
unregisterSensors()
|
||||
unregisterLocation()
|
||||
_binding = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user