vietmap_tracking_plugin 1.1.2
vietmap_tracking_plugin: ^1.1.2 copied to clipboard
A comprehensive Flutter plugin for GPS tracking and location data transmission to Vietmap's tracking API with background service support.
vietmap_tracking_plugin #
A Flutter plugin for GPS location tracking powered by VietmapTrackingSDK. Supports real-time background tracking, speed alerts, offline cache with auto-sync, fake GPS detection, and smart battery optimization. Built on native platform channels (MethodChannel + EventChannel) — no third-party location wrappers.
Contact Vietmap to obtain an API key before integrating: maps.info@vietmap.vn
Table of Contents #
- Features
- Requirements
- Installation
- Android Setup
- iOS Setup
- Quick Start
- Tracking Modes & Presets
- Fake GPS Detection
- Speed Alert System
- Offline Cache & Sync
- Smart Battery Optimization
- API Reference
- Data Models
- Utility Functions
- Architecture
- Troubleshooting
Features #
| Feature | Description |
|---|---|
| Background GPS | Foreground service (Android) / background location mode (iOS) |
| API Key Validation | initializeTracking validates server-side — throws INVALID_API_KEY on failure |
| Metadata Attachment | setMetadata stamps every GPS record with arbitrary key-value pairs |
| Speed Alerts | Real-time speed monitoring via configurable Alert API |
| Offline Cache | SQLite cache with auto-upload when network recovers |
| Speed Fallback | Derives speed from position when the OS reports no/zero speed while moving (Xiaomi/MIUI); tags each fix with speedSource |
| Fake GPS Detection | Native detection with 4 configurable response policies; SDK issues its own notification even when app is killed |
| Smart Battery | Auto-adjusts tracking precision based on battery level and movement |
| Tracking Presets | Navigation / Fitness / General / BatterySaver — interval and distance variants |
| Location Utilities | Haversine distance, speed conversion, geofence check |
Requirements #
| Minimum | |
|---|---|
| Flutter | 3.3.0+ |
| Dart | 3.8.0+ |
| iOS | 15.0+ |
| Android | API 21 (Android 5.0+) |
Native SDKs
| Platform | SDK | Version |
|---|---|---|
| iOS | VietmapTrackingSDK (CocoaPods) | 1.5.0 |
| Android | com.github.vietmap-company:vietmap-tracking-sdk-android | 1.5.1 |
Installation #
dependencies:
vietmap_tracking_plugin: ^1.0.9
flutter pub get
Android Setup #
1. Permissions (android/app/src/main/AndroidManifest.xml) #
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<!-- Required for showing notifications on Android 13+ -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Required to reschedule notifications after device reboot -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
Android 10+ (API 29+):
ACCESS_BACKGROUND_LOCATIONmust be requested after foreground location is granted. The plugin handles this two-step flow automatically.
2. Foreground Service Notification receivers (<application> block) #
Required for flutter_local_notifications runtime permission requests:
<receiver android:exported="false"
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
<receiver android:exported="false"
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/>
</intent-filter>
</receiver>
<receiver android:exported="false"
android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver" />
3. Maven repository (android/build.gradle) #
allprojects {
repositories {
maven { url 'https://jitpack.io' }
}
}
iOS Setup #
1. Location Usage Descriptions (ios/Runner/Info.plist) #
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app needs location access to track your GPS position.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>This app needs background location access for continuous GPS tracking.</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>This app needs background location access for continuous GPS tracking.</string>
2. Background Modes (ios/Runner/Info.plist) #
<key>UIBackgroundModes</key>
<array>
<string>location</string>
<string>background-processing</string>
<string>background-fetch</string>
</array>
3. Background Task Identifiers (iOS 15+) #
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.vietmaptrackingsdk.location-sync</string>
<string>com.vietmaptrackingsdk.background-location</string>
</array>
4. Foreground Notifications (ios/Runner/AppDelegate.swift) #
By default iOS suppresses notification banners when the app is in the foreground. Two changes are required:
- Set
UNUserNotificationCenter.current().delegate = selfbeforeGeneratedPluginRegistrant.register(with: self). - Override
userNotificationCenter(_:willPresent:)and callcompletionHandler([.banner, .sound, .badge])directly — do not callsuper.
5. CocoaPods #
# ios/Podfile
platform :ios, '15.0'
cd ios && pod install
First install: run
pod install --repo-updateto ensureVietmapTrackingSDKresolves from the latest spec repo.
Quick Start #
1. Initialize the plugin (once, before runApp) #
2. Configure the SDK #
import 'package:flutter/services.dart';
import 'package:vietmap_tracking_plugin/vietmap_tracking_plugin.dart';
final controller = VietmapTrackingController.instance;
Future<void> initSdk() async {
try {
// Validates API key server-side — throws INVALID_API_KEY if rejected
await controller.initializeTracking(
'YOUR_API_KEY',
baseURL: 'https://live.fleetwork.vn/api/v1', // optional
);
// Register lifecycle observer for background/foreground transitions
controller.registerLifecycleObserver();
} on PlatformException catch (e) {
if (e.code == 'INVALID_API_KEY') {
print('Invalid API key: ${e.message}');
}
}
}
3. Request Permissions #
// Step 1 — foreground location
final result = await controller.requestLocationPermissions();
if (!result.granted) return;
// Step 2 — background location (Android 10+ requires separate prompt)
await controller.requestAlwaysLocationPermissions();
// Step 3 — notification permission (Android 13+ / iOS)
// Android
await Permission.notification.request();
// iOS
await FlutterLocalNotificationsPlugin()
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(alert: true, sound: true, badge: false);
4. Start Tracking #
userIdis required — GPS records are keyed to this value server-side. Omitting it will cause records to be unattributed.
await controller.startTracking(
LocationTrackingConfig(
userId: 'user-123', // required
intervalMs: 30000, // 30 s interval
accuracy: LocationAccuracy.high,
backgroundMode: true,
notificationTitle: 'GPS Tracking Active',
notificationMessage: 'Your location is being recorded',
),
);
// Or use a preset:
await controller.startTracking(
TrackingPresets.general().copyWith(userId: 'user-123'),
);
5. Listen for Updates #
controller.onLocationUpdate.listen((LocationData loc) {
print('${loc.latitude}, ${loc.longitude} @ ${loc.speed} m/s');
});
controller.onTrackingStatusChanged.listen((TrackingStatus s) {
print('Tracking: ${s.isTracking}');
});
controller.onFakeGpsDetected.listen((FakeGpsEvent ev) {
print('Fake GPS at ${ev.lat}, ${ev.lng}');
});
6. Stop Tracking #
await controller.stopTracking();
controller.unregisterLifecycleObserver();
Tracking Modes & Presets #
LocationTrackingConfig supports two mutually exclusive tracking strategies. Pass only one of intervalMs or distanceFilter; set the other to null.
| Mode | intervalMs |
distanceFilter |
Behaviour |
|---|---|---|---|
| Interval (timer-driven) | > 0 |
null |
Update every N milliseconds regardless of movement |
| Distance | null |
> 0 |
Update only after the device has moved M metres |
Pre-built Presets #
Interval-based
TrackingPresets.navigation() // 5 s — real-time vehicle / turn-by-turn
TrackingPresets.fitness() // 10 s — outdoor activities
TrackingPresets.general() // 30 s — balanced fleet tracking (default)
TrackingPresets.batterySaver() // 10 min — parked or low-battery assets
Distance-based
TrackingPresets.navigationDistance() // every 5 m
TrackingPresets.fitnessDistance() // every 10 m
TrackingPresets.generalDistance() // every 30 m
TrackingPresets.batterySaverDistance() // every 100 m
Always attach userId via copyWith:
await controller.startTracking(
TrackingPresets.general().copyWith(userId: 'user-123'),
);
Fake GPS Detection #
The native SDK detects mock/spoofed locations independently. Even when the app is killed, the SDK issues its own notification via the Android VietmapTracking notification channel (no dependency on flutter_local_notifications).
Configure the notification content #
await controller.setFakeGpsNotificationConfig(
title: 'Fake GPS Detected',
message: 'Please disable mock locations to ensure accurate tracking.',
);
Call this after
initializeTracking. The config is persisted by the SDK for the lifetime of the process (including background/killed scenarios on Android).
Set a policy #
await controller.setFakeGpsPolicy(FakeGpsPolicy.warn);
controller.onFakeGpsDetected.listen((FakeGpsEvent ev) {
print('Fake GPS: lat=${ev.lat}, lng=${ev.lng}, reason=${ev.reason}');
});
| Policy | Behaviour |
|---|---|
skip |
Emit event only — no notification, no stop. Default. |
warn |
SDK issues a native notification (debounced 30 s per window) |
stopTracking |
Auto-stop tracking on first detection |
logToServer |
Upload record with is_fake=1 + X-Fake-GPS: true header |
Enable detection #
Fake GPS detection is disabled by default (allowMockLocation: true). To enable it:
await controller.startTracking(
TrackingPresets.general().copyWith(
userId: 'user-123',
allowMockLocation: false, // enables native fake GPS checks
),
);
await controller.setFakeGpsPolicy(FakeGpsPolicy.warn);
iOS note #
On iOS the app icon always appears as the notification icon. The reason field in FakeGpsEvent can be "simulatedBySoftware" or "producedByAccessory".
Speed Fallback #
Some devices — notably Xiaomi/MIUI under aggressive battery management — deliver GPS fixes without a valid speed (or report speed = 0 while the vehicle is clearly moving). By default the SDK detects this and derives the speed from the Haversine distance to the previous fix over the elapsed time, so the value sent to the server reflects real motion instead of 0.
The fallback is conservative and self-correcting:
- Triggers only when the OS speed is missing or implausibly low versus the actual movement; a valid hardware speed is always trusted.
- Validated against fix accuracy and time gap, and runs through a sliding-window outlier clamp so a single GPS jump cannot produce a spike.
- Truly stationary fixes stay
0.
Each uploaded record carries the origin under metadata.speedSource:
speedSource |
Meaning |
|---|---|
gps |
Hardware speed from the chip (trusted as-is) |
derived |
OS reported no speed → computed from position/time |
corrected |
OS reported a speed but it was ~0 while moving → replaced with the computed value |
unknown |
No speed and the fallback could not be validated (sent as 0) |
Enabled by default. Turn it off (keep the raw OS speed) via LocationTrackingConfig:
await controller.startTracking(
TrackingPresets.general().copyWith(
userId: 'user-123',
enableSpeedFallback: false, // default true
),
);
Speed Alert System #
// Call after initializeTracking
await controller.configureAlertAPI('your-alert-api-key', 'your-alert-api-id');
await controller.turnOnAlert();
await controller.turnOffAlert();
Offline Cache & Sync #
// Disable auto-upload to take manual control
await controller.setAutoUpload(false);
final pending = await controller.getCachedLocationsCount();
final sizeBytes = await controller.getDatabaseSizeBytes();
// Configure limits
await controller.configureCacheLimits(
maxRecords: 5000,
maxDbSizeBytes: 50 * 1024 * 1024, // 50 MB
batchSize: 50,
);
// Upload and clear
await controller.uploadCachedLocationsManually();
await controller.clearCachedLocations();
Smart Battery Optimization #
Smart Battery is automatically started with startTracking() and stopped with stopTracking(). No manual enable/disable needed.
// Set preferred profile for moving sessions (default: general)
controller.setSmartBatteryPreferredProfile(SmartBatteryProfile.navigation);
// Listen for profile changes
controller.onSmartBatteryProfileChanged.listen((profile) {
print('Battery profile: $profile');
});
| Profile | Interval | Use case |
|---|---|---|
navigation |
5 s | Turn-by-turn, high accuracy |
general |
30 s | Standard fleet tracking |
batterySaver |
10 min | Low battery / parked |
The manager switches profiles automatically:
- Battery < 15% + not charging →
batterySaver - Vehicle stationary ≥ 60 s →
general - Vehicle cornering (heading delta > 15°) →
navigation - Otherwise →
preferredMovingProfile(default:general)
Custom config and Smart Battery #
Smart Battery is always active during tracking and will silently override the native tracking interval whenever a profile transition occurs (low battery, stationary, cornering). When using a standard preset this is the intended behaviour. When using a custom LocationTrackingConfig with non-preset values, you must register a customGeneralConfigOverride callback before calling startTracking() to prevent Smart Battery from replacing your config with a native preset.
// Register before startTracking — tells Smart Battery to re-apply your config
// instead of switching to a native preset on any profile transition.
SmartBatteryManager.instance.customGeneralConfigOverride = () async {
await controller.updateTrackingConfig(myCustomConfig);
};
await controller.startTracking(myCustomConfig);
Clear the override when stopping to avoid it persisting across sessions:
await controller.stopTracking();
SmartBatteryManager.instance.customGeneralConfigOverride = null;
Note: The callback is invoked for every profile transition — including
batterySaverandnavigation— so Smart Battery continues to detect and report profile changes viaonSmartBatteryProfileChanged, but the actual tracking interval is always governed by your config.
API Reference #
Initialization #
| Method | Description |
|---|---|
initializeTracking(apiKey, {baseURL}) |
Validates key server-side. Throws INVALID_API_KEY on failure. |
setMetadata(Map) |
Stamps every GPS record with arbitrary key-value pairs. |
configureAlertAPI(apiKey, apiID) |
Configures speed alert credentials. |
setFakeGpsNotificationConfig({title, message}) |
Customises SDK native fake GPS notification. |
registerLifecycleObserver() |
Enables automatic background/foreground SDK transitions. |
Tracking Control #
| Method | Returns | Description |
|---|---|---|
startTracking(config) |
bool |
Start GPS tracking. userId in config is required. |
stopTracking() |
bool |
Stop tracking; SDK may flush pending records. |
isTrackingActive() |
bool |
Whether tracking is currently running. |
getCurrentLocation() |
LocationData |
Actively resolves the current device location on demand — works even before startTracking(). Throws if the location cannot be resolved (permission denied, location services off, or timeout). |
getTrackingStatus() |
TrackingStatus |
Live status snapshot. |
updateTrackingConfig(config) |
bool |
Apply new config to running session. See platform note. |
getTrackingHealthStatus() |
Map |
Diagnostic snapshot: network, cache, SDK flags. |
updateTrackingConfig— iOS caveat: iOS performsstop → startinternally. Side-effects: brief location gap,trackingDurationresets, status stream emitsfalse → true.
Permissions #
await controller.requestLocationPermissions(); // → PermissionResult
await controller.hasLocationPermissions(); // → PermissionResult
await controller.requestAlwaysLocationPermissions(); // → String status
Fake GPS #
await controller.setFakeGpsPolicy(String policy);
await controller.setFakeGpsNotificationConfig({required String title, required String message});
controller.onFakeGpsDetected // → Stream<FakeGpsEvent>
Tracking Interrupted #
Fires when background tracking stops producing GPS while a session is still active, and again when it recovers. The OS does not resume on its own — use this to prompt the user to stop and start tracking again.
await controller.setTrackingInterruptedNotificationEnabled(bool enabled);
await controller.setTrackingInterruptedNotificationConfig({required String title, required String message});
controller.onTrackingInterrupted // → Stream<TrackingInterruptedEvent>
The reason is SDK-owned and is one of the TrackingInterruptedReason
constants: locationUnavailable, providerDisabled, paused,
authDowngraded, authDenied, locationServicesOff, permissionRevoked,
staleNoUpdates. Disabling the notification does not stop the stream.
Event Streams #
controller.onLocationUpdate // Stream<LocationData>
controller.onTrackingStatusChanged // Stream<TrackingStatus>
controller.onFakeGpsDetected // Stream<FakeGpsEvent>
controller.onTrackingInterrupted // Stream<TrackingInterruptedEvent>
controller.onSmartBatteryProfileChanged // Stream<SmartBatteryProfile>
Cache & Sync #
await controller.setAutoUpload(bool);
await controller.isNetworkConnected(); // → bool
await controller.getCachedLocationsCount(); // → int
await controller.getDatabaseSizeBytes(); // → int
await controller.uploadCachedLocationsManually();
await controller.clearCachedLocations();
await controller.configureCacheLimits(
maxRecords: int,
maxDbSizeBytes: int,
batchSize: int,
);
History #
final List<GpsLocation> history = await controller.getTrackingHistory(
userId: 'user-123',
fromTimestamp: DateTime(2026, 5, 1).millisecondsSinceEpoch, // optional
toTimestamp: DateTime(2026, 5, 12).millisecondsSinceEpoch, // optional
pageNumber: 1,
pageSize: 50,
sortDescending: true,
);
Data Models #
LocationTrackingConfig #
| Field | Type | Default | Description |
|---|---|---|---|
userId |
String? |
null |
Required. GPS records are keyed to this value. |
vehicleId |
String? |
null |
Optional vehicle identifier. |
intervalMs |
int? |
null |
Timer mode: update every N ms. |
distanceFilter |
double? |
null |
Distance mode: update after M metres. |
accuracy |
LocationAccuracy |
high |
high / medium / low |
backgroundMode |
bool |
true |
Continue tracking in background. |
notificationTitle |
String? |
— | Android foreground service notification title. |
notificationMessage |
String? |
— | Android foreground service notification body. |
allowMockLocation |
bool |
true |
false to enable native fake GPS detection. |
LocationData #
latitude, longitude — degrees (double)
altitude — metres above sea level
accuracy — horizontal accuracy in metres
speed — m/s
bearing — heading 0–360° (0 = North)
timestamp — Unix milliseconds
dateTime — convenience DateTime getter
TrackingStatus #
isTracking — bool
lastLocationUpdate — Unix ms (nullable)
trackingDuration — ms
lastUpdateTime — DateTime? (convenience)
duration — Duration (convenience)
FakeGpsEvent #
lat, lng — spoofed coordinate
timestamp — Unix seconds
isFirstDetection — bool (first in 30s debounce window)
reason — iOS only: "simulatedBySoftware" | "producedByAccessory"
TrackingInterruptedEvent #
reason — SDK-owned, see TrackingInterruptedReason
recovered — bool (true when location became available again)
isInBackground — bool (app was backgrounded when it fired)
secondsSinceLastFix — int (-1 if unknown)
PermissionResult #
granted — bool
status — 'granted' | 'denied' | 'notGranted'
fineLocation — bool
coarseLocation — bool
backgroundLocation — bool
Utility Functions #
// Haversine distance (metres)
LocationUtils.calculateDistance(lat1, lng1, lat2, lng2);
LocationUtils.distanceBetween(location1, location2);
// Coordinate formatting
LocationUtils.formatCoordinates(21.0285, 105.8542);
// → "21.028500° N, 105.854200° E"
// Speed conversion
LocationUtils.metersPerSecondToKmh(25.0); // → 90.0
LocationUtils.kmhToMetersPerSecond(90.0); // → 25.0
// Geofence check
LocationUtils.isWithinRadius(location, targetLat, targetLng, radiusMetres);
Architecture #
┌──────────────────────────────────────────────────┐
│ Flutter App │
│ VietmapTrackingController │
│ SmartBatteryManager │
├──────────────────────────────────────────────────┤
│ Platform Interface (Dart) │
│ MethodChannel: vietmap_tracking_plugin │
│ EventChannel: /location_updates │
│ EventChannel: /tracking_status │
│ EventChannel: /fake_gps │
├──────────────────────┬───────────────────────────┤
│ iOS Native Bridge │ Android Native Bridge │
│ Swift │ Kotlin │
├──────────────────────┼───────────────────────────┤
│ VietmapTrackingSDK │ vietmap-tracking-sdk │
│ 1.5.0 (CocoaPods) │ 1.5.1 (JitPack) │
└──────────────────────┴───────────────────────────┘
Troubleshooting #
Android #
Background tracking stops unexpectedly
- Declare
FOREGROUND_SERVICE+FOREGROUND_SERVICE_LOCATIONinAndroidManifest.xml. - Whitelist the app from battery optimisation in device settings.
- On Xiaomi / Huawei / Samsung, manually allow background activity in system settings.
Fake GPS notification doesn't appear
- Ensure
POST_NOTIFICATIONSis in the manifest and granted at runtime (Android 13+). - On MIUI: Settings → Apps → [App] → Notifications → enable "Floating notifications".
Gradle build fails — SDK not found
- Confirm
maven { url 'https://jitpack.io' }is insideallprojects { repositories { ... } }.
iOS #
Location stops updating in background
UIBackgroundModesmust containlocation.- User must have granted "Always" permission.
backgroundMode: truemust be set inLocationTrackingConfig.
Notification banner not showing while app is in foreground
- Verify
UNUserNotificationCenter.current().delegate = selfis set beforeGeneratedPluginRegistrant.register. willPresentoverride must callcompletionHandler([.banner, .sound, .badge])— do not callsuper.
pod install fails
- Run
pod repo updatethenpod install. Podfilemust specifyplatform :ios, '15.0'or higher.
FileSystemException: Failed to decode data using encoding 'utf-8'
- Occurs when project is on a non-APFS volume (e.g. ExFAT). Add this to
ios/Podfile:
post_install do |installer|
Dir.glob(File.join(installer.sandbox.root, '**', '._*')).each { |f| FileUtils.rm_f(f) }
end
Testing #
# Dart unit tests
flutter test
# Android JVM unit tests
cd example/android && ./gradlew :vietmap_tracking_plugin:testDebugUnitTest
# Flutter integration tests (requires device/emulator)
cd example && flutter test integration_test/
# iOS unit tests
cd example/ios
xcodebuild test \
-workspace Runner.xcworkspace \
-scheme Runner \
-destination 'platform=iOS Simulator,name=iPhone 16'
Documentation #
Contributing #
Contributions are welcome — please submit a pull request with a clear description of the change.
License #
MIT — see LICENSE.