
Inside Grindr's Android client — thirteen advertising companies, a 129-field telemetry pipeline, and the behavioural score it prints on your profile.
Grindr is the world's largest dating app for gay, bi, trans and queer people, and it asks for 37 permissions to do it. This report documents a full static reverse-engineering of the Android client, build 26.22.0, and two findings follow. The first is that Grindr is not one application — it ships at least thirteen separate advertising and tracking companies, one of which runs a continuous telemetry pipeline that samples 129 distinct fields about the device, from battery level and screen brightness to USB connection state and raw accelerometer readings. The second is that Grindr scores its users: it computes response rates, initiation rates and habitual chat patterns, then labels other people's profiles with predictions such as "Reply likely" and sells the fuller picture behind a subscription.
Every claim below is traceable to a specific artefact and a reproducible step. The client was acquired from APKMirror as a signed, unmodified distribution, fingerprinted before extraction, and decompiled through a deliberately segmented pipeline — a full-APK run exceeded the memory available on the analysis host, so the ten DEX files were batched instead.
Artefact : grindr-26.22.0.apkm
Size : 108,988,089 bytes
SHA-256 : 223123d0f6d5eabbd37776d095b32ab03bd5782cb8c2bc4db4f13f51dd7e6e7b
(matches APKMirror's published hash for this release)
Extracted : bundle/base.apk · 109,282,803 bytes
SHA-256 : 007bb88b59e5844f9adeec8fb801c664e5bd558b7917f41576e7afdaf8bcbd60
Cert SHA-256 : 6e21a5f966a7252628cd8f5982d93086a0fcf76c09edb9d7731964bb1c842897
Package : com.grindrapp.android
Version : 26.22.0 (versionCode 192710) · arm64-v8a · min API 29
Pipeline : jadx 1.5.6 on Temurin JDK 21.0.12.1
10 DEX files, 69,162,012 bytes of bytecode
batched 3 ways + 1 resource-only pass
output 59,859 .java files, 0 empty
decompilation errors: 960 classes (2.5%)
Counting third-party code inside a binary is a blunt instrument, but file counts say something a package name alone does: how much of the application you are actually running belongs to somebody else. Grindr's own first-party code accounts for 2,372 files. Google's does not merely match it — it exceeds it.
| Company | Files | Role |
|---|---|---|
| 3,602 | Ads, Firebase analytics, Maps, Play | |
| Mbridge | 1,813 | Ad network |
| InMobi | 1,674 | Ad network |
| Fyber | 1,563 | Ad exchange (Inneractive) |
| Unity / ironSource | 940 + 305 | Ads — owner of the telemetry pipeline in §III |
| AppLovin | 749 | Ad network |
| Vungle | 610 | Video advertising |
| PubMatic | 341 | Ad exchange |
| AppsFlyer | 318 | Install attribution |
| Braze | 292 | Marketing email and push |
| 276 | Login and advertising | |
| Explorestack | 251 | OpenRTB / AdCOM bidding protocol |
| Digital Turbine | 2 | Ignite app-install delivery |
The endpoint sweep corroborates the inventory rather than relying on it: googleads.g.doubleclick.net, pagead2.googlesyndication.com, ads.pubmatic.com, telemetry.sdk.inmobi.com, cdn.pubnative.net, cdn2.inner-active.mobi, cdn.doubleverify.com, ms.applovin.com, gateway.unityads.unity3d.com, app-measurement.com, www.braze.com, dev.facetec.com. These are hard-coded in the decompiled sources, not inferred from behaviour.
The most substantial third-party payload belongs to Unity. Buried in the sources is a package named gatewayprotocol — 305 generated schema files across two versioned namespaces, p025v1 and p036v1. It is not Grindr's own format. Two hundred and three Unity files feed it, and it posts to a single hard-coded address.
public static final String GATEWAY_URL = "https://gateway.unityads.unity3d.com/v1";
public static final String GATEWAY_HOST = "gateway.unityads.unity3d.com";
// and the HTTP client primes HTTP/3 for it:
builderCreateCronetEngineBuilder.addQuicHint("gateway.unityads.unity3d.com", 443, 443);
Two message types carry the device state. StaticDeviceInfo holds 64 fields that describe what the phone is; DynamicDeviceInfo holds 65 fields that describe what the phone is doing. Together, 129.
| StaticDeviceInfo — 64 fields | DynamicDeviceInfo — 65 fields |
|---|---|
|
build fingerprint, board, bootloader, brand, device, display, hardware, host, id, product cpu model and count · gpu model · webgl renderer and vendor keyboard languages · sim operator · phone type screen width, height, scale, density rooted · simulator · adb-enabled app · app installer · signing-certificate hash os version, api level, bundle id, version code total disk, total RAM, JVM max memory, system boot time |
battery level, battery status, charging type volume, max volume, ringer mode · screen brightness language, locale list, timezone and offset, orientation, UI theme network type, operator, metered, RTT, downlink, effective type, interfaces adb enabled · USB connected · SD card present · stay-awake-while-charging animator scale · display inversion · grayscale · font scale · daltonizer free RAM and disk, JVM free/total, uptime and elapsed realtime accelerometer samples: x, y, z, timestamp |
The accelerometer is not merely declared. An AccelerometerRepository registers a real sensor listener, buffers readings against a configurable samplingIntervalMs and maxSamples, and drains them into the outgoing packet:
public DynamicDeviceInfoOuterClass.DynamicDeviceInfo fetch(boolean drainAccelerometerSamples) {
...
if (drainAccelerometerSamples) {
dsl_create.addAllAccelerometerSamples(
dsl_create.getAccelerometerSamples(),
this.accelerometerRepository.drainSamples());
}
In fairness to the implementation, there is a real gate, and it defaults to closed. Identifiers are attached one at a time, each behind its own flag, and the flags start out false.
public PiiOuterClass.Pii fetch(AllowedPiiOuterClass.AllowedPii allowed) {
allowed.getClass();
initializeAdvertisingIds();
...
if (allowed.getIdfa()) { dsl.setAdvertisingId(...);
dsl.setOpenAdvertisingTrackingId(...); }
if (allowed.getFid()) { dsl.setFid(...); }
if (allowed.getAppsetId()) { dsl.setAppsetId(...); }
return dsl._build();
}
// AndroidDeviceInfoRepository.java · line 56 — the default
this.allowedPii = AllowedPii.getDefaultInstance(); // every boolean = false
allowedPii = mutableData.getAllowedPii() in AndroidHandleGatewayUniversalResponse. Separately, setAllowedPii is exposed as a JavaScript-callable function to the advertising WebView, meaning code running inside an advertisement can alter which identifiers the app considers permissible.That second point is the sharpest thing in this report, and it is the one that most needs stating precisely: the function's existence is proven by its decompiled definition, but whether Grindr's specific integration exposes it to untrusted ad creative was not established here. It should be confirmed with a live capture before it is asserted.
The second finding lives in a different corner of the codebase. The app contains an insights package with 29 files, and it explains itself in a dialog the user can open:
Profile Insights looks at a person's activity and behavior on the app to give you a clearer picture of what they're into. We then compare your profile details with their behavior to show how well you match up.
Read carefully, that is a description of the matching model: your profile is compared against their behaviour. The application then displays the result as a set of named scores and labels.
| Label | Source string | Meaning |
|---|---|---|
| Response rate | %1$d%% response rate | How often they answer |
| Initiation rate | %1$d%% initiation rate | How often they start the chat |
| Reply likely | edge_insights_badge_reply_likely | Predicted to answer you |
| Responsive | edge_insights_badge_responsive | Answers quickly |
| Habit | Often chats with %1$s | Who they habitually talk to |
| Prediction | Most likely to interact with %1$s | Predicted preference |
| Intent | Looking for chats / dates / hookups | Stated goal |
| Timing | last chatted %1$s · Recent activity | Recency |
res/values/strings.xml, of which there are 66 edge_insights_* entries. Behaviour is additionally bucketed by time of day: Morning, Afternoon, Evening, Night — an enum in the compiled source.The data model confirms this is computed and shipped, not derived locally. ProfileInsightsInference carries an insightKey, an inferenceType, a title, a value, an optional chart and an isRemovable flag. ProfileInsightsBehavior carries title, value, type, insightKey and timeOfDay.
edge_insights_locked_age, edge_insights_locked_tribe, edge_insights_locked_position, edge_insights_locked_initiation_rate and edge_insights_locked_response_rate are all rendered with edge_insights_locked_state_description = "Locked" until the user buys Edge. Age, tribal label and sexual position are therefore presented as premium insight attributes about other people.Insights Off, Insights not available for this profile for targets who have opted out, and a dedicated GaiFeaturesOptOutConfirmationDialogFragment with a "Keep insights unpersonalized" action. The pressure is soft rather than absent: To unlock the full value of Edge, turn on Personalize gAI™ Features.The browse grid is served as a single ordered list, and the response model says plainly that the server controls the order.
CascadeResponse(
items, // the mixed stream you are scrolling
nextPage, // cursor pagination
shuffled, // the server tells the client it reordered the list
hiddenProfiles, // locally hidden accounts
hiddenProfilesInfo
)
No ordering logic exists in the client. Every compareTo call in Grindr's own code is an Android lifecycle comparison, not a profile sort. Ranking happens server-side and is not recoverable from the binary. What is recoverable is what the list is made of — and it is not homogeneous:
| Feed item type | What it is |
|---|---|
Profile / PartialProfile | An actual person |
SponsoredProfile | Someone paid to appear here |
SmartBoostProfile | A boosted account |
ForYou · ForYouNow · RightNow | Recommendation modules |
EdgeRecommends | Subscription-tier suggestions |
Ad · MpuCardRewardedAd · AdsFreeSessionCardRewardedAd | Advertising units |
FavoritesXtraUpsell · FavoritesHeader | Upgrade prompts |
BrazeEventProfile | A marketing card driven by the Braze CRM |
HiddenProfile | Accounts you have hidden |
cascade/data/model/. Advertising and upsell units are first-class members of the same list as people, which is why the grid cannot be understood as a pure directory of users.Thirteen ad companies and a telemetry pipeline are the headline, but they are not the whole cargo. Seven further artefacts sit in this binary that a dating app has no obvious reason to carry, each of which changed how the analysis read. They are listed together because their common feature is that none of them is visible in the permission prompt.
Twenty-two native libraries ship in the arm64 split. Three of them belong to Agora, and the largest — libagora-rtc-sdk.so at 13,489,584 bytes — is bigger than every advertising native library in the package combined. Agora is a real-time voice and video engine, and its 347 decompiled Java files are wired to Grindr's own video-call screen: VideoCallActivity imports io.agora.rtc2.video.VideoCanvas directly.
lib/arm64-v8a/libagora-rtc-sdk.so 13,489,584 ← Grindr video calls
lib/arm64-v8a/libagora_ffmpeg.so 804,784
lib/arm64-v8a/libagora-soundtouch.so 67,344
lib/arm64-v8a/libvideo_dec.so 1,312,816 ← Unity ad decode
lib/arm64-v8a/libapplovin-native-crash-reporter.so 843,152
lib/arm64-v8a/libpairipcore.so 608,896 ← anti-repackaging
lib/arm64-v8a/libunitycoherencenative.so 514,424 ← Unity game engine
22 files total, arm64-v8a only
RECORD_AUDIO and CAMERA, and it is a product feature rather than a tracker — which is worth saying plainly, because the rest of this section is not.§IV established that code running inside an advertisement can read and set which identifiers are permitted. The exposed-function table that carries that hook carries considerably more:
SET_ALLOWED_PII = "…AdViewer.setPrivacyAllowedPii";
SET_PRIVACY = "…AdViewer.setPrivacyPayload";
SET_PRIVACY_FSM = "…AdViewer.setPrivacyFsm"; // privacy state machine
REQUEST_GET = "…Request.get"; // arbitrary HTTP GET
REQUEST_POST = "…Request.post"; // arbitrary HTTP POST
REQUEST_HEAD = "…Request.head";
OPEN_URL = "…AdViewer.openUrl";
CAPTURE_SCREENSHOT= "…AdViewer.captureScreenshot";
DOWNLOAD = "…AdViewer.download";
GET_BLACK_PIXEL_PERCENTAGE = "…"; // viewability probe
Two things follow. First, the privacy apparatus in §IV is not a one-way gate but a mutable state machine — SET_PRIVACY_FSM and SEND_PRIVACY_UPDATE_REQUEST are both callable from creative, which is what PrivacyFsm and OnPrivacyFsmChangeEvent in the ad-player package implement. Second, and more consequentially, an advertisement can issue GET and POST requests through the host application's own networking stack. The reachability caveat from §IV applies here too: the bridge is defined, and whether any given integration exposes it to untrusted creative was not established without a live capture.
Unity's initialisation response carries a RequestUrlOverride message — a list of request types paired with replacement URLs. The client stores it on the session repository and consults it before every call:
InitializationResponseOuterClass.RequestType t =
getRequestUrlOverrideType(operationType);
if (t != null) {
Iterator it = sessionRepository.getRequestUrlOverrides().iterator();
do { } while (((RequestUrlOverride) next).getRequestType() != t);
String url = requestUrlOverride != null ? requestUrlOverride.getUrl() : null;
}
The endpoint is nominally hard-coded (§III), but the server can replace it at runtime without the app updating. That is a normal operational feature for an ad SDK. It also means the hard-coded constant is not the final word on where the telemetry goes — an observation that belongs in any honest reading of §III.
BidMachine's request token payload is compiled into the binary as a protobuf descriptor, and its field list is readable directly from CacheProto. It binds the auction to a context object:
device_data → bidmachine.protobuf.sdk.context.Device
user_data → bidmachine.protobuf.sdk.context.User
app_data → bidmachine.protobuf.sdk.context.App
regs_data → bidmachine.protobuf.sdk.context.Regs // consent strings
session_data → session_id, sessionduration
hb_placement → headerbidding.HeaderBiddingPlacement
custom_data → free-form key/value map
session_placement_data → impdepth, lastbundle, lastadomain,
clickrate, lastclick, completionrate
regs_data is the TCF consent string — so the regulatory signal travels with the bid. lastadomain and clickrate mean the exchange learns which advertisers you have previously seen and clicked, per session. The header-bidding side is equally explicit: HeaderBiddingPlacement and HeaderBiddingAd are compiled in as their own messages, with token counters exposed as bridge functions (HEADER_BIDDING_TOKEN_INCREMENT_WINS, …_INCREMENT_STARTS), so the client tracks its own auction win rate.
Three attribution artefacts round out the picture:
com.android.installreferrer API is bundled, with getInstallReferrer(), getReferrerClickTimestampSeconds(), getInstallBeginTimestampSeconds(), getInstallVersion() and getGooglePlayInstantParam(). This is what lets AppsFlyer reconstruct the ad you clicked before you installed. It is a platform-standard API and not, by itself, a finding — but it closes the loop from §II's ad inventory to attribution.receipt, signature, jwsRepresentation, product, productId, transactionState, transactionId and timestamp. In other words, in-app purchase receipts are packaged into the same gateway event stream as the telemetry in §III, in signed (JWS) form.SCREENSHOT_PATH, a CONTENT_TYPE_HEADER, a SESSION_TOKEN_HEADER and an upload timeout, driven by the CAPTURE_SCREENSHOT bridge above.PairIP anti-repackaging ships as 17 Java files plus libpairipcore.so (608,896 bytes), alongside a devicesigning package. Separately, the manifest declares its own permission:
<permission android:name="com.grindrapp.android"
android:protectionLevel="signature"/>
<uses-permission android:name="com.grindrapp.android"/>
A signature-level permission can only be held by an application signed with the same certificate as its definer — Grindr's own (cert SHA-256, §I). This is a legitimate inter-process-integration pattern and grants the app nothing from third parties; it is documented here because it is the one entry in the 37-permission count that is neither an Android platform permission nor obviously risky, and an earlier pass over the manifest misread it as an error.
Static analysis establishes what an application contains. It does not establish what a company did with it. For that this report cross-checks against the public enforcement record, and the correspondence is close enough to be worth stating explicitly.
In December 2021 the Norwegian Data Protection Authority fined Grindr €6.5 million for disclosing user data to third parties for behavioural advertising without a legal basis — the largest fine the regulator had issued at the time. The regulator's own list of the data involved was GPS location, IP address, advertising ID, age, gender, and the fact that the user was on Grindr. Compare that with the schema in §III: advertising ID is a gated Pii field, location is requested through ACCESS_FINE_LOCATION, and IP, age and presence-on-platform are carried by the surrounding telemetry and session envelope.
For the reader who came here wondering what a dating app does with their phone: it is not one app, it is a host environment for thirteen advertising companies, and the most sophisticated of them maintains a 129-field model of your device that refreshes continuously and includes your motion sensor. Your personal identifiers are gated and gated off by default, which is more than several of its peers manage — but the gate is not entirely yours to control.
Separately, Grindr computes behavioural scores about its users, labels them with predictions, and shows those labels to other people. Some of that is opt-out. The fuller version, including age, tribe and position, is for sale.
And the thing that started most people's suspicion — why one profile appears above another — cannot be answered from the binary at all. The order arrives pre-computed and frequently shuffled. Anyone claiming otherwise is guessing.
gatewayprotocol schema and the gateway.unityads.unity3d.com endpoint observed in the decompiled client). docs.unity3d.comPrimary technical evidence is from the analysed artefact grindr-26.22.0.apkm (SHA-256 above), its extracted base.apk, and the 59,859 Java files produced from it. Code blocks and field lists are transcribed from that decompilation. The regulatory figures were verified against the linked BBC report in October 2026. Several claims that circulate alongside this material — including a later appeal outcome and a separate fine against an advertising partner — were omitted because they could not be confirmed against a primary source in the time available.