Location Tracking
Overview
Section titled “Overview”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.
Use Cases
Section titled “Use Cases”- 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
Offline-First Design
Section titled “Offline-First Design”Location tracking follows offline-first principles:
- GPS data is always collected (foreground or background)
- When online: Upload immediately to backend
- When offline: Store in MMKV buffer for later sync
- On reconnect: Flush buffer via batch upload endpoint
Architecture
Section titled “Architecture”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:
- BackgroundFetch — iOS/Android background location service
- Location Buffer — MMKV storage for offline GPS points (max 200 entries)
- Batch Upload — Send up to 50 pings per request when online
- Battery Saver — Reduce tracking frequency when battery < 15%
Data Model
Section titled “Data Model”LocationEntry (Mobile)
Section titled “LocationEntry (Mobile)”| Field | Type | Description |
|---|---|---|
| visit_id | number | undefined | Associated visit ID (optional) |
| lat | number | GPS latitude (required) |
| lng | number | GPS longitude (required) |
| accuracy_m | number | undefined | GPS accuracy in meters |
| speed_mps | number | undefined | Speed in meters per second |
| source | string | undefined | Location source: “gps” | “network” | “passive” |
| battery_level | number | undefined | Battery level (0.0–1.0) |
| is_mock | boolean | Whether location is mocked/spoofed |
| signal_quality | string | undefined | Signal quality: “good” | “fair” | “poor” |
LocationPing (Backend)
Section titled “LocationPing (Backend)”| Field | Type | Description |
|---|---|---|
| id | uint | Auto-generated primary key |
| visit_id | *uint | Foreign key to outlet_visits table |
| user_id | uint | Foreign key to users table |
| lat | float64 | GPS latitude |
| lng | float64 | GPS longitude |
| accuracy_m | *float64 | Accuracy in meters |
| speed_mps | *float64 | Speed in m/s |
| source | *string | Location source |
| battery_level | *float64 | Battery level (0.0–1.0) |
| is_mock | bool | Mock location flag |
| signal_quality | *string | Signal quality |
| workspace_id | uint | Workspace scoping (auto-assigned) |
| created_at | timestamp | GPS ping timestamp |
| updated_at | timestamp | Last update timestamp |
Storage
Section titled “Storage”MMKV Key
Section titled “MMKV Key”Location buffer stored in MMKV under key: merq_location_buffer
Buffer Structure
Section titled “Buffer Structure”interface LocationBuffer { entries: SaveLocationRequest[];}Max buffer size: 200 entries (oldest evicted when limit exceeded)
Buffer Operations
Section titled “Buffer Operations”import { appStorage } from '@core/libs/storage';
const BUFFER_KEY = 'merq_location_buffer';const MAX_BUFFER_SIZE = 200;
// Add GPS point to bufferfunction 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 pointsfunction 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 bufferfunction 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 uploadfunction clearLocationBuffer(): void { appStorage.delete(BUFFER_KEY);}Background Fetch
Section titled “Background Fetch”Frequency
Section titled “Frequency”| State | Interval |
|---|---|
| Foreground (app open) | Every 5 minutes |
| Background (app closed) | Every 15 minutes |
| Battery saver mode | Every 30 minutes |
Battery Saver Mode
Section titled “Battery Saver Mode”Activated when battery level < 15%:
const batteryLevel = await getBatteryLevel();const isBatterySaver = batteryLevel < 0.15;const interval = isBatterySaver ? 30 * 60 : 15 * 60; // 30min vs 15minPermissions Required
Section titled “Permissions Required”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" />Batch Upload API
Section titled “Batch Upload API”Endpoint
Section titled “Endpoint”POST /app/v1/locations/batchContent-Type: application/jsonAuthorization: Bearer <token>X-Idempotency-Key: <uuid>Request Body
Section titled “Request Body”{ "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:
pingsarray: min 1, max 50 entrieslat: required, float64lng: required, float64is_mock: required, boolean- All other fields: optional
Response
Section titled “Response”{ "data": { "count": 2 }, "message": "Locations Saved", "code": "CREATED"}HTTP Status: 201 Created
Backend Handler
Section titled “Backend Handler”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)})}Sync Strategy
Section titled “Sync Strategy”When Online
Section titled “When Online”- Get location from GPS
- Upload immediately to API via
locationService.saveLocation() - Don’t store in buffer
When Offline
Section titled “When Offline”- Get location from GPS
- Store in MMKV buffer via
appendLocationPing() - Show sync indicator on Home screen (pending count badge)
- On reconnect: flush buffer in batch
Flush Trigger
Section titled “Flush Trigger”import NetInfo from '@react-native-community/netinfo';import { drainLocationBuffer } from '@core/offline/location.buffer';import { locationService } from '@core/services/location.service';
// Listen to network changesNetInfo.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)); }}Privacy & Security
Section titled “Privacy & Security”Data Collected
Section titled “Data Collected”- 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
Retention
Section titled “Retention”- Server: 90 days (configurable per workspace)
- Mobile: Until successful upload + buffer limit (200 entries)
User Controls
Section titled “User Controls”Users can:
- View location history in Profile → My Locations (admin only)
- Request data export (contact workspace admin)
- Request data deletion (workspace admin required)
Security Measures
Section titled “Security Measures”- 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)
Related Topics
Section titled “Related Topics”- Offline Mutation — Outbox pattern for offline mutations
- Attendance (User Guide) — Check-in/check-out workflow
- Visits (User Guide) — Visit execution and tracking
- Idempotency — Batch upload uses idempotency headers
- Offline-First Architecture — Core offline strategy