Architecture
Colota is a monorepo with three workspace packages:
colota/
├── apps/
│ ├── mobile/ # React Native + Kotlin Android app
│ └── docs/ # Docusaurus documentation site
└── packages/
└── shared/ # Shared colors, typography, types
Mobile App Stack
The mobile app has a React Native UI layer and native Kotlin modules for background GPS tracking.
┌─────────────────────────────────────────┐
│ React Native UI │
│ ┌──────────┐ ┌──────────┐ ┌─────────┐ │
│ │ Screens │ │ Hooks │ │ Context │ │
│ └────┬─────┘ └────┬─────┘ └────┬────┘ │
│ └─────────────┼────────────┘ │
│ NativeLocationService │
│ (TypeScript bridge) │
├─────────────────────────────────────────┤
│ React Native Bridge │
├─────────────────────────────────────────┤
│ Native Kotlin Layer │
│ ┌──────────────────────────────────┐ │
│ │ LocationServiceModule │ │
│ │ (bridge entry point) │ │
│ └──────────────┬───────────────────┘ │
│ ┌─────────────┼──────────────────┐ │
│ ▼ ▼ ▼ │
│ ForegroundService DatabaseHelper ... │
│ SyncManager GeofenceHelper │
│ NetworkManager SecureStorage │
└─────────────────────────────────────────┘
Native Kotlin Modules
All native code lives in apps/mobile/android/app/src/, organized by build flavor:
src/main/java/com/colota/- Shared code:bridge/,service/,data/,sync/,util/,location/(interface),backup/,export/src/gms/java/com/colota/location/- Google Play Services location providersrc/foss/java/com/colota/location/- Native Android location provider
Bridge Modules
Three React Native bridge modules are registered by LocationServicePackage: LocationServiceModule (the primary tracking-side bridge), BackupServiceModule (the encrypted-backup bridge) and ImportServiceModule (the external-file-import bridge). The two file-touching modules (Backup and Import) share SafPickerCoordinator (util/SafPickerCoordinator.kt) for the SAF ACTION_CREATE_DOCUMENT / ACTION_OPEN_DOCUMENT plumbing - each module holds its own instance with its own request codes so multiple coordinators coexist without interfering.
LocationServiceModule
The primary React Native bridge module (exposed as "LocationServiceModule"). Handles all JS-to-native communication for:
- Service control (
startService,stopService) - Database queries (
getStats,getTableData,getLocationsByDateRange,getDaysWithData,getDailyStats,countOlderThan,countUnsentOlderThan) - Geofence CRUD operations
- Settings persistence
- Device info, file operations, authentication
Emits events back to JavaScript:
onLocationUpdate- new GPS fix receivedonTrackingStopped- service stopped (user action or OOM kill)onSyncError- 3+ consecutive sync failuresonSyncProgress- batch sync progress updates with{sent, failed, total}, plusremainingon the one event that ends a pass. A pass caps atMAX_BATCHES_PER_SYNC, sosent + failedreachingtotaldoes not mean the queue is emptyonDatabaseCompacted- the detachedVACUUMafter a bulk delete has finished, with{success}, so a size read before it is known to be staleonPauseZoneChange- entered or exited a geofence pause zoneonProfileSwitch- a tracking profile was activated or deactivatedonAutoExportComplete- auto-export finished with{success, fileName, rowCount, error}
BackupServiceModule
Second React Native bridge module (exposed as "BackupServiceModule"). Owns the encrypted backup pipeline end-to-end so the JS layer never touches credentials, the SQLite file, or the Argon2 key directly. JS-callable methods:
pickBackupDestination/pickBackupSource- launches the Storage Access Framework picker (ACTION_CREATE_DOCUMENT/ACTION_OPEN_DOCUMENT); a single in-flight picker is enforced via a 5-minute timeout.createBackup(uri, password)- validates password strength, claims an operation mutex, runsBackupBuilderagainst a cacheDir-staged file, then atomically copies into the SAF destination. Stops promotion to a foreground service when finished.describeBackup(uri, password)- reads the archive'smanifest.jsonand stops there, returningcreatedAt,appVersion,appBuildandschemaDb. It claims the same operation mutex but starts no foreground service, pauses no writers and extracts nothing, so a wrong password costs a retry rather than a stopped recording. The manifest is the first entry the builder writes, so the read costs one key derivation and the first chunk. A schema newer than the installed app is refused here, before any destructive control is offered.restoreBackup(uri, password)- cancels the location service and any auto-export work, polls until both stop, then runsBackupRestorer. Itsfinallywritestracking_enabled=falsewhenever the database was replaced, success or failure, so the destination device doesn't auto-resume; the write sits in thefinallyand not the try because a throw after the swap would otherwise leave the flag as the archive carried it and recording could resume at the next revive. The samefinallyre-arms the auto-export alarm from whichever DB is live, becausepauseAllDbWriterscancelled it whether or not the restore went through, and starts tracking again throughTrackingControl.startwhen the restore failed before it replaced the database. Any failure raised after the swap that is notSECRETS_PARTIALis reported to JS asE_BACKUP_RESTORED_INCOMPLETE, because a caller cannot otherwise tell from the code whether the swap already happened, and that answer decides whether it reloads the bundle.applyRestore- JS calls this after the success dialog is dismissed; it triggersreactHost.reload()so all modules re-read state from the restored DB.
Both createBackup and restoreBackup await BackupOrphanCleanup.awaitComplete() before claiming the operation mutex, ensuring the launch-time orphan sweeper can't race active operations.
Errors are surfaced to JS as E_BACKUP_<ERROR_NAME> codes that map onto the BackupError enum (WRONG_PASSWORD, BAD_MAGIC, UNSUPPORTED_VERSION, UNSUPPORTED_KDF, UNSUPPORTED_SCHEMA, MISSING_ENTRY, INTEGRITY_FAIL, MIGRATION_FAILED, NO_SPACE, TRUNCATED, TAMPERED, SECRETS_PARTIAL), plus E_BACKUP_RESTORED_INCOMPLETE, which is not an enum member but the code a post-swap failure is reported under.
Backup Pipeline
Native-only modules in backup/ package. The on-disk format is documented in BackupFormat.kt.
| Module | Purpose |
|---|---|
BackupCrypto | Chunked AES-256-GCM encrypt/decrypt keyed by Argon2id. Each chunk binds the file header into its GCM AAD so any header tamper invalidates the first tag. Argon2 is deferred until the first ciphertext chunk arrives so wrong-password rejection costs no key derivation. |
BackupFormat | On-disk layout constants, BackupHeader data class, BackupError enum, and BackupException. 76-byte header (magic, format version, KDF id, KDF params, 32-byte salt, 8-byte nonce prefix, chunk size, reserved) followed by length-prefixed ciphertext chunks and an end-marker + chunk-count footer. |
BackupBuilder | Snapshots the SQLite database via DatabaseHelper.snapshotTo (uses VACUUM INTO on API 30+, file-copy with WAL checkpoint as fallback), runs quick_check, extracts secrets via SecureStorageHelper.exportPlaintextForBackup(), deflates everything into a zip, then streams it through BackupCrypto.encrypt. Picks Argon2 memory by ActivityManager.isLowRamDevice (32 MiB / 64 MiB). |
BackupRestorer | Decrypts the file via a PipedInputStream into a ZipInputStream, extracts entries to a temp dir with bounded reads (zip-bomb defense), validates the manifest schema version, runs PRAGMA integrity_check on the candidate DB, calls DatabaseHelper.migrateCandidate to run any required migrations on the candidate, then atomically swaps the live DB via DatabaseHelper.replaceLiveDatabase, then re-imports secrets. A migration that fails raises MIGRATION_FAILED rather than INTEGRITY_FAIL, because the archive is intact and it is this app's reading of it that failed. A failed secrets commit is surfaced as SECRETS_PARTIAL so the UI can prompt the user to re-enter credentials. describe(input, password) is the read-only half behind describeBackup: it stops at manifest.json and extracts nothing. |
BackupForegroundService | Notification-only foreground service shown during long backups/restores. Uses FOREGROUND_SERVICE_TYPE_DATA_SYNC. Holds no work itself - the actual encryption stays in BackupServiceModule's coroutine so the password CharArray lives only on the heap, not in service state. |
BackupOrphanCleanup | Singleton kicked off from MainApplication.onCreate on a daemon thread. Sweeps cacheDir/backup_temp, cacheDir/restore_temp, cacheDir/pending_backup.colota, and <dbDir>/Colota.db.incoming left behind by a process death mid-operation. Exposes a CompletableDeferred so BackupServiceModule can await completion before claiming the operation mutex. |
PasswordStrength | Mirror of the JS-side passwordStrength.ts. Enforces the 12-character floor and ~50-bit entropy floor; sequential runs and <4 distinct chars cap the score. |
ImportServiceModule
Third React Native bridge module (exposed as "ImportServiceModule"). Owns the location-import pipeline end-to-end: file picker, streaming parse, dedup against the live DB, and the two-stage commit. JS-callable methods:
pickImportSource- launches the SAFACTION_OPEN_DOCUMENTpicker via the sharedSafPickerCoordinator. MIME hints cover JSON, XML, CSV andoctet-stream; the baretype = "*/*"keeps.geojsonand.gpxfrom being filtered out by strict providers.importLocationsFromFile(uri)- acquires the operation mutex, opens the file once, sniffs 4 KB for format detection, then prepends those bytes back viaSequenceInputStreamso the format-specific parser sees the full document without a second SAF open. Stashes the surviving rows on the module instance for the commit step.commitImport(asQueued)- applies the staged rows in a single transaction. WhenasQueued=trueit also reads the liveServiceConfig, rebuilds payloads viaPayloadBuilder(the same one the live tracking path uses), and inserts paired queue rows. Rejects withE_IMPORT_SYNC_UNAVAILABLEif no endpoint or offline mode is on, so a stale UI state can't bypass the gate.cancelImport- sets the cancellation flag and clears the stash.
The staged rows are guarded by a 15-minute TTL job - a preview-then-walk-away doesn't pin memory indefinitely. Cancellation flows through an AtomicBoolean that the parsers and dedup walker check at array boundaries.
Errors surface to JS as E_IMPORT_* codes: E_IMPORT_UNSUPPORTED, E_IMPORT_CANCELLED, E_IMPORT_NO_PENDING, E_IMPORT_SYNC_UNAVAILABLE, E_IMPORT_FAILED, plus the shared E_BUSY when another operation holds the mutex.
Import Pipeline
Native-only modules in the importer/ package. Format-specific parsers all return the same ParseResult(rows, invalid); the dispatch logic in LocationImporter picks one based on a 4 KB content sniff.
| Module | Purpose |
|---|---|
LocationImporter | Orchestrator. Detects format from the sniff (XML root for GPX/KML, JSON top-level keys for GeoJSON / Google Timeline, CSV header for CSV), dispatches to the per-format parser, runs streaming merge-walk dedup against an ASC-ordered DB cursor (no existing-key HashSet is materialised - memory is O(1) on top of the parsed-rows list, so the dedup scales to multi-million-row user histories without OOM). On commit, the recovery path writes sent = 1 and skips the queue; the migration path (asQueued=true) builds payloads via the live PayloadBuilder against a synthetic android.location.Location so the import path can't drift from the live tracking path. |
GeoJsonParser | Streaming android.util.JsonReader-based parser for a FeatureCollection of Point features (scalar properties) or the columnar MultiPoint features Colota exported in 1.12.0 through 1.16.0 (per-point attributes as parallel arrays, including note / battery_status), zipped by index. The MultiPoint reader is permanent: such a file can be the last copy of data since deleted from the device. The writer emits Point features only. Tolerates foreign shapes - any feature without a recognised time / non-Point-or-MultiPoint geometry is counted as invalid and dropped, but parsing continues. |
GoogleTimelineParser | Handles both Google Timeline schemas in a single pass: legacy Takeout (locations[].latitudeE7/longitudeE7/timestampMs) and the new on-device export (semanticSegments[].timelinePath[] + rawSignals[].position with degree-suffix coord strings). Visit/activity inferences are intentionally skipped. |
GpxParser | XmlPullParser-based. Collects <wpt> + <rtept> + <trkpt> uniformly into the flat locations table. Recognises Garmin's nested TrackPointExtension wrapper so sport-watch metadata (speed, course) lands on the row. |
KmlParser | XmlPullParser-based. Reads Placemark/Point/coordinates (KML's lon,lat[,alt] order, flipped back internally) with TimeStamp/when. LineString-only Placemarks are dropped + counted as invalid since the KML schema doesn't carry per-vertex timestamps. |
CsvParser | Header-driven; maps columns by name with aliases (lat/latitude, lon/lng/longitude, time/timestamp/iso_time, etc.) so foreign column orders work without renaming. Rejects headers missing the required lat+lon+time columns with UnsupportedFormatException. |
JsonReadHelpers | Shared JsonReader extensions (readNullableInt, readNullableDouble) plus parseIso8601Seconds backed by pre-built immutable DateTimeFormatter instances (thread-safe and ~10× faster than SimpleDateFormat, which matters for Timeline files with 100k+ timestamps). |
ImportFormat / ImportRow / UnsupportedFormatException | Shared data types. ImportRow is the normalised location shape produced by every parser before dedup; format-specific quirks (E7 coords, ISO timestamps, degree-suffix strings) are resolved before construction so the orchestrator never sees them. |
The on-disk format is never authoritative for sync decisions: imported rows go in with sent = 1 by default (no re-upload), and only the Also queue for upload switch on the import preview flips that to sent = 0 + queue rows. See Data Import for the user-facing semantics.
LocationProvider Abstraction
Location services are abstracted behind a LocationProvider interface (location/LocationProvider.kt), with flavor-specific implementations:
- GMS (
src/gms/) -GmsLocationProviderwraps Google Play ServicesFusedLocationProviderClient - FOSS (
src/foss/) -NativeLocationProviderwraps Android's nativeLocationManager, using the platformFUSED_PROVIDERon Android 12+ where available andGPS_PROVIDERotherwise
Each flavor provides a LocationProviderFactory that instantiates and returns the correct implementation at runtime. The service and bridge code in src/main/ depends only on the LocationProvider interface, never on a concrete class.
LocationForegroundService
An Android foreground service that runs continuously for GPS tracking. Manages:
- GPS location capture via the
LocationProviderabstraction - Pause zone detection (geofencing)
- Geofence entry delay - keeps recording for 3.5× the configured tracking interval before pausing on zone entry, logging real arrival points for backends like GeoPulse
- Anchor points - a synthetic location saved on zone exit as a clean start point for the departing trip, timestamped 1s before the first real GPS fix
- Battery critical shutdown (below 5% while unplugged), detected via a battery-broadcast receiver so it fires even while GPS is paused in a zone
- Location accuracy filtering
- Stationary detection - slows GPS to the profile's interval after 60s of fixes without movement; resume is driven by the shared
MotionStateDetector(accelerometer variance, with SIG_MOTION as a fast-path for sharp wake events). It keeps working inside a pause zone and during an entry delay, since fixes reachProfileManagerbefore the in-zone drop; only a hold that stops the stream can prevent a verdict. - Queuing data for server sync
Alarms go through a receiver, not the service. Each scheduler targets a BroadcastReceiver that then starts the service. That keeps the PendingIntent a plain explicit broadcast, and the foreground-service start runs inside the alarm's temporary allowlist window.
Intent vs liveness. The tracking_enabled setting records that the user wants tracking and deliberately survives process death and reboot. Whether a service exists right now is LocationForegroundService.isRunning. Anything asking "is tracking alive" must read isRunning, because a service the system killed leaves the flag true. Recovery is layered: the app reconciles the two whenever it reaches the foreground, and TrackingWatchdogScheduler covers the window while the app stays closed. Reconciling can also drop the intent rather than honour it: a revoked location permission means no service can be started again, so the foreground reconciler clears the flag and the watchdog stops re-arming itself.
NotificationHelper
Handles all notification logic for the tracking service:
- Channel creation and notification building
- Title is the recording state: "Tracking", "Paused", "Searching for GPS" or "Location services are off". From Android 12 the collapsed row shows the app name only when there is no title, so the notification sets none there: the state leads the text and the expanded view shows it as the title. Before Android 12 the state is the title. The stopped notification follows the same rule
- Text is the sending state ("All sent", "12 queued · last sync 3 min ago", "Offline mode"), after "Stationary", "Not recording" or how a pause resumes where one applies.
NotificationHelper.StatusInputhas no coordinate, zone or profile field andstatusInput()in the service is its only builder Notification.whenis the last fix received, so the header age updates without a post. An update posts when the title, the text or the fix minute changes. A state change always posts- Both notifications are
VISIBILITY_PUBLIC, since the text holds nothing private - Small icon
drawable/ic_notification: the launcher's monochrome pin and inner arc at 24 dp, without the outer arc and with the stroke doubled.setColor(ICON_COLOR), the launcher background, colors the circle Android 12 and later draw behind it.BatteryRecoveryWorkerand the stopped notification use the same icon and color, so the status bar shows one Colota icon whether tracking runs or has stopped; backup and auto-export keepic_menu_save. - Deduplication to avoid unnecessary notification redraws
- Stopped notification, posted on a deliberate stop, by the tracking watchdog when Android refuses a background restart, and by the service itself when Android refuses the location foreground type because location services are off; in both refusal cases tapping it opens the app so the reconciler can resume. Two channels back it.
location_service_channelatIMPORTANCE_LOWcarries the ongoing status and any stop the user asked for;tracking_stopped_channelatIMPORTANCE_DEFAULTcarries the ones they did not, so a killed service is audible instead of sitting silently under the ongoing notification.buildStoppedNotificationdefaults to the silent channel and callers opt in - Notification text and channel names live in
res/values/strings.xml, counts as<plurals>. A translation is avalues-<lang>/strings.xml; a missing key falls back to English - Stop reasons are the
StopReasonenum. The log and theonTrackingStoppedevent carry its name, the notification its translated text
DatabaseHelper
SQLite database singleton with six tables:
| Table | Purpose |
|---|---|
locations | All recorded GPS locations |
queue | Locations pending upload |
settings | App configuration key-value pairs |
geofences | Pause zone definitions |
tracking_profiles | Condition-based tracking profile definitions |
trip_boundary_overrides | Manual trip merges and splits |
Uses WAL (Write-Ahead Logging) mode and prepared statements for performance.
Three additional methods support the backup pipeline:
snapshotTo(destFile)- produces a transactional copy of the live DB. UsesVACUUM INTOon API 30+; falls back to a file copy with awal_checkpoint(FULL)on API 26-29.migrateCandidate(file)- runs schema migrations on a candidate file inDELETEjournal mode before it is swapped in, so a migration failure leaves the live DB untouched.replaceLiveDatabase(context, newDb)- drains the live WAL viawal_checkpoint(TRUNCATE), deletes the live-wal/-shm/-journalsidecars, then atomically moves the candidate into place. HandlesAtomicMoveNotSupportedExceptionby staging through an<dbName>.incomingfile. Synchronizes withgetInstance()so concurrent callers either see the old singleton or the new file.
SyncManager
Orchestrates batch location uploads with:
- Configurable batch size (50 items per batch, 10 concurrent HTTP requests)
- Exponential backoff on failure
- A pass stops on a 429, and nothing is sent until its
Retry-Afterwait is over (60 s without one, capped at 15 min): the periodic tick, a manual or zone-entry flush and an instant send all hold. In the per-point format a pass also stops on a 5xx or a network error once a group of 10 requests got nothing through, so an unreachable server costs one group of requests. A batch upload stops on its first 429, 5xx or network error - Periodic sync scheduling. A new interval (a profile switch) applies to the wait already running, measured from when it began; a send or a backoff in progress finishes first
- Manual flush support
- One pass at a time: a periodic tick and a manual flush wait for the running pass. An instant send leaves its row queued while a pass runs, and a pass skips rows an instant send is still posting
NetworkManager
HTTP client. Injects auth headers, caches connectivity checks, and detects unmetered connections and VPN status for sync condition filtering. SSID detection needs a location-flagged network callback, which is attributed as location access, so it is registered only while the wifi_ssid condition is active or the SSID picker asks. Endpoint policy (HTTPS-for-public, private host detection) is delegated to UrlSafety.
For mTLS-protected endpoints, builds the HttpsURLConnection with a custom SSLSocketFactory supplied by ClientCertSslContextProvider (per-instance, never setDefaultSSLSocketFactory() - the override is scoped to outbound location sync, not the whole process).
UrlSafety
HTTP endpoint policy: HTTPS is required for public hosts, HTTP is only allowed when the host resolves to a private/local address (loopback, RFC 1918 site-local, link-local, or CGNAT 100.64.0.0/10). Resolves hostnames via InetAddress so server.local-style mDNS names work. The lookup runs on every call, so a host that stops resolving to a private address is no longer trusted for plain HTTP after a network change. Pure validation with no transport state, also exposed to the JS bridge for pre-save endpoint checks.
ClientCertSslContextProvider
Builds the cached SSLSocketFactory used for mTLS. The key managers are supplied by DynamicKeyManager so the cert source can be swapped without rebuilding the SSL context; trust managers come from a CompositeX509TrustManager that layers an optional user-imported CA on top of Android's system roots. Includes a lazy migration: legacy PKCS12 + password material in EncryptedSharedPreferences (if any) is unwrapped into Android Keystore the first time the provider builds a factory or the Settings UI calls runMigrationIfNeeded. Legacy storage is wiped only on permanent failures (bad blob, wrong password, PKCS12-shape errors); transient failures (AndroidKeyStore unavailable, OOM) leave legacy data so the next request can retry.
DynamicKeyManager
X509KeyManager that resolves the active client cert at TLS handshake time from one of two sources: an alias stored in Android's system KeyChain (private key stays in the OS / hardware, app never sees the bytes) or a .p12-imported entry in Android Keystore (key sealed under the app's UID, hardware-backed where available). The resolved alias is cached and invalidated alongside the SSLSocketFactory cache, so cert swaps take effect on the next request without an app restart.
CompositeX509TrustManager
X509TrustManager that accepts a server chain if any delegate accepts it. Used to layer a user-imported private CA on top of the system trust store without losing system CA validation - additive trust, not pinning.
GeofenceHelper
Manages pause zones using the haversine formula for distance calculations. Reads geofences directly from SQLite on each lookup.
Each geofence supports three independent GPS pause modes, configured per zone:
- Pause tracking - Stops saving and syncing locations inside the zone. GPS continues running to detect exit.
- WiFi pause - Stops GPS entirely when connected to an unmetered network (WiFi/Ethernet). Implemented via
ConnectivityManager.NetworkCallback, which fires immediately on network availability changes. An active network counter handles devices with multiple simultaneous unmetered networks - GPS only resumes once all of them are gone, after a short debounce. - Motionless pause - Stops GPS after the device has been still for the configured per-zone dwell window (default 1 minute). Stillness is detected by
RawSensorMotionDetector(batched accelerometer variance + parallel SIG_MOTION); any motion above the variance threshold resets the timer. The same detector fires GPS resume when movement returns.
A per-zone stationary heartbeat records a point at the geofence center at a relaxed interval while paused. The point is a synthetic fix, so no GPS wake is required. The save is unconditional and transmission goes through SyncManager.queueAndSend, so offline mode and sync conditions gate the upload without dropping the recording. It fires once on zone entry; restoring a zone after a service restart schedules the interval without recording, so repeated restarts cannot stack duplicate points. Configured via heartbeatEnabled and heartbeatIntervalMinutes per geofence.
Separately, a Stationary tracking profile drives its own heartbeat via StationaryHeartbeatScheduler (an allow-while-idle alarm delivered through StationaryHeartbeatReceiver): a still device produces no passive fixes, so it wakes at the profile's interval, requests a real GPS fix and records it through the normal path.
When both WiFi and motionless pause are enabled, GPS only resumes when both conditions clear - WiFi disconnected and motion detected. Changes made in the editor take effect immediately even when already inside the zone, via applyZoneSettingsIfChanged on the next zone recheck.
ProfileManager
Evaluates tracking profile conditions and switches GPS settings automatically. Supports five condition types: charging, Android Auto / car mode, speed above threshold, speed below threshold, and stationary. Uses a rolling speed buffer for averaged speed readings, deactivation delays (hysteresis) to prevent rapid toggling, and priority-based resolution when multiple profiles match.
The stationary condition is decided by the fixes and never by a timer. evaluateStationaryState tracks a run of consecutive fixes below 0.3 m/s and the verdict is reached by the fix that completes the window, so a stream that stops delivering cannot produce one. A gap restarts the run unless it is within three times the previous gap, or 60 seconds if that is larger, and no gap over 15 minutes is ever tolerated. Calibrating on the previous gap keeps a sparse Doze cadence usable while a fast stream that goes quiet is treated as unobserved time rather than stillness; the window length deliberately plays no part, or a long window would license a long blackout. The motion sensor discards a run in progress as well as an active verdict.
ProfileHelper
Database access layer for tracking profiles and trip events. Maintains a TimedCache of enabled profiles (30s TTL) ordered by priority then id, so equal priorities go to the older profile, and provides CRUD operations plus trip event logging.
ConditionMonitor
Monitors charging state via BroadcastReceiver and Android Auto connection via the CarConnection API. Forwards state changes to ProfileManager for condition evaluation.
ProfileConstants
Centralized constants for condition type strings (charging, android_auto, speed_above, speed_below, stationary), event types (activated, deactivated), cache TTL, speed buffer size, and minimum interval.
SecureStorageHelper
Wraps Android's EncryptedSharedPreferences for encrypted credential storage (AES-256-GCM for values, AES-256-SIV for keys). Stores Basic Auth passwords, Bearer tokens, custom headers, and the user-imported server CA (public cert, no key material). Client certificate private keys live in Android Keystore instead, not here - see ClientCertSslContextProvider.
For backups, two internal methods support the export/import flow without exposing plaintext secrets to other modules:
exportPlaintextForBackup()- returns the BACKED_UP_KEYS as aMap<String, String>for inclusion in the encrypted backup container.importPlaintextFromBackup(secrets)- clears all BACKED_UP_KEYS then writes the new map in a single synccommit(). Throws on commit failure so the restore path can reportSECRETS_PARTIAL.
Other Modules
| Module | Purpose |
|---|---|
LocationBootReceiver | Auto-restarts tracking after device reboot |
AlarmScheduler | Shared one-shot wake-up alarm used by the geofence heartbeat, the stationary heartbeat and the tracking watchdog. setAndAllowWhileIdle fires through Doze without SCHEDULE_EXACT_ALARM, at the cost of being inexact, so every interval built on it is a minimum and not a schedule. Floors the delay at 60s, and each alarm needs its own request code. Whoever handles a tick re-arms the next one |
GeofenceHeartbeatScheduler / GeofenceHeartbeatReceiver | Wakes the service for the zone heartbeat while paused in a geofence. Replaced a coroutine delay, which stopped counting while the phone slept |
TrackingWatchdogReceiver | Fired by TrackingWatchdogScheduler - restarts the service when the user still wants tracking but none is running. Where Android refuses a background start it posts a resume notification instead, and records when notifications are denied too |
TrackingWatchdogScheduler | Arms a 15min setAndAllowWhileIdle alarm for as long as tracking is wanted, re-armed by each tick and cancelled on stop; a start refused because location services are off re-arms it at 5min instead. Distinct from the in-service pause watchdog, which dies with the service it would have to watch. Doze defers these alarms, so the interval is a minimum |
MotionStateDetector / RawSensorMotionDetector | Single detector behind a MotionState { STATIONARY, MOVING } interface. Backed by 30s-batched accelerometer variance (hysteresis: > 0.30 m/s² for 3s -> MOVING; < 0.15 m/s² for the configured per-zone dwell -> STATIONARY) and parallel TYPE_SIGNIFICANT_MOTION as a fast-path for sharp events. Fans out to both motionless-pause and stationary-profile exit consumers via one callback site in LocationForegroundService.onMotionStateChange. |
DeviceInfoHelper | Device metadata and battery status with caching |
FileOperations | File I/O, sharing via FileProvider, and clipboard access |
PayloadBuilder | Builds outgoing JSON payloads (field-mapped, Overland batch envelope, Traccar JSON) and extracts envelope custom fields |
ServiceConfig | Centralized configuration data class |
TimedCache | Generic TTL cache used for queue count, device info, profiles, and network state |
BuildConfigModule | Exposes the version name, version code, flavor and device language to JS, plus getSystemPalette - the Android 12+ wallpaper tonal steps as #RRGGBB hex, null below API 31 - and the in-app language through AppCompat's per-app locale API: getAppLanguage (the pick and the language in use) and setAppLanguage. MainActivity handles locale and layoutDirection itself, so a switch does not recreate it, and native text is read through util/AppLanguage, because below Android 13 AppCompat localises activities only |
AppLogger | Centralized logger - always active, all tags prefixed with Colota. for logcat filtering |
AutoExportWorker | WorkManager CoroutineWorker enqueued by AutoExportAlarmReceiver - performs the export (chunked writes to a per-run temp file, foreground service, retries, retention cleanup), verifies the copy by bytes written and re-arms the next alarm in finally |
AutoExportAlarmReceiver | Broadcast receiver fired by AlarmManager at the configured time - hands off to AutoExportWorker because the receiver's 10s budget can't run an export |
AutoExportScheduler | Arms AlarmManager.setAndAllowWhileIdle for the next configured wall-clock time. Called on enable, by the alarm receiver on every fire, after each worker run, after schedule edits and on boot |
AutoExportConfig | Typed data class wrapping auto-export settings (interval, time-of-day, weekday, day-of-month, enabledAt) from the SQLite settings table with validation, isExportDue() and nextExportTimestamp() |
ExportConverters | Native CSV/GeoJSON/GPX/KML serialization for both "Export all" (flat, streamed via exportToFile) and per-trip / multi-select export (trip-segmented via convertTrips, reached through the exportTripsToFile bridge). In-memory, streaming, and file-based interfaces |
ShortcutHandlerActivity | Transparent activity handling app shortcut intents (start/stop tracking) without UI. Delegates to the shared TrackingControl helper |
TrackingControlReceiver | Exported broadcast receiver for automation apps - com.Colota.action.START_TRACKING / STOP_TRACKING start or stop tracking from saved settings. Delegates to TrackingControl |
TrackingControl | Shared start/stop tracking actions used by both triggers above: reads config from DB via ServiceConfig.fromDatabase(), starts the foreground service and fires the started event; stop is routed through the service so stopForegroundServiceWithReason runs |
React Native Layer
Screens
| Screen | Purpose |
|---|---|
DashboardScreen | Full-bleed map with no header. One banner slot under the status inset for a missing permission, location services off or a critical battery, and a docked card over the bottom edge: the tracking state line, the interval row (fix cadence with the sync cadence under it, from the active profile or from settings; a zero sync interval reads as instant, offline mode replaces the sync cadence) and the server row. The Start/Stop pill and the Route toggle float above the dock. Nothing polls: it reads on focus and takes the rest from events |
SettingsScreen | The hub, a tab of five grouped lists: Tracking (Connection, Tracking & sync, Tracking profiles), Display, Data, Help and About. Two rows carry live state through the derivation their target screen opens with (serverState.describeServer, profileRow.profileStateLabel); every other sub is a stored value, an on-disk fact or the nouns inside the screen, computed by utils/settingsRow |
ConnectionScreen | The server hub: a sync state line from utils/serverState, offline mode, the endpoint (saved on blur), Test connection, and rows to Request format, Authentication and Client certificate |
TrackingSyncScreen | GPS interval, distance filter, accuracy threshold and sync strategy preset |
AppearanceScreen | Theme, wallpaper colors on Android 12 and up, unit system, time format and custom map tile URLs (light and dark, refused unless they parse as an http or https URL) |
ApiSettingsScreen | Route Request Format. Backend template as a row opening the picker, HTTP method and Dawarich mode as radio rows, and the field mapping |
BackendTemplateScreen | The eight backend templates as radio rows, each stating what it sends, returning the choice with popTo and merge. Eight options that each need a sentence do not fit inline on a form screen |
AuthSettingsScreen | Authentication method as a radio group (None, Basic auth, Bearer token) with never-echoed secrets, and custom HTTP headers |
MtlsSettingsScreen | Client certificate (device store pick or PKCS12 import into the Android Keystore) and the trusted server CA, each with a state line from utils/certificateState |
GeofenceScreen | Pause zones as rows over a map. Create geofence opens the editor on an empty draft; deletion happens there |
GeofenceEditorScreen | Every property of a zone: name, radius, location, record pause, WiFi pause, motionless pause and timeout, stationary heartbeat |
PlaceZoneScreen | Picks a zone coordinate on a map with the radius drawn live, returning it to the editor with popTo and merge |
TrackingProfilesScreen | One card: a state line naming the profile in force (or what applies instead), then a row per profile in evaluation order reading as a rule, each with its enabled switch |
ProfileEditorScreen | The rule as a live sentence, then Condition, Profile (name, priority), Tracking while active and Switching, with inline validation and a Save button |
LocationHistoryScreen | One day at a time: a day header (chevrons, title opening the calendar dialog, ledger caption) over Map, Trips and Data lenses. Map docks a card over the track: the trip rows as legend, the tapped point (split, delete, note) or the empty day's next step. Trips is one card of trip rows; long-press turns the header into a contextual action bar for export, merge and delete. Data is a frozen-time table whose row tap opens the point on the map. Export of the day and the summary live in the header |
TripDetailScreen | A trip stepper (swatch, name, date and times) over the trip's map, where a tapped point docks its card for split and note; a ledger of distance, duration, speed, points and elevation, then the speed and elevation charts; export and delete in the header |
LocationSummaryScreen | A period stepper (week or month) over a ledger of distance, trips, active days and average, and the period's days as rows that open the day in Location History; each period is read from the daily stats on its own, since a year or all time would walk every row |
ExportLocationsScreen | Export all tracked locations via native streaming converters as CSV, GeoJSON, GPX, or KML |
ImportLocationsScreen | Import external location files (GeoJSON, Google Timeline legacy + new, GPX, KML, CSV) with auto format detection, dedup preview, and recovery vs migration (queue-for-sync) commit choice |
AutoExportScreen | Configure scheduled auto-export: directory, format, frequency, time of day, weekday or day-of-month, export range and file retention |
OfflineMapsScreen | Download and manage offline map areas - the viewport as the bounding box with a live estimate, one download at a time with progress the screen picks up again on return, re-download and delete, a Map style changed mark |
DataManagementScreen | Database ledger, manual flush, compaction, deletes by sync state or age |
BackupRestoreScreen | Create or restore a password-encrypted .colota archive of all data, with strength meter and no-recovery confirmation |
SetupImportScreen | Confirmation screen for colota://setup deep link imports |
ShareSetupScreen | Bundles selected config categories into a colota://setup link to share; credentials opt-in |
LoggingScreen | Records a log file and saves it as one file: capture state, the toggle, and the save and delete actions, which are absent while the file is empty |
LogPreviewScreen | Reads the recorded file, or the system log while recording is off. Newest first, search, and a single-select severity floor whose chips carry their own counts |
AboutScreen | The app's icon and name over the version with its build code, then the privacy policy, licence and source links and the copyright notice; map credits stay on the map's own dialog |
Services
| Service | Purpose |
|---|---|
NativeLocationService | TypeScript bridge to the native LocationServiceModule with typed methods for all native operations |
LocationServicePermission | Sequential Android permission requests (fine location → background location → notifications → battery exemption) |
ProfileService | Thin wrapper over NativeLocationService for tracking profile CRUD and trip event queries |
SettingsService | Bridges UI state to native SQLite with type conversion (seconds↔ms, objects↔JSON) |
BackupService | Thin TypeScript wrapper over the native BackupServiceModule exposing pickBackupDestination, pickBackupSource, createBackup, restoreBackup, applyRestore. Surfaces typed BackupErrorCode strings for screen-side messaging |
ImportService | Thin TypeScript wrapper over the native ImportServiceModule exposing pickImportSource, importLocationsFromFile, commitImport(asQueued), cancelImport. Returns a typed ImportPreview with format, counts, date range, and canQueueForSync so the UI can gate the queue-for-sync button |
modalService | Centralized alert and confirm dialogs via showAlert() and showConfirm() |
Map Components
The app uses MapLibre GL Native (@maplibre/maplibre-react-native) for GPU-accelerated map rendering. The default tile server is a self-hosted instance at maps.mxd.codes serving OpenMapTiles-compatible vector tiles. A custom tile server URL can be configured in Settings - see the tile server guide. No API tokens required. Fully FOSS-compatible.
| Component | Purpose |
|---|---|
ColotaMapView | Shared base map wrapping MapLibre's Map with the light or dark style URL, a custom compass and the credits dialog. Credits come from each source in the style or its TileJSON; a custom server that publishes none is credited by its host |
DashboardMap | Live tracking map with user marker, accuracy circle, today's track (its visibility is the screen's Route toggle, the map only draws it), geofence polygons with labels, follow-me until the user pans, then a centre button in the disc column. Frames the last known fix, or the zones when there is none |
TrackMap | Location history map: trip-colored track segments over a casing layer, a focused trip drawn wider while the rest dim, 48 dp point hitboxes, fit-to-day and fit-to-trip bounds. Selection and focus are props; it draws no popup or legend |
CalendarPicker | Day, month and year panes inside DayPickerModal, dot indicators for days with data, per-day stats spoken by the cell |
LocationTable | The day's points newest first with a frozen time column beside a horizontally scrolling pane; a Sync column when an endpoint is set; a row tap opens the point on the map |
TripList | One card of TripRows (swatch, number, time range, distance, duration, speed) with controlled selection: long-press enters, tap toggles; the screen owns the action bar, export, merge and delete |
GeofenceLayers | Shared geofence rendering (fill polygons, stroke outlines, labels) used by DashboardMap and GeofenceScreen |
UserLocationOverlay | User position dot with accuracy circle, used by DashboardMap and GeofenceScreen |
MapCenterButton | Reusable button overlay to re-center the map |
OfflinePackManager.ts handles the offline maps feature:
| Export | Purpose |
|---|---|
createOfflinePack | Creates a MapLibre offline pack for a bounding box at z8-14 |
loadOfflineAreas | Fetches all stored packs from MapLibre's OfflineManager and returns status info (size, complete, active) and each pack's bounds |
subscribeOfflinePack | Re-attaches progress and error listeners to a pack native reports active and returns its status. Never call it on an inactive pack: observing one re-activates it |
pruneOfflineAreaBounds | Drops sidecar entries whose pack is gone, in one write |
deleteOfflineArea | Unsubscribes, pauses, and deletes a pack; resets the tile database when the last pack is removed to reclaim OS storage |
willExceedTileLimit | Estimates whether an area would hit the 100k-tile cap before downloading |
estimateSizeLabel / estimateSizeBytes | Pre-download size estimates using per-zoom tile counting and per-tile byte averages |
loadOfflineAreaBounds / saveOfflineAreaBounds / removeOfflineAreaBounds | Persist area metadata (style URL and completion time; the extent comes from the pack) to the native SQLite settings table |
Supporting utilities in mapUtils.ts:
| Utility | Purpose |
|---|---|
lerpColor | Linearly interpolates between two hex colors by factor t - used by getSpeedColor |
getSpeedColor | Returns a theme-aware color for a given speed (m/s) using green→yellow→red interpolation |
createCirclePolygon | Generates a 64-point GeoJSON Polygon approximating a circle on Earth's surface (for meter-based geofence radius) |
buildTrackSegmentsGeoJSON | Creates per-segment LineString features with pre-computed speed colors for data-driven styling |
buildTrackPointsGeoJSON | Creates Point features with speed, timestamp, accuracy, and altitude properties |
buildGeofencesGeoJSON | Creates fill polygons and label points for geofence visualization |
computeTrackBounds | Computes the bounding box for a set of track locations |
darkifyStyle | Transforms OpenFreeMap vector style JSON into a dark theme variant by overriding paint properties |
Utils
| Utility | Purpose |
|---|---|
logger | Environment-aware logging - suppresses debug/info console output in production via __DEV__, always logs warn/error to console. All levels are always captured in a ring buffer (MAX_BUFFER_SIZE, 2000 entries) which the Logging screen previews and logExport.buildAppLog formats into AppFileLogger's own line shape for the exported file |
logExport | Parses native log lines into MergedLogEntry and builds the two halves of an export. A line matching neither the file nor the logcat shape is a continuation and inherits the time and level of the line above it, so a stack trace filters and sorts with the throw it belongs to. buildExportHeader writes the version, flavor, device and capture window; buildAppLog writes the ring buffer with an APP_LOG_COVERAGE marker at its first line, because the buffer spans one process while the recorded file spans restarts |
LogExportMerger (Kotlin, util/) | Interleaves the app log into the recorded segments by timestamp while streaming them to the SAF document, so an export is one timeline and never holds a multi-megabyte file in memory. The merge is native because the segments are copied natively and the bridge only hands JS a capped tail of them, so a JS-side merge would truncate the file it completes |
geo | Haversine distance, speed/distance/duration/time formatting with configurable unit system (metric/imperial) and time format (12h/24h), auto-detected from locale on first use |
exportConverters | Export-format metadata (labels, icons, extensions, MIME types) for the export UI. Serialization itself is native - see ExportConverters.kt |
trips | Trip segmentation via time-gap detection (15-min threshold), dropping segments whose bounding box spans under 100 m so stationary heartbeat runs do not become trips, plus distance computation, trip stats (avg speed, elevation gain/loss), and trip color assignment. Elevation is smoothed over a time window before accumulating, since raw altitude swings between fixes overstate the climb. Manual trip_boundary_overrides take priority over the gap threshold, and a segment abutting a forced split is exempt from the 100 m filter so an explicit edit is never silently dropped. getDailyStats in DatabaseHelper.kt mirrors all of this for the calendar and summary |
dashboardState | Pure functions behind the Dashboard: pickBannerCondition ranks the one banner slot (location grant, background grant while tracking, location services, battery), describeState turns tracking, pause and fix state into the dock's state line, intervalLines and formatLastFix |
settingsValidation | URL validation and security checks for endpoint configuration |
Hooks
| Hook | Purpose |
|---|---|
useLocationTracking | Manages the foreground service lifecycle, native event subscriptions, and location state. On app foreground it reconciles the user's intent against real service liveness and restarts a service that died |
useTheme | Provides theme colors, mode, and toggle from ThemeProvider context |
useAutoSave | Debounced auto-save pattern for settings screens |
useTimeout | Managed timeout with automatic cleanup on unmount |
State Management
The app uses React Context for global state:
- ThemeProvider - Light/dark theme with system preference sync
- TrackingProvider - Single source of truth for tracking state, coordinates, settings and the active profile's name and id. Hydrates from SQLite on mount, restores the active profile's name and id from the running service on reconnect, and persists changes back through
SettingsService.
Data Flow
User taps "Start" → TrackingProvider.startTracking()
→ NativeLocationService.start(config)
→ LocationServiceModule.startService(config)
→ LocationForegroundService starts
→ GPS fix received
→ DatabaseHelper.saveLocation()
→ SyncManager.queueAndSend()
→ NetworkManager.sendToEndpoint()
→ LocationServiceModule emits "onLocationUpdate"
→ NativeEventEmitter → useLocationTracking → UI updates
Shared Package
packages/shared is the single source of truth for:
- Colors -
lightColorsanddarkColorsobjects with all theme colors - Typography -
fontFamily("Inter") andfontSizesscale - Types -
ThemeColorsinterface andThemeModetype
Both the mobile app and docs site import from @colota/shared. The package compiles TypeScript to dist/ via tsc so Docusaurus can consume it without a custom webpack loader.