refactor: replace Service-based sensor collection with direct Fragment sensor registration
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:
茂之钳
2026-05-27 14:10:00 +00:00
parent 1a554b10a2
commit d4843c0250
2 changed files with 220 additions and 152 deletions
@@ -1,16 +1,42 @@
package com.senstools package com.senstools
import android.app.Application import android.app.Application
import android.os.Environment
import java.io.File
import java.io.FileWriter
class SensToolsApp : Application() { class SensToolsApp : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
instance = this 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 { companion object {
lateinit var instance: SensToolsApp lateinit var instance: SensToolsApp
private set private set
private val prevHandler = Thread.getDefaultUncaughtExceptionHandler()
} }
} }
@@ -1,88 +1,68 @@
package com.senstools.presentation.ui.dashboard package com.senstools.presentation.ui.dashboard
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.hardware.Sensor
import android.content.ServiceConnection 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.Bundle
import android.os.IBinder
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels 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.databinding.FragmentDashboardBinding
import com.senstools.domain.ekf.EKFAttitude 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.domain.repository.DataRepository
import com.senstools.presentation.ui.charts.AccelChartManager import com.senstools.presentation.ui.charts.AccelChartManager
import com.senstools.presentation.ui.charts.GyroChartManager import com.senstools.presentation.ui.charts.GyroChartManager
import com.senstools.presentation.ui.charts.MagnetometerBarManager import com.senstools.presentation.ui.charts.MagnetometerBarManager
import com.senstools.presentation.viewmodel.DashboardViewModel 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() { class DashboardFragment : Fragment(), SensorEventListener, LocationListener {
companion object {
private const val TAG = "DashboardFragment"
}
private var _binding: FragmentDashboardBinding? = null private var _binding: FragmentDashboardBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
private val viewModel: DashboardViewModel by viewModels() private val viewModel: DashboardViewModel by viewModels()
private var collectionService: DataCollectionService? = null private var sensorManager: SensorManager? = null
private var serviceBound = false 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 recordingFile: java.io.File? = null
private var isRecording = false
private lateinit var repository: DataRepository private lateinit var repository: DataRepository
private lateinit var ekfAttitude: EKFAttitude private lateinit var ekfAttitude: EKFAttitude
// Chart managers
private lateinit var accelChartManager: AccelChartManager private lateinit var accelChartManager: AccelChartManager
private lateinit var gyroChartManager: GyroChartManager private lateinit var gyroChartManager: GyroChartManager
private lateinit var magnetometerManager: MagnetometerBarManager private lateinit var magnetometerManager: MagnetometerBarManager
private var ekfInitialized = false private var ekfInitialized = false
private var sensorsRegistered = false
private val connection = object : ServiceConnection { private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
override fun onServiceConnected(name: ComponentName?, service: IBinder?) { private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
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
}
}
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@@ -99,27 +79,24 @@ class DashboardFragment : Fragment() {
repository = DataRepository(requireContext()) repository = DataRepository(requireContext())
ekfAttitude = EKFAttitude() ekfAttitude = EKFAttitude()
sensorManager = requireContext().getSystemService(Context.SENSOR_SERVICE) as SensorManager
locationManager = requireContext().getSystemService(Context.LOCATION_SERVICE) as LocationManager
setupCharts() setupCharts()
setupObservers() setupObservers()
setupListeners() setupListeners()
bindService()
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
if (!serviceBound) { registerSensors()
bindService() registerLocation()
}
} }
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
if (serviceBound) { unregisterSensors()
try { unregisterLocation()
requireContext().unbindService(connection)
} catch (_: Exception) { }
serviceBound = false
}
} }
private fun setupCharts() { private fun setupCharts() {
@@ -128,74 +105,155 @@ class DashboardFragment : Fragment() {
magnetometerManager = MagnetometerBarManager(binding.chartMagnetometer) 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 { try {
val intent = Intent(requireContext(), DataCollectionService::class.java) if (requireContext().checkSelfPermission(
requireContext().bindService(intent, connection, Context.BIND_AUTO_CREATE) android.Manifest.permission.ACCESS_FINE_LOCATION
} catch (e: Exception) { ) == android.content.pm.PackageManager.PERMISSION_GRANTED
Log.e(TAG, "bindService failed", e) ) {
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() { override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) { }
val service = collectionService ?: return
viewLifecycleOwner.lifecycleScope.launch { // LocationListener
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { override fun onLocationChanged(location: Location) {
launch { val now = System.currentTimeMillis()
service.currentGPSData.collect { gps -> val gps = GPSData(
viewModel.updateGPSData(gps) timestamp = now,
} longitude = location.longitude,
} latitude = location.latitude,
launch { altitude = location.altitude,
service.currentSensorData.collect { sensor -> speed = location.speed,
viewModel.updateSensorData(sensor) bearing = location.bearing,
satellites = 0,
time = timeFormat.format(Date(now)),
date = dateFormat.format(Date(now))
)
viewModel.updateGPSData(gps)
if (!ekfInitialized) { if (isRecording && recordingFile != null) {
ekfAttitude.initializeFromSensors( repository.appendGPSData(gps, recordingFile!!)
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!!)
}
}
}
}
} }
} }
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() { private fun setupObservers() {
viewModel.gpsData.observe(viewLifecycleOwner) { gps -> viewModel.gpsData.observe(viewLifecycleOwner) { gps ->
binding.apply { binding.apply {
@@ -205,10 +263,6 @@ class DashboardFragment : Fragment() {
tvSpeed.text = String.format("%.2f m/s", gps.speed) tvSpeed.text = String.format("%.2f m/s", gps.speed)
tvBearing.text = String.format("%.2f°", gps.bearing) tvBearing.text = String.format("%.2f°", gps.bearing)
tvSatellites.text = gps.satellites.toString() 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) 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() { private fun setupListeners() {
binding.btnRecord.setOnClickListener { startRecording() } binding.btnRecord.setOnClickListener {
binding.btnStopRecording.setOnClickListener { stopRecording() } isRecording = true
}
private fun startRecording() {
if (collectionService != null) {
viewModel.startRecording() viewModel.startRecording()
recordingFile = repository.createRecordingFile() recordingFile = repository.createRecordingFile()
binding.btnRecord.isEnabled = false
binding.btnStopRecording.isEnabled = true
binding.tvRecordingStatus.text = "Recording..."
Toast.makeText(requireContext(), "Recording started", Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), "Recording started", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(requireContext(), "Service not ready", Toast.LENGTH_SHORT).show()
} }
} binding.btnStopRecording.setOnClickListener {
isRecording = false
private fun stopRecording() { viewModel.stopRecording()
viewModel.stopRecording() recordingFile = null
recordingFile = null binding.btnRecord.isEnabled = true
Toast.makeText(requireContext(), "Recording saved", Toast.LENGTH_SHORT).show() binding.btnStopRecording.isEnabled = false
binding.tvRecordingStatus.text = "Idle"
Toast.makeText(requireContext(), "Recording saved", Toast.LENGTH_SHORT).show()
}
} }
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() unregisterSensors()
if (serviceBound) { unregisterLocation()
try {
requireContext().unbindService(connection)
} catch (_: Exception) { }
serviceBound = false
}
_binding = null _binding = null
super.onDestroyView()
} }
} }