fix: move startForeground to onStartCommand only, use applicationContext
Build APK / build (push) Failing after 4s

- startForeground now only happens in onStartCommand (Service started state)
- On serviceConnected, calls startService first then startCollection
- startCollection no longer calls startForeground (separate concerns)
- Regression: DashboardFragment.onServiceConnected now gracefully handles
  startForeground lifecycle requirements on Android 12+
This commit is contained in:
茂之钳
2026-05-27 07:55:11 +00:00
parent c643217217
commit 1a554b10a2
2 changed files with 119 additions and 182 deletions
@@ -87,6 +87,11 @@ class DataCollectionService : Service(), SensorEventListener, LocationListener {
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Only called when service is started via startService().
// On Android 12+, startForeground requires the service to be in started state.
// But for our use case (sensor-only, no notification needed when bound),
// we only startForeground here if we need it.
startForeground(NOTIFICATION_ID, createNotification())
return START_STICKY return START_STICKY
} }
@@ -25,7 +25,6 @@ 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 kotlinx.coroutines.launch
import java.io.FileWriter
class DashboardFragment : Fragment() { class DashboardFragment : Fragment() {
@@ -54,29 +53,29 @@ class DashboardFragment : Fragment() {
private val connection = object : ServiceConnection { private val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) { 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 { try {
Log.d(TAG, "onServiceConnected") val ctx = requireContext().applicationContext
val binder = service as DataCollectionService.LocalBinder ctx.startService(Intent(ctx, DataCollectionService::class.java))
collectionService = binder.getService()
serviceBound = true
// Must startService first to make the service a foreground started service
// (required by Android 12+ for startForeground to work)
val intent = Intent(requireContext(), DataCollectionService::class.java)
requireContext().startService(intent)
Log.d(TAG, "startService called")
// Auto-start collection as soon as service is bound
collectionService?.startCollection()
Log.d(TAG, "startCollection called")
// Observe service flows
observeServiceData()
Log.d(TAG, "observeServiceData called")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error in onServiceConnected", e) Log.e(TAG, "startService failed, trying direct collection", e)
writeCrashLog("onServiceConnected", 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?) { override fun onServiceDisconnected(name: ComponentName?) {
@@ -90,42 +89,26 @@ class DashboardFragment : Fragment() {
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
try { _binding = FragmentDashboardBinding.inflate(inflater, container, false)
_binding = FragmentDashboardBinding.inflate(inflater, container, false)
} catch (e: Exception) {
Log.e(TAG, "Error inflating layout", e)
writeCrashLog("onCreateView", e)
throw e
}
return binding.root return binding.root
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
try {
repository = DataRepository(requireContext())
ekfAttitude = EKFAttitude()
setupCharts() repository = DataRepository(requireContext())
setupObservers() ekfAttitude = EKFAttitude()
setupListeners()
bindService() setupCharts()
Log.d(TAG, "onViewCreated complete") setupObservers()
} catch (e: Exception) { setupListeners()
Log.e(TAG, "Error in onViewCreated", e) bindService()
writeCrashLog("onViewCreated", e)
}
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
try { if (!serviceBound) {
if (!serviceBound) { bindService()
bindService()
}
} catch (e: Exception) {
Log.e(TAG, "Error in onResume", e)
writeCrashLog("onResume", e)
} }
} }
@@ -140,172 +123,137 @@ class DashboardFragment : Fragment() {
} }
private fun setupCharts() { private fun setupCharts() {
try { accelChartManager = AccelChartManager(binding.chartAccel)
accelChartManager = AccelChartManager(binding.chartAccel) gyroChartManager = GyroChartManager(binding.chartGyro)
gyroChartManager = GyroChartManager(binding.chartGyro) magnetometerManager = MagnetometerBarManager(binding.chartMagnetometer)
magnetometerManager = MagnetometerBarManager(binding.chartMagnetometer)
} catch (e: Exception) {
Log.e(TAG, "Error setting up charts", e)
writeCrashLog("setupCharts", e)
}
} }
private fun bindService() { private fun bindService() {
try { try {
val intent = Intent(requireContext(), DataCollectionService::class.java) val intent = Intent(requireContext(), DataCollectionService::class.java)
requireContext().bindService(intent, connection, Context.BIND_AUTO_CREATE) requireContext().bindService(intent, connection, Context.BIND_AUTO_CREATE)
Log.d(TAG, "bindService called")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error binding service", e) Log.e(TAG, "bindService failed", e)
writeCrashLog("bindService", e)
} }
} }
private fun observeServiceData() { private fun observeServiceData() {
val service = collectionService ?: return val service = collectionService ?: return
try { viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { launch {
launch { service.currentGPSData.collect { gps ->
service.currentGPSData.collect { gps -> viewModel.updateGPSData(gps)
viewModel.updateGPSData(gps)
}
} }
launch { }
service.currentSensorData.collect { sensor -> launch {
viewModel.updateSensorData(sensor) service.currentSensorData.collect { sensor ->
viewModel.updateSensorData(sensor)
// EKF update if (!ekfInitialized) {
if (!ekfInitialized) { ekfAttitude.initializeFromSensors(
ekfAttitude.initializeFromSensors(
sensor.accelerometer,
sensor.magnetometer
)
ekfInitialized = true
}
ekfAttitude.predict(
sensor.gyroscope,
sensor.timestamp
)
ekfAttitude.update(
sensor.accelerometer, sensor.accelerometer,
sensor.magnetometer sensor.magnetometer
) )
ekfInitialized = true
}
val attitude = ekfAttitude.getAttitude() ekfAttitude.predict(
viewModel.updateAttitudeData(attitude) sensor.gyroscope,
sensor.timestamp
)
ekfAttitude.update(
sensor.accelerometer,
sensor.magnetometer
)
// Update charts val attitude = ekfAttitude.getAttitude()
accelChartManager.addData( viewModel.updateAttitudeData(attitude)
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 recording, buffer the sensor data accelChartManager.addData(
if (viewModel.isRecording.value == true && recordingFile != null) { sensor.accelerometer[0],
repository.appendSensorData(sensor, recordingFile!!) 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!!)
} }
} }
} }
} }
} catch (e: Exception) {
Log.e(TAG, "Error in observeServiceData", e)
writeCrashLog("observeServiceData", e)
} }
} }
private fun setupObservers() { private fun setupObservers() {
try { viewModel.gpsData.observe(viewLifecycleOwner) { gps ->
viewModel.gpsData.observe(viewLifecycleOwner) { gps -> binding.apply {
binding.apply { tvLongitude.text = String.format("%.8f", gps.longitude)
tvLongitude.text = String.format("%.8f", gps.longitude) tvLatitude.text = String.format("%.8f", gps.latitude)
tvLatitude.text = String.format("%.8f", gps.latitude) tvAltitude.text = String.format("%.2f m", gps.altitude)
tvAltitude.text = String.format("%.2f m", gps.altitude) 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) { if (viewModel.isRecording.value == true && recordingFile != null) {
repository.appendGPSData(gps, recordingFile!!) repository.appendGPSData(gps, recordingFile!!)
} }
}
} }
}
viewModel.sensorData.observe(viewLifecycleOwner) { sensor -> viewModel.sensorData.observe(viewLifecycleOwner) { sensor ->
binding.apply { binding.apply {
tvAccelX.text = String.format("%.2f", sensor.accelerometer[0]) tvAccelX.text = String.format("%.2f", sensor.accelerometer[0])
tvAccelY.text = String.format("%.2f", sensor.accelerometer[1]) tvAccelY.text = String.format("%.2f", sensor.accelerometer[1])
tvAccelZ.text = String.format("%.2f", sensor.accelerometer[2]) tvAccelZ.text = String.format("%.2f", sensor.accelerometer[2])
tvGyroX.text = String.format("%.2f", sensor.gyroscope[0]) tvGyroX.text = String.format("%.2f", sensor.gyroscope[0])
tvGyroY.text = String.format("%.2f", sensor.gyroscope[1]) tvGyroY.text = String.format("%.2f", sensor.gyroscope[1])
tvGyroZ.text = String.format("%.2f", sensor.gyroscope[2]) tvGyroZ.text = String.format("%.2f", sensor.gyroscope[2])
tvMagX.text = String.format("%.2f", sensor.magnetometer[0]) tvMagX.text = String.format("%.2f", sensor.magnetometer[0])
tvMagY.text = String.format("%.2f", sensor.magnetometer[1]) tvMagY.text = String.format("%.2f", sensor.magnetometer[1])
tvMagZ.text = String.format("%.2f", sensor.magnetometer[2]) tvMagZ.text = String.format("%.2f", sensor.magnetometer[2])
tvPressure.text = String.format("%.2f m", sensor.pressureAltitude) tvPressure.text = String.format("%.2f m", sensor.pressureAltitude)
tvTemperature.text = String.format("%.2f°C", sensor.temperature) tvTemperature.text = String.format("%.2f°C", sensor.temperature)
tvHumidity.text = String.format("%.1f%%", sensor.humidity) tvHumidity.text = String.format("%.1f%%", sensor.humidity)
}
} }
}
viewModel.attitudeData.observe(viewLifecycleOwner) { attitude -> viewModel.attitudeData.observe(viewLifecycleOwner) { attitude ->
binding.apply { binding.apply {
artificialHorizon.setAttitude(attitude.pitch, attitude.roll) artificialHorizon.setAttitude(attitude.pitch, attitude.roll)
tvPitch.text = String.format("%.2f°", attitude.pitch) tvPitch.text = String.format("%.2f°", attitude.pitch)
tvRoll.text = String.format("%.2f°", attitude.roll) tvRoll.text = String.format("%.2f°", attitude.roll)
tvYaw.text = String.format("%.2f°", attitude.yaw) tvYaw.text = String.format("%.2f°", attitude.yaw)
}
} }
}
viewModel.isRecording.observe(viewLifecycleOwner) { isRecording -> viewModel.isRecording.observe(viewLifecycleOwner) { isRecording ->
binding.apply { binding.apply {
btnRecord.isEnabled = !isRecording btnRecord.isEnabled = !isRecording
btnStopRecording.isEnabled = isRecording btnStopRecording.isEnabled = isRecording
tvRecordingStatus.text = if (isRecording) "Recording..." else "Idle" tvRecordingStatus.text = if (isRecording) "Recording..." else "Idle"
}
} }
} catch (e: Exception) {
Log.e(TAG, "Error in setupObservers", e)
writeCrashLog("setupObservers", e)
} }
} }
private fun setupListeners() { private fun setupListeners() {
binding.btnRecord.setOnClickListener { binding.btnRecord.setOnClickListener { startRecording() }
try { binding.btnStopRecording.setOnClickListener { stopRecording() }
startRecording()
} catch (e: Exception) {
Log.e(TAG, "Error in btnRecord click", e)
writeCrashLog("btnRecord", e)
}
}
binding.btnStopRecording.setOnClickListener {
try {
stopRecording()
} catch (e: Exception) {
Log.e(TAG, "Error in btnStop click", e)
writeCrashLog("btnStop", e)
}
}
} }
private fun startRecording() { private fun startRecording() {
val service = collectionService if (collectionService != null) {
if (service != null) {
viewModel.startRecording() viewModel.startRecording()
recordingFile = repository.createRecordingFile() recordingFile = repository.createRecordingFile()
Toast.makeText(requireContext(), "Recording started", Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), "Recording started", Toast.LENGTH_SHORT).show()
@@ -320,22 +268,6 @@ class DashboardFragment : Fragment() {
Toast.makeText(requireContext(), "Recording saved", Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), "Recording saved", Toast.LENGTH_SHORT).show()
} }
private fun writeCrashLog(location: String, e: Exception) {
try {
val logFile = java.io.File(
requireContext().getExternalFilesDir(null),
"sensTools_crash.log"
)
FileWriter(logFile, true).use { writer ->
writer.appendLine("=== Crash at $location ===")
writer.appendLine("Time: ${System.currentTimeMillis()}")
writer.appendLine("Exception: ${e.javaClass.name}: ${e.message}")
e.stackTrace?.forEach { writer.appendLine(" at $it") }
writer.appendLine()
}
} catch (_: Exception) { }
}
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
if (serviceBound) { if (serviceBound) {