tracpulse/docs/superpowers/plans/2026-04-30-traccar-client-bugfixes.md
fiatcode bbd51d1c35
fix: critical bugs preventing location reporting to server
- Send config to native before starting tracking (SharedPreferences sync)
- Register ConnectivityReceiver for network state detection
- Fix Settings number fields using TextEditingControllers
- Cancel stream subscription on widget dispose (memory leak)
- Replace WorkManager with AlarmManager for heartbeat
- Add log cleanup for logs older than 24 hours
- Change HTTP method from GET to POST

These fixes resolve the 'device always offline' issue where:
1. Config was not being sent to native service
2. ConnectivityReceiver was never registered
3. Settings number fields were not saving
4. Heartbeat never fired due to WorkManager process isolation
5. Server expected POST not GET
2026-04-30 15:38:24 +07:00

21 KiB

Traccar Client Bug Fixes Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Fix all critical bugs preventing location data from reaching the Traccar server, plus important memory leaks and usability issues.

Architecture: The app uses Flutter for UI with a native Kotlin service for background location tracking. Flutter communicates with native via MethodChannel/EventChannel. Settings are stored in SharedPreferences (Flutter side) and must be synced to native SharedPreferences before starting the tracking service.

Tech Stack: Flutter 3.11+, Kotlin, Android FusedLocationProvider, Room Database, OkHttp


File Structure

Files to Modify

File Responsibility Tasks
lib/main_screen.dart Main UI, tracking toggle Task 1, 4
lib/settings_screen.dart Settings form Task 3
lib/bridge/location_bridge.dart Flutter-Native bridge Task 1
android/.../service/LocationTrackingService.kt Background tracking service Task 2, 5, 6
android/.../location/HeartbeatScheduler.kt Heartbeat scheduling Task 5
android/.../storage/EventLogDao.kt Event log database Task 6

Task 1: Fix SharedPreferences Sync - Send Config Before Starting Tracking

Problem: When tracking starts, the native service reads from empty SharedPreferences and uses default https://demo.traccar.org instead of the user's configured server.

Files:

  • Modify: lib/main_screen.dart:74-98

  • Modify: lib/bridge/location_bridge.dart:15-25

  • Reference: lib/preferences.dart (read current values)

  • Step 1: Modify LocationBridge.startTracking to accept config parameter

Edit lib/bridge/location_bridge.dart:

static Future<bool> startTracking({Map<String, dynamic>? config}) async {
  debugPrint('LocationBridge.startTracking called with config: $config');
  try {
    // First sync config to native if provided
    if (config != null) {
      await _methodChannel.invokeMethod('updateConfig', config);
    }
    final result = await _methodChannel.invokeMethod<bool>('startTracking');
    debugPrint('LocationBridge.startTracking result: $result');
    return result ?? false;
  } on PlatformException catch (e) {
    debugPrint('LocationBridge.startTracking failed: ${e.message}');
    return false;
  }
}
  • Step 2: Update main_screen.dart to pass current config when starting tracking

Edit lib/main_screen.dart, replace the _toggleTracking method:

Future<void> _toggleTracking() async {
  final previousState = _isTracking;
  final newState = !_isTracking;

  setState(() {
    _isTracking = newState;
  });

  bool success;
  if (newState) {
    // Send current config to native before starting
    final config = {
      'serverUrl': Preferences.serverUrl,
      'deviceId': Preferences.deviceId,
      'accuracy': Preferences.accuracy,
      'distanceFilter': Preferences.distanceFilter,
      'interval': Preferences.interval,
      'heartbeat': Preferences.heartbeat,
      'offlineBuffer': Preferences.offlineBuffer,
      'stopDetection': Preferences.stopDetection,
    };
    success = await LocationBridge.startTracking(config: config);
  } else {
    success = await LocationBridge.stopTracking();
  }

  if (!success && mounted) {
    setState(() {
      _isTracking = previousState;
    });
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(previousState ? 'Failed to stop tracking' : 'Failed to start tracking'),
      ),
    );
  }
}
  • Step 3: Add import for Preferences at top of main_screen.dart

Edit lib/main_screen.dart, add import after line 1:

import 'package:flutter/material.dart';
import 'package:traccar_client/bridge/location_bridge.dart';
import 'package:traccar_client/preferences.dart';
import 'package:traccar_client/settings_screen.dart';
import 'package:traccar_client/status_screen.dart';
  • Step 4: Test manually
  1. Set your server URL in Settings
  2. Tap "Start Tracking"
  3. Check Status/Logs screen for "SYNC" entries
  4. Check your Traccar server - device should now appear online
  • Step 5: Commit
git add lib/main_screen.dart lib/bridge/location_bridge.dart
git commit -m "fix: sync config to native before starting tracking

The native service was reading from empty SharedPreferences and using
the default demo.traccar.org server instead of the user's configured
server URL. Now we explicitly send the config via MethodChannel before
starting the tracking service."

Task 2: Fix ConnectivityReceiver Registration

Problem: The ConnectivityReceiver is created but never registered with the system, so network state changes are never detected.

Files:

  • Modify: android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt:63-78 and 102-106

  • Step 1: Add IntentFilter import at top of LocationTrackingService.kt

Edit android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt, add import after line 9:

import android.content.IntentFilter
import android.net.ConnectivityManager
  • Step 2: Register the ConnectivityReceiver in onCreate()

Edit android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt, replace lines 71-78:

        connectivityReceiver = ConnectivityReceiver { online ->
            isNetworkAvailable = online
            logEvent("NETWORK_CHANGE", "Network: ${if (online) "online" else "offline"}")
            if (online) syncBufferedLocations()
        }
        
        // Register the connectivity receiver
        val intentFilter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)
        registerReceiver(connectivityReceiver, intentFilter)
        
        // Initialize network state
        isNetworkAvailable = ConnectivityReceiver.isNetworkAvailable(this)

        createNotificationChannel()
  • Step 3: Unregister the ConnectivityReceiver in onDestroy()

Edit android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt, replace lines 102-106:

    override fun onDestroy() {
        super.onDestroy()
        try {
            unregisterReceiver(connectivityReceiver)
        } catch (e: IllegalArgumentException) {
            // Receiver was not registered
            Log.w(TAG, "ConnectivityReceiver was not registered", e)
        }
        stopTracking()
        serviceScope.cancel()
    }
  • Step 4: Build and verify no compile errors

Run:

cd android && ./gradlew assembleDebug

Expected: BUILD SUCCESSFUL

  • Step 5: Commit
git add android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt
git commit -m "fix: register ConnectivityReceiver for network state detection

The ConnectivityReceiver was created but never registered with the
system. Now it properly registers in onCreate() and unregisters in
onDestroy(). Also initializes the network state on startup."

Task 3: Fix Settings Number Fields Not Saving

Problem: TextFormField with initialValue creates an internal controller. The onSaved callbacks require form.save() which is never called, so user edits to Distance Filter, Interval, and Heartbeat are ignored.

Files:

  • Modify: lib/settings_screen.dart

  • Step 1: Add TextEditingControllers for number fields

Edit lib/settings_screen.dart, replace the class state variables (lines 12-20):

class _SettingsScreenState extends State<SettingsScreen> {
  late TextEditingController _serverUrlController;
  late TextEditingController _deviceIdController;
  late TextEditingController _distanceFilterController;
  late TextEditingController _intervalController;
  late TextEditingController _heartbeatController;
  late int _accuracy;
  late bool _offlineBuffer;
  late bool _stopDetection;
  • Step 2: Initialize the new controllers in initState()

Edit lib/settings_screen.dart, replace initState() (lines 22-33):

  @override
  void initState() {
    super.initState();
    _serverUrlController = TextEditingController(text: Preferences.serverUrl);
    _deviceIdController = TextEditingController(text: Preferences.deviceId);
    _distanceFilterController = TextEditingController(text: Preferences.distanceFilter.toString());
    _intervalController = TextEditingController(text: Preferences.interval.toString());
    _heartbeatController = TextEditingController(text: Preferences.heartbeat.toString());
    _accuracy = Preferences.accuracy;
    _offlineBuffer = Preferences.offlineBuffer;
    _stopDetection = Preferences.stopDetection;
  }
  • Step 3: Dispose the new controllers

Edit lib/settings_screen.dart, replace dispose() (lines 35-40):

  @override
  void dispose() {
    _serverUrlController.dispose();
    _deviceIdController.dispose();
    _distanceFilterController.dispose();
    _intervalController.dispose();
    _heartbeatController.dispose();
    super.dispose();
  }
  • Step 4: Update _saveSettings() to read from controllers

Edit lib/settings_screen.dart, replace _saveSettings() (lines 42-68):

  Future<void> _saveSettings() async {
    // Parse number fields with validation
    final distanceFilter = int.tryParse(_distanceFilterController.text) ?? 75;
    final interval = int.tryParse(_intervalController.text) ?? 300;
    final heartbeat = int.tryParse(_heartbeatController.text) ?? 60;

    await Preferences.setServerUrl(_serverUrlController.text);
    await Preferences.setDeviceId(_deviceIdController.text);
    await Preferences.setAccuracy(_accuracy);
    await Preferences.setDistanceFilter(distanceFilter);
    await Preferences.setInterval(interval);
    await Preferences.setHeartbeat(heartbeat);
    await Preferences.setOfflineBuffer(_offlineBuffer);
    await Preferences.setStopDetection(_stopDetection);

    await LocationBridge.updateConfig({
      'serverUrl': _serverUrlController.text,
      'deviceId': _deviceIdController.text,
      'accuracy': _accuracy,
      'distanceFilter': distanceFilter,
      'interval': interval,
      'heartbeat': heartbeat,
      'offlineBuffer': _offlineBuffer,
      'stopDetection': _stopDetection,
    });

    if (mounted) {
      ScaffoldMessenger.of(
        context,
      ).showSnackBar(const SnackBar(content: Text('Settings saved')));
    }
  }
  • Step 5: Replace _buildNumberField() to use controllers

Edit lib/settings_screen.dart, replace _buildNumberField() (lines 149-174):

  Widget _buildNumberField(String label, TextEditingController controller, int min, int max) {
    return TextField(
      controller: controller,
      decoration: InputDecoration(labelText: label),
      keyboardType: TextInputType.number,
    );
  }
  • Step 6: Update the build() method to pass controllers instead of values

Edit lib/settings_screen.dart, replace lines 83-85 in build():

          _buildNumberField('Distance Filter (m)', _distanceFilterController, 0, 1000),
          _buildNumberField('Update Interval (s)', _intervalController, 30, 3600),
          _buildNumberField('Heartbeat (s)', _heartbeatController, 60, 3600),
  • Step 7: Test manually
  1. Open Settings
  2. Change Distance Filter to 100
  3. Change Interval to 60
  4. Tap Save Settings
  5. Close and reopen Settings
  6. Verify the values are still 100 and 60
  • Step 8: Commit
git add lib/settings_screen.dart
git commit -m "fix: settings number fields now properly save user edits

Previously, TextFormField with initialValue created an internal
controller and onSaved was never called because form.save() wasn't
invoked. Now using explicit TextEditingControllers that are read
directly in _saveSettings()."

Task 4: Fix Memory Leak - Stream Subscription Never Cancelled

Problem: The location updates stream subscription is never cancelled when the widget is disposed, causing a memory leak.

Files:

  • Modify: lib/main_screen.dart

  • Step 1: Add StreamSubscription import and field

Edit lib/main_screen.dart, add import at top and field to state class:

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:traccar_client/bridge/location_bridge.dart';
import 'package:traccar_client/preferences.dart';
import 'package:traccar_client/settings_screen.dart';
import 'package:traccar_client/status_screen.dart';

class MainScreen extends StatefulWidget {
  const MainScreen({super.key});

  @override
  State<MainScreen> createState() => _MainScreenState();
}

class _MainScreenState extends State<MainScreen> {
  bool _isTracking = false;
  String _lastLat = '--';
  String _lastLon = '--';
  String _lastSpeed = '--';
  String _lastTime = '--';
  StreamSubscription<Map<String, dynamic>>? _locationSubscription;
  • Step 2: Store the subscription in _initLocationStream()

Edit lib/main_screen.dart, replace lines 52-67:

    _locationSubscription = LocationBridge.locationUpdates.listen((location) {
      if (mounted) {
        setState(() {
          _lastLat = location['latitude']?.toStringAsFixed(4) ?? '--';
          _lastLon = location['longitude']?.toStringAsFixed(4) ?? '--';
          _lastSpeed = location['speed'] != null
              ? '${(location['speed'] * 3.6).toStringAsFixed(0)} km/h'
              : '--';
          _lastTime = location['timestamp'] != null
              ? _formatTime(
                  DateTime.fromMillisecondsSinceEpoch(location['timestamp']),
                )
              : '--';
        });
      }
    });
  • Step 3: Add dispose() method to cancel the subscription

Edit lib/main_screen.dart, add after initState():

  @override
  void dispose() {
    _locationSubscription?.cancel();
    super.dispose();
  }
  • Step 4: Commit
git add lib/main_screen.dart
git commit -m "fix: cancel location stream subscription on dispose

The stream subscription was never cancelled, causing a memory leak
when navigating away from MainScreen. Now properly stored and
cancelled in dispose()."

Task 5: Fix Heartbeat Mechanism (WorkManager Process Isolation)

Problem: WorkManager runs HeartbeatWorker in a separate process where the static callback is null. The heartbeat never fires.

Solution: Replace WorkManager with AlarmManager which runs in the same process as the service.

Files:

  • Modify: android/app/src/main/kotlin/com/traccar/traccar_client/location/HeartbeatScheduler.kt

  • Modify: android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt

  • Step 1: Rewrite HeartbeatScheduler to use AlarmManager

Edit android/app/src/main/kotlin/com/traccar/traccar_client/location/HeartbeatScheduler.kt, replace entire file:

package com.traccar.traccar_client.location

import android.app.AlarmManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.SystemClock
import android.util.Log

class HeartbeatScheduler(private val context: Context) {

    private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
    private var heartbeatReceiver: HeartbeatReceiver? = null
    private var onHeartbeatCallback: (() -> Unit)? = null

    fun scheduleHeartbeat(intervalSeconds: Long, onHeartbeat: () -> Unit) {
        onHeartbeatCallback = onHeartbeat
        
        // Register the broadcast receiver
        heartbeatReceiver = HeartbeatReceiver { 
            Log.d(TAG, "Heartbeat fired")
            onHeartbeatCallback?.invoke() 
        }
        val intentFilter = IntentFilter(ACTION_HEARTBEAT)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            context.registerReceiver(heartbeatReceiver, intentFilter, Context.RECEIVER_NOT_EXPORTED)
        } else {
            context.registerReceiver(heartbeatReceiver, intentFilter)
        }

        // Schedule the alarm
        val intent = Intent(ACTION_HEARTBEAT)
        val pendingIntent = PendingIntent.getBroadcast(
            context,
            REQUEST_CODE,
            intent,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        )

        val intervalMillis = intervalSeconds * 1000
        val triggerAtMillis = SystemClock.elapsedRealtime() + intervalMillis

        alarmManager.setRepeating(
            AlarmManager.ELAPSED_REALTIME_WAKEUP,
            triggerAtMillis,
            intervalMillis,
            pendingIntent
        )
        
        Log.d(TAG, "Heartbeat scheduled every ${intervalSeconds}s")
    }

    fun cancelHeartbeat() {
        // Cancel the alarm
        val intent = Intent(ACTION_HEARTBEAT)
        val pendingIntent = PendingIntent.getBroadcast(
            context,
            REQUEST_CODE,
            intent,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        )
        alarmManager.cancel(pendingIntent)

        // Unregister the receiver
        heartbeatReceiver?.let {
            try {
                context.unregisterReceiver(it)
            } catch (e: IllegalArgumentException) {
                Log.w(TAG, "HeartbeatReceiver was not registered", e)
            }
        }
        heartbeatReceiver = null
        onHeartbeatCallback = null
        
        Log.d(TAG, "Heartbeat cancelled")
    }

    class HeartbeatReceiver(private val onHeartbeat: () -> Unit) : BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent) {
            if (intent.action == ACTION_HEARTBEAT) {
                onHeartbeat()
            }
        }
    }

    companion object {
        private const val TAG = "HeartbeatScheduler"
        private const val ACTION_HEARTBEAT = "com.traccar.traccar_client.HEARTBEAT"
        private const val REQUEST_CODE = 1001
    }
}
  • Step 2: Build and verify no compile errors

Run:

cd android && ./gradlew assembleDebug

Expected: BUILD SUCCESSFUL

  • Step 3: Test manually
  1. Set Heartbeat to 60 seconds in Settings
  2. Start tracking
  3. Wait 60-90 seconds
  4. Check Status/Logs for "HEARTBEAT" entries
  • Step 4: Commit
git add android/app/src/main/kotlin/com/traccar/traccar_client/location/HeartbeatScheduler.kt
git commit -m "fix: replace WorkManager with AlarmManager for heartbeat

WorkManager runs workers in a separate process where the static
callback is always null. AlarmManager with a BroadcastReceiver
runs in the same process as the service, so the callback works."

Task 6: Add Log Cleanup to Prevent Unbounded Growth

Problem: Event logs accumulate indefinitely with no cleanup.

Files:

  • Modify: android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt

  • Step 1: Add log cleanup in startTracking()

Edit android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt, add after line 134 (prefs.edit().putBoolean("tracking_active", true).apply()):

        // Cleanup old logs (keep last 24 hours)
        serviceScope.launch {
            val cutoffTime = System.currentTimeMillis() - (24 * 60 * 60 * 1000)
            database.eventLogDao().deleteOlderThan(cutoffTime)
        }
  • Step 2: Commit
git add android/app/src/main/kotlin/com/traccar/traccar_client/service/LocationTrackingService.kt
git commit -m "fix: cleanup old event logs on tracking start

Deletes logs older than 24 hours when tracking starts to prevent
unbounded database growth."

Task 7: Final Integration Test

Files: None (manual testing)

  • Step 1: Clean build

Run:

flutter clean && flutter pub get && flutter build apk --debug
  • Step 2: Install and test full flow
  1. Install the app on device
  2. Configure your server URL and Device ID in Settings
  3. Save Settings
  4. Toggle tracking ON
  5. Tap "Send Location" button
  6. Check Status/Logs screen:
    • Should see "INFO: Tracking started"
    • Should see "LOCATION: Lat: X, Lon: Y"
    • Should see "SYNC: Location sent to server"
  7. Check your Traccar server - device should be ONLINE with location
  • Step 3: Test heartbeat
  1. Wait 60+ seconds (default heartbeat interval)
  2. Check Status/Logs for "HEARTBEAT" entries
  3. Should see another "SYNC" after heartbeat
  • Step 4: Test offline buffering
  1. Enable airplane mode
  2. Wait for a location update (should see "ERROR" in logs)
  3. Disable airplane mode
  4. Should see "NETWORK_CHANGE: Network: online"
  5. Should see "SYNC: Synced X buffered locations"
  • Step 5: Create final commit
git add -A
git commit -m "test: verify all bug fixes working together"

Summary of Fixes

Bug File(s) Fix
Config not synced to native main_screen.dart, location_bridge.dart Send config via MethodChannel before starting
ConnectivityReceiver not registered LocationTrackingService.kt Register in onCreate(), unregister in onDestroy()
Number fields not saving settings_screen.dart Use TextEditingController instead of initialValue
Memory leak - stream subscription main_screen.dart Store subscription, cancel in dispose()
Heartbeat never fires HeartbeatScheduler.kt Replace WorkManager with AlarmManager
Logs never cleaned up LocationTrackingService.kt Delete logs > 24h old on tracking start