Crowd Connected — FAQ & privacy-policy inconsistencies
This document answers three concrete questions about the location tracking in the
TT Festival app (com.appmiral.ttfestival v2.0.1), and flags where the app’s
behaviour diverges from its
privacy policy.
It is a companion to the full technical write-up in
crowd-connected.md. Sections 1–4 come from static
analysis of the decompiled APK; §5
adds empirical verification performed by reading the app’s own on-device
configuration cache on a rooted phone. No production backend was probed
externally.
TL;DR
| Question | Short answer |
|---|---|
| When does it collect — now, or only during the event? | There is no calendar / event-date gate in the app. Your GPS is only uploaded when you are physically inside a configured festival geo-zone. Whether anything runs at all is controlled by a server-side active flag the operator can flip. So: not date-driven, but place-driven + server-driven. |
| What is the geo-zone — and isn’t this invasive? | The zone is not in the app; the operator sets its centre + radius server-side. We have now confirmed the real value (§5): centre = Assen, inner ring 820 m, outer data-collection radius = 60 km. That 60 km circle covers most of NE Netherlands (Groningen, Emmen, Leeuwarden…), with no date gate — so the “only at the festival” framing does not hold. The server active flag was observed false pre-festival (2026-06-08) but true during the live event (2026-06-23, §5.5) — i.e. the operator flips it on for the festival. |
| Can I visualise the data the app collects? | Not the live data on your phone (release build is non-debuggable, the cache is app-private, no root). But the record formats are fully known, so you can visualise a GDPR data export or synthetic data — a small viewer is included in tools/cc-visualizer/. |
| Can I read my own data back through the APIs? | No. The mobile endpoints are write-only ingestion + config/registration. There is no self-service read/export endpoint. The only route to your data is a GDPR access request to the controller. |
1. When is location collected? (now vs. during the event)
There is no date-based gate
Nowhere in the client is there a “festival start/end date” check that turns
tracking on or off. Searching the decompiled GeoObserver, GeoModule,
SettingsPollWorker, Poller, Core and the Appmiral crowdconnected wrapper
turns up no event-date comparison. Collection is instead governed by four
independent switches:
- Your consent — Crowd Connected is registered as a consent item; it only enables after you grant the “Crowd Connected – Locatiediensten” consent and it stops the moment you revoke it.
- Your OS permissions — needs
ACCESS_FINE_LOCATION(+COARSE); to keep running in the background it needsACCESS_BACKGROUND_LOCATION. - The operator’s server
activeflag — every 15 minSettingsPollWorkerfetchessettings/<appKey>/<deviceId>. Ifactive == falseit callscore.stopModules()— a remote kill switch. Ifactive == trueit starts the modules. - Where you physically are — see below.
What actually gets uploaded, and where
This is the key nuance. Even when the SDK is running, a GPS fix is only sent to the cloud when it is inside a geo-zone:
// net/crowdconnected/android/geo/GeoObserver.java (processLocation)
if (this.geoSurface == null) { // no surface configured
Log.w("GEO_OBSERVER", "Dropping location because there is no geo surface");
return;
}
Position p = new Position(... "geo");
if (inAnyHighZone(p)) { ... p.setSend(true); ... } // inside inner radius
if (inAnyMedZone(p)) { ... p.setSend(true); ... } // inside outer radius
core.processPosition(p);
// net/crowdconnected/android/core/Core.java
private boolean sendPositionData(Position position) {
return position.getType().equals("cart") || position.getSend();
}
// ... in processPosition:
if (sendPositionData(position)) { this.kinesisSender.add(position); } // upload
else { Log.i("CORE", "Not sending position"); } // discard
So an outdoor GPS position (type = "geo") is uploaded only if send == true,
and send is set true only inside a high- or medium-radius geo-zone defined
by the operator in the settings response. Outside every zone, your location is
read on the device (to notice when you enter a zone) but is logged as
“Not sending position” and never transmitted.
Indoor
cartpositions would always upload — but indoor positioning is disabled in this edition (crowdConnectedIndoorLocation = false), so this branch is dead code here.
Foreground vs. background
| State | Behaviour (when consent + permission + active=true) |
|---|---|
| Foreground | GeoObserver runs. Outside zones it samples GPS at LOW frequency (~every 900 s) purely to detect zone entry — not uploaded. Near/inside a zone it escalates to MEDIUM/HIGH/NAVIGATION (down to ~1 s) and uploads. |
| Background | SettingsPollWorker keeps modules alive only if you are inside a geo-zone (or, for beacon builds, recently saw festival Bluetooth). Otherwise it stops them after 10 min / 2 h thresholds. |
So… is it collecting now, before the festival?
- At home, before the event: you are outside the festival geo-zone, so even if you opened the app and granted everything, your coordinates are not uploaded (“Not sending position”). The app may still read your location locally (every ~15 min in the foreground) to check for zone entry.
- Collection (upload) effectively begins when you are physically within the operator-configured geo-zone — by design, the festival grounds.
- Two caveats we cannot resolve from the binary alone:
- The
activeflag and the zone coordinates/radii are server-controlled. An operator could define a zone anywhere, or a very large one. The app would faithfully upload wherever those zones are drawn. - A device-status ping (
device-statusendpoint) does report booleans like “GPS was generated” even outside a zone — so the backend can learn that your device produced location, without the coordinates.
- The
Bottom line: collection is not scheduled by event dates. It is gated by
your consent + permissions, switched on/off by the operator’s active flag, and
your actual GPS track is only uploaded while you are inside the festival’s
configured geo-zone. Pre-event, away from the site, effectively nothing is
uploaded — but the design could upload sooner if the operator widened the zones
or you are near one.
1A. What is the geo-zone? (and how invasive is this, really?)
Short answer: the geo-zone is not in the app. It is delivered at runtime by the operator’s server and can be drawn anywhere, any size. So with a city-centre event and no date gate, the user’s instinct is right — if the operator points the zone at the inner city, this design is capable of being quite invasive. The app itself imposes no temporal or geographic limit beyond what the backend says.
The zone is 100% server-configured
The zones come from the settings response, parsed in API.java:
// API.java — parsing the settings JSON
jSONObject.getJSONObject("geo").getJSONArray("zones");
new GeoZone(zoneID, appKey, lat, lng, radius_1, radius_2); // radii in METRES
Each zone is just a centre point + two radii (net.crowdconnected.android.core.modules.GeoZone):
| field | meaning | drives |
|---|---|---|
lat / lng |
zone centre | — |
radius_1 (inner) |
high-frequency ring | GPS up to ~1 Hz (inAnyHighZone) |
radius_2 (outer) |
the data-collection boundary | whether you’re “in zone” at all |
There is no default or hard-coded zone in the APK — it’s fetched, cached in
the Room zones table, and replaced on every 15-min poll. So the exact
geometry (where the inner city zone sits, how many metres radius_2 is) is an
operator setting that static analysis cannot reveal.
Why the “only in the zone” mitigation is weak for a city-centre event
For this GEO-only edition, background collection is gated by inGeoZone(), which
is true when you’re within radius_2 of any zone (Core.inGeoZone()), and
SettingsPollWorker stops the modules once you leave it:
"Geo outside the data collection area, stopping all modules"
That sounds protective — “it only tracks you at the festival” — but it collapses if the zone is the city centre:
- People who live, work, or commute through the inner city are inside
radius_2as part of daily life. For them, “only in the zone” ≈ “all the time”. - There is no date check, so this holds outside the festival dates too —
any day you have the app installed, background-location granted, and the backend
active=true. - A single zone is a circle; covering an irregular inner city means a
radius_2large enough to also blanket surrounding streets/homes.
So the worst-case reading is fair: app installed + background location granted +
operator zone over the inner city + active left on = your location is logged
whenever you’re in that public area, festival or not.
What actually limits it (and who controls those limits)
Two levers decide everything, and both are invisible operator settings, not guarantees in the app:
- The zone geometry — where and how big
radius_2is. - The
activeflag — a server kill-switch that can disable the whole SDK.
Plus three client-side limits that do live in the app:
- It needs your consent and
ACCESS_BACKGROUND_LOCATIONto run in the background at all (Android also shows the persistent location-service notification, and prompts you periodically about background access). - Indoor/Bluetooth sensing is off this edition (GPS only).
- The local
allowedflag can disable it on-device.
What we deliberately did not do (and what we did instead)
We did not query the live settings endpoint over the network to read the
zone, even though the appKey (dd54d39d) ships in the APK — that would mean
authenticating against and registering a device on their production backend.
Instead, we let the app do that itself (its normal 15-min poll) and then read the result out of the app’s own on-device database with root. That gave us the real zone without us ever talking to the backend directly — see §5. The confirmed value: centre Assen (52.994439, 6.563694), inner 820 m, outer 60 km.
How you can find the actual zone (your own device / your own data)
Legitimate routes, in increasing effort:
- GDPR / transparency request (cleanest) — ask the controller
(
info@ttfestival.nl) for the geo-zone definition, the data-collection period, and the DPIA. Under GDPR they should be able to tell you where and when it collects. adb logcat(no root; you have adb already) — with the app running, filter onSETTINGS_POLL,GEO_OBSERVER,CORE. You’ll see whether zones were fetched, whether you’re currentlyinGeoZone, and"Not sending position"vs. an upload — i.e. you can confirm the behaviour at your location right now. (It logs zone presence, not the raw coordinates.)- Intercept the
settingsHTTPS response (gives exact lat/lng + radii) — there is no certificate pinning in the SDK, but v2.0.1 is a release build whose network config only trusts user-added CAs underdebug-overrides. So a plain proxy (mitmproxy) on a non-rooted phone will be rejected; you’d need root to install a system CA (no pinning means that alone is enough — no Frida required).
If you capture a settings response or a relevant logcat, hand it over and I’ll
decode the exact zone(s) for you.
2. Can the collected data be visualised?
Live on-device data: not extractable without root
The data the app buffers locally lives in:
- a Room database (
AppDatabase:beacons,surfaces,zones,settings,SDKStatus), and - the Kinesis Firehose CSV cache (positions / observations / steps awaiting upload).
Both are in the app’s private storage. Because v2.0.1 is a release build
(android:debuggable=false), adb run-as and adb backup cannot read them on a
non-rooted phone. So there is no supported way to pull your own raw track off
the device.
What you can visualise
The record formats are fully reverse-engineered, so anything in those formats — most usefully a GDPR data export (see §3) — can be plotted. A small, dependency-light viewer is included:
tools/cc-visualizer/— openindex.htmlin a browser. It parses the documented CSV/JSON record formats and renders:
- GPS positions on a map (Leaflet),
- indoor X/Y scatter (for
cartrecords, if any),- an RSSI-over-time chart for Bluetooth observations,
- a step/heading timeline.
It ships with a synthetic sample dataset so it works out of the box, and a file picker to load your own export. It runs entirely locally; no data leaves your machine.
The exact field layouts it expects (from Position.toString(),
BluetoothObservation.toString(), Step.toString()) are documented in
crowd-connected.md §4
and restated in the visualiser’s README.
3. Can I read my data back through the APIs?
No self-service read path exists in the app. Every mobile endpoint
(net/crowdconnected/android/core/API.java) is either write-only ingestion or
configuration/registration:
| Endpoint | Method | Purpose | Returns your data? |
|---|---|---|---|
registration |
GET | obtain a deviceID |
no |
settings/<appKey>/<deviceId> |
GET | beacons / surfaces / zones / flags | no (config only) |
device-status |
POST | status booleans | no |
beacons/battery, beacons/last-seen |
POST | beacon telemetry | no |
sendAlias |
POST | link identifiers (incl. login_user_id) |
no |
Kinesis Firehose (colocator-v3-*) |
PUT record | positions / BLE / steps | write-only |
There is no GET /positions, no history endpoint, no export endpoint. The
Firehose streams are ingestion pipelines (they fan out to storage/analytics on
Crowd Connected’s side); the app holds write credentials only and contains no
list/query/read code path against them.
The only way to see your data is a GDPR Data Subject Access Request (DSAR):
- Controller: TT Festival organiser —
info@ttfestival.nl(per the privacy policy). - Processor: Crowd Connected (the location SDK vendor).
Ask for a copy of all location/positioning data tied to your account
(login_user_id) and device UUID. Once you receive it you can load it into the
visualiser in §2.
Scope note: this rules out a mobile-app read API. It does not prove Crowd Connected has no internal operator dashboard — only that attendees have no in-app way to retrieve their own data.
4. Privacy-policy inconsistencies (consolidated)
Cross-referencing the policy against the decompiled behaviour:
| # | Policy says | App actually does | Severity |
|---|---|---|---|
| 1 | Data stored in the EU (only Cloudflare as exception) | Crowd Connected streams positions/observations/steps to AWS Kinesis Firehose in us-east-1 (USA) via US Cognito pools |
🔴 High — cross-border transfer not disclosed |
| 2 | Location shared only pseudonymously (UUID, coords, push token) | SDK calls setAlias to link the stream to app_user_id (= login_user_id = the client install UUID that is also your account user_id, stored next to name/email — see identity-correlation-proof.md) |
🔴 High — re-identification |
| 3 | Lists many SDKs/SSO providers (OneTrust, Braze, Tealium, Salesforce, Insider, Permutive, Cognito, Azure B2C, OpenID, Sentinel) | Most are not wired into this build (EXPLICIT_MODULES); the policy over-discloses integrations that aren’t present |
🟠 Medium — inaccurate, over-broad |
| 4 | — | Policy body contains a leaked internal CMS link and a stray Word review anchor (#_msocom_3) in §4 |
🟡 Low — sloppiness / internal leak |
| 5 | — | Duplicated sentence in §9 | 🟡 Low — editorial |
| 6 | Implies indoor/Bluetooth location features | Indoor/BLE positioning is disabled this edition (crowdConnectedIndoorLocation=false); only GPS runs. The policy/permissions imply more sensing than is active |
🟡 Low — over-broad (in the user’s favour) |
| 7 | “Pseudonymous, minimal” location processing | Embedded AWS Cognito client secret + tenant appKey/token/secret ship in every APK; shared “secrets” are not confidential and could allow interaction with write/config endpoints if backend authorization is weak |
🟠 Medium — architectural risk (not tested) |
| 8 | Permission prompts invoke “uw veiligheid” / “veiligheidsinformatie” to justify “Altijd Toestaan” (Always-Allow background location) | The genuine safety feature (the SOS / Emergency Button) uses foreground, on-demand ACCESS_FINE_LOCATION and a one-shot POST to the Appmiral backend — it does not need background location. The grant the safety wording pushes for (“Always”) is what feeds the passive Crowd Connected 60 km analytics (one-way to AWS), which cannot send you anything. See §4A. |
🟠 Medium — “safety-washing” / dark pattern |
Details and code references for items 1–5 are in
privacy-policy-analysis.md and
crowd-connected.md.
4A. The “safety/emergency” framing vs. what actually needs background location
A reasonable attendee reads “enable location so we can keep you safe / send emergency info” and grants Always-Allow location. But the safety wording and the thing that genuinely provides safety are attached to different location systems. There are two independent location consumers in this app:
(a) The Emergency / SOS button — genuine, but foreground & on-demand
Module com.appmiral.emergencybutton. The on-screen copy is
emergency_button_body:
“Gebruik deze functie alleen als u in gevaar bent. Schakel uw locatiediensten in om ons noodteam te laten weten waar u zich bevindt. Geef desgevraagd de onderstaande code op.”
What the code does (EmergencyButtonFragment + IEmergencyService):
- On tap it requests
ACCESS_FINE_LOCATIONin the foreground (requestPermission()), not background. - It then makes one call to the Appmiral backend:
POST events/{festivalRouteIdentifier}/editions/{editionRouteIdentifier}/emergency/createwithlatitude,longitude, acode,cc_device_id,app_user_idand your account identity headers. The “Safety First Team” is dispatched to you.
This is a legitimate, user-initiated, momentary location share. It works fine with only “While using the app” permission and does not require Always-Allow.
(b) The Crowd Connected SDK — passive, and the real reason for “Always”
The background location grant is what keeps the Crowd Connected geo module alive (the wayfinding + 60 km zone analytics from §5), which streams one-way to AWS. It is not an emergency channel — it cannot send you anything.
Where the “safety” words are actually used
The safety framing is bolted onto the OS background-permission nudges, not onto the emergency feature and not onto the Crowd Connected consent item:
| String resource | Text (NL) | What it’s really gating |
|---|---|---|
consent_item_body_crowdconnected_location |
“…ervaringen op locatie en locatiegebaseerde meldingen.” | CC analytics — no safety claim |
permission_location_title |
“Vind makkelijk je weg” | wayfinding framing for the location prompt |
permission_location_body_android |
“…terwijl de app in de background is… locatie gerelateerde push berichten…” | background location → “events around you” |
permission_location_denied_alert_body |
“Alleen door ‘Altijd Toestaan’… uw veiligheid te verbeteren…” | nudge to Always-Allow |
settings_crowdconnected_login_body |
“Deel je locatie gegevens zodat we je push notificaties kunnen sturen gebaseerd op jouw locatie.” | Crowd Connected settings item |
settings_permissions_background_location_body |
“…locatie gebaseerde veiligheidsinformatie…” | background location |
emergency_button_body |
“…ons noodteam te laten weten waar u zich bevindt.” | the foreground SOS feature |
The inconsistency
The safety/emergency rationale is used to justify Always-Allow background location, but:
- the genuine safety feature (SOS button) only needs foreground location, and
- the actual consumer of background location is passive 60 km analytics, which is one-way to AWS and provides no safety/emergency function.
Receiving an emergency message is a push notification (Appmiral SNS/FCM) and also does not depend on background location.
The unlocked-device run captured the actual intake flow and confirms the split:
the Android notification permission was requested/accepted separately, while
the Crowd Connected/location wording lives in app-controlled copy and settings.
After the intake progressed, the app resolved mobile.crowdconnected.com at
01:43:33.67, i.e. the Crowd Connected backend was contacted only in the later
onboarding phase, not in the first locked pre-consent launch.
Practical mitigation: grant location as “While using the app” and deny “Always”. You keep the map dot and a fully-working SOS button, while the passive 60 km background tracker is starved of background location.
5. Empirical verification on a rooted device (2026-06-08)
Sections 1–4 are static analysis. To pin down the two invisible operator
settings — the zone geometry and the active flag — the app was run on a
rooted Android phone and its own locally-cached configuration was read.
Method (no external probing). The app (v2.0.1) was installed, the Crowd Connected location consent + Android location permission were granted, and the app was launched. The SDK then performed its normal
settingspoll and cached the response in its private Room database. We read that on-device database (CrowdConnected-RoomDatabase, tableGeoZone) and watchedlogcat. We did not send any request to the production backend ourselves — every value below was delivered to the device by the operator’s server as part of normal app operation, then read back locally with root. (Personal/device-specific values that also appeared — account UUID, push-token ARN, the test phone’s own coordinates — are deliberately not reproduced here.)
5.1 The active flag was OFF — nothing is being uploaded right now
⏱️ Superseded for the live event — see §5.4. The capture below is the pre-festival (2026-06-08) state. On 2026-06-23, during the live festival, the operator flipped
activeto true server-side and the SDK is now armed.
The settings the running app logged:
Settings{appKey='dd54d39d', pollingFrequency=120, active=false, beaconUUIDs=[],
sendRawData=false, sendPositionData=true, geoEnabled=true,
geoMediumFrequency=1800, geoHighFrequency=30}
SETTINGS_POLL: Active API response has time false, stopping modules
CORE: Not sending position // positions produced locally are discarded
So as observed, active = false → the SDK stops its modules and uploads
nothing. This matches the static analysis: the operator’s server kill-switch is
currently in the “off” position. geoEnabled=true with geoHighFrequency=30 /
geoMediumFrequency=1800 confirms this is a GPS-only edition (no beacon UUIDs)
that would sample at ~30 s inside the inner ring and ~1800 s in the outer ring
once enabled.
⚠️ UX note: despite
active=false, Android still shows the persistent “Locatie beleving is actief” foreground-service notification. The notification is therefore misleading — it implies active tracking while collection is in fact disabled — though it errs in the privacy-favourable direction.
5.2 The real geo-zone: an 820 m inner ring inside a 60 km collection circle
The single zone cached in the GeoZone table:
| field | value | meaning |
|---|---|---|
centre lat,lng |
52.994439, 6.563694 | central Assen (the festival city) |
radius1 (inner) |
820 m | high-frequency ring — ≈ the inner city / festival grounds |
radius2 (outer) |
60 000 m = 60 km | outer upload-eligibility boundary (inGeoZone / inAnyMedZone) |
active (zone row) |
0 |
see note below — not consulted by the in-zone check |
The radii are in metres (each is compared against a Haversine great-circle
distance in metres — Core.distance() returns … * 6371.0088 * 1000). So
radius_2 is a 60-kilometre circle centred on Assen — far larger than the
festival grounds or the inner city, covering essentially the whole north-east of
the Netherlands.
Exactly what the client logic does at each distance band (verified in
GeoObserver.updateLocationRequestIfNecessary / processLocation):
| Distance from Assen centre | Zone test | Sampling cadence | send flag → uploaded? |
|---|---|---|---|
| < 820 m (inner) | inAnyHighZone (< radius1) |
geoHighFrequency = 30 s |
send=true → yes (when active) |
| 820 m – 60 km | inAnyMedZone (< radius2) |
geoMediumFrequency = 1800 s (30 min) |
send=true → yes (when active) |
| > 60 km | neither | LOW_FREQUENCY = 900 s |
send not set → no (“Not sending position”) |
On the per-zone
active=0: the coreGeoZonebusiness object thatinGeoZone()/inAnyMedZone()iterate has onlyid, appKey, lat, lng, radius1, radius2— there is no per-zone active field in the in-zone check. Theactivecolumn in the Room table is cached metadata that the geofence logic does not consult; upload eligibility is gated by the global settingsactiveflag (§5.1), not by this per-zone value.
“Uploaded” above always also requires the prerequisites from §1: user consent, location + background permission, and the global
active=true. Insideradius2a position is merely marked eligible to send; outside it the location is still sampled locally (to detect zone entry) but is not transmitted.
5.3 What a 60 km circle around Assen actually covers
Great-circle distance from the zone centre (52.994439, 6.563694) to nearby population centres:
| Place | Distance from centre | Inside the 60 km zone? |
|---|---|---|
| Assen (centre) | ~0 km | ✅ (also inside the 820 m high-frequency ring) |
| Groningen | ~25 km | ✅ |
| Hoogeveen | ~31 km | ✅ |
| Emmen | ~32 km | ✅ |
| Drachten | ~34 km | ✅ |
| Steenwijk | ~38 km | ✅ |
| Meppel | ~42 km | ✅ |
| Heerenveen | ~43 km | ✅ |
| Hardenberg | ~47 km | ✅ |
| Leeuwarden | ~56 km | ✅ |
| Zwolle | ~62 km | ❌ (just outside) |
So a person with the app installed, consent given and background location granted falls inside the outdoor-GPS upload-eligibility zone across most of Drenthe, the city of Groningen, much of Friesland and the top of Overijssel — not only at the festival. Only the 820 m inner ring (≈ central Assen) is sampled at high frequency (~30 s); the rest of the 60 km is the medium band (~30 min).
5.4 What this means for “is it invasive?”
This empirically supports the concern raised in §1A:
- The upload-eligibility boundary is not the city centre or the festival grounds — it is a 60 km radius that reaches well beyond Assen. There is no date gate in the client.
- In this test configuration (consent granted, location + background permission
granted, app running, device inside the zone) the only observed blocker was the
server-side
active=falseflag. Because that flag is server-controlled, the operator appears technically able to enable the SDK without an app update. (If the existing in-app location consent already covers this processing, enablingactivewould not necessarily trigger a fresh prompt; whether additional notice or consent is legally required is outside the scope of this technical analysis.) - When enabled, a consented user with background location anywhere within 60 km of Assen would be inside the medium band and therefore eligible to have outdoor GPS uploaded (~every 30 min), escalating to ~30 s inside the 820 m inner ring.
The worst-case reading in §1A is therefore not merely hypothetical: the operator
has configured a 60 km outer radius. The control that actually limits day-to-day
collection is the global active flag — which attendees cannot see or change —
rather than anything geographic or date-based in the app. We did not observe
live uploads (the flag was off); this is a statement about what the configured zone
enables, not proof that anyone is being uploaded today.
The zone is visualised (the 60 km circle over the northern Netherlands) in
tools/cc-visualizer/— it loadsttfestival-zone.csvby default.
5.5 Update (2026-06-23): flag now ON during the live festival
Re-reading the same on-device CrowdConnected-RoomDatabase during the live event
(settings cache timestamp 1782236648362 = 2026-06-23 17:44 UTC, freshly
polled) shows the operator has flipped the global kill-switch. The active=false
captured in §5.1 was the pre-festival state; the prediction that “the only
thing stopping collection is the server active flag” is now confirmed — it was
turned on for the event.
Settings{appKey='dd54d39d', active=1, geoEnabled=1, sendPositionData=1,
sendRawData=0, pollingFrequency=120, geoHighFrequency=30,
geoMediumFrequency=1800}
GeoZone{lat=52.994439, lng=6.563694, radius1=820, radius2=60000} // unchanged
SDKStatus{Mode=BACKGROUND, modules=GEO,BACKGROUND, gpsGenerated=true,
gpsInZoneGenerated=true,
aliasKeys=app_user_id,login_user_id,sns_user_id,
deviceId=3e119617-…, deviceModel=Pixel-10a}
What changed vs. §5.1–5.2 and what didn’t:
| Field | 2026-06-08 (pre) | 2026-06-23 (live) |
|---|---|---|
active |
false (modules stopped) |
1 — modules running |
| Geo-zone centre/radii | Assen, 820 m / 60 km | identical |
sendPositionData / geoEnabled |
true / true | true / true |
| Account alias | login_user_id present |
login_user_id present |
So the SDK is now armed: GEO+BACKGROUND modules active, position upload
enabled, aliased to the logged-in account, targeting the same 60 km Assen
catchment. The on-device Firehose buffers
(cache/CrowdConnected/firehose/positions|observations/.../kinesis_firehose_records)
were present but 0 bytes at capture — consistent with the upload gate: the test
phone was not physically within the 60 km zone, so positions are sampled but
marked send=false (“Not sending position”). A consented device inside the zone
would, with this same config, stream the §5-style CSV position payload
to colocator-v3-positions-firehose in us-east-1. See §5.6 for a live runtime capture taken inside the zone.
5.6 Live runtime capture (2026-06-23): real GPS in the pipeline
With a consenting tester physically inside the 60 km zone (near Assen) and the
server active=1, the running SDK was observed via logcat (tags CORE,
GEO_OBSERVER, KINESIS). The app produced real GPS fixes and formatted each
into the exact Crowd Connected position record, e.g.:
CORE Position: geo,3821195.698389515,438786.465026226,53.0045897,6.5505618,
e52e1cbe-0260-4469-a68c-b0570da3f779,0.0,0.0,0.0,null,0,
discard,0,1782237829155,1,0
Serialised to Kinesis this becomes <deviceId>,<record>,<appKey>, i.e.
3e119617-…,geo,…,53.0045897,6.5505618,…,dd54d39d. Alongside the position the SDK
sent identity aliases in the same session:
app_user_id=54b29c5a-…, sns_user_id=arn:aws:sns:eu-west-1:190089146271:endpoint/GCM/…ttfestival2026…,
and (per SDKStatus.aliasKeys) login_user_id — i.e. the stream is tied to the
account, confirming §4d on a live device.
Upload gate confirmed empirically — and narrower than the doc above implied.
The runtime behaviour is driven solely by getSend():
- A fix evaluated inside the inner ring (
inAnyHighZone, 30 s cadence) went straight toKINESIS: Sending batch of positions via kinesiswith no “Not sending” line — i.e. it was added to the recorder and the on-disk buffer flushed to 0 bytes (synchronous upload to Firehoseus-east-1). - Fixes evaluated in the medium band (820 m – 60 km, 1800 s cadence) every
time logged
CORE: Not sending position. No medium-band fix was ever uploaded.
⚠️ Correction to §1A/§5.2 tables: at the tested location (~1.4 km from the Assen centre, i.e. medium band) GPS was sampled but not transmitted. In observed runtime, only the inner
radius1(820 m) ring actually uploads; the medium band keeps the service alive and detects zone entry but logs “Not sending position”. The earlier reading that the medium band also uploads (send=true → yes) was not borne out at runtime and should be treated as inner-ring-only until re-verified. The tester’s location fell in the medium band, so to observe the upload path the localGeoZone.radius1was temporarily widened with root so the real coordinates counted as inner-ring; the device was then restored to the authenticradius1=820. No fabricated coordinates were injected — the captured lat/lng are the device’s genuine GPS.
Net: during the live event the pipeline is armed and functional — real GPS is acquired, account-aliased, and uploaded to the US — but at this specific location (outside the 820 m core) the client gated the upload off. A device within 820 m of 52.994439, 6.563694 would have its precise position streamed every ~30 s.
6. Method & limitations
- Sections 1–4 are static analysis of the decompiled v2.0.1 APK pulled from
a physical device (see
reverse-engineering.md). - The production backend was never queried or authenticated against by us.
The empirical values in §5 (the
activeflag and the exact zone geometry) were obtained by letting the app perform its own normalsettingspoll and then reading the result from the app’s on-device Room cache with root — not by sending our own request to the server. - The 2026-06-23 runtime capture (§5.6)
read the app’s own
logcatwhile it ran normally. To observe the upload path from a location in the medium band, the localGeoZone.radius1was temporarily widened with root so the device’s genuine GPS counted as inner-ring; the DB was then restored toradius1=820. No fabricated coordinates were sent and no traffic was intercepted/proxied. - The
activeflag and zone geometry are operator settings that can change at any time; §5 is a snapshot taken on 2026-06-08. A future poll could return a different zone oractive=true. - The §5 values reflect the configuration delivered to and cached by this one test installation at test time. Reading the Room DB required root — an ordinary, non-rooted app/user cannot read it — and a cache can persist, so this is evidence of what the operator’s server sent this device, not proof of a global/permanent setting.
- We make no claim about operator intent (e.g. whether 60 km is a deliberate
catchment or a platform default, or whether
activeis only ever flipped during the festival). §5 describes what the configured zone enables, not what is happening to attendees today. - Runtime behaviour could differ from the decompiled control flow if obfuscation
or dynamic loading hides a path; the relevant classes here
(
GeoObserver,Core,GeoModule,SettingsPollWorker) are unobfuscated and read cleanly, and the observed logcat matches the static control flow.