Locations & history
There are three ways to read a position out of Beekon:
- Live stream —
locations. Real-time, replay-1. Only flows while the host process is alive. - One-shot fix —
getCurrentLocation(timeout, accuracy). A single immediate fix, independent of tracking. - History reads —
getLocations(from, to). Durable: reads the on-device SQLite database, survives process death.
The Location struct
Section titled “The Location struct”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 presentaccuracy?, speed?, bearing?, altitude?, // null when unreportedquality, trigger, motion, activity, isMock // derived metadata| Field | Unit | Notes |
|---|---|---|
id | opaque | stable per-row identifier (native) |
latitude, longitude | degrees (WGS-84) | always present |
accuracy | metres (horizontal, 1σ) | null when the OS did not report |
speed | metres/second | null when the OS did not report (e.g. stationary) |
bearing | degrees, [0, 360) clockwise from true north | null when unreported; direction of travel, not heading |
altitude | metres above WGS-84 ellipsoid | null when unreported or vertical accuracy invalid |
timestamp | wall-clock | OS provider’s fix time, not the time Beekon received it |
quality, trigger, motion, activity | enum/derived | native metadata describing how/why the fix was captured |
isMock | boolean | whether the OS flagged the fix as mock/simulated |
Raw passthrough
Section titled “Raw passthrough”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
Section titled “The live stream”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")}Task { for await loc in await Beekon.shared.locations { let acc = loc.accuracy.map { String($0) } ?? "—" print("\(loc.latitude), \(loc.longitude) acc=\(acc)m") }}Beekon.instance.locations.listen((loc) { print('${loc.latitude}, ${loc.longitude} acc=${loc.accuracy ?? "—"}m');});const off = Beekon.onLocation((loc) => { console.log(loc.latitude, loc.longitude, 'acc=', loc.accuracy ?? '—');});The live stream only emits while the host process is alive — for fixes captured during process death, query history.
Filtering on top
Section titled “Filtering on top”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 */ }for await loc in await Beekon.shared.locations where (loc.accuracy ?? .infinity) <= 25 { // render}Beekon.instance.locations .where((loc) => (loc.accuracy ?? double.infinity) <= 25) .listen((loc) { /* render */ });// filter in your onLocation callbackOne-shot fix
Section titled “One-shot fix”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 failurelet fix = try await Beekon.shared.getCurrentLocation(timeout: .seconds(15), accuracy: .high)if let fix { print("now at \(fix.latitude), \(fix.longitude)") }// nil ⇒ timed out; throws BeekonError.locationUnavailable(reason:) on a precondition failurefinal fix = await Beekon.instance.getCurrentLocation( timeout: const Duration(seconds: 15), accuracy: AccuracyMode.high,);// null ⇒ timed out; throws LocationUnavailable on a precondition failureconst fix = await Beekon.getCurrentLocation({ timeoutMs: 15000, accuracy: 'high' });// null ⇒ timed out; rejects with a BeekonError on a precondition failureHistory
Section titled “History”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.
Schema
Section titled “Schema”Each row is one emitted (gated) position.
| Column | Type | Meaning |
|---|---|---|
ts | INTEGER NOT NULL | Unix milliseconds (timestamp from the OS provider) |
lat | REAL NOT NULL | Degrees |
lng | REAL NOT NULL | Degrees |
accuracy | REAL (nullable) | Metres, horizontal 1σ; NULL when unreported |
speed | REAL (nullable) | Metres/second; NULL when unreported |
bearing | REAL (nullable) | Degrees, [0, 360); NULL when unreported |
altitude | REAL (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.
| Platform | Path |
|---|---|
| Android | App-private data (Room default), inside the foreground-service process |
| iOS | Library/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.
Retention
Section titled “Retention”A two-axis policy that runs on every write batch:
TTL: 7 daysCap: 100,000 rowsAfter 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.
Reading history
Section titled “Reading history”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 failurelet now = Date()let hourAgo = now.addingTimeInterval(-3600)let points = try await Beekon.shared.getLocations(from: hourAgo, to: now)// async throws BeekonError.storage on DB failurefinal now = DateTime.now();final hourAgo = now.subtract(const Duration(hours: 1));final points = await Beekon.instance.getLocations(from: hourAgo, to: now);const now = new Date();const hourAgo = new Date(Date.now() - 3_600_000);const points = await Beekon.getLocations(hourAgo, now);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.
Deleting history
Section titled “Deleting history”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 failurelet removed = try await Beekon.shared.deleteLocations(before: Date().addingTimeInterval(-30 * 86_400))try await Beekon.shared.deleteLocations(before: nil) // wipe the whole bufferfinal removed = await Beekon.instance.deleteLocations( before: DateTime.now().subtract(const Duration(days: 30)),);await Beekon.instance.deleteLocations(); // wipe the whole bufferconst removed = await Beekon.deleteLocations(new Date(Date.now() - 30 * 86_400_000));await Beekon.deleteLocations(); // wipe the whole bufferRetention 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.
Activity detection
Section titled “Activity detection”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 ?: "—"}")}try await Beekon.shared.configure(BeekonConfig.selfManaged(detectActivity: true))
for await loc in await Beekon.shared.locations { print("moving as \(loc.activity.map { "\($0)" } ?? "—")")}await Beekon.instance.configure( const BeekonConfig.selfManaged(detectActivity: true),);
Beekon.instance.locations.listen((loc) { print('moving as ${loc.activity ?? "—"}');});await Beekon.configure({ mode: 'selfManaged', detectActivity: true });
Beekon.onLocation((loc) => console.log('moving as', loc.activity ?? '—'));activity also rides the upload payload — it appears on each locations[] entry (omitted when detection is off).