Skip to the content.

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.


Activation is gated (com.appmiral.crowdconnected/Module.java, CrowdConnected.java):

  1. Consent: registered with the in-app ConsentManager as Sdk.CrowdConnected. It only enables when SDK consent is granted; revoking consent calls disable() → CrowdConnected.stop().
  2. Permissions: requires both ACCESS_COARSE_LOCATION and ACCESS_FINE_LOCATION. On Android ≥10 it only continues running in the background if ACCESS_BACKGROUND_LOCATION is granted (otherwise it disables on leaving the foreground).
  3. A local kill-switch exists: SharedPreferences("CrowdConnected").allowed (default true) plus a guard that disables it inside the .com.appmiral.UnityAR process.
  4. 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 via AppmiralApp.getCrowdConnectedApiKey/Token/Secret():

    resource value (in this build)
    crowdConnectedKey (appKey) dd54d39d
    crowdConnectedToken a86f…ecb0 (redacted)
    crowdConnectedSecret kW2g…6xJ7 (redacted)
    crowdConnectedIndoorLocation false
    crowdConnectedSurface / 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. See hardcoded-credentials-and-keys.md for 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:

⚠️ For this edition the IPS module is NOT wired in. EditionKt.isIndoorLocationEnabled() returns getString(R.string.crowdConnectedIndoorLocation) == "true", and the resource is "false" (see §2). So CrowdConnected.enable() only adds GeoModule + BackgroundModule. No Bluetooth beacon scanning and no indoor positioning run in TT Festival 2026 — libpositioner.so, BluetoothObserver and 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_id when 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_id is not a separate server account id. For the profile login UserIdentity.getId() returns the same client-generated getGeneratedUUID() — so login_user_id == app_user_id == 54b29c5a-…. The re-identification point stands (that UUID is also the user_id stored 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:


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:

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

  1. 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.
  2. Account linkage. The location stream is aliased to app_user_id (and the identical login_user_id) — a client-generated install UUID that is also stored as the account user_id next to name/email, not just an anonymous UUID/push token as the policy implies. See identity-correlation-proof.md.
  3. Foreground location service with background-location support; GPS sampled adaptively up to ~1 Hz near geo-zones.
  4. GPS is only uploaded inside a configured geo-zone. GeoObserver marks a position send=true only when it falls inside a high/medium geo-zone (inAnyHighZone/inAnyMedZone); Core.sendPositionData() then gates the Firehose upload on getSend(). Outside every zone the location is sampled locally to detect zone entry but logged as “Not sending position” and not transmitted. (Indoor cart positions always send, but IPS is disabled here.)
  5. No Bluetooth / indoor scanning this edition — crowdConnectedIndoorLocation = false, so the BLE beacon pipeline and libpositioner.so never start (§3).
  6. Hardcoded AWS Cognito client secret in the SDK BuildConfig, plus the tenant appKey/token/secret shipped in plain string resources (§2).
  7. 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.
  8. Mitigations that are present: consent-gated, requires fine+background location, local allowed kill-switch, server active kill-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 settings endpoint can disable the SDK (active=false) and defines the geo-zones that decide where GPS is uploaded. See crowd-connected-faq.md for 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-device settings/GeoZone cache was read. The live config is active=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 the active flag to 1 (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,dd54d39d

This proves that when active, the device transmits:

Empirical update (2026-06-23, festival live — server flipped the flag itself): Re-read the app’s own on-device CrowdConnected-RoomDatabase after letting it perform its normal settings poll (cache timestamp 1782236648362 = 2026-06-23 17:44 UTC, i.e. fresh). The active flag is now 1 (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 server active flag, observed false”) is confirmed: the operator turns the global kill-switch on for the live event.

Live Settings row (unchanged geometry, flag flipped): active=1, geoEnabled=1, sendPositionData=1, sendRawData=0, pollingFrequency=120, geoHighFrequency=30, geoMediumFrequency=1800. GeoZone is 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, device 3e119617-… (Pixel-10a), Cognito authToken issued by cognito-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 but send=false (“Not sending position”). Inside the zone, the same payload shape shown above would be streamed to colocator-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.