# Reverse-engineering the official ParkSmarter app A technical teardown of the official **Park Smarter** Android app (`com.ipsgroupinc.parksmarter`, **v4.4.0**, versionCode 170), focused on **what data leaves the device, when, and to whom** — with special attention to the **device identifier** and **GPS location**. Combines static decompilation with a live man-in-the-middle capture. > Independent, good-faith security/privacy research on a publicly distributed > app, for the purpose of building a privacy-respecting alternative > (BigBrainParking). Not affiliated with or endorsed by IPS Group. Findings > reflect v4.4.0. The full decompiled source is kept in a **private** mirror; this > report quotes only the excerpts needed as evidence. --- ## TL;DR | Claim | Verdict | Evidence | |---|---|---| | The app reads your hardware **IMEI** | **False** | No `TelephonyManager.getImei/getDeviceId` anywhere; `react-native-device-info` isn't even bundled; and Android 10+ blocks IMEI for normal apps regardless. | | The app sends a **persistent device ID** to its backend | **True** | `PUT /api/Device` carries the **Android SSAID** in a field misleadingly named `IMEINumber`, plus the push token as `DeviceID`. | | The app sends your **GPS** to its servers | **True**, for meter search only | `GET /api/Meter?Lat=…&Long=…`; captured live. Not attached to login/session/payment. | | GPS is sent **automatically** on map open | **True in code**, situational at runtime | Wired to map-ready when authenticated + permission granted; on GrapheneOS the map fails to render (needs Play Services), which suppresses the auto-fetch. | | Bundles Segment / Amplitude / Firebase-Analytics / Sentry | **False** | None present. | | Bundles heavy telemetry | **True** | **Instabug** — session replay, screenshot capture, network-request logging, visual user-steps → `api.instabug.com`. | | Advertising ID / install-referrer | **False** | Neither is present. | --- ## Methodology Both static and dynamic analysis were used. ```mermaid flowchart LR A["Park+Smarter_4.4.0.xapk
(base + 3 config splits)"] --> B[jadx 1.5.1
dex → Java] A --> C["hermes-dec
index.android.bundle → JS"] A --> D[apktool 2.10.0
manifest + resources] A --> E["APKEditor merge → 1 universal APK
+ apk-mitm (trust user CA)"] E --> F["mitmproxy on device
(live HTTPS capture)"] B --> G[(Findings)] C --> G D --> G F --> G ``` - **jadx** decompiled the dex → Java. This is only the RN framework + native modules + SDKs; the app's own logic is **not** here. - The app is **React Native / Expo SDK 53**, so its business logic ships as **Hermes bytecode** (`assets/index.android.bundle`, Hermes v96). **hermes-dec** recovered readable pseudocode (`decomp.js`) with de-obfuscated function names. - **apktool** decoded the manifest and resources. - For the live capture, the split APKs were merged into one universal APK (APKEditor) and repackaged to trust a user CA (`network_security_config`), then driven through **mitmproxy**. (Merging first was necessary: patching only the base split renumbers its resource IDs and desyncs the untouched `config.hdpi` split, which crashes the app on the first text field.) --- ## App architecture & network model Expo SDK 53 RN app. All API traffic goes through one request builder that stamps these headers on **every** call: | Header | Value | Notes | |---|---|---| | `Application_Token` | `B66EEDDA-…` (static) | App-wide, embedded in the bundle | | `X-Request-Id` | random UUID | Per request | | `Auth_Token` | rolling | Rotates on each response | | `ParkSmarter_SessionId` | per session | | | `Content-Type` | `application/json` | POST/PUT only | | `User-Agent` | `okhttp/4.12.0` | RN's HTTP stack | Five hard-coded environments (bundle string literals): - Prod: `https://apiv2.parksmarter.com`, `https://apiv3.parksmarter.com` - Staging: `https://staging-parksmarter-api.ipsmeters.com` - Testing: `https://testing-parksmarter-api.ipsmeters.com` No TLS pinning is configured (okhttp's `CertificatePinner` class is present but no pins are set), and the manifest declares no `networkSecurityConfig` — which is why a stock install can't be MITM'd without repackaging. --- ## Finding 1 — Device identifier: the "IMEINumber" that isn't an IMEI The app defines a Redux thunk literally named `device/setIMEINumberAsyncThunk`. Despite the name, it **never reads an IMEI**. It reads the **Android SSAID** (`Application.androidId` from `expo-application`), caches it in secure storage as `psUUID`, and later transmits it. Evidence (`decomp.js`): ```js // getAndroidId → expo-application's Application.androidId (the SSAID) r0 = r0.default; r0 = r0.androidId; return r0; // stored under 'psUUID' r2 = 'psUUID'; r2 = setObjectAsync.bind(...)('psUUID', androidId); // later placed on the wire: r2['DeviceID'] = pushNotificationsToken; r2['IMEINumber'] = imeiNumber; // === Application.androidId (SSAID) r2['DeviceType'] = '1'; // endpoint: putUpdateDeviceToken → { url: '/api/Device' } ``` Confirmations: - **No real IMEI read.** Zero `getImei` / `TelephonyManager.getDeviceId` / `getSubscriberId` / `getSimSerialNumber` in the Java **or** the bundle. Every `getDeviceId()` match in the Java is unrelated — `MotionEvent`/`KeyEvent` device IDs (input routing) or `Context.getDeviceId()` (the API-34 *virtual* device id, an `int`). `react-native-device-info` (which could read IMEI) isn't bundled at all. - **`READ_PHONE_STATE`** comes from `@react-native-community/netinfo`, which uses `TelephonyManager` only for `getNetworkOperatorName()` (carrier name) and network type — not identity. - **Modern Android blocks it anyway**: since Android 10, `getImei()` throws for non-privileged apps regardless of the permission. ```mermaid sequenceDiagram participant App participant OS as Android OS participant PS as apiv2.parksmarter.com Note over App: cold start / login / token change App->>OS: Application.androidId (SSAID) OS-->>App: e.g. "a1b2c3…" App->>App: cache as psUUID (secure store) App->>PS: PUT /api/Device
{ IMEINumber: SSAID, DeviceID: pushToken, DeviceType: "1" } Note over PS: a stable per-device ID,
tied to the account — just not the IMEI ``` **Net:** the app does transmit a persistent, per-device identifier to its own backend, mislabeled `IMEINumber`. It is the SSAID, not the hardware IMEI, and it is **not** sent to any third party. The earlier "sends your IMEI" claim was wrong in substance but pointed at something real — this field. --- ## Finding 2 — GPS location Location is acquired via **expo-location** (`getLastKnownPositionAsync` + `watchPositionAsync` at `Accuracy.Balanced`) and sent to the backend **only** on the meter-search endpoints: ``` GET /api/Meter?Lat=&Long=&localeCode=en-US ``` The query carries **only** `Lat`/`Long` (no radius/limit); "all vs limited meters" is an endpoint choice, not a parameter. Location is **not** attached to login, session load, or payment — confirmed both in code and in the live capture. **Auto-send on map open.** The map hub wires `useAutoGoToUserLocation`, a state machine that — when `isAuthenticated && isMapReady && locationPermissionGranted` and the app is foreground — recenters on the user's GPS and fires the meter query **without any tap**. Panning/zooming fires more (debounced). ```mermaid sequenceDiagram participant User participant Map as Map screen participant Loc as expo-location participant PS as apiv2.parksmarter.com User->>Map: open Map (logged in, permission granted) Map->>Loc: watchPositionAsync (Balanced) Loc-->>Map: {lat, long} Map->>PS: GET /api/Meter?Lat=…&Long=… (auto, no tap) User->>Map: pan / zoom Map->>PS: GET /api/Meter?Lat=…&Long=… (debounced) ``` **GrapheneOS caveat (observed).** `react-native-maps` needs Google Play Services for tiles; on GrapheneOS the map doesn't render, so `isMapReady` may never flip and the auto-fetch can be suppressed. In the live capture the single coordinate sent (`Lat=48.274147628&Long=-116.550122619`) exactly matched a *known lot* location rather than a fresh arbitrary GPS fix — consistent with the map being degraded. Location egress is proven; the fully-automatic-on-open behavior is a property of the code that a Play-Services device would exercise more visibly. **BigBrainParking, by contrast**, never sends GPS to the API except when you explicitly tap "My location" and search; the map opens on your last lot from history, not your GPS. --- ## Finding 3 — Third-party telemetry: Instabug (and only Instabug) Contrary to the earlier analysis, **Segment, Amplitude, Sentry, Firebase Analytics, Crashlytics, advertising-ID, and Play install-referrer are all absent** (verified by package-dir and endpoint-host search). Firebase is present only as **Cloud Messaging** (push), not analytics. The one real telemetry SDK is **Instabug** (2,328 classes), and its scope is broad: - **Session replay** (`library/sessionreplay`) - **Screenshot / screen capture** (`instacapture`, `screenshot`) - **Network-request logging & interception** (`apm/networking`, `networkinterception`) - **Visual user-steps / interaction tracking** (`visualusersteps`, `interactionstracking`) - Crash reports, APM, surveys, user attributes All reporting to `api.instabug.com`. Captured live on startup: `POST /api/sdk/v3/sessions/v2`, `GET /api/sdk/v3/features`, `/api/sdk/v3/first_seen`. ```mermaid flowchart TD D[Your device] D -->|"SSAID as IMEINumber + push token
GPS (Lat/Long) on meter search
account, vehicle, payment, sessions"| PS[apiv2.parksmarter.com] D -->|"session replay, screenshots,
network logs, user-steps, crashes"| IB[api.instabug.com] D -->|push registration| FCM[Firebase Cloud Messaging] style PS fill:#294,color:#fff style IB fill:#922,color:#fff style FCM fill:#247,color:#fff ``` --- ## Permissions (from the manifest) Present: `ACCESS_FINE/COARSE_LOCATION`, `READ_PHONE_STATE` (netinfo), `ACCESS_WIFI_STATE`, `CAMERA`, `RECORD_AUDIO`, `BLUETOOTH_SCAN/CONNECT` (meter BLE), `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, `FOREGROUND_SERVICE`, `USE_BIOMETRIC`, `SYSTEM_ALERT_WINDOW`, `DETECT_SCREEN_CAPTURE`, install-referrer service binding, plus a long list of launcher badge permissions. **Absent (important):** `ACCESS_BACKGROUND_LOCATION`. The app **cannot** track location while closed. --- ## Live capture — evidence timeline Captured through mitmproxy against a repackaged (user-CA-trusting) universal APK, authenticated session: ``` 18:44:07 GET apiv2.parksmarter.com/api/ParkingSession 18:44:07 GET apiv2.parksmarter.com/api/Session 18:44:08 POST api.instabug.com/api/sdk/v3/sessions/v2 (414B) ← telemetry 18:44:08 GET api.instabug.com/api/sdk/v3/first_seen 18:44:16 GET apiv2.parksmarter.com/api/Meter?ZoneName=dl 18:44:17 GET apiv2.parksmarter.com/api/Meter?Lat=48.274…&Long=-116.550… ← GPS 18:44:20 GET apiv2.parksmarter.com/api/ParkingEstimateItems 18:44:33 GET apiv2.parksmarter.com/api/ApplicationValidity 18:44:39 GET apiv2.parksmarter.com/api/ParkSmarterPrivacyPolicies ``` Header sample on a parksmarter request: `Application_Token: B66EEDDA-…`, `Auth_Token: …` (rolling), `ParkSmarter_SessionId: …`, `User-Agent: okhttp/4.12.0`. `PUT /api/Device` (the `IMEINumber` PUT) did **not** fire in this session because the login was cached — it sends on fresh login / token change. --- ## Reproducing this The repackaged capture-ready APK and step-by-step mitmproxy instructions live in the private source mirror (`ParkSmarterSourceCode`, `MITMPROXY.md`). In short: merge splits → inject a user-CA `network_security_config` → resign → install the CA as a user cert → proxy the phone through mitmproxy (mobile data **off** so the Wi-Fi proxy applies). --- ## Appendix — backend endpoint inventory (from the bundle) `ApplicationValidity`, `Auth`, `SignUp`, `User`, `Device`, `Session`, `ParkingSession`, `Meter` (by location / scanner code / serial / zone name), `ParkingLots`/`ParkingLogix`, `ParkingEstimateSingle/Multi/Items`, `StartParkingSession`, `ParkingReceipt`, credit-card + vehicle CRUD, `NotificationSettings`, `ParkSmarterPrivacyPolicies` / `Terms` / `About` / `FAQ`. ```