Skip to content

Location Tracking

The Location Tracking system enables the Merq mobile app to continuously collect GPS location data from field users, even when offline. Location pings are stored locally in MMKV storage and uploaded in batches when connectivity is restored.

  • Attendance Verification — Validate check-in/check-out locations against outlet coordinates
  • Visit Check-in — Confirm user presence at outlet during visit execution
  • Security & Audit — Track field team movement for safety and compliance
  • Route Optimization — Analyze travel patterns for efficient territory planning

Location tracking follows offline-first principles:

  1. GPS data is always collected (foreground or background)
  2. When online: Upload immediately to backend
  3. When offline: Store in MMKV buffer for later sync
  4. On reconnect: Flush buffer via batch upload endpoint
graph LR
    A[BackgroundFetch] --> B[getLocation]
    B --> C{Online?}
    C -->|Yes| D[Upload Immediately]
    C -->|No| E[MMKV Buffer]
    E --> F{Reconnect?}
    F -->|Yes| G[Batch Upload]
    F -->|No| H[Keep Buffered]

Components:

  1. BackgroundFetch — iOS/Android background location service
  2. Location Buffer — MMKV storage for offline GPS points (max 200 entries)
  3. Batch Upload — Send up to 50 pings per request when online
  4. Battery Saver — Reduce tracking frequency when battery < 15%
FieldTypeDescription
visit_idnumber | undefinedAssociated visit ID (optional)
latnumberGPS latitude (required)
lngnumberGPS longitude (required)
accuracy_mnumber | undefinedGPS accuracy in meters
speed_mpsnumber | undefinedSpeed in meters per second
sourcestring | undefinedLocation source: “gps” | “network” | “passive”
battery_levelnumber | undefinedBattery level (0.0–1.0)
is_mockbooleanWhether location is mocked/spoofed
signal_qualitystring | undefinedSignal quality: “good” | “fair” | “poor”
FieldTypeDescription
iduintAuto-generated primary key
visit_id*uintForeign key to outlet_visits table
user_iduintForeign key to users table
latfloat64GPS latitude
lngfloat64GPS longitude
accuracy_m*float64Accuracy in meters
speed_mps*float64Speed in m/s
source*stringLocation source
battery_level*float64Battery level (0.0–1.0)
is_mockboolMock location flag
signal_quality*stringSignal quality
workspace_iduintWorkspace scoping (auto-assigned)
created_attimestampGPS ping timestamp
updated_attimestampLast update timestamp

Location buffer stored in MMKV under key: merq_location_buffer

interface LocationBuffer {
entries: SaveLocationRequest[];
}

Max buffer size: 200 entries (oldest evicted when limit exceeded)

import { appStorage } from '@core/libs/storage';
const BUFFER_KEY = 'merq_location_buffer';
const MAX_BUFFER_SIZE = 200;
// Add GPS point to buffer
function appendLocationPing(ping: SaveLocationRequest): void {
const buffer = getLocationBuffer();
buffer.push(ping);
// Evict oldest if over limit
const trimmed = buffer.length > MAX_BUFFER_SIZE
? buffer.slice(buffer.length - MAX_BUFFER_SIZE)
: buffer;
appStorage.set(BUFFER_KEY, JSON.stringify(trimmed));
}
// Get all buffered points
function getLocationBuffer(): SaveLocationRequest[] {
const raw = appStorage.getString(BUFFER_KEY);
if (!raw) return [];
return JSON.parse(raw) as SaveLocationRequest[];
}
// Drain up to `batchSize` pings from front of buffer
function drainLocationBuffer(batchSize = 50): SaveLocationRequest[] | null {
const buffer = getLocationBuffer();
if (buffer.length === 0) return null;
const batch = buffer.slice(0, batchSize);
const remaining = buffer.slice(batchSize);
appStorage.set(BUFFER_KEY, JSON.stringify(remaining));
return batch;
}
// Clear after successful upload
function clearLocationBuffer(): void {
appStorage.delete(BUFFER_KEY);
}
StateInterval
Foreground (app open)Every 5 minutes
Background (app closed)Every 15 minutes
Battery saver modeEvery 30 minutes

Activated when battery level < 15%:

const batteryLevel = await getBatteryLevel();
const isBatterySaver = batteryLevel < 0.15;
const interval = isBatterySaver ? 30 * 60 : 15 * 60; // 30min vs 15min

iOS (Info.plist):

<key>NSLocationWhenInUseUsageDescription</key>
<string>Merq needs your location to track visit attendance</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>Merq tracks location for field team safety and audit</string>
<key>UIBackgroundModes</key>
<array>
<string>location</string>
</array>

Android (AndroidManifest.xml):

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- Foreground service for background tracking -->
<service
android:name=".location.LocationTrackingService"
android:foregroundServiceType="location" />
POST /app/v1/locations/batch
Content-Type: application/json
Authorization: Bearer <token>
X-Idempotency-Key: <uuid>
{
"pings": [
{
"lat": -6.2088,
"lng": 106.8456,
"accuracy_m": 10.5,
"speed_mps": 0.0,
"source": "gps",
"battery_level": 0.85,
"is_mock": false,
"signal_quality": "good"
},
{
"lat": -6.2090,
"lng": 106.8458,
"accuracy_m": 12.0,
"speed_mps": 1.2,
"source": "gps",
"battery_level": 0.84,
"is_mock": false,
"signal_quality": "good"
}
]
}

Validation:

  • pings array: min 1, max 50 entries
  • lat: required, float64
  • lng: required, float64
  • is_mock: required, boolean
  • All other fields: optional
{
"data": {
"count": 2
},
"message": "Locations Saved",
"code": "CREATED"
}

HTTP Status: 201 Created

internal/handler/location_handler.go
func (h *locationHandler) BatchSaveUserLocation(c *gin.Context) {
uid, _ := c.Get("userID")
userID := uid.(uint)
var req dto.BatchUserLocationRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.HandleResponse(c, http.StatusBadRequest, "Validation failed",
"VALIDATION_ERROR", utils.FormatValidationErrors(err, req))
return
}
pings := make([]*domain.LocationPing, 0, len(req.Pings))
for _, p := range req.Pings {
pings = append(pings, &domain.LocationPing{
UserID: userID,
Lat: p.Lat,
Lng: p.Lng,
IsMock: p.IsMock,
AccuracyM: p.AccuracyM,
Source: p.Source,
VisitID: p.VisitID,
SpeedMPS: p.SpeedMPS,
BatteryLevel: p.BatteryLevel,
SignalQuality: p.SignalQuality,
})
}
if err := h.svc.BatchSaveUserLocations(c, pings); err != nil {
h.handleError(c, err)
return
}
utils.HandleResponse(c, http.StatusCreated, "Locations Saved",
"CREATED", gin.H{"count": len(pings)})
}
  1. Get location from GPS
  2. Upload immediately to API via locationService.saveLocation()
  3. Don’t store in buffer
  1. Get location from GPS
  2. Store in MMKV buffer via appendLocationPing()
  3. Show sync indicator on Home screen (pending count badge)
  4. On reconnect: flush buffer in batch
import NetInfo from '@react-native-community/netinfo';
import { drainLocationBuffer } from '@core/offline/location.buffer';
import { locationService } from '@core/services/location.service';
// Listen to network changes
NetInfo.addEventListener((state) => {
if (state.isConnected) {
flushLocationBuffer();
}
});
async function flushLocationBuffer(): Promise<void> {
const batch = drainLocationBuffer(50);
if (!batch) return;
try {
await locationService.saveBatchLocation(batch);
// Success: buffer already cleared by drainLocationBuffer
} catch (error) {
// Re-add failed batch to buffer
batch.forEach(ping => appendLocationPing(ping));
}
}
  • GPS coordinates (latitude, longitude)
  • Timestamp (server-side)
  • Accuracy (meters)
  • Battery level (0.0–1.0)
  • Signal quality (“good” | “fair” | “poor”)
  • Mock location flag (boolean)
  • Speed (meters per second)

NOT collected:

  • Device identifiers (IMEI, MAC address)
  • Personal information beyond user ID
  • App usage data or browsing history
  • Contacts, photos, or files
  • Server: 90 days (configurable per workspace)
  • Mobile: Until successful upload + buffer limit (200 entries)

Users can:

  • View location history in Profile → My Locations (admin only)
  • Request data export (contact workspace admin)
  • Request data deletion (workspace admin required)
  • All location data scoped by workspace_id
  • Batch upload requires idempotency key to prevent duplicates
  • Mock location detection enabled (flags spoofed GPS)
  • RBAC permission required: livemap:view (admin access only)