Skip to the content.

On-device egress proof — Dutch GP & Museumnacht (live geo-collection capture)

Phase 3 of the comparison. Phases 1–2 (01-on-device-cohort.md, 02-full-platform-deep-dive.md) fingerprinted the SDK statically and probed runtime active state via a 35 s LAUNCHER probe, which left two gaps open: Dutch GP was logged as “🟠 IPS dormant / active=false” and Museumnacht as “SDK present, consent not passed — did not enable within the 35 s probe.” Phase 2 explicitly noted: “to capture their true active/radius state we would need to tap through each onboarding/consent flow.” This document does exactly that and records the result.

Method: on-device signals only — no proxy, no CA, no MITM, no packet capture. Mock GPS (Android cmd location test providers) placed the device at each app’s geozone centre; evidence = the apps’ own AWS Kinesis Firehose record-buffer files (…/cache/CrowdConnected/firehose/{positions,observations,logs}/KinesisRecorder/kinesis_firehose_records) plus logcat upload lines, attributed by process PID. Dedicated rooted Pixel 10a, Android 16, 2026-06-27. Raw artifacts + SHA-256 manifest are held off-repo in the capture evidence set (evidence/ondevice-cc/).

Related — Phase 4 (background / screen-off): background / app-not-open collection is demonstrated separately in 06-background-location-app-not-open.md, which independently corroborates the two-gate model (OS permission vs server active) and captures TT Festival uploading while backgrounded and Dozing.


0. TL;DR

  1. Dutch GP (com.appmiral.dutchgp, appKey 36fe15a0) was caught collecting and uploading a precise GPS position to CrowdConnected’s AWS Firehose (firehose.us-east-1.amazonaws.com, Firehose_20150804.PutRecordBatch) while the device sat inside its Zandvoort geozone. Both halves are on-device evidence:
    • Egress (10:19:37): PID 20328 → AbstractKinesisRecorder: submitAllRecords sent 2 records to firehose.us-east-1.amazonaws.com.
    • Collected datum (10:20:03): positions buffer 0 → 261 bytes; decoded record = deviceId 382c3ab8…, geo, lat 52.3874734, lon 4.5445238, …, appKey 36fe15a0, stream colocator-v3-positions-firehose.
  2. That deviceId 382c3ab8… is the same one already shown in identity-correlation-proof.md to be aliased to app_user_id bcc5c494… — i.e. the live-captured location is keyed to a re-identifiable pseudonymous id, not an anonymous token.
  3. Correction to Phase 2’s “active=false / dormant”: the steady state is gated off, but the legacy SDK performs a transient first-activation collect+upload the moment onboarding completes inside the zone. A second relaunch produced no new record — confirming a one-shot activation, then suppression.
  4. The legacy apps’ real runtime kill-switch is a server-fetched value, CrowdConnected.xml → activeAPIResponse = 120,false,false,…, not the local Settings.geoEnabled DB flag (which is still 1). This is the mechanism behind the “dormant” readings.
  5. Correction to Phase 2’s “Museumnacht — consent not passed”: Museumnacht (30d143df) is in fact registered and consented on this device (deviceId 8fdc7e2d…, gdpr_consent.xml → SDK.Crowdconnected.Core = true, Intake.Seen = true). Its config-poll workers run and succeed — but with activeAPIResponse=…,false,… it collected nothing (buffers stayed 0) during the test window. No egress observed.
  6. Permission-granted re-test isolates the gate (2026-06-27, 12:06–12:10). The Museumnacht runs in (5) had MN’s OS location permission off — an independent confound (with location denied the app cannot collect regardless of the server gate). Repeated with ACCESS_FINE/COARSE/BACKGROUND_LOCATION = granted=true, device at the zone centre, geoEnabled=1, sendPositionData=1, registered + consented, it still collected nothing for the full 210 s while activeAPIResponse=…,false,… (re-polled live during the run). MN’s only AWS-signed egress was SNS push registration (sns.eu-west-1), not the location Firehose. ⇒ the server active value is the operative kill-switch; the OS location permission is a separate, additional gate. Evidence: evidence/ondevice-cc/recheck2-permgrant-2026-06-27/.
  7. The kill-switch relocated between SDK generations (config comparison, §6). Pulling the live-collecting app TT Festival (dd54d39d) alongside the two gated apps shows the server gate moved from the legacy activeAPIResponse heartbeat string into a Settings.active column in TT’s newer SDK (sdkVersion 2.1.0): TT reads active=1 (ON, Mode=BACKGROUND, collecting) while MN/DGP read activeAPIResponse=…,false (OFF). Same user-invisible, no-rebuild remote switch — just relocated from XML to DB.

1. Apps and baseline state

App appKey CC deviceId Geozone centre inner r1 outer r2 geoEnabled sendPositionData
com.appmiral.dutchgp 36fe15a0 382c3ab8-a294-4c68-887c-0d99653511ec 52.387479, 4.544506 (Circuit Zandvoort) 800 m 34 000 m 1 1
com.appmiral.museumnachtamsterdam 30d143df 8fdc7e2d-310a-4401-ae13-ce3548ce54d9 52.363654, 4.89389 (Amsterdam) 10 155 m 80 000 m 1 1

Both read live from each app’s databases/CrowdConnected-RoomDatabase (Settings + GeoZone tables) and shared_prefs/CrowdConnected.xml. Both apps’ three Firehose record buffers were 0 bytes at rest before the test → any growth is attributable to the mock-GPS placement.

The legacy schema (these two apps) has no active kill-switch column in the Settings table — collection is governed at runtime by the server value activeAPIResponse. Contrast TT Festival’s newer SDK, which carries no activeAPIResponse and a gpsInZoneGenerated=true flag, and which was caught actively uploading 45+ position batches during its live event (see the festival-night capture set). A full side-by-side of the live (TT) vs gated (MN/DGP) configs — and the relocated kill-switch — is in §6.

2. Dutch GP — the collected position (what leaves the device)

positions buffer grew 0 → 261 bytes at 10:20:03. The raw record is a single AWS Firehose record: the stream name in clear text, followed by a base64 payload.

colocator-v3-positions-firehose,<base64>

Decoded payload (comma-separated):

382c3ab8-a294-4c68-887c-0d99653511ec,   # CC deviceId (persistent)
geo,                                     # record type
3888632.059094894,309082.68882861035,    # projected X,Y
52.3874734,4.5445238,                    # LATITUDE, LONGITUDE  (= geozone centre, Δ≈7 m)
bf28eb2d-bc20-4876-84f2-555fa1eb0b00,    # zone/session UUID
use,0,
1782548403551,                           # 2026-06-27 10:20:03 +02:00
2,2,
36fe15a0                                 # appKey = Dutch GP

So the record carries precise location + a stable device identifier + the app key, shaped for the colocator-v3-positions-firehose Kinesis stream. Via the alias map in CrowdConnected.xml (app_user_id,bcc5c494-43ab-432d-b28f-3a8c0de6d87c; sns_user_id,…dutchgp2025…), that deviceId resolves to the same re-identifiable id analysed in identity-correlation-proof.md.

3. Dutch GP — the upload (egress to AWS)

From the CC-filtered logcat (threadtime), all from PID 20328:

10:17:04  20328  okhttp: connection to https://api.crowdconnected.com/ was leaked      (CC control-plane)
10:17:30  20328  SQLiteConnectionPool: …/com.appmiral.dutchgp/databases/CrowdConnected-RoomDatabase leaked
10:19:37  20328  AWS4Signer: host:firehose.us-east-1.amazonaws.com
10:19:37  20328  AWS4Signer: x-amz-target:Firehose_20150804.PutRecordBatch
10:19:37  20328  AbstractKinesisRecorder: submitAllRecords sent 2 records

PID-20328 = com.appmiral.dutchgp is proven by the same process leaking a SQLite handle to …/com.appmiral.dutchgp/databases/CrowdConnected-RoomDatabase and an OkHttp connection to api.crowdconnected.com, immediately before issuing the Firehose PutRecordBatch. → Dutch GP uploaded 2 records to firehose.us-east-1.amazonaws.com at 10:19:37, then collected the 261-byte position at 10:20:03 (which remained queued once the SDK fell dormant).

4. The runtime kill-switch, and why “dormant” is the steady state

CrowdConnected.xml → activeAPIResponse was 120,false,false,<ts> for both apps (Dutch GP timestamp 10:26:31; refreshed on retest, still …,false,false). Re-launching Dutch GP a second time at the inner zone (10:34) produced no new record (buffer stayed 261). Reading: the SDK does a one-shot collect+upload at first activation inside the zone, then the server active=false poll suppresses further collection. This both confirms Phase 2’s “active=false / IPS dormant” steady-state reading and shows it is not the whole story — a transient in-zone collect+upload still occurs.

5. Museumnacht — registered, consented, but gated off (negative result)

Conclusion for MN: SDK installed + registered + consented + live geozone, but observed not collecting/uploading while the server runtime gate is off. Egress is neither proven nor refuted for a future active window; the capability is fully present.

Update (2026-06-27 — permission-granted re-test, isolating the gate). The runs above were later found to carry a confound: Museumnacht’s OS location permission was not granted during them (owner-observed; consistent with the appops FINE_LOCATION first-use timestamp ~11:50). With location denied the app cannot collect regardless of the server gate, so those runs could not isolate activeAPIResponse as the cause. The test was repeated with the confound removed:

Strengthened conclusion: with every local precondition for collection satisfied — location-permitted, in-zone, geoEnabled=1, registered + consented — Museumnacht still uploads nothing because the server runtime gate is off. This isolates the server-fetched activeAPIResponse as the operative kill-switch and shows the OS location permission is a separate, additional gate (it was off in the earlier runs). Evidence: evidence/ondevice-cc/recheck2-permgrant-2026-06-27/ (own RECHECK2-SUMMARY.md, chain-of-custody.md, MANIFEST.sha256).

6. Live config comparison — the kill-switch relocated (legacy activeAPIResponse → newer Settings.active)

To put the two gated legacy apps in context, the live collecting app on the same device — TT Festival (com.appmiral.ttfestival, appKey dd54d39d) — was pulled and compared side-by-side. All three apps’ CrowdConnected.xml (SharedPrefs) + Room DB (Settings, GeoZone, and, on the newer SDK, SDKStatus) were read on-device on 2026-06-27. The comparison shows the apps are configured to collect identically in intent (geoEnabled=1, sendPositionData=1, a real geofence each); what differs is the SDK generation and therefore where the remote on/off switch lives.

6.1 The remote kill-switch has two implementations

CrowdConnected’s collection only runs when the server says the app is “active”. The switch lives on their server — the local geoEnabled / sendPositionData flags only mean “is this feature compiled in”, not “is it allowed to run right now”. The two SDK generations implement that server switch differently:

Legacy SDK (Museumnacht, Dutch GP) — the activeAPIResponse heartbeat string. Stored in CrowdConnected.xml as a 4-field CSV, observed activeAPIResponse = 120,false,false,<epoch_ms>:

field example meaning
0 120 validity / re-poll window in SECONDS (~2-min heartbeat). Confirmed: the timestamp advanced ~104 s between polls and refreshed the instant the app was foregrounded.
1 false the active flag — the master collect gate. true = allowed to collect; false = suppressed.
2 false secondary flag (purpose unconfirmed; tracked equal to field 1 in every run).
3 <epoch_ms> timestamp the response was fetched.

The legacy Settings table has no active column — the runtime gate lives only in this string.

Newer SDK (TT Festival, sdkVersion 2.1.0) — a Settings.active column. The newer Room Settings table adds an active column (server-pushed on each settings poll, cadence pollingFrequency = 120 s), plus an SDKStatus table and Mode / modules state. There is no activeAPIResponse string in TT’s prefs at all — the gate moved into the database. TT’s value is active = 1 → ON.

Why “deadman” switch. In both generations the switch fails safe to OFF: the SDK must keep re-hearing “active” from the server within the ~120 s window (legacy: refresh the heartbeat string; newer: re-fetch settings). If the server stops answering, or answers “not active”, collection stops. It is a remote kill-switch the operator holds — invisible to the user, and revoking it needs no app update. Current observed state:

app switch location value meaning
TT Festival dd54d39d Settings.active (DB) 1 ON — actively collecting & uploading
Museumnacht 30d143df activeAPIResponse field 1 (XML) false OFF — held inactive by server
Dutch GP 36fe15a0 activeAPIResponse field 1 (XML) false OFF — held inactive by server

So the legacy two apps are the same switch turned the other way from the live app.

6.2 Side-by-side configuration (the three live apps)

field TT Festival dd54d39d Museumnacht 30d143df Dutch GP 36fe15a0
runtime state LIVE — collecting gated OFF gated OFF
SDK generation newer (SDKStatus, active col) legacy legacy
sdkVersion 2.1.0 (from SDKStatus) (no SDKStatus table) (no SDKStatus table)
remote switch Settings.active = 1 activeAPIResponse = 120,false,… activeAPIResponse = 120,false,…
pollingFrequency 120 s (n/a — uses heartbeat) (n/a — uses heartbeat)
geozone centre (lat,lng) 52.994439, 6.563694 (Assen / TT circuit) 52.363654, 4.893890 (Amsterdam) 52.387479, 4.544506 (Zandvoort)
inner ring radius1 → geoHighFrequency 820 m → 30 s 10 155 m → 30 s 800 m → 30 s
outer ring radius2 → geoMediumFrequency 60 000 m (60 km) → 1800 s 80 000 m (80 km) → 1800 s 34 000 m (34 km) → 1800 s
geoEnabled / sendPositionData 1 / 1 1 / 1 1 / 1
sendRawData 0 0 0
foregroundOnly (column removed in new schema) 0 (bg allowed) 0 (bg allowed)
Mode / modules (SDKStatus) BACKGROUND / GEO,BACKGROUND (n/a) (n/a)
gpsInZoneGenerated (state) true (has produced in-zone fixes) (n/a) (n/a)
positions buffer at read 0 (cycles 0→N→0: collect then upload) 0 (nothing collected) 261 (queued — cannot flush while inactive)
consented SDKs (gdpr_consent.xml) many (Braze.Location, AWS.SNS, Core…) Core only Core only
aliasKeys app_user_id,sns_user_id sns_user_id app_user_id,sns_user_id
CC deviceId 9043ca3d-724d-49c7-97a7-0076493085e5 8fdc7e2d-… 382c3ab8-…

Schema evolution (the structural tell of old vs new SDK). The newer Settings table dropped foregroundOnly + bluetoothCycleOnTime/OffTime and added debugEnabled, debugDeviceIdList, pollingFrequency, and active; the newer build also adds an SDKStatus table (device model/OS, sdkVersion, Mode, modules, gpsGenerated, gpsInZoneGenerated, aliasKeys). This matches the two-schema split already noted in ../methodology-and-reproduction.md §2.3.

Buffer semantics (how the three states read off the same signal). A positions buffer that cycles 0→N→0 means collect then upload (TT — matches the parallel screen-off capture’s 0→281→0). A buffer stuck at a non-zero value (Dutch GP’s 261) means records were collected earlier but the app cannot flush them while inactive — they sit queued. Flat 0 and never moving (Museumnacht) means nothing was collected.

Auth upkeep ≠ collection. All three refresh a Cognito authToken (JWT, ~5-min TTL, issuer us-east-1_xsD5yIcIS) even while gated OFF. Token refresh is identity/session upkeep, not a collection signal — only buffer growth + a PutRecordBatch is.

6.3 What stands out

  1. The kill-switch moved house, but it’s the same switch. Legacy apps gate on the activeAPIResponse heartbeat string; the newer TT SDK gates on a Settings.active column. Same server-controlled, user-invisible, no-rebuild-required remote switch — just relocated from XML to DB.
  2. TT Festival is the live one. active=1, in-zone, Mode=BACKGROUND, gpsInZoneGenerated=true, buffers cycling → it is collecting precise location and uploading to Firehose, including screen-off (see Phase 4). The two legacy apps are configured identically in intent but held OFF by the server.
  3. The local flags are not the protection. All three carry geoEnabled=1 and sendPositionData=1. The only thing between the two legacy apps and live tracking is the server returning active=false. Flip it to true (no app update needed) and they behave like TT.
  4. Geometry differs a lot. TT’s inner high-frequency ring is a tight 820 m but its outer ring is a huge 60 km — i.e. logs you at 30-min cadence from up to 60 km away, then every 30 s within 820 m. Museumnacht’s inner ring is ~10 km (almost all of Amsterdam is “high-frequency”); Dutch GP mirrors TT’s tight 800 m inner with a 34 km outer.

7. Reproduction — pulling and comparing the three live configs

Prerequisites: rooted device (su), adb on PATH, Python 3.11+ (sqlite3). General bench/setup and the GPS-spoof + chain-of-custody procedure are in ../methodology-and-reproduction.md; this section is the finding-specific subset.

Techniques used: on-device Room-DB read with WAL checkpoint (no backend contact); firehose record-buffer size deltas as the collect/upload signal; PID-attributed logcat (-v threadtime) for egress; AOSP test-provider GPS mock to place the device in-zone.

1 — Pull each app’s CrowdConnected Room DB (root):

$pkg = "com.appmiral.ttfestival"   # repeat for com.appmiral.museumnachtamsterdam / com.appmiral.dutchgp
adb shell "su -c 'cp /data/data/$pkg/databases/CrowdConnected-RoomDatabase /data/local/tmp/cc.db; cp /data/data/$pkg/databases/CrowdConnected-RoomDatabase-wal /data/local/tmp/cc.db-wal 2>/dev/null; chmod 666 /data/local/tmp/cc.db*'"
adb pull /data/local/tmp/cc.db ; adb pull /data/local/tmp/cc.db-wal

2 — Dump Settings / GeoZone / SDKStatus (Python sqlite3, applies the WAL):

import sqlite3
con = sqlite3.connect("cc.db"); cur = con.cursor()
cur.execute("PRAGMA wal_checkpoint(TRUNCATE)"); con.commit()   # merge the WAL first
for tbl in ("Settings", "GeoZone", "SDKStatus"):
    cols = [r[1] for r in cur.execute(f"PRAGMA table_info({tbl})")]
    print(tbl, cols)                                            # presence of 'active'/'SDKStatus' = newer SDK
    for row in cur.execute(f"select * from {tbl}"):
        print(dict(zip(cols, row)))

The active column in Settings (and the presence of SDKStatus) distinguishes the newer SDK from the legacy schema; GeoZone gives centre + radius1/radius2.

3 — Read the firehose record buffers (collection signal):

$b = "/data/data/$pkg/cache/CrowdConnected/firehose"
adb shell "su -c 'stat -c %s $b/positions/KinesisRecorder/kinesis_firehose_records 2>/dev/null; stat -c %s $b/observations/KinesisRecorder/kinesis_firehose_records 2>/dev/null'"

Growth = collection; flush back to 0 = upload; a stuck non-zero = queued-but-inactive.

4 — (Isolation control) place the device in-zone and poll. For the Museumnacht negative, grant location, mock GPS to the geozone centre, and poll the buffers (here ~210 s) while re-reading activeAPIResponse:

adb shell appops set --uid 2000 android:mock_location allow
foreach ($p in 'gps','network','fused') {
  adb shell cmd location providers add-test-provider $p
  adb shell cmd location providers set-test-provider-enabled $p true
  adb shell cmd location providers set-test-provider-location $p --location 52.363654,4.89389 --accuracy 5
}
# poll step 3 in a loop; teardown: remove-test-provider each + appops … android:mock_location default

Format coordinates with InvariantCulture F7 (an EU locale’s decimal comma corrupts the fix). Result on this bench: buffers stayed 0/0/0 while activeAPIResponse=…,false,… — isolating the server gate (see §5).

8. Limitations & disclosures

9. Evidence set (off-repo, hashed)

Held in the capture evidence directory evidence/ondevice-cc/ with a SHA-256 MANIFEST.sha256 (25 artifacts):

Plus a separate permission-granted re-test set with its own manifest:


Appendix A — Reusable prompt (any CrowdConnected app)

Hand this to an execution session/agent to run the same on-device-only egress test against any CC-enabled app by plugging in the package + geozone params. (This is the generic, parameterised version of the executed test above — originally drafted as a standalone egress-test doc, folded in here.)

For CC geofence semantics, the buffer-file egress signal, and the GPS-spoof mechanism it relies on, see ../methodology-and-reproduction.md §2.3 (Room DB), §5 (GPS mocking) and the firehose/.../kinesis_firehose_records buffers described in §6.2 above.

# Task: Prove whether a CrowdConnected-enabled app actually SENDS location data when inside its geofence

You are operating against a physical ADB-connected, Magisk-rooted Pixel 10a
(Android 16, serial 5B161JEA308016). The target app embeds the CrowdConnected
(CC) location SDK. Determine whether it COLLECTS and TRANSMITS location to
CrowdConnected when the device is inside the geofence's OUTER band and INNER
circle, versus a CONTROL position far outside. Use ON-DEVICE SIGNALS ONLY (no
network interception).

## Parameters (fill in per app)
- PKG            = com.appmiral.dutchgp            # or com.appmiral.museumnachtamsterdam
- APPKEY         = 36fe15a0                        # 30d143df for museumnacht
- CENTRE_LAT,LON = 52.387479, 4.544506             # 52.363654, 4.89389
- INNER_R1_m     = 800                             # 10155
- OUTER_R2_m     = 34000                           # 80000

## CC geofence semantics
- Inside R2 (outer): SDK active, collects at geoMediumFrequency (~1800 s).
- Inside R1 (inner): switches to geoHighFrequency (~30 s) -> "Transitioning to high settings".
- Outside R2: dormant / minimal.

## The on-device egress signal
CC buffers outbound data in on-disk Kinesis Firehose record files:
  /data/data/$PKG/cache/CrowdConnected/firehose/positions/KinesisRecorder/kinesis_firehose_records
  /data/data/$PKG/cache/CrowdConnected/firehose/observations/KinesisRecorder/kinesis_firehose_records
  /data/data/$PKG/cache/CrowdConnected/firehose/logs/KinesisRecorder/kinesis_firehose_records
- File GROWS  => a position/observation was collected and queued.
- File FLUSHES to ~0 => the batch was uploaded to firehose.us-east-1.amazonaws.com.
- CC Room DB /data/data/$PKG/databases/CrowdConnected-RoomDatabase gains position rows.
- logcat confirms sends: "KINESIS : Sending batch", "submitAllRecords sent N records",
  "GEO_OBSERVER: Transitioning to high settings". (root: `adb shell su -c '...'` for app-private paths.)

## Step 1 - Preconditions (do FIRST, abort if unmet)
1. Establish a scriptable mock-GPS mechanism. Check current state:
   `adb shell settings get secure mock_location` and
   `adb shell appops get $PKG android:mock_location`.
   If not configured, register AOSP test providers (gps/network/fused) and
   `adb shell appops set <uid> android:mock_location allow`, then VERIFY you can
   drive the device location to an arbitrary lat/lon (read back via
   `adb shell dumpsys location`).
2. Launch $PKG. Grant location: `adb shell pm grant $PKG android.permission.ACCESS_FINE_LOCATION`
   and set background to "Allow all the time". Pass any in-app privacy/consent gate.
3. Confirm the SDK is running: pull the CC Room DB and check for a populated
   SDKStatus row + geoEnabled=1. If the SDK never starts, a negative egress result
   is INCONCLUSIVE - note it.

## Step 2 - Baseline snapshot
Record sizes of the 3 buffer files, CC DB position row count, and start a filtered logcat:
  `adb logcat -c; adb logcat | Select-String 'CrowdConnected|KINESIS|Firehose|submitAllRecords|high settings'`

## Step 3 - Run the 3 states (hold each 5-10 min)
For each state: set mock GPS, wait, then snapshot buffer sizes, CC DB row delta, and grep logcat.
  A. CONTROL: lat 0.0, lon 0.0 (or any point > OUTER_R2_m from centre).
  B. OUTER : a point between R1 and R2 from centre (~5-10 km for Dutch GP, ~30 km for museumnacht).
  C. INNER : CENTRE_LAT, CENTRE_LON (well within R1).

## Step 4 - Verdict
Data IS being sent if, in OUTER and/or INNER, the positions buffer grows+flushes AND/OR
logcat shows "submitAllRecords sent" AND/OR new CC DB position rows appear - while CONTROL
shows none. Report a per-state evidence table (buffer bytes, DB row delta, send log lines)
and a clear pass/fail per app. Do NOT decrypt payloads; this is destination-agnostic
on-device proof of collection + upload activity tied to geofence state.

Upgrade paths (stronger proof):