Deep Dive — Crowd Connected (location intelligence) & libpositioner.so
A full static analysis of the Crowd Connected integration in the TT Festival
app (decompiled v2.0.1, versionCode 5). Crowd Connected is the location-
intelligence partner named in the app’s privacy policy; this document traces
exactly what it collects, how it positions the device, and where the data
goes — including the bundled native positioning engine libpositioner.so.
All findings come from static analysis of the decompiled APK + the native
.so. No traffic was intercepted, so runtime payload contents are inferred from the code that builds them.
1. Two layers + a native engine
| Layer | Package | Role |
|---|---|---|
| Appmiral wrapper | com.appmiral.crowdconnected |
Consent gating, lifecycle, identity aliasing |
| Crowd Connected SDK | net.crowdconnected.android.* |
“Colocator” SDK v2.1.0, GEO + IPS + Background modules, AWS transport |
| Native lib | libpositioner.so |
C/C++ indoor-positioning solver (BLE trilateration) |
The SDK’s product/codename is “Colocator” (Crowd Connected’s platform); Kinesis stream names and the API host confirm it.
2. When it runs — consent & lifecycle
Activation is gated (com.appmiral.crowdconnected/Module.java,
CrowdConnected.java):
- Consent: registered with the in-app
ConsentManagerasSdk.CrowdConnected. It only enables when SDK consent is granted; revoking consent callsdisable()→CrowdConnected.stop(). - Permissions: requires both
ACCESS_COARSE_LOCATIONandACCESS_FINE_LOCATION. On Android ≥10 it only continues running in the background ifACCESS_BACKGROUND_LOCATIONis granted (otherwise it disables on leaving the foreground). - A local kill-switch exists:
SharedPreferences("CrowdConnected").allowed(defaulttrue) plus a guard that disables it inside the.com.appmiral.UnityARprocess. -
API keys must be present: the festival-specific Crowd Connected credentials are shipped in the APK as Android string resources (
res/values/strings.xml) and read back viaAppmiralApp.getCrowdConnectedApiKey/Token/Secret():resource value (in this build) crowdConnectedKey(appKey)dd54d39dcrowdConnectedTokena86f…ecb0(redacted)crowdConnectedSecretkW2g…6xJ7(redacted)crowdConnectedIndoorLocationfalsecrowdConnectedSurface/crowdConnectedAuthKey(empty) The token/secret are shipped in every copy of the APK and must therefore be treated as publicly extractable credentials. Whether they can do damage depends on Crowd Connected’s backend-side scoping/rate-limiting. The
appKey(dd54d39d) scopes this app to its tenant on the Crowd Connected backend. Seehardcoded-credentials-and-keys.mdfor the complete key inventory.
Foreground service is declared in the manifest:
<service android:name="net.crowdconnected.android.background.CrowdConnectedService"
android:label="CrowdConnected"
android:exported="false"
android:foregroundServiceType="location"/>
So while active it runs as a persistent foreground location service.
3. Modules enabled
From CrowdConnected.enable(...) the configuration always adds:
GeoModule— outdoor GPS via Google Play ServicesFusedLocationProviderClient. Adaptive sampling:LOW(~900 s) up toNAVIGATION(~1 s) when inside a configured high-frequency geo-zone.BackgroundModule— runs the foreground service / keeps it alive.IPSModule(only if the edition has indoor location enabled) — the Bluetooth indoor-positioning system that driveslibpositioner.so.
⚠️ For this edition the IPS module is NOT wired in.
EditionKt.isIndoorLocationEnabled()returnsgetString(R.string.crowdConnectedIndoorLocation) == "true", and the resource is"false"(see §2). SoCrowdConnected.enable()only addsGeoModule+BackgroundModule. No Bluetooth beacon scanning and no indoor positioning run in TT Festival 2026 —libpositioner.so,BluetoothObserverand the beacon/step pipeline are all shipped in the binary but dormant. Effective collection for this build is outdoor GPS only.
4. What data is collected and sent
Three record types are streamed (see §5 for transport). Each record is a flat CSV line prefixed with the device id and suffixed with the app key:
add(Position) -> <deviceId>,<Position.toString()>,<appKey>
add(BluetoothObservation)-> <deviceId>,<BluetoothObservation.toString()>,<appKey>
add(Step) -> <deviceId>,<Step.toString()>,<appKey>
4a. Position (Position.toString())
type, x, y, lat, lng, surfaceId, maxRssi, beaconXForMaxRssi, beaconYForMaxRssi,
surfaceIdForMaxRssi, numberObservedSurfaces, use|discard, calcTime, timestamp,
position_quality, sequenceNumber
i.e. both GPS lat/lng and indoor x/y on a “surface” (floor/map), plus the strongest-beacon coordinates, a quality score, and a floor indicator.
4b. Bluetooth observation (BluetoothObservation.toString())
observedId, rssi, observationTimestamp, processTimestamp, sequenceNumber
observedId is a beacon identifier (the venue’s Crowd Connected beacons),
not a scan of random nearby phones — see §6. rssi is signal strength used for
ranging.
4c. Step (Step.toString())
[heading,] observationTimestamp, processTimestamp, sequenceNumber
A pedestrian-dead-reckoning step with compass heading, used to smooth indoor position between beacon fixes.
4d. Identity aliases (the linkage that matters for privacy)
On successful start, and via push-driven handleData(...), the SDK attaches
aliases that tie the location stream to identifiers:
| Alias key | Value | Source |
|---|---|---|
app_user_id |
app-generated UUID | CoreApp.getGeneratedUUID() |
login_user_id |
same client UUID (UserIdentity.getId() → getGeneratedUUID() for the profile login) |
IdentityProviderManager current identity |
sns_user_id |
push endpoint + locale | snsEndpoint push data |
tracker_user_id |
external tracker id | push data |
Validation code |
validation code | push data |
Note for the privacy comparison: the policy says location is shared “only in pseudonymized form … only a unique device identifier (UUID), coordinates, and optionally a push token.” In code the device is also aliased to the real
login_user_idwhen the user is logged in — i.e. location can be linked to an account identity, which is more than the policy states.Correction (see
identity-correlation-proof.md):login_user_idis not a separate server account id. For theprofileloginUserIdentity.getId()returns the same client-generatedgetGeneratedUUID()— sologin_user_id==app_user_id==54b29c5a-…. The re-identification point stands (that UUID is also theuser_idstored next to name/email), but the populated binding is the client install UUID, surfaced under both alias keys — not a registration id.
4e. Device id
deviceId is assigned by the Crowd Connected backend (registerDevice
returns deviceID) and persisted locally; on registration the app sends OS
version, library version (2.1.0), and device model.
5. Where the data goes 🚩 (US, not EU)
Two transports, both AWS in the United States:
HTTPS control/config API
https://mobile.crowdconnected.com/prod/<path> header: x-cognito-token
GET settings/<appKey>/<deviceId> -> beacons, surfaces (maps/floors), geo zones
POST registerDevice -> returns deviceID
POST beacons/battery , beacons/last-seen
Settings are polled (SettingsPollWorker, cached ~24 h).
AWS Kinesis Firehose (telemetry) — region US_EAST_1
colocator-v3-positions-firehose (positions)
colocator-v3-bluetooth-observations-firehose (raw BLE observations)
colocator-v3-steps (steps)
KinesisSender constructs KinesisFirehoseRecorder(..., Regions.US_EAST_1, ...)
and authenticates via AWS Cognito (IDENTITY_POOL_ID / USER_POOL_ID in
us-east-1).
This directly contradicts the privacy policy’s claim that “all personal data are stored within the European Union … with the exception of Cloudflare.” Position, Bluetooth and step telemetry are streamed to AWS Kinesis Firehose in
us-east-1(N. Virginia, USA), and the Cognito identity/user pools are US-East-1. EU-only does not hold for the Crowd Connected pipeline.
Local caching (on device)
Firehose records are buffered on disk in the app cache before upload:
- positions: up to 100 MB (
/CrowdConnected/firehose/positions) - raw BLE observations: up to 400 MB (
/CrowdConnected/firehose/observations)
6. Bluetooth scanning (IPS)
net.crowdconnected.android.ips.BluetoothObserver performs BLE scans
(BluetoothLeScanner) filtered to Eddystone beacons:
EDDYSTONE_SERVICE = 0000feaa-0000-1000-8000-00805f9b34fb
Beacons to look for are downloaded from the settings endpoint as a list of
{ RFID, surfaceId, encryptedValue, encryptedValueLength, type }. The observer
reports (observedId, rssi, timestamps) per sighting. So it ranges against the
venue’s own deployed Eddystone beacons, not arbitrary devices; the beacon
payload is matched against an encryptedValue provisioned per beacon.
7. The native engine — libpositioner.so
| Property | Value |
|---|---|
| File | native_libs/libpositioner.so (from the arm64_v8a split) |
| Size | ~63 KB |
| Format | ELF 64-bit, AArch64 |
| Toolchain | Android clang 18.0.1 (+pgo +bolt +lto +mlgo, r522817) |
| Links | libc.so |
JNI surface
The Java class net.crowdconnected.android.ips.CWrapper does
System.loadLibrary("positioner") and binds 14 native methods, confirmed by
exported symbols in the binary:
Java_net_crowdconnected_android_ips_CWrapper_a create positioner (txPower, minBeacons, minObs)
Java_net_crowdconnected_android_ips_CWrapper_b add beacon observation (encryptedBeacon, len, ts, rssi)
Java_net_crowdconnected_android_ips_CWrapper_c add step (ts, heading)
Java_net_crowdconnected_android_ips_CWrapper_d advance/compute to time
Java_net_crowdconnected_android_ips_CWrapper_e/f x / y estimate
Java_net_crowdconnected_android_ips_CWrapper_g destroy
Java_net_crowdconnected_android_ips_CWrapper_h surfaceId (UUID string)
Java_net_crowdconnected_android_ips_CWrapper_i position quality
Java_net_crowdconnected_android_ips_CWrapper_j/k/l max-RSSI beacon x/y + value
Java_net_crowdconnected_android_ips_CWrapper_m surfaceId for max-RSSI beacon
Java_net_crowdconnected_android_ips_CWrapper_n number of observed surfaces
(The Java Positioner.getPosition() calls h,i,j,k,l,m,n,e,f and packs the
result into a Position of type = "cart".)
What it computes
Embedded strings reveal the algorithm:
Matrix in 1st Cholesky factorization in formk is not Pos. Def.
Matrix in the Cholesky factorization in formt is not Pos. Def.
Matrix in 2nd Cholesky factorization in formk is not Pos. Def.
These are textbook Kalman-filter / least-squares error messages: the engine
maintains a covariance matrix and solves it via Cholesky factorization
(formk/formt = form K gain / form transition). In other words
libpositioner.so is a sensor-fusion indoor-positioning filter that fuses:
- BLE beacon ranges (RSSI → distance, with a default
startingTx = -40 dBm,minBeaconCount = 3,minObsCount = 12), and - pedestrian steps + heading,
to output a smoothed indoor coordinate (x, y), the surface/floor, and a
quality metric. Beacon identifiers are passed in as an encrypted string
(beacon.getEncryptedString()), so beacon identity is obfuscated even to the
native layer.
The native code is small (~63 KB) and contains no network, crypto, or file-system strings — it is purely the math kernel; all I/O happens in Java.
8. Hardcoded credentials 🚩
net.crowdconnected.android.core.BuildConfig ships AWS Cognito credentials in
the clear (these are SDK-level, shared by Crowd Connected’s prod flavor
v2.1.0 — not festival-specific, but still embedded in every app that bundles the
SDK):
CLIENT_ID = 31iqmf255m1dai4bpqq3an55pg
CLIENT_SECRET = aegqfgfsvlenp7oc77et8f9fftat1h4phg3j30k02c5t6tiikju
IDENTITY_POOL_ID = us-east-1:fff6e6ce-18f7-4e82-8afc-646117cbc9fe
USER_POOL_ID = us-east-1_xsD5yIcIS
REGION = us-east-1
LIBVER = 2.1.0
Shipping a Cognito app-client secret inside a distributed mobile binary is
notable: client secrets are meant to stay server-side, and anyone can extract
these from the APK. (The festival-specific appKey/token/secret are supplied
separately at runtime via CoreApp and were not found hardcoded in this build.)
9. Summary of privacy-relevant observations
- Data leaves the EU. Positions, BLE observations and steps stream to AWS
Kinesis Firehose in
us-east-1(USA) via US-region Cognito pools — contradicting the policy’s EU-only statement. - Account linkage. The location stream is aliased to
app_user_id(and the identicallogin_user_id) — a client-generated install UUID that is also stored as the accountuser_idnext to name/email, not just an anonymous UUID/push token as the policy implies. Seeidentity-correlation-proof.md. - Foreground location service with background-location support; GPS sampled adaptively up to ~1 Hz near geo-zones.
- GPS is only uploaded inside a configured geo-zone.
GeoObservermarks a positionsend=trueonly when it falls inside a high/medium geo-zone (inAnyHighZone/inAnyMedZone);Core.sendPositionData()then gates the Firehose upload ongetSend(). Outside every zone the location is sampled locally to detect zone entry but logged as “Not sending position” and not transmitted. (Indoorcartpositions always send, but IPS is disabled here.) - No Bluetooth / indoor scanning this edition —
crowdConnectedIndoorLocation = false, so the BLE beacon pipeline andlibpositioner.sonever start (§3). - Hardcoded AWS Cognito client secret in the SDK
BuildConfig, plus the tenantappKey/token/secretshipped in plain string resources (§2). - Large on-device buffers (up to 400 MB raw BLE + 100 MB positions) cached before upload — though with IPS off the BLE buffer is unused here.
- Mitigations that are present: consent-gated, requires fine+background
location, local
allowedkill-switch, serveractivekill-switch, beacon ids passed encrypted, and the integration no-ops if festival API keys are absent.
Caveat: this is static analysis of v2.0.1. Actual processing also depends on server-side configuration — the
settingsendpoint can disable the SDK (active=false) and defines the geo-zones that decide where GPS is uploaded. Seecrowd-connected-faq.mdfor the “when / can I see my data / can I visualise it” walkthrough.Empirical update (2026-06-08, rooted device — see
crowd-connected-faq.md§5): the app’s own on-devicesettings/GeoZonecache was read. The live config isactive=false(collection currently off) and the single geo-zone is centred on Assen (52.994439, 6.563694) with radius_1 = 820 m (high-frequency inner ring) and radius_2 = 60 km (the data-collection boundary). A 60 km circle around Assen covers most of NE Netherlands — confirming the upload gate is far wider than “the festival grounds.”Empirical update (2026-06-16, root spoofing test): By artificially modifying the app’s local SQLite database (
CrowdConnected-RoomDatabase) to force theactiveflag to1(true), we empirically confirmed the immediate ingestion pipeline. The SDK instantly generated Kinesis CSV payloads and queued them for AWS.Example raw intercepted payload:
6a22dea5-919e-41ef-a5aa-f6bebf43ced6,geo,3821185.97,438783.74,53.0047012,6.5505381,e52e1cbe-...,0.0,0.0,0.0,null,0,use,0,1781623643922,2,0,dd54d39dThis proves that when active, the device transmits:
- Precise GPS coordinates (
53.0047012, 6.5505381)- The Crowd Connected Device ID (
6a22dea5-...) which the SDK explicitly links — via thesetAlias/device-alias API (verified in logcat) — toapp_user_id(=login_user_id= the client install UUID that is also your accountuser_id; seeidentity-correlation-proof.md).- The tenant App Key (
dd54d39d)- Timestamps and Confidence metrics
Empirical update (2026-06-23, festival live — server flipped the flag itself): Re-read the app’s own on-device
CrowdConnected-RoomDatabaseafter letting it perform its normalsettingspoll (cache timestamp1782236648362= 2026-06-23 17:44 UTC, i.e. fresh). Theactiveflag is now1(true) — set server-side by the operator, not spoofed this time. The earlier prediction (§9 caveat / FAQ §5: “the only thing stopping collection right now is the serveractiveflag, observedfalse”) is confirmed: the operator turns the global kill-switch on for the live event.Live
Settingsrow (unchanged geometry, flag flipped):active=1, geoEnabled=1, sendPositionData=1, sendRawData=0, pollingFrequency=120, geoHighFrequency=30, geoMediumFrequency=1800.GeoZoneis identical to the 2026-06-08 capture — centre Assen (52.994439, 6.563694),radius1=820 m,radius2=60 000 m(60 km).SDKStatus:Mode=BACKGROUND, modules=GEO,BACKGROUND, gpsGenerated=true, gpsInZoneGenerated=true, aliasKeys=app_user_id,login_user_id,sns_user_id, device3e119617-…(Pixel-10a), CognitoauthTokenissued bycognito-idp.us-east-1.amazonaws.com/us-east-1_xsD5yIcIS.So as of the live festival the SDK is armed and running: GEO+BACKGROUND modules active, position upload enabled, account-aliased, targeting the same 60 km Assen catchment. The on-device Firehose buffers (
cache/CrowdConnected/firehose/positions|observations/KinesisRecorder/kinesis_firehose_records) existed but were 0 bytes at capture — consistent with the upload gate: this phone was not physically inside the 60 km zone, so positions are sampled butsend=false(“Not sending position”). Inside the zone, the same payload shape shown above would be streamed tocolocator-v3-positions-firehose(us-east-1).
10. Data Poisoning / Sybil Vulnerability ��
Because the AWS Cognito credentials (CLIENT_ID, CLIENT_SECRET, IDENTITY_POOL_ID) are hardcoded in the clear (see §8) and the Kinesis Firehose stream (colocator-v3-positions-firehose) ingests payloads directly without an intermediate validating API, the system is vulnerable to mass Data Poisoning.
An attacker can extract the AWS Cognito keys, authenticate directly, and script the ingestion of thousands of fake devices submitting artificial GPS data. The payloads are simple Base64-encoded CSV strings lacking cryptographic device attestation (e.g., Google Play Integrity). This means one script could trivially spoof 50,000 “users” to artificially inflate festival density maps or draw shapes with GPS coordinates, rendering the platform’s analytics completely untrustworthy.