Skip to content

Locations & history

There are three ways to read a position out of Beekon:

  • Live streamlocations. Real-time, replay-1. Only flows while the host process is alive.
  • One-shot fixgetCurrentLocation(timeout, accuracy). A single immediate fix, independent of tracking.
  • History readsgetLocations(from, to). Durable: reads the on-device SQLite database, survives process death.

A Location carries 13 fields on both native platforms. The core trio — latitude, longitude, timestamp — is always present; the OS cannot return a fix without them. accuracy, speed, bearing, altitude are optional: null means “the OS did not measure this field.” Beekon never substitutes 0 for absence — 0 always means “the OS reported zero.” On top of those, native fixes carry a stable id plus derived metadata: quality, trigger, motion, activity, and isMock.

id, latitude, longitude, timestamp, // always present
accuracy?, speed?, bearing?, altitude?, // null when unreported
quality, trigger, motion, activity, isMock // derived metadata
FieldUnitNotes
idopaquestable per-row identifier (native)
latitude, longitudedegrees (WGS-84)always present
accuracymetres (horizontal, 1σ)null when the OS did not report
speedmetres/secondnull when the OS did not report (e.g. stationary)
bearingdegrees, [0, 360) clockwise from true northnull when unreported; direction of travel, not heading
altitudemetres above WGS-84 ellipsoidnull when unreported or vertical accuracy invalid
timestampwall-clockOS provider’s fix time, not the time Beekon received it
quality, trigger, motion, activityenum/derivednative metadata describing how/why the fix was captured
isMockbooleanwhether the OS flagged the fix as mock/simulated

Non-negotiable in v1: Beekon does not modify locations. No Kalman filter, no outlier rejection, no speed clamp, no minimum-accuracy filter. Whatever the OS provider produces is what you get on the stream and in history.

The single exception is value-level normalisation in the Location constructor: invalid floats (NaN, Infinity, bearing outside [0, 360)) are coerced to null. This rejects values that can’t be meaningful — it never invents one. A consequence: adjacent fixes can disagree dramatically (urban canyon, tunnel, cold start). Surface that to users via accuracy rather than hiding it.

The live stream is hot — it emits the latest gated fix to all subscribers. On Android the buffer is SharedFlow with replay = 1, DROP_OLDEST, capacity 64 (a slow consumer drops old emissions but never blocks the producer). iOS uses AsyncStream with equivalent semantics.

Beekon.locations.collect { loc ->
Log.d("beekon", "${loc.latitude}, ${loc.longitude} acc=${loc.accuracy ?: "—"}m")
}

The live stream only emits while the host process is alive — for fixes captured during process death, query history.

A common shape: drop low-accuracy fixes when rendering on a map. Do this in your own stream consumer — the persistence layer still records the raw fix.

Beekon.locations
.filter { (it.accuracy ?: Double.MAX_VALUE) <= 25.0 }
.collect { /* render */ }

When you need an immediate position at a discrete moment — the instant a trip starts — and don’t want to spin up tracking, ask for a single fix and await it. getCurrentLocation is independent of tracking state: it works whether or not a session is running, and never starts, stops, or disturbs one. The fix is returned to the caller only — it is not persisted and does not appear on the live stream — and is tagged trigger = Manual.

It returns null only when no usable fix arrives within the timeout (default 15 s); a precondition failure (permission missing, location services off) throws instead, so you can tell “ask for permission” apart from “try again”.

val fix = Beekon.getCurrentLocation(timeoutMs = 15_000, accuracy = AccuracyMode.High)
if (fix != null) Log.d("beekon", "now at ${fix.latitude}, ${fix.longitude}")
// null ⇒ timed out; throws BeekonException.LocationUnavailable on a precondition failure

Every gated position is persisted to a SQLite database the native library owns. The schema, retention, and write semantics match on both platforms — only the engine differs (Room on Android, GRDB on iOS). The write happens directly from the platform’s native location callback, so it never depends on a higher-level runtime being alive — a missed write would be a lost point.

Each row is one emitted (gated) position.

ColumnTypeMeaning
tsINTEGER NOT NULLUnix milliseconds (timestamp from the OS provider)
latREAL NOT NULLDegrees
lngREAL NOT NULLDegrees
accuracyREAL (nullable)Metres, horizontal 1σ; NULL when unreported
speedREAL (nullable)Metres/second; NULL when unreported
bearingREAL (nullable)Degrees, [0, 360); NULL when unreported
altitudeREAL (nullable)Metres above WGS-84 ellipsoid; NULL when unreported

The column names (ts, lat, lng) are internal storage names, not the public API — the native Location model exposes timestamp, latitude, longitude. There’s an index on ts ASC for range queries.

PlatformPath
AndroidApp-private data (Room default), inside the foreground-service process
iOSLibrary/Application Support/beekon/beekon.db, isExcludedFromBackup = true

iOS deliberately excludes the DB from iCloud — silent sync of a 100K-row trip database isn’t user-friendly.

A two-axis policy that runs on every write batch:

TTL: 7 days
Cap: 100,000 rows

After each write, rows older than 7 days and beyond 100K (from the oldest end) are pruned. With sync enabled, the full backlog is kept (bounded only by the cap) so an offline device can drain a long catch-up when it reconnects; in local-only mode the 7-day prune applies.

getLocations(from, to) returns locations inclusive of both bounds, sorted ascending by timestamp.

val now = Instant.now()
val hourAgo = now.minus(1, ChronoUnit.HOURS)
val points: List<Location> = Beekon.getLocations(from = hourAgo, to = now)
// suspend; throws BeekonException.StorageException on DB failure

Reads and writes share the same database in WAL mode, so concurrent readers don’t block the writer — you can read history while state == Tracking without affecting emission cadence.

deleteLocations(before) purges stored locations and returns the row count removed. Pass a cutoff to delete everything at or before that instant; omit it to wipe the entire buffer — the on-device half of a GDPR-style “delete my data”.

val removed: Int = Beekon.deleteLocations(before = Instant.now().minus(30, ChronoUnit.DAYS))
Beekon.deleteLocations() // wipe the whole buffer (before defaults to null)
// suspend; throws BeekonException.StorageException on DB failure

Retention already prunes old rows on every write — deleteLocations() is for on-demand erasure, not routine cleanup. With sync enabled, deleting a row that hasn’t uploaded yet drops it for good: it never reaches your server.

By default every fix’s activity field is null. Set detectActivity = true in your config and Beekon classifies the device’s physical motion onto each fix. It’s off by default because it costs battery and needs a second OS permission.

When on, activity carries one of six values: Stationary, Walking, Running, Cycling, Automotive, Unknown. It stays null whenever detection can’t run: it’s off, the permission wasn’t granted, or — on Android — the device has no Google Mobile Services.

Beekon.configure(BeekonConfig.SelfManaged(detectActivity = true))
Beekon.locations.collect { loc ->
Log.d("beekon", "moving as ${loc.activity ?: "—"}")
}

activity also rides the upload payload — it appears on each locations[] entry (omitted when detection is off).