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 runtimeactivestate 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 locationtest 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) pluslogcatupload 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 serveractive) and captures TT Festival uploading while backgrounded and Dozing.
0. TL;DR
- Dutch GP (
com.appmiral.dutchgp, appKey36fe15a0) 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 recordstofirehose.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, streamcolocator-v3-positions-firehose.
- Egress (10:19:37): PID 20328 →
- That
deviceId 382c3ab8…is the same one already shown inidentity-correlation-proof.mdto be aliased toapp_user_id bcc5c494…— i.e. the live-captured location is keyed to a re-identifiable pseudonymous id, not an anonymous token. - 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.
- The legacy apps’ real runtime kill-switch is a server-fetched value,
CrowdConnected.xml → activeAPIResponse = 120,false,false,…, not the localSettings.geoEnabledDB flag (which is still1). This is the mechanism behind the “dormant” readings. - 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 withactiveAPIResponse=…,false,…it collected nothing (buffers stayed 0) during the test window. No egress observed. - 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 whileactiveAPIResponse=…,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 serveractivevalue is the operative kill-switch; the OS location permission is a separate, additional gate. Evidence:evidence/ondevice-cc/recheck2-permgrant-2026-06-27/. - 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 legacyactiveAPIResponseheartbeat string into aSettings.activecolumn in TT’s newer SDK (sdkVersion 2.1.0): TT readsactive=1(ON,Mode=BACKGROUND, collecting) while MN/DGP readactiveAPIResponse=…,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)
- State on device:
deviceId 8fdc7e2d…,authTokenpresent,gdpr_consent.xml → SDK.Crowdconnected.Core = true,Intake.Seen = true. This corrects Phase 2’s “consent not passed.” (Phase 2’s 35 s LAUNCHER probe simply never reached the collection path; the app was in fact already onboarded.) - After relaunch at both the OUTER (Zandvoort) and INNER (Amsterdam centre) positions,
the CC WorkManager config workers ran and succeeded
(
CrowdConnected_Core_SETTINGS,_ZONES,_SURFACES) — but thepositions,observationsandlogsbuffers stayed at 0 bytes, and nosubmitAllRecords/PutRecordBatchwas emitted from MN’s PID. - With
activeAPIResponse=…,false,…, no collection or egress was observed. MN’s first-activation window predated this session (already onboarded), and was not recreated because doing so would require clearing app data (tampering), which was deliberately avoided.
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_LOCATIONfirst-use timestamp ~11:50). With location denied the app cannot collect regardless of the server gate, so those runs could not isolateactiveAPIResponseas the cause. The test was repeated with the confound removed:
- Permission verified granted:
ACCESS_FINE_LOCATION,ACCESS_COARSE_LOCATIONandACCESS_BACKGROUND_LOCATIONallgranted=true(dumpsys package).- Device in-zone at the MN geozone centre (
52.363654,4.89389, mockhAcc=5, confirmed dominant), local configgeoEnabled=1, sendPositionData=1, consented.- Result: MN
positions/observations/logsbuffers stayed 0/0/0 for the full 210 s (PID 10667); nofirehose.us-east-1/PutRecordBatchfrom MN. MN’s only AWS-signed egress was SNS push registration (sns.eu-west-1). The singlesubmitAllRecords sent 0lines came fromcom.appmiral.ttfestival(PID 25675), not MN.activeAPIResponsere-polled live during the run still read120,false,false.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-fetchedactiveAPIResponseas 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/(ownRECHECK2-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
- The kill-switch moved house, but it’s the same switch. Legacy apps gate on the
activeAPIResponseheartbeat string; the newer TT SDK gates on aSettings.activecolumn. Same server-controlled, user-invisible, no-rebuild-required remote switch — just relocated from XML to DB. - 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. - The local flags are not the protection. All three carry
geoEnabled=1andsendPositionData=1. The only thing between the two legacy apps and live tracking is the server returningactive=false. Flip it totrue(no app update needed) and they behave like TT. - 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
- GPS was spoofed to the geozone centres (Android test providers). This alters the input, not the subject; what is evidenced is the app’s own collection/upload behaviour. App binaries were not modified or re-signed.
- On-device evidence only. The exact bytes transmitted at 10:19:37 were not
packet-captured (already flushed); egress is proven by the app’s own AWS-SDK
logcatand the queued record proves the record shape. - Single device / single run. Dutch GP’s positive is one observed collect+upload
cycle; Museumnacht’s negative is bounded to the test window with
active=false. - The re-test’s location permission was granted programmatically (
adb pm grant): Museumnacht’s runtimeACCESS_FINE/COARSE_LOCATIONflags show noUSER_SET(a Settings-UI/dialog tap would set it), so the grant simulates a user who has allowed location rather than reproducing an actual tap. This is the intended control — it opens the OS-permission gate so the serveractivegate can be tested in isolation; it does not assert the real user granted location. (A parallel background-capture set records the same grant state per app inpermissions_grant_state.txt.) - The Dutch GP collection coincided with SDK first-activation after onboarding inside the zone; zone-entry vs SDK-start was not independently isolated, but the collected coordinate is the in-zone position.
9. Evidence set (off-repo, hashed)
Held in the capture evidence directory evidence/ondevice-cc/ with a SHA-256
MANIFEST.sha256 (25 artifacts):
dutchgp/collected-record/dgp_positions_record.bin— raw 261-byte record (sha2564331…A9BA) +dgp_positions_record_decoded.txtdutchgp/dgp_egress_logcat.txt— PID-attributed upload prooffinal/{dutchgp,museumnacht}/— CrowdConnected.xml, gdpr_consent.xml, CrowdConnected-RoomDatabase.sqlite (final snapshots)final/timeline.csv,final/monitor.log,final/logcat_cc.txt,final/final_bufstat.txtscreenshots/— control, onboarding, final MainActivityEVIDENCE-SUMMARY.md,chain-of-custody.md,MANIFEST.sha256
Plus a separate permission-granted re-test set with its own manifest:
recheck2-permgrant-2026-06-27/— Museumnacht re-run with location permissiongranted=true(negative result isolating the server gate):RECHECK2-SUMMARY.md,chain-of-custody.md,logs/timeline.csv,logs/logcat_cc.txt,museumnacht/CrowdConnected.xml|gdpr_consent.xml|CrowdConnected-RoomDatabase.sqlite, ownMANIFEST.sha256recheck2-permgrant-2026-06-27/compare/— the three-way live config comparison (§6): pulled Room DBsttfestival.db,museumnachtamsterdam.db,dutchgp.db;config-comparison.md(decode + side-by-side); reader scriptsdump_both.py,dump_tt.py,dump_sdkstatus.py
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):
- Destination proof: add on-device
tcpdumpto show bytes leaving to the Firehose/CrowdConnected hosts per state (TLS not decrypted). - Payload proof: add a system-CA mitmproxy to decrypt and capture the actual
position JSON/CSV (most invasive). Full pipeline in
../methodology-and-reproduction.md§6.