# 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](https://app.appmiral.com/ttfestival2026/privacy-policy).

It is a companion to the full technical write-up in
[`crowd-connected.md`](crowd-connected.md). Sections 1–4 come from **static
analysis of the decompiled APK**; [§5](#5-empirical-verification-on-a-rooted-device-2026-06-08)
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](#5-empirical-verification-on-a-rooted-device-2026-06-08)): 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](#55-update-2026-06-23-flag-now-on-during-the-live-festival)) — 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/`](../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:

```java
// 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);
```

```java
// 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?
- **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:**
  1. The `active` flag 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.
  2. A **device-status ping** (`device-status` endpoint) does report booleans
     like "GPS was generated" even outside a zone — so the backend can learn
     *that* your device produced location, without the coordinates.

**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`:

```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_2` as 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_2` large 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:

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:
- It needs your **consent** and **`ACCESS_BACKGROUND_LOCATION`** to 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 `allowed` flag 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](#5-empirical-verification-on-a-rooted-device-2026-06-08). 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:
- 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/`](../tools/cc-visualizer/) — open `index.html` in a
> browser. It parses the documented CSV/JSON record formats and renders:
> - GPS positions on a map (Leaflet),
> - indoor X/Y scatter (for `cart` records, 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](crowd-connected.md#4-what-data-is-collected-and-sent)
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](https://app.appmiral.com/ttfestival2026/privacy-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`](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](#4a-the-safetyemergency-framing-vs-what-actually-needs-background-location). | 🟠 Medium — "safety-washing" / dark pattern |

Details and code references for items 1–5 are in
[`privacy-policy-analysis.md`](privacy-policy-analysis.md) and
[`crowd-connected.md`](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_LOCATION` in the foreground** (`requestPermission()`),
  not background.
- It then makes **one** call to the **Appmiral** backend:
  `POST events/{festivalRouteIdentifier}/editions/{editionRouteIdentifier}/emergency/create`
  with `latitude`, `longitude`, a `code`, `cc_device_id`, `app_user_id` and 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:
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](#54-update-2026-06-23-flag-now-on-during-the-live-festival).** 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](#1a-what-is-the-geo-zone-and-how-invasive-is-this-really):

- 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=false` flag**. 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, enabling
  `active` would 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/`](../tools/cc-visualizer/) — it loads
> [`ttfestival-zone.csv`](../tools/cc-visualizer/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](crowd-connected.md#9-summary-of-privacy-relevant-observations)
to `colocator-v3-positions-firehose` in **us-east-1**. See [§5.6](#56-live-runtime-capture-2026-06-23-real-gps-in-the-pipeline) 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 to `KINESIS: Sending batch of positions via kinesis` with **no "Not
  sending"** line — i.e. it was added to the recorder and the on-disk buffer
  flushed to **0 bytes** (synchronous upload to Firehose `us-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 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

- Sections 1–4 are **static analysis** of the decompiled v2.0.1 APK pulled from
  a physical device (see [`reverse-engineering.md`](reverse-engineering.md)).
- The **production backend was never queried or authenticated against** by us.
  The empirical values in §5 (the `active` flag and the exact zone geometry) were
  obtained by letting the app perform its own normal `settings` poll 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](#56-live-runtime-capture-2026-06-23-real-gps-in-the-pipeline))
  read the app's own `logcat` while it ran normally. To observe the upload path
  from a location in the medium band, the **local** `GeoZone.radius1` was
  temporarily widened with root so the device's **genuine** GPS counted as
  inner-ring; the DB was then restored to `radius1=820`. No fabricated coordinates
  were sent and no traffic was intercepted/proxied.
- The `active` flag 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 or `active=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 `active` is 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.
