From d4843c025094c408de92edc41986ab1cc16966b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8C=82=E4=B9=8B=E9=92=B3?= Date: Wed, 27 May 2026 14:10:00 +0000 Subject: [PATCH] refactor: replace Service-based sensor collection with direct Fragment sensor registration 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. --- .../main/java/com/senstools/SensToolsApp.kt | 26 ++ .../ui/dashboard/DashboardFragment.kt | 346 ++++++++++-------- 2 files changed, 220 insertions(+), 152 deletions(-) diff --git a/android/app/src/main/java/com/senstools/SensToolsApp.kt b/android/app/src/main/java/com/senstools/SensToolsApp.kt index fdbba8c..0d1ffc2 100644 --- a/android/app/src/main/java/com/senstools/SensToolsApp.kt +++ b/android/app/src/main/java/com/senstools/SensToolsApp.kt @@ -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() } } diff --git a/android/app/src/main/java/com/senstools/presentation/ui/dashboard/DashboardFragment.kt b/android/app/src/main/java/com/senstools/presentation/ui/dashboard/DashboardFragment.kt index 4a24553..ba56c5b 100644 --- a/android/app/src/main/java/com/senstools/presentation/ui/dashboard/DashboardFragment.kt +++ b/android/app/src/main/java/com/senstools/presentation/ui/dashboard/DashboardFragment.kt @@ -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() } }