Skip to the content.

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:

  1. 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.
  2. Your OS permissions — needs ACCESS_FINE_LOCATION (+ COARSE); to keep running in the background it needs ACCESS_BACKGROUND_LOCATION.
  3. The operator’s server active flag — every 15 min SettingsPollWorker fetches settings/<appKey>/<deviceId>. If active == false it calls core.stopModules() — a remote kill switch. If active == true it starts the modules.
  4. 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 cart positions 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?

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:

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:

  1. The zone geometry — where and how big radius_2 is.
  2. The active flag — a server kill-switch that can disable the whole SDK.

Plus three client-side limits that do live in the app:

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:

  1. 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.
  2. adb logcat (no root; you have adb already) — with the app running, filter on SETTINGS_POLL, GEO_OBSERVER, CORE. You’ll see whether zones were fetched, whether you’re currently inGeoZone, 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.)
  3. Intercept the settings HTTPS 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 under debug-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:

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/ — open index.html in a browser. It parses the documented CSV/JSON record formats and renders:

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):

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):

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:

  1. the genuine safety feature (SOS button) only needs foreground location, and
  2. 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 settings poll and cached the response in its private Room database. We read that on-device database (CrowdConnected-RoomDatabase, table GeoZone) and watched logcat. 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 active to 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 core GeoZone business object that inGeoZone()/inAnyMedZone() iterate has only id, appKey, lat, lng, radius1, radius2 — there is no per-zone active field in the in-zone check. The active column in the Room table is cached metadata that the geofence logic does not consult; upload eligibility is gated by the global settings active flag (§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. Inside radius2 a 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 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 loads ttfestival-zone.csv by 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():

⚠️ 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 local GeoZone.radius1 was temporarily widened with root so the real coordinates counted as inner-ring; the device was then restored to the authentic radius1=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