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
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()
}
}
@@ -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,49 +105,138 @@ 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 ->
// 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)
}
}
launch {
service.currentSensorData.collect { sensor ->
viewModel.updateSensorData(sensor)
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
)
ekfAttitude.initializeFromSensors(sensor.accelerometer, sensor.magnetometer)
ekfInitialized = true
} else {
ekfAttitude.predict(sensor.gyroscope, sensor.timestamp)
ekfAttitude.update(sensor.accelerometer, sensor.magnetometer)
}
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],
@@ -186,14 +252,6 @@ class DashboardFragment : Fragment() {
sensor.magnetometer[1],
sensor.magnetometer[2]
)
if (viewModel.isRecording.value == true && recordingFile != null) {
repository.appendSensorData(sensor, recordingFile!!)
}
}
}
}
}
}
private fun setupObservers() {
@@ -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() {
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()
}
}