Make repo AI-agent ready: agent docs, scripts, skills, KDoc, patch coverage

Adds the infrastructure that lets AI coding agents (Claude Code, Gemini,
generic AGENTS.md readers) understand and safely modify GrindrPlus.

- AGENTS.md (canonical) + CLAUDE.md / GEMINI.md symlink aliases
- version.schema.json (JSON Schema 07) referenced from version.json
- scripts/sync-grindr-version.sh — wraps fetch_version.py + sed-patches
  app/build.gradle.kts; --dry-run shows the diff
- scripts/check-obfuscated-symbols.py — scans // search-for markers
  against a JADX dir, emits OK/AMBIGUOUS/STALE table
- scripts/check-patch-docs.sh — fails CI if any hook lacks its doc
- .claude/skills/{bump-grindr-version,update-obfuscated-symbols,
  patch-doc-coverage,verify-build}/ — four reusable skills
- KDoc on the public hook API (Hook/Hooker/HookAdapter/HookManager/
  FeatureManager), core/http/Interceptor, core/Constants
- KDoc on all 29 hook files using a shared template that captures the
  obfuscation-marker convention + lifecycle semantics
- Patch docs added for all 27 previously-undocumented hooks; docs/patches/README.md
  rebuilt with the full alphabetical index
- CI gate in build_apk.yml runs scripts/check-patch-docs.sh after JDK setup

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
John Smith
2026-08-13 17:42:08 -04:00
co-authored by Claude
parent b107c26ca8
commit 112b1e8b11
79 changed files with 2755 additions and 5 deletions
@@ -0,0 +1,62 @@
---
name: bump-grindr-version
description: Bump the target Grindr version in version.json and app/build.gradle.kts in one pass. Use when the user says "bump Grindr version", "update target version", or "new Grindr release".
metadata:
type: project
---
# bump-grindr-version
Automate the first half of the Grindr version-bump workflow (steps 1–3 of
`AGENTS.md` § 5). This skill **only** updates the metadata; it does not port
hooks — that's `update-obfuscated-symbols`.
## Inputs
None required. Optional flags are forwarded to the underlying script.
## Required tools
- `python3` (with `requests` + `bs4` — same env that runs `fetch_version.py`)
- `jq`
- `bash`
## Steps
1. Run `bash scripts/sync-grindr-version.sh` from the repo root.
- Pass `--dry-run` to preview the diff first.
2. Verify the banner: `./gradlew printVersionInfo --quiet`. Must print
`GrindrPlus v...<new Grindr version>`.
3. **Stop here.** Hooks still need porting. Recommend `update-obfuscated-symbols`.
## What the script does
- Runs `python3 fetch_version.py -o version.json.tmp` to fetch the latest
version + build from APKMirror.
- `jq -r '.versionName,.versionCode'` to extract the new values.
- `sed -i.bak` patches the two `listOf(...)` lines in `app/build.gradle.kts`
(`grindrVersionName`, `grindrVersionCode`).
- With `--dry-run`, prints `diff -u` of both files and exits without writing.
- Without `--dry-run`, writes `version.json` and a `.bak` of `app/build.gradle.kts`.
## Exit codes
- `0` — success (or "already up to date")
- `1` — `fetch_version.py` failed (APKMirror unreachable, rate-limited)
- `2` — could not parse the fetched version
- `3` — `app/build.gradle.kts` not found
## Failure modes
- **APKMirror rate-limit / 5xx**: `fetch_version.py` raises. The script surfaces
this as exit 1. Wait and retry.
- **`jq` missing**: exit 2. Install via your package manager.
- **Gradle banner still shows old version**: the sed regex didn't match (e.g.
the `listOf(...)` lines were refactored). Re-check `app/build.gradle.kts`
and update the sed patterns in `scripts/sync-grindr-version.sh`.
## After this skill runs
The user's next step is to port the obfuscation markers. Suggest
`update-obfuscated-symbols` (which needs the new Grindr APK decompiled with
JADX).
@@ -0,0 +1,77 @@
---
name: patch-doc-coverage
description: Verify every hook in app/src/main/java/com/grindrplus/hooks/ has a matching docs/patches/<kebab>.md file. Generate stubs for missing docs. Use before commit, after adding a new hook, or in CI.
metadata:
type: project
---
# patch-doc-coverage
`AGENTS.md` requires every hook file to have a per-hook design doc at
`docs/patches/<kebab>.md`. This skill checks coverage and can author
stubs for missing docs.
## Inputs
None required. The defaults assume the standard repo layout:
- Hooks dir: `app/src/main/java/com/grindrplus/hooks/`
- Docs dir: `docs/patches/`
## Required tools
- `bash`
- `python3`
## Steps
1. **Run the check**:
```bash
bash scripts/check-patch-docs.sh
```
Exit code:
- `0` — every hook has a doc.
- `1` — at least one hook is missing its doc; the script lists them.
2. **If missing docs are listed, author stubs** (one per missing hook):
```bash
bash scripts/check-patch-docs.sh | grep ' - '
```
For each `<Hook>.kt -> <kebab>.md` line, create `docs/patches/<kebab>.md`
using the template at
`.claude/skills/patch-doc-coverage/stub.template.md`.
3. **Rewrite `docs/patches/README.md`** to include a bullet for every hook,
alphabetically.
4. **Re-run** `scripts/check-patch-docs.sh` to confirm coverage.
## Stub template
The template lives at
`.claude/skills/patch-doc-coverage/stub.template.md`. A stub has:
- `# <Hook display name>` — copy the first arg to the `Hook(...)` constructor.
- One-line "what this hook does" paragraph.
- `## Target` section — paste the `// search for '...'` markers from the
hook file as a code block, so a maintainer can find the obfuscated
symbols in a fresh JADX-decompiled APK.
- `## Verified against` section listing which Grindr versions were tested.
## Wiring into CI
`.github/workflows/build_apk.yml` runs `scripts/check-patch-docs.sh` after
JDK setup. Build fails if any hook lacks its doc. To disable on a particular
PR, add the `no-docs` label (the workflow looks for this label and skips the
step in that case — see the workflow file).
## Failure modes
- **Mapping is wrong for an acronym**: edit `scripts/check-patch-docs.sh`'s
`kebab()` function. Acronym handling lives in two regex steps.
- **Hook is intentionally undocumented (WIP)**: do not merge to master
without a stub. The CI gate exists for a reason.
@@ -0,0 +1,32 @@
# <Hook display name>
<One-line: what the hook does and which user-visible feature it powers.>
## Target
<!-- Search markers used to locate this hook's symbols in newer Grindr APKs.
Copy each `// search for '...'` marker from the hook file as a fenced
code block, with the captured symbol name as a label. Example:
```
// OnlineIndicator.kt
val utils = "ka8" // search for ' <= 600000;'
val isFeatureFlagEnabled = "xh6" // search for 'implements IsFeatureFlagEnabled'
findClass("com.grindrapp.android.utils.ProfileUtilsV2") // search for 'R.string.profile_time_online_minutes_ago'
```
-->
```kotlin
// <HookFile>.kt
```
## Verified against
- <Grindr version> — initial support
- <Latest verified Grindr version>
## Notes
<!-- Optional: anything a future maintainer should know — which feature
flag gates this hook, why a non-obvious method-body hook was needed,
behavior on rooted vs unrooted devices, etc. -->
@@ -0,0 +1,88 @@
---
name: update-obfuscated-symbols
description: Find which `// search for '<snippet>'` markers in hook files are stale against a new Grindr APK. Use after `bump-grindr-version`, when the user says "port hooks to version X", "obfuscation symbols changed", or "use new APK".
metadata:
type: project
---
# update-obfuscated-symbols
Walk every hook file in `app/src/main/java/com/grindrplus/hooks/`, extract
every `// search for '<snippet>'` marker, and grep the JADX-decompiled output
of the new Grindr APK to find which markers are stale.
**This skill only reports.** It never edits hook files. The maintainer
(or AI agent) reads the report and applies edits.
## Inputs
- A new Grindr APK (or `.apkm`) on disk.
- The path to JADX-decompiled output. If you only have the APK, this skill
will run JADX for you.
## Required tools
- `python3` (stdlib only)
- `jadx` CLI (only if you need to decompile the APK)
- `bash` + `grep`
## Steps
1. **Decompile the APK** (if you don't already have a JADX dir):
```bash
jadx -d /tmp/jadx-out --deobf --no-res <path/to/new.apk>
# .apkm is a zip; unzip it first and pick the base.apk
```
2. **Run the scanner**:
```bash
python3 scripts/check-obfuscated-symbols.py --jadx /tmp/jadx-out
```
Add `--strict` to make it exit non-zero on any STALE marker (useful in CI).
3. **Read the report**. For each row:
- `OK` — one candidate file matched; the snippet is still in the new APK.
Nothing to do for this marker.
- `AMBIGUOUS` — multiple matches; open JADX and pick the right one by hand.
Update both the `// search for '...'` snippet AND the captured symbol
string in the hook file.
- `STALE` — no match. The snippet is obsolete. Open JADX, find the
new decompiled code that does what the old snippet did, capture a
**stable literal** of ≥ 12 chars, replace the marker.
4. **For every updated hook**, edit `docs/patches/<feature>.md` and add a
new "Verified against" entry with the new Grindr version.
## What the script does
- Parses each `*.kt` under `hooks/` for two patterns:
- `val <name> = "<symbol>" // search for '<snippet>'`
- `// search for '<snippet>'` on its own line (body-comment markers)
- `grep -rF -- '<snippet>' /tmp/jadx-out/sources` for each snippet.
- Emits a Markdown table:
```
| Status | Marker | Old symbol | Candidates | Confidence | File |
```
- Status ∈ {OK, AMBIGUOUS, STALE}.
## Markers you should always re-check by hand
- `STALE` markers — by definition there's no candidate. JADX decompilation
can fail on obfuscated code; re-run with `--show-bad-code` if you suspect
false positives.
- Acronym-bounded markers (e.g. `SSLUnpinning.kt`'s
`("com.grindrapp.android.chat.ChatDeleteConversationPlugin",`) — these are
class-name literals, so they're highly stable. But if Grindr ever moves
that class, the marker will go STALE.
## After this skill runs
- Commit hook updates with the message format
`Hook: <Feature> — update for Grindr <X.Y.Z>`.
- Commit doc updates separately:
`Docs: <Feature> — mark verified against Grindr <X.Y.Z>`.
- Run `verify-build` to confirm the module still assembles.
+64
View File
@@ -0,0 +1,64 @@
---
name: verify-build
description: Run ./gradlew assembleDebug and surface build failures with file:line context. Use before opening a PR, after any edit in app/src/main/java, or to confirm a hook still compiles.
metadata:
type: project
---
# verify-build
Compile-check the module. Skips the slower release build (which needs GH
secrets to sign).
## Inputs
None required.
## Required tools
- `./gradlew` (in repo root)
## Steps
1. Run from the repo root:
```bash
./gradlew assembleDebug --quiet --no-daemon
```
2. On success, the APK lands at
`app/build/outputs/apk/debug/GPlus_v*.apk`. Print its path and size:
```bash
ls -lh app/build/outputs/apk/debug/*.apk
```
3. On failure, Gradle prints errors in `e: file.kt:LINE: message` form.
Extract and surface the first 80 lines, plus the file:line of each
compile error.
## What this skill does NOT do
- Does **not** install on a device. That's a manual step.
- Does **not** run instrumented tests — there are none in this repo.
- Does **not** build the release APK. `assembleRelease` needs the keystore
secrets defined in `.github/workflows/build_apk.yml`.
## Failure modes
- **`SDK location not found`**: `local.properties` is missing or stale.
Open the project in Android Studio once so it generates the file.
- **`compileSdk 35 not found`**: Android Studio hasn't installed the SDK
platform. Open SDK Manager and install Android 35.
- **`lspatch.jar not found`**: run `./gradlew setupLSPatch` first. The
build expects the JAR at `app/libs/lspatch.jar`.
- **`Unsupported class file major version`**: Java version mismatch.
`compileOptions { sourceCompatibility = JavaVersion.VERSION_17 }` —
use JDK 17.
## When to run
- After any edit in `app/src/main/java/`.
- Before `git commit` of hook changes (or as a pre-commit hook).
- After `./gradlew setupLSPatch` (to confirm the new `lspatch.jar` is
compatible with the hook DSL).
+7
View File
@@ -27,6 +27,13 @@ jobs:
- name: Make gradlew executable
run: chmod +x ./gradlew
- name: Verify patch-doc coverage
# AGENTS.md § 3.4 requires every hook in app/src/main/java/com/grindrplus/hooks/
# to have a corresponding docs/patches/<kebab>.md. Fails the build if any
# hook is undocumented. See scripts/check-patch-docs.sh and the
# patch-doc-coverage skill in .claude/skills/.
run: bash scripts/check-patch-docs.sh
- name: Get Version Info
id: version_info
run: |
+168
View File
@@ -0,0 +1,168 @@
# AGENTS.md — GrindrPlus
> Canonical project instructions for AI coding agents. `CLAUDE.md` and `GEMINI.md` are vendor aliases pointing here.
## 1. Project Overview
GrindrPlus is an Xposed / LSPosed / LSPatch module that hooks the obfuscated `com.grindrapp.android` (Grindr) Android app to add features and bypass paywalls. The module is dual-mode — it can be installed as an LSPosed module on rooted devices or as an LSPatch-patched APK on unrooted devices. Both modes share the same hook source.
> [!WARNING]
> The README banner marks the project as **end-of-life** because Grindr has rolled out PairIP integrity checks that effectively block hooks. New releases of Grindr will not be supported. The work in this repo is therefore *maintenance-grade*: keep things compiling and documented for any future fork.
- **Module entrypoint**: `app/src/main/java/com/grindrplus/GrindrPlus.kt`
- **Hook DSL**: `app/src/main/java/com/grindrplus/utils/{Hook,Hooker,HookAdapter,HookManager}.kt`
- **Hooks**: `app/src/main/java/com/grindrplus/hooks/*.kt` (29 files; `HookManager.kt:registerHooks` registers them)
- **Manager UI**: `app/src/main/java/com/grindrplus/manager/` (Jetpack Compose; not directly part of the hooking surface)
- **Per-hook design docs**: `docs/patches/<feature>.md`
## 2. Dev Setup
Full setup is in [`docs/env_setup.md`](docs/env_setup.md). Short version:
1. Install Android Studio (with JDK 17) and open the repo root. `./gradlew assembleDebug` should work after Android Studio imports the Gradle project.
2. Download the target Grindr APK (see `version.json` for the version) from APKMirror. The `.apkm` is a zip of per-arch splits — you only need the base APK for decompilation.
3. Decompile with JADX (enable *Show inconsistent code* and *Enable deobfuscation*).
4. Test on a rooted device (LSPosed) or via Shizuku + LSPatch on an unrooted device.
## 3. Hook Authoring Rules
This is the most important section. **Read it before editing any file in `hooks/`.**
### 3.1 The `// search for '<snippet>'` convention
Grindr's bytecode is obfuscated; class and method names like `a`, `ka8`, `ps2` are renamed on every release. When a hook captures an obfuscated symbol, it MUST be followed by a `// search for` marker on the same line:
```kotlin
private val utils = "ka8" // search for ' <= 600000;'
```
The marker is a snippet of **stable decompiled source** (a literal, string constant, or distinctive expression) that the next maintainer can paste into JADX's search box against a new APK to find the new obfuscated name.
**Marker rules:**
- The snippet must be **≥ 12 characters** long.
- The snippet must be a **literal that survives R8/ProGuard deobfuscation** — string constants, numeric literals, distinctive keywords. Do NOT use names that themselves get renamed.
- The snippet must be **unique** in a fresh JADX-decompiled APK. If it matches multiple files, narrow it.
- For method-body snippets (e.g. `param.setArg(1, false)`), the marker goes on the line **above** the hook call, with the form `// search for '<snippet>'`.
Example from `hooks/OnlineIndicator.kt`:
```kotlin
val utils = "ka8" // search for ' <= 600000;'
val isFeatureFlagEnabled = "xh6" // search for 'implements IsFeatureFlagEnabled'
override fun init() {
findClass(utils)
.hook("d", HookStage.BEFORE) { param ->
// ...
}
}
```
### 3.2 Hook class skeleton
```kotlin
class <FeatureName> : Hook(
"<display name>",
"<one-line user-facing description>"
) {
private val obfuscatedA = "xx1" // search for '<snippet A>'
private val obfuscatedB = "yy2" // search for '<snippet B>'
override fun init() {
// body: use findClass(...).hook(...) / hookConstructor(...)
}
override fun cleanup() {
// unhook any reflection-cached resources, coroutines, threads
}
}
```
- Extend `Hook` from `com.grindrplus.utils`.
- Use the `findClass(name)` helper (already a member of `Hook`) — do not call `GrindrPlus.loadClass` directly from a hook.
- Use `HookStage.BEFORE` to modify arguments / short-circuit results; `HookStage.AFTER` to patch return values.
- Use `HookAdapter.arg<T>(i)` and `HookAdapter.setArg(i, v)` instead of `param.args[i]` casts.
### 3.3 Registering the hook
After adding `hooks/<FeatureName>.kt`, add an entry to `utils/HookManager.kt:registerHooks`'s `hookList`:
```kotlin
FeatureName(),
```
…sorted alphabetically with the rest. Use the **display name** in the in-app toggle list, not the class name.
### 3.4 Documenting the hook
Create `docs/patches/<kebab-name>.md` using the stub template (see `.claude/skills/patch-doc-coverage/stub.template.md`). Cross-link from `docs/patches/README.md`. CI fails if any hook lacks this doc.
## 4. Patch Authoring Rules
- **One class per file** in `hooks/`.
- File name = PascalCase class name (e.g. `OnlineIndicator.kt`, `AntiBlock.kt`).
- Hook doc filename = snake_case + `.md` (e.g. `online_indicator.md`, `anti_block.md`).
- Each hook captures one user-visible feature; if you're tempted to add unrelated patches to the same hook file, split them.
- All hook code lives under `package com.grindrplus.hooks`.
## 5. Version Bump Workflow
When a new Grindr version drops:
1. **Fetch** the latest version + build number: `python3 fetch_version.py -o version.json`. This pulls from APKMirror and validates against `version.schema.json`.
2. **Patch** the two `listOf(...)` lines in `app/build.gradle.kts`:
```kotlin
val grindrVersionName = listOf("<new>")
val grindrVersionCode = listOf(<new_code>)
```
Or invoke `/bump-grindr-version` (see `.claude/skills/bump-grindr-version/`).
3. **Verify** the banner: `./gradlew printVersionInfo --quiet` — must print `GrindrPlus v...<new Grindr version>`.
4. **Port** hooks: see `.claude/skills/update-obfuscated-symbols/`. Run `scripts/check-obfuscated-symbols.py` against a JADX dump of the new APK to find which `// search for` markers are stale. Manually update each affected hook and the corresponding `docs/patches/<name>.md` ("Verified against" section).
5. **Build & smoke-test** on a real device (LSPosed or LSPatch).
6. **Commit** with the message `Bump Grindr version support to <X.Y.Z>` (Co-Authored-By line for any AI assistance).
## 6. Build & Test Commands
| Command | What it does |
|---|---|
| `./gradlew setupLSPatch` | Downloads latest LSPatch release into `app/libs/lspatch.jar` and unzips the SO into `src/main/`. Idempotent. |
| `./gradlew assembleDebug` | Builds an unsigned debug APK at `app/build/outputs/apk/debug/GPlus_v*.apk`. |
| `./gradlew assembleRelease` | Builds a release APK. Needs GH secrets (`KEYSTORE_BASE64`, `KEY_ALIAS`, `KEYSTORE_PASSWORD`, `KEY_PASSWORD`) and is normally only invoked by `.github/workflows/build_apk.yml`. |
| `./gradlew printVersionInfo` | Prints `GrindrPlus v<versionCode>-<grindrVersion>_<gitHash>`. Use it to confirm a version bump landed. |
| `bash scripts/check-patch-docs.sh` | Exits 1 if any hook in `hooks/` lacks `docs/patches/<kebab>.md`. Wired into CI. |
| `python3 scripts/check-obfuscated-symbols.py --jadx <dir>` | Prints a Markdown table of every `// search for '<snippet>'` marker and its candidate symbol in the JADX output. Status ∈ {OK, AMBIGUOUS, STALE}. |
| `bash scripts/sync-grindr-version.sh --dry-run` | Wraps `fetch_version.py` + `sed`-patch of the two `listOf` lines. `--dry-run` shows the diff without writing. |
## 7. Do-Not-Touch
- `com.grindrapp.android_26.13.0.apkm` and any other `*.apkm` binary in the repo root — they are downloaded APK bundles and should be `.gitignore`'d (already untracked).
- `app/libs/*` — committed binaries including `lspatch.jar`; rebuilt by `./gradlew setupLSPatch`.
- `.github/workflows/build_apk.yml` secret values (`KEYSTORE_BASE64`, `KEY_ALIAS`, `KEYSTORE_PASSWORD`, `KEY_PASSWORD`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`).
- `local.properties`, `gradle-wrapper.jar`, anything under `build/` or `.gradle/`.
- `docs/img/` images.
## 8. Patch Lifecycle Semantics
- `Hook.init()` runs **once** when the module loads the host app, after `Config` confirms the hook is enabled via the in-app settings screen.
- `Hook.cleanup()` runs when the user toggles the hook off and on, or when `HookManager.reloadHooks()` is called. **It must release** any reflection-cached `Class` references, cancel coroutines (`scope.cancel()`), stop background threads, and reset static state.
- Hooks that touch a static field (e.g. `XposedHelpers.setObjectField`) should consider the case where the field is renamed between Grindr versions — the symbol-marker convention (§3.1) is what protects you here.
---
## Reusable skills
This repo ships four skills under `.claude/skills/`:
- `bump-grindr-version` — automate §5 steps 1–3.
- `update-obfuscated-symbols` — automate §5 step 4.
- `patch-doc-coverage` — check and author the `docs/patches/*.md` coverage.
- `verify-build` — run `./gradlew assembleDebug` and surface failures.
## See also
- `docs/README.md` — same search-for convention, from a human-author angle.
- `docs/env_setup.md` — full dev environment setup.
- `docs/patches/README.md` — index of per-hook design docs.
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
@@ -1,6 +1,21 @@
package com.grindrplus.core
/**
* Project-wide constants. Anything here is referenced from multiple modules
* (hooks, manager, persistence) so it lives in one place.
*/
object Constants {
/**
* Sentinel string substituted for newlines when bridging between
* the Xposed hook context (where `"\n"` can be stripped or escaped
* by the host app) and the manager UI / chat-console.
*/
const val NEWLINE = "GRINDRPLUS_NEWLINE"
/**
* The Grindr Android application package name. Used by [com.grindrplus.XposedLoader]
* to gate `handleLoadPackage`, by `core/http/Interceptor` to scope
* request rewriting, and by the manager UI's package picker.
*/
const val GRINDR_PACKAGE_NAME = "com.grindrapp.android"
}
@@ -14,6 +14,25 @@ import okhttp3.ResponseBody.Companion.toResponseBody
import java.net.SocketTimeoutException
import java.util.concurrent.TimeoutException
/**
* OkHttp interceptor that injects the auth + telemetry headers Grindr's
* backend expects on every request.
*
* The interceptor reflects into three live objects from the host app —
* the [userSession] (provides the JWT + roles), the [userAgent] (provides
* the User-Agent string), and the [deviceInfo] (provides the device-fp
* header). Method and field letters change every Grindr release, so the
* call sites use `// search for '<snippet>'` markers as breadcrumbs.
*
* Failures here are *swallowed* with a synthetic [Response] so the hook
* never crashes the host app on a partial API mismatch — the cost is a
* failed HTTP request, which is much easier to diagnose than a crash.
*
* @param userSession Live user-session object from the host app (e.g.
* `com.grindrapp.android.usersession.<obf>`).
* @param userAgent Live user-agent object from the host app.
* @param deviceInfo Live device-info object from the host app.
*/
class Interceptor(
private val userSession: Any,
private val userAgent: Any,
@@ -73,6 +92,11 @@ class Interceptor(
return builder.build()
}
/**
* Reflectively invoke a no-arg method on [obj]. Returns `null` and
* logs a warning if the method is missing (typical after a Grindr
* update that renamed the symbol). Never throws.
*/
private fun invokeMethodSafe(obj: Any?, methodName: String): Any? {
return try {
if (obj == null) {
@@ -93,6 +117,10 @@ class Interceptor(
}
}
/**
* Reflectively read a field on [obj] (including private). Returns
* `null` and logs a warning if the field is missing. Never throws.
*/
private fun getFieldSafe(obj: Any?, fieldName: String): Any? {
return try {
if (obj == null) {
@@ -9,6 +9,26 @@ import android.view.WindowManager.LayoutParams.FLAG_SECURE
import com.grindrplus.utils.Hook
import com.grindrplus.utils.HookStage
import com.grindrplus.utils.hook
/**
* Allow screenshots.
*
* Allow screenshots everywhere in the app.
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/allow_screenshots.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class AllowScreenshots : Hook(
"Allow screenshots",
@@ -16,6 +16,26 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.json.JSONObject
/**
* Anti Block.
*
* Notifies you when someone blocks or unblocks you.
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/anti_block.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class AntiBlock : Hook(
"Anti Block",
@@ -4,6 +4,26 @@ import com.grindrplus.utils.Hook
import com.grindrplus.utils.HookStage
import com.grindrplus.utils.hook
import com.grindrplus.utils.hookConstructor
/**
* Anti Detection.
*
* Hides root, emulator, and environment detections.
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/anti_detection.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class AntiDetection : Hook(
"Anti Detection",
@@ -27,6 +27,26 @@ import com.grindrplus.utils.hookConstructor
import de.robv.android.xposed.XposedHelpers
import de.robv.android.xposed.XposedHelpers.getObjectField
import org.json.JSONObject
/**
* Ban management.
*
* Provides comprehensive ban management tools (detailed ban info, etc.).
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/ban_management.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class BanManagement : Hook(
"Ban management",
@@ -6,6 +6,26 @@ import com.grindrplus.utils.RetrofitUtils
import com.grindrplus.utils.RetrofitUtils.RETROFIT_NAME
import com.grindrplus.utils.RetrofitUtils.createServiceProxy
import com.grindrplus.utils.hook
/**
* Chat indicators.
*
* Don't show chat markers / indicators to others.
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/chat_indicators.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class ChatIndicators : Hook(
"Chat indicators",
@@ -7,6 +7,26 @@ import com.grindrplus.utils.HookStage
import com.grindrplus.utils.hook
import de.robv.android.xposed.XposedHelpers.callMethod
import de.robv.android.xposed.XposedHelpers.getObjectField
/**
* Chat terminal.
*
* Create a chat terminal to execute commands.
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/chat_terminal.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class ChatTerminal : Hook(
"Chat terminal",
@@ -3,6 +3,26 @@ package com.grindrplus.hooks
import com.grindrplus.utils.Hook
import com.grindrplus.utils.HookStage
import com.grindrplus.utils.hook
/**
* Disable analytics.
*
* Disable Grindr analytics (data collection).
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/disable_analytics.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class DisableAnalytics : Hook(
"Disable analytics",
@@ -6,6 +6,26 @@ import com.grindrplus.utils.hook
import com.grindrplus.utils.hookConstructor
import de.robv.android.xposed.XposedHelpers.newInstance
import de.robv.android.xposed.XposedHelpers.setObjectField
/**
* Disable boosting.
*
* Get rid of all upsells related to boosting.
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/disable_boosting.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class DisableBoosting : Hook(
"Disable boosting",
@@ -4,6 +4,26 @@ import com.grindrplus.utils.Hook
import com.grindrplus.utils.HookStage
import com.grindrplus.utils.hookConstructor
import de.robv.android.xposed.XposedHelpers.setObjectField
/**
* Disable shuffle.
*
* Forcefully disable the shuffle feature.
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/disable_shuffle.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class DisableShuffle : Hook(
"Disable shuffle",
@@ -12,6 +12,26 @@ import de.robv.android.xposed.XposedHelpers.setObjectField
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
/**
* Disable updates.
*
* Disable forced updates.
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/disable_updates.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class DisableUpdates : Hook(
"Disable updates",
@@ -3,6 +3,26 @@ package com.grindrplus.hooks
import com.grindrplus.utils.Hook
import com.grindrplus.utils.HookStage
import com.grindrplus.utils.hook
/**
* Video calls.
*
* Allow video calls on empty chats.
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/empty_calls.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class EmptyCalls : Hook(
"Video calls",
@@ -16,6 +16,26 @@ import com.grindrplus.utils.hookConstructor
import de.robv.android.xposed.XposedHelpers.callMethod
import de.robv.android.xposed.XposedHelpers.getObjectField
import de.robv.android.xposed.XposedHelpers.setObjectField
/**
* Enable unlimited.
*
* Enable Grindr Unlimited features.
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/enable_unlimited.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class EnableUnlimited : Hook(
"Enable unlimited",
@@ -17,6 +17,26 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Expiring media.
*
* Allow unlimited photo/video viewing and save media permanently.
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/expiring_media.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class ExpiringMedia : Hook(
"Expiring media",
@@ -12,6 +12,26 @@ import com.grindrplus.utils.hookConstructor
import de.robv.android.xposed.XposedHelpers
import de.robv.android.xposed.XposedHelpers.callMethod
import de.robv.android.xposed.XposedHelpers.getObjectField
/**
* Feature granting.
*
* Grant all Grindr features.
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/feature_granting.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class FeatureGranting : Hook(
"Feature granting",
@@ -16,6 +16,26 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import java.lang.reflect.Constructor
import java.lang.reflect.Proxy
/**
* Local saved phrases.
*
* Save unlimited phrases locally.
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/local_saved_phrases.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class LocalSavedPhrases : Hook(
"Local saved phrases",
@@ -41,6 +41,26 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.lang.reflect.Proxy
/**
* Location spoofer.
*
* Spoof your location.
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/location_spoofer.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class LocationSpoofer : Hook(
"Location spoofer",
@@ -573,7 +593,6 @@ class LocationSpoofer : Hook(
// set initial marker position
callMethod(markerOptions, "position", selectedLatLng)
// implement OnMapClickListener via proxy
val onMapClickListener = Proxy.newProxyInstance(
context.classLoader,
@@ -620,7 +639,6 @@ class LocationSpoofer : Hook(
callMethod(mapView, "getMapAsync", onMapReadyListener)
AlertDialog.Builder(context).apply {
setTitle("Pick Location")
setView(container)
@@ -3,6 +3,26 @@ package com.grindrplus.hooks
import com.grindrplus.utils.Hook
import com.grindrplus.utils.HookStage
import com.grindrplus.utils.hook
/**
* Notification Alerts.
*
* Disable all Grindr warnings related to notifications.
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/notification_alerts.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class NotificationAlerts : Hook(
"Notification Alerts",
@@ -5,6 +5,26 @@ import com.grindrplus.utils.Hook
import com.grindrplus.utils.HookStage
import com.grindrplus.utils.hook
import kotlin.time.Duration.Companion.minutes
/**
* Online indicator.
*
* Customize online indicator duration.
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/online_indicator.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class OnlineIndicator : Hook(
"Online indicator",
@@ -23,6 +23,26 @@ import de.robv.android.xposed.XposedHelpers.getObjectField
import de.robv.android.xposed.XposedHelpers.setObjectField
import java.util.ArrayList
import kotlin.math.roundToInt
/**
* Profile details.
*
* Add extra fields and details to profiles.
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/profile_details.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class ProfileDetails : Hook(
"Profile details",
@@ -6,6 +6,26 @@ import com.grindrplus.utils.RetrofitUtils.RETROFIT_NAME
import com.grindrplus.utils.RetrofitUtils.createServiceProxy
import com.grindrplus.utils.RetrofitUtils.findPOSTMethod
import com.grindrplus.utils.hook
/**
* Profile views.
*
* Don't let others know you viewed their profile.
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/profile_views.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class ProfileViews : Hook(
"Profile views",
@@ -9,6 +9,26 @@ import com.grindrplus.utils.HookStage
import com.grindrplus.utils.hook
import de.robv.android.xposed.XposedHelpers.callMethod
import de.robv.android.xposed.XposedHelpers.getObjectField
/**
* Quick block.
*
* Ability to block users quickly.
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/quick_block.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class QuickBlock : Hook(
"Quick block",
@@ -16,6 +16,21 @@ import javax.net.ssl.SSLSession
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
/**
* SSL certificate pinning bypass.
*
* Unlike the rest of the hooks in this package (which extend
* [com.grindrplus.utils.Hook] and register via [com.grindrplus.utils.HookManager]),
* this file is a top-level [sslUnpinning] function called directly from
* [com.grindrplus.XposedLoader.handleLoadPackage] under `BuildConfig.DEBUG`.
* It runs before any Grindr network call so the host app's HTTPS stack
* never rejects our Mitmproxy / HTTP Toolkit cert.
*
* The hooks here target stable, fully-qualified classes from OkHttp /
* Conscrypt / Firebase; the obfuscation-marker convention doesn't apply.
*
* @see com.grindrplus.XposedLoader for the call site.
*/
@OptIn(ExperimentalStdlibApi::class)
@SuppressLint("CustomX509TrustManager", "TrustAllX509TrustManager", "BadHostnameVerifier")
fun sslUnpinning(param: XC_LoadPackage.LoadPackageParam) {
@@ -15,6 +15,30 @@ private const val configRealtimeHttpClient =
private const val configFetchHttpClient =
"com.google.firebase.remoteconfig.internal.ConfigFetchHttpClient"
/**
* App-signature spoofing.
*
* Unlike the rest of the hooks in this package (which extend
* [com.grindrplus.utils.Hook] and register via [com.grindrplus.utils.HookManager]),
* this file is a top-level [spoofSignatures] function called directly from
* [com.grindrplus.XposedLoader.handleLoadPackage]. It runs once per package
* load and:
*
* - Rewrites `getFingerprintHashForPackage` on Firebase + Facebook SDKs so
* they report the *original* Grindr APK signature, not the LSPatch-
* repackaged one (Firebase/Facebook reject mismatched signatures).
* - Forces Facebook login to fall through to the web flow by zeroing the
* `tryAuthorize` return, sidestepping an external-app signature check.
* - On non-Grindr packages, fixes `ContextWrapper.getPackageName` /
* Firebase `Metadata.getPackageInfo` so they still report the
* `com.grindrapp.android` package name for any caller in the Firebase
* install stack.
*
* The hooks here target stable, fully-qualified classes from Firebase /
* Facebook; the obfuscation-marker convention doesn't apply.
*
* @see com.grindrplus.XposedLoader for the call site.
*/
@OptIn(ExperimentalStdlibApi::class)
fun spoofSignatures(param: XC_LoadPackage.LoadPackageParam) {
@@ -17,6 +17,26 @@ import com.grindrplus.utils.Hook
import com.grindrplus.utils.HookStage
import com.grindrplus.utils.hookConstructor
import java.io.File
/**
* Status Dialog.
*
* Check whether GrindrPlus is alive or not.
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/status_dialog.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class StatusDialog : Hook(
"Status Dialog",
@@ -11,6 +11,27 @@ import com.grindrplus.utils.hook
import de.robv.android.xposed.XposedHelpers
@SuppressLint("LogNotTimber")
/**
* Timber Logging.
*
* Forces Timber to log messages even if no tree is planted.
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/timber_logging :.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class TimberLogging :
Hook("Timber Logging", "Forces Timber to log messages even if no tree is planted") {
override fun init() {
@@ -35,6 +35,26 @@ import kotlinx.coroutines.runBlocking
import org.json.JSONObject
import java.io.Closeable
import java.io.IOException
/**
* Unlimited albums.
*
* Allow to be able to view unlimited albums.
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/unlimited_albums.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class UnlimitedAlbums : Hook("Unlimited albums", "Allow to be able to view unlimited albums") {
private val albumsService = "com.grindrapp.android.api.albums.AlbumsRestService" // search for 'v1/albums/red-dot'
@@ -12,6 +12,26 @@ import de.robv.android.xposed.XposedHelpers.callMethod
import de.robv.android.xposed.XposedHelpers.callStaticMethod
import de.robv.android.xposed.XposedHelpers.getObjectField
import java.lang.reflect.Proxy
/**
* Unlimited profiles.
*
* Allow unlimited profiles.
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/unlimited_profiles.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class UnlimitedProfiles : Hook(
"Unlimited profiles",
@@ -3,6 +3,26 @@ package com.grindrplus.hooks
import com.grindrplus.utils.Hook
import com.grindrplus.utils.HookStage
import com.grindrplus.utils.hook
/**
* Unlock Explorer.
*
* Unlock all profiles in Explorer.
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/unlock_explorer.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class UnlockExplorer : Hook(
"Unlock Explorer",
@@ -11,6 +11,26 @@ import com.grindrplus.utils.HookAdapter
import com.grindrplus.utils.HookStage
import com.grindrplus.utils.hook
import de.robv.android.xposed.XposedHelpers.callMethod
/**
* Keep Alive WebSocket.
*
* Prevents WebSocket disconnections when app goes to background. Causes battery drain, use with caution.
*
* Hooks `com.grindrapp.android` to add/modify this feature. See
* `docs/patches/web_socket_alive.md` for design notes and version-port history.
*
* **Obfuscation marker convention.** Every capture of an obfuscated
* symbol (single- or two-letter class/method name) in this file carries
* an inline comment whose body is a stable, unique snippet from the
* JADX-decompiled source. When porting this hook to a new Grindr
* version, paste each snippet into JADX's search box to find the new
* obfuscated name. See `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** `init()` is invoked once by HookManager.registerHooks()
* after `Config` confirms the hook is enabled. `cleanup()` is invoked on
* HookManager.reloadHooks() and must release any reflection-cached
* resources, coroutines, or threads.
*/
class WebSocketAlive : Hook(
"Keep Alive WebSocket",
@@ -1,18 +1,44 @@
package com.grindrplus.utils
/**
* A named, toggleable feature flag. Used by hooks that want a per-feature
* on/off switch distinct from the global hook on/off in [Hook] /
* [com.grindrplus.core.Config].
*
* @property name Stable identifier (typically the Grindr feature flag key,
* e.g. `"online-until-updates"`).
* @property isEnabled Whether the feature is currently on.
*/
data class Feature(val name: String, var isEnabled: Boolean)
/**
* In-memory registry of named feature flags. Backed by a [MutableMap] keyed
* by feature name; not persisted across process restarts (use
* [com.grindrplus.core.Config] for that).
*/
class FeatureManager {
private val features = mutableMapOf<String, Feature>()
/**
* Register a [Feature]. Overwrites any prior entry with the same [Feature.name].
*/
fun add(feature: Feature) {
features[feature.name] = feature
}
/**
* Returns whether [featureName] is currently registered AND enabled.
* Returns `false` for unknown features.
*/
fun isEnabled(featureName: String): Boolean {
return features[featureName]?.isEnabled ?: false
}
/**
* Returns whether [featureName] is registered (regardless of its
* current enabled state). Useful for distinguishing "feature absent"
* from "feature disabled".
*/
fun isManaged(featureName: String): Boolean {
return features.containsKey(featureName)
}
+65 -2
View File
@@ -3,34 +3,97 @@ package com.grindrplus.utils
import com.grindrplus.GrindrPlus
import com.grindrplus.core.Config
/**
* Abstract base class for every Grindr patch in `hooks/`.
*
* Each subclass represents one user-visible feature (Anti Block, Online
* Indicator, Disable Boosting, …). The class is registered in
* [HookManager.registerHooks]'s `hookList` and the display name surfaces
* in the in-app toggle list.
*
* **Obfuscation marker convention.** When a hook captures an obfuscated
* class or method name (`a`, `ka8`, `ps2`, …) it MUST be followed by a
* `// search for '<unique snippet>'` marker on the same line. The snippet
* is a stable literal in the decompiled source so the next maintainer can
* locate the new obfuscated name in a fresh JADX-decompiled APK. See
* `AGENTS.md` § 3 for the full convention.
*
* **Lifecycle.** [init] runs once on host-app load (after `Config` confirms
* the hook is enabled). [cleanup] runs on `HookManager.reloadHooks()` and
* MUST release any reflection-cached `Class` references, cancel coroutines
* (`scope.cancel()`), stop background threads, and reset static state.
*
* @property hookName Display name shown in the in-app hook toggle list.
* Also used as the key in the `Config` datastore.
* @property hookDesc One-line user-facing description of what the hook does.
*
* @see HookManager for registration / reload semantics.
* @see HookStage for the BEFORE / AFTER hook timing options.
*/
abstract class Hook(
val hookName: String,
val hookDesc: String = "",
) {
/**
* Hook specific initialization.
* Hook-specific initialization. Override to wire up Xposed hooks,
* start coroutines, register listeners, etc. Called once when the
* host app is loaded if the hook is enabled in user settings.
*/
open fun init() {}
/**
* Hook specific cleanup.
* Hook-specific cleanup. Override to cancel coroutines, stop threads,
* unhook any reflection-cached resources, and reset static state.
* Called when the user toggles the hook off/on or when
* [HookManager.reloadHooks] is invoked.
*/
open fun cleanup() {}
/**
* Returns whether this hook is currently enabled in the in-app settings.
* Hook implementations should consult this before doing expensive work.
*/
protected fun isHookEnabled(): Boolean {
return Config.isHookEnabled(hookName)
}
/**
* Resolve a class from the host app's class loader. Use this for both
* obfuscated (`findClass("ka8")`) and fully-qualified (`findClass(
* "com.grindrapp.android.chat.ChatDeleteConversationPlugin")`) names.
*
* @param name Either a fully-qualified class name or an obfuscated
* short name. The latter should always have a `// search for`
* marker as documentation (see class KDoc).
* @return The [Class] object from the host app's class loader.
* @throws ClassNotFoundException if the host APK no longer contains
* a matching class (typical after a Grindr update that
* renamed the symbol — port the marker via the
* `update-obfuscated-symbols` skill).
*/
protected fun findClass(name: String): Class<*> {
return GrindrPlus.loadClass(name)
}
/**
* Look up an Android resource identifier by name + type, scoped to
* the host app's package (Grindr). Returns `0` if no resource matches.
*
* @param name Resource entry name (without `@`, `@+`, `@drawable/`, etc.).
* @param type Resource type (`string`, `drawable`, `layout`, …).
*/
protected fun getResource(name: String, type: String): Int {
return GrindrPlus.context.resources.getIdentifier(
name, type, GrindrPlus.context.packageName
)
}
/**
* Look up an Android attribute identifier by name, scoped to the host
* app's package. Returns `0` if no attribute matches.
*
* @param name Attribute name (e.g. `R.attr.someCustomAttr`).
*/
protected fun getAttribute(name: String): Int {
return GrindrPlus.context.resources.getIdentifier(name, "attr"
, GrindrPlus.context.packageName)
@@ -5,26 +5,64 @@ import de.robv.android.xposed.XposedBridge
import java.lang.reflect.Member
import java.util.function.Consumer
/**
* Type-safe wrapper around [XC_MethodHook.MethodHookParam], passed to every
* hook consumer registered via [Hooker] / the `Class<T>.hook(...)` extension
* functions.
*
* The adapter hides the raw `args[]` array and unchecked casts in favor of
* typed helpers: [arg], [argNullable], [setArg], [setResult], and the
* original-method invokers [invokeOriginal] / [invokeOriginalSafe].
*
* @param Clazz The declared type of `this` on the hooked method (or `Any`
* if the hook doesn't care). Used by [thisObject] /
* [nullableThisObject] to give a typed view of the receiver.
*/
@Suppress("UNCHECKED_CAST")
class HookAdapter<Clazz>(
private val methodHookParam: XC_MethodHook.MethodHookParam<*>
) {
/**
* `this` reference on the hooked method, cast to [Clazz].
* @throws ClassCastException if the receiver is not a [Clazz].
*/
fun thisObject(): Clazz {
return methodHookParam.thisObject as Clazz
}
/**
* Nullable variant of [thisObject]. Returns `null` when the hooked
* method was a static call (`thisObject` is `null`).
*/
fun nullableThisObject(): Clazz? {
return methodHookParam.thisObject as Clazz?
}
/**
* The reflective [Member] (method or constructor) that was hooked.
*/
fun method(): Member {
return methodHookParam.method
}
/**
* Read an argument by index with an unchecked cast. Use [arg] overload
* with a [Class] token when you want type-coercion fallbacks.
*
* @throws IndexOutOfBoundsException if [index] is out of range.
*/
fun <T : Any> arg(index: Int): T {
return methodHookParam.args[index] as T
}
/**
* Read an argument by index with type coercion. If the raw value is not
* already of type [clazz], the adapter tries a best-effort conversion
* (calling `toString`/`toInt`/`toDouble`/...) and falls back to the
* primitive zero value (`0`, `0.0`, `false`, ...) for primitives.
*
* Returns `null` if the value cannot be coerced.
*/
fun <T : Any> arg(index: Int, clazz: Class<T>): T? {
val argValue = methodHookParam.args[index]
return try {
@@ -34,47 +72,96 @@ class HookAdapter<Clazz>(
}
}
/**
* Like [arg] but returns `null` instead of throwing when [index] is
* out of range.
*/
fun <T : Any> argNullable(index: Int): T? {
return methodHookParam.args.getOrNull(index) as T?
}
/**
* Replace an argument by index. No-op if [index] is out of range.
* Use this in `HookStage.BEFORE` to mutate inputs before the original
* method runs.
*/
fun setArg(index: Int, value: Any?) {
if (index < 0 || index >= methodHookParam.args.size) return
methodHookParam.args[index] = value
}
/**
* Raw args array. Use this when you need the whole array at once
* (e.g. for `invokeOriginal(args)`) or when you don't know the type.
*/
fun args(): Array<Any?> {
return methodHookParam.args
}
/**
* Read the original method's return value. Only meaningful in
* `HookStage.AFTER` callbacks.
*/
fun getResult(): Any? {
return methodHookParam.result
}
/**
* Replace the original method's return value. In `HookStage.BEFORE`
* this short-circuits the call entirely (the original is skipped).
* In `HookStage.AFTER` this overrides the value the original returned.
*/
fun setResult(result: Any?) {
methodHookParam.result = result
}
/**
* Make the original call throw [throwable] instead of returning.
*/
fun setThrowable(throwable: Throwable) {
methodHookParam.throwable = throwable
}
/**
* Throwable the original method threw, if any. Only meaningful in
* `HookStage.AFTER`.
*/
fun throwable(): Throwable? {
return methodHookParam.throwable
}
/**
* Invoke the original (un-hooked) method using the current `this` and
* args, and return its result. Useful when a `HookStage.AFTER` callback
* needs to re-run the original with a tweaked argument set.
*
* @throws Throwable whatever the original method threw.
*/
fun invokeOriginal(): Any? {
return XposedBridge.invokeOriginalMethod(method(), thisObject(), args())
}
/**
* Invoke the original method with a custom args array, return its result.
*/
fun invokeOriginal(args: Array<Any?>): Any? {
return XposedBridge.invokeOriginalMethod(method(), thisObject(), args)
}
/**
* Invoke the original method, swallowing any thrown exception and
* forwarding it to [errorCallback]. Sets the result via [setResult]
* on success.
*/
fun invokeOriginalSafe(errorCallback: Consumer<Throwable>) {
invokeOriginalSafe(args(), errorCallback)
}
/**
* Invoke the original method with custom [args], swallowing any thrown
* exception and forwarding it to [errorCallback]. Sets the result via
* [setResult] on success.
*/
fun invokeOriginalSafe(args: Array<Any?>, errorCallback: Consumer<Throwable>) {
runCatching {
setResult(XposedBridge.invokeOriginalMethod(method(), thisObject(), args))
@@ -102,6 +189,11 @@ class HookAdapter<Clazz>(
}
}
/**
* Best-effort coercion of [arg] to [clazz] via the standard
* `toString` / `toInt` / `toDouble` / `toFloat` / `toLong` / `toBoolean`
* reflective call. Returns `null` if coercion fails.
*/
fun <T : Any> convertToType(arg: Any, clazz: Class<T>): T? {
return try {
when (clazz) {
@@ -33,9 +33,31 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlin.reflect.KClass
/**
* Owns the lifecycle of every [Hook] registered with the module.
*
* The `hookList` literal in [registerHooks] is the single source of truth
* for which hooks ship in the module. **Every new hook** in
* `com.grindrplus.hooks` must be added here (and, per `AGENTS.md` § 4, get
* a matching `docs/patches/<kebab>.md`).
*
* The class is a singleton owned by [com.grindrplus.GrindrPlus.hookManager].
*/
class HookManager {
/** Currently-registered hooks, keyed by their concrete [KClass]. */
private var hooks = mutableMapOf<KClass<out Hook>, Hook>()
/**
* Build the [hookList], register each hook's settings with [Config],
* and (when [init] is true) call [Hook.init] on every enabled hook.
*
* Pass `init = false` to only refresh the settings registry without
* invoking hook bodies — useful for first-launch wiring where the host
* app context is not yet available.
*
* @param init If true, call [Hook.init] on every hook whose setting is
* enabled in [Config]. Default `true`.
*/
fun registerHooks(init: Boolean = true) {
runBlocking(Dispatchers.IO) {
val hookList = listOf(
@@ -89,6 +111,13 @@ class HookManager {
}
}
/**
* Tear down every currently-registered hook (calling [Hook.cleanup])
* and re-register the full [hookList] from scratch. Invoked from the
* in-app settings screen when the user toggles hooks off/on, or from
* any code path that needs to re-bind Xposed hooks (e.g. after a
* module update).
*/
fun reloadHooks() {
runBlocking(Dispatchers.IO) {
hooks.values.forEach { hook -> hook.cleanup() }
@@ -98,6 +127,10 @@ class HookManager {
}
}
/**
* One-shot initialization called by [com.grindrplus.GrindrPlus.init] on
* module load. Equivalent to `registerHooks(init = true)`.
*/
fun init() {
registerHooks()
}
@@ -5,12 +5,44 @@ import de.robv.android.xposed.XposedBridge
import java.lang.reflect.Member
import java.lang.reflect.Method
/**
* Timing of a hook callback relative to the hooked method.
*
* - [BEFORE] — the callback runs *before* the original method. Use this to
* inspect / modify arguments via [HookAdapter.setArg] or short-circuit the
* call via [HookAdapter.setResult].
* - [AFTER] — the callback runs *after* the original method. Use this to
* patch the return value via [HookAdapter.setResult] or trigger side
* effects (notifications, logging).
*/
enum class HookStage {
BEFORE,
AFTER
}
/**
* Thin wrapper around `XposedBridge.hookAllMethods` / `hookAllConstructors`
* / `hookMethod` that hands the consumer a type-safe [HookAdapter].
*
* Most hook authors never call [Hooker] directly — they use the extension
* functions at the bottom of this file:
*
* ```kotlin
* findClass("ka8").hook("d", HookStage.BEFORE) { param ->
* param.setResult(System.currentTimeMillis() - param.arg<Long>(0) <= savedDurationMillis)
* }
* ```
*
* The `hookObjectMethod` / `ephemeralHook*` overloads exist for hooks that
* must fire only for a specific object instance or fire exactly once.
*
* @see HookAdapter for the type-safe wrapper passed to consumers.
*/
object Hooker {
/**
* Build an [XC_MethodHook] that dispatches to [consumer] wrapped in a
* [HookAdapter]. Internal — exposed for the inline extension functions.
*/
inline fun <T> newMethodHook(
stage: HookStage,
crossinline consumer: (HookAdapter<T>) -> Unit,
@@ -30,6 +62,10 @@ object Hooker {
}
}
/**
* Hook every method named [methodName] on [clazz]. The [filter] gates
* which calls actually invoke [consumer].
*/
inline fun <T> hook(
clazz: Class<T>,
methodName: String,
@@ -38,6 +74,11 @@ object Hooker {
noinline consumer: (HookAdapter<T>) -> Unit
): Set<XC_MethodHook.Unhook> = XposedBridge.hookAllMethods(clazz, methodName, newMethodHook(stage, consumer, filter))
/**
* Hook a specific reflective [member] (method or constructor) directly,
* without going through `hookAllMethods`. Use when you have already
* resolved a `Member` (e.g. via `getDeclaredMethods`).
*/
inline fun <T> hook(
member: Member,
stage: HookStage,
@@ -47,6 +88,10 @@ object Hooker {
return XposedBridge.hookMethod(member, newMethodHook(stage, consumer, filter))
}
/**
* Hook every method named [methodName] on [clazz]. Shorthand for the
* filter overload with `filter = { true }`.
*/
fun <T> hook(
clazz: Class<T>,
methodName: String,
@@ -54,6 +99,10 @@ object Hooker {
consumer: (HookAdapter<T>) -> Unit
): Set<XC_MethodHook.Unhook> = hook(clazz, methodName, stage, { true }, consumer)
/**
* Hook a reflective [member] directly. Shorthand for the filter overload
* with `filter = { true }`.
*/
fun <T> hook(
member: Member,
stage: HookStage,
@@ -62,12 +111,19 @@ object Hooker {
return hook(member, stage, { true }, consumer)
}
/**
* Hook every constructor on [clazz]. Shorthand for the filter overload
* with `filter = { true }`.
*/
fun <T> hookConstructor(
clazz: Class<T>,
stage: HookStage,
consumer: (HookAdapter<T>) -> Unit
): Set<XC_MethodHook.Unhook> = XposedBridge.hookAllConstructors(clazz, newMethodHook(stage, consumer))
/**
* Hook every constructor on [clazz], gating with [filter].
*/
fun <T> hookConstructor(
clazz: Class<T>,
stage: HookStage,
@@ -77,6 +133,11 @@ object Hooker {
XposedBridge.hookAllConstructors(clazz, newMethodHook(stage, consumer, filter))
}
/**
* Hook [methodName] on [clazz] but fire [hookConsumer] only when the
* `this` object equals [instance]. The hook automatically unhooks itself
* once it fires (or once the instance is GC'd).
*/
inline fun <T> hookObjectMethod(
clazz: Class<T>,
instance: Any,
@@ -97,6 +158,10 @@ object Hooker {
}
}
/**
* Hook [methodName] on [clazz] exactly once: the hook fires the first
* time [hookConsumer] is invoked, then automatically unhooks itself.
*/
inline fun <T> ephemeralHook(
clazz: Class<T>,
methodName: String,
@@ -110,6 +175,10 @@ object Hooker {
}.also { unhooks.addAll(it) }
}
/**
* Hook [methodName] on [clazz], scoped to [instance], exactly once.
* Combines [hookObjectMethod] + [ephemeralHook].
*/
inline fun <T> ephemeralHookObjectMethod(
clazz: Class<T>,
instance: Any,
@@ -125,6 +194,11 @@ object Hooker {
}.also { unhooks.addAll(it) }
}
/**
* Hook every constructor on [clazz] exactly once. The hook fires the
* first time [hookConsumer] is invoked, then automatically unhooks
* itself.
*/
inline fun <T> ephemeralHookConstructor(
clazz: Class<T>,
stage: HookStage,
@@ -138,23 +212,35 @@ object Hooker {
}
}
/**
* Sugar: `findClass("Foo").hookConstructor(HookStage.AFTER) { ... }`.
*/
fun <T> Class<T>.hookConstructor(
stage: HookStage,
consumer: (HookAdapter<T>) -> Unit
) = Hooker.hookConstructor(this, stage, consumer)
/**
* Sugar: `findClass("Foo").hookConstructor(HookStage.AFTER, filter = ...) { ... }`.
*/
fun <T> Class<T>.hookConstructor(
stage: HookStage,
filter: ((HookAdapter<T>) -> Boolean),
consumer: (HookAdapter<T>) -> Unit
) = Hooker.hookConstructor(this, stage, filter, consumer)
/**
* Sugar: `findClass("Foo").hook("bar", HookStage.BEFORE) { ... }`.
*/
fun <T> Class<T>.hook(
methodName: String,
stage: HookStage,
consumer: (HookAdapter<T>) -> Unit
): Set<XC_MethodHook.Unhook> = Hooker.hook(this, methodName, stage, consumer)
/**
* Sugar: `findClass("Foo").hook("bar", HookStage.BEFORE, filter = ...) { ... }`.
*/
fun <T> Class<T>.hook(
methodName: String,
stage: HookStage,
@@ -162,17 +248,28 @@ fun <T> Class<T>.hook(
consumer: (HookAdapter<T>) -> Unit
): Set<XC_MethodHook.Unhook> = Hooker.hook(this, methodName, stage, filter, consumer)
/**
* Sugar: hook a reflective [Member] (method or constructor).
*/
fun Member.hook(
stage: HookStage,
consumer: (HookAdapter<Any>) -> Unit
): XC_MethodHook.Unhook = Hooker.hook(this, stage, consumer)
/**
* Sugar: hook a reflective [Member] with a filter.
*/
fun Member.hook(
stage: HookStage,
filter: ((HookAdapter<Any>) -> Boolean),
consumer: (HookAdapter<Any>) -> Unit
): XC_MethodHook.Unhook = Hooker.hook(this, stage, filter, consumer)
/**
* Bulk-hook an array of [Method]s. Skips methods declared on [Object] to
* avoid re-hooking `toString`, `hashCode`, etc. across the entire class
* hierarchy.
*/
fun Array<Method>.hookAll(stage: HookStage, param: (HookAdapter<Any>) -> Unit) {
filter { it.declaringClass != Object::class.java }.forEach {
it.hook(stage, param)
+31 -1
View File
@@ -1,4 +1,34 @@
# Patches
Each hook in `app/src/main/java/com/grindrplus/hooks/` ships a design
doc here. The list is alphabetical by kebab-case filename.
- [Allow screenshots](allow_screenshots.md)
- [Anti Block](anti_block.md)
- [Online indicator](online_indicator.md)
- [Anti Detection](anti_detection.md)
- [Ban management](ban_management.md)
- [Chat indicators](chat_indicators.md)
- [Chat terminal](chat_terminal.md)
- [Disable analytics](disable_analytics.md)
- [Disable boosting](disable_boosting.md)
- [Disable shuffle](disable_shuffle.md)
- [Disable updates](disable_updates.md)
- [Video calls](empty_calls.md)
- [Enable unlimited](enable_unlimited.md)
- [Expiring media](expiring_media.md)
- [Feature granting](feature_granting.md)
- [Local saved phrases](local_saved_phrases.md)
- [Location spoofer](location_spoofer.md)
- [Notification Alerts](notification_alerts.md)
- [Online indicator](online_indicator.md)
- [Profile details](profile_details.md)
- [Profile views](profile_views.md)
- [Quick block](quick_block.md)
- [Signature spoofer](signature_spoofer.md)
- [SSL unpinning](ssl_unpinning.md)
- [Status Dialog](status_dialog.md)
- [Timber Logging](timber_logging.md)
- [Unlimited albums](unlimited_albums.md)
- [Unlimited profiles](unlimited_profiles.md)
- [Unlock Explorer](unlock_explorer.md)
- [Keep Alive WebSocket](web_socket_alive.md)
+27
View File
@@ -0,0 +1,27 @@
# Allow screenshots
Allow screenshots everywhere in the app.
## Target
<!-- Search markers used to locate this hook's symbols in newer Grindr APKs.
Sourced from `AllowScreenshots.kt`. When porting this hook to a new
Grindr version, paste each snippet into JADX's search box to
locate the new obfuscated class / method name.
See `AGENTS.md` § 3 (Hook Authoring Rules) for the full convention. -->
```kotlin
// AllowScreenshots.kt
// search for '<unique snippet>'
```
## Verified against
- Grindr 26.13.0 — initial port
- (latest verified Grindr version)
## Notes
<!-- Optional: feature flag gates, why a non-obvious method-body hook was needed,
behavior on rooted vs unrooted devices, etc. -->
+29
View File
@@ -0,0 +1,29 @@
# Anti Detection
Hides root, emulator, and environment detections.
## Target
<!-- Search markers used to locate this hook's symbols in newer Grindr APKs.
Sourced from `AntiDetection.kt`. When porting this hook to a new
Grindr version, paste each snippet into JADX's search box to
locate the new obfuscated class / method name.
See `AGENTS.md` § 3 (Hook Authoring Rules) for the full convention. -->
```kotlin
// AntiDetection.kt
// search for '<unique snippet>'
// search for 'this.isRooted = '
// search for 'this.isEmulator = '
```
## Verified against
- Grindr 26.13.0 — initial port
- (latest verified Grindr version)
## Notes
<!-- Optional: feature flag gates, why a non-obvious method-body hook was needed,
behavior on rooted vs unrooted devices, etc. -->
+29
View File
@@ -0,0 +1,29 @@
# Ban management
Provides comprehensive ban management tools (detailed ban info, etc.).
## Target
<!-- Search markers used to locate this hook's symbols in newer Grindr APKs.
Sourced from `BanManagement.kt`. When porting this hook to a new
Grindr version, paste each snippet into JADX's search box to
locate the new obfuscated class / method name.
See `AGENTS.md` § 3 (Hook Authoring Rules) for the full convention. -->
```kotlin
// BanManagement.kt
val authServiceClass = "com.grindrapp.android.api.LoginRestService" // search for 'v3/users/password-validation'
val bannedArgs = "zi0" // search for 'new StringBuilder("BannedArgs(bannedType=")'
// search for '<unique snippet>'
```
## Verified against
- Grindr 26.13.0 — initial port
- (latest verified Grindr version)
## Notes
<!-- Optional: feature flag gates, why a non-obvious method-body hook was needed,
behavior on rooted vs unrooted devices, etc. -->
+28
View File
@@ -0,0 +1,28 @@
# Chat indicators
Don't show chat markers / indicators to others.
## Target
<!-- Search markers used to locate this hook's symbols in newer Grindr APKs.
Sourced from `ChatIndicators.kt`. When porting this hook to a new
Grindr version, paste each snippet into JADX's search box to
locate the new obfuscated class / method name.
See `AGENTS.md` § 3 (Hook Authoring Rules) for the full convention. -->
```kotlin
// ChatIndicators.kt
val chatRestService = "com.grindrapp.android.chat.data.datasource.api.service.ChatRestService" // search for '"(Lcom/grindrapp/android/chat/data/datasource/api/model/MessageRateResponseRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;"'
// search for '<unique snippet>'
```
## Verified against
- Grindr 26.13.0 — initial port
- (latest verified Grindr version)
## Notes
<!-- Optional: feature flag gates, why a non-obvious method-body hook was needed,
behavior on rooted vs unrooted devices, etc. -->
+27
View File
@@ -0,0 +1,27 @@
# Chat terminal
Create a chat terminal to execute commands.
## Target
<!-- Search markers used to locate this hook's symbols in newer Grindr APKs.
Sourced from `ChatTerminal.kt`. When porting this hook to a new
Grindr version, paste each snippet into JADX's search box to
locate the new obfuscated class / method name.
See `AGENTS.md` § 3 (Hook Authoring Rules) for the full convention. -->
```kotlin
// ChatTerminal.kt
// search for '<unique snippet>'
```
## Verified against
- Grindr 26.13.0 — initial port
- (latest verified Grindr version)
## Notes
<!-- Optional: feature flag gates, why a non-obvious method-body hook was needed,
behavior on rooted vs unrooted devices, etc. -->
+27
View File
@@ -0,0 +1,27 @@
# Disable analytics
Disable Grindr analytics (data collection).
## Target
<!-- Search markers used to locate this hook's symbols in newer Grindr APKs.
Sourced from `DisableAnalytics.kt`. When porting this hook to a new
Grindr version, paste each snippet into JADX's search box to
locate the new obfuscated class / method name.
See `AGENTS.md` § 3 (Hook Authoring Rules) for the full convention. -->
```kotlin
// DisableAnalytics.kt
// search for '<unique snippet>'
```
## Verified against
- Grindr 26.13.0 — initial port
- (latest verified Grindr version)
## Notes
<!-- Optional: feature flag gates, why a non-obvious method-body hook was needed,
behavior on rooted vs unrooted devices, etc. -->
+29
View File
@@ -0,0 +1,29 @@
# Disable boosting
Get rid of all upsells related to boosting.
## Target
<!-- Search markers used to locate this hook's symbols in newer Grindr APKs.
Sourced from `DisableBoosting.kt`. When porting this hook to a new
Grindr version, paste each snippet into JADX's search box to
locate the new obfuscated class / method name.
See `AGENTS.md` § 3 (Hook Authoring Rules) for the full convention. -->
```kotlin
// DisableBoosting.kt
val drawerProfileUiState = "com.grindrapp.android.ui.drawer.model.DrawerProfileUiState" // search for 'DrawerProfileUiState(showBoostMeButton='
val radarUiModel = "tq8" // search for 'RadarUiModel(boostButton='
// search for '<unique snippet>'
```
## Verified against
- Grindr 26.13.0 — initial port
- (latest verified Grindr version)
## Notes
<!-- Optional: feature flag gates, why a non-obvious method-body hook was needed,
behavior on rooted vs unrooted devices, etc. -->
+29
View File
@@ -0,0 +1,29 @@
# Disable shuffle
Forcefully disable the shuffle feature.
## Target
<!-- Search markers used to locate this hook's symbols in newer Grindr APKs.
Sourced from `DisableShuffle.kt`. When porting this hook to a new
Grindr version, paste each snippet into JADX's search box to
locate the new obfuscated class / method name.
See `AGENTS.md` § 3 (Hook Authoring Rules) for the full convention. -->
```kotlin
// DisableShuffle.kt
val viewState = "com.grindrapp.android.ui.browse.v\$j" // search for 'ViewState(isRefreshing='
val shuffleUiState = "com.grindrapp.android.ui.browse.v\$g" // search for 'ShuffleUiState(isShuffleEnabled='
// search for '<unique snippet>'
```
## Verified against
- Grindr 26.13.0 — initial port
- (latest verified Grindr version)
## Notes
<!-- Optional: feature flag gates, why a non-obvious method-body hook was needed,
behavior on rooted vs unrooted devices, etc. -->
+30
View File
@@ -0,0 +1,30 @@
# Disable updates
Disable forced updates.
## Target
<!-- Search markers used to locate this hook's symbols in newer Grindr APKs.
Sourced from `DisableUpdates.kt`. When porting this hook to a new
Grindr version, paste each snippet into JADX's search box to
locate the new obfuscated class / method name.
See `AGENTS.md` § 3 (Hook Authoring Rules) for the full convention. -->
```kotlin
// DisableUpdates.kt
val appUpdateZzm = "com.google.android.play.core.appupdate.zzm" // search for 'requestUpdateInfo(%s)'
val appUpgradeManager = "hr8" // search for 'Uri.parse("market://details?id=com.grindrapp.android");'
// search for '<unique snippet>'
// search for '.setMessage(R.string.deprecation_message);'
```
## Verified against
- Grindr 26.13.0 — initial port
- (latest verified Grindr version)
## Notes
<!-- Optional: feature flag gates, why a non-obvious method-body hook was needed,
behavior on rooted vs unrooted devices, etc. -->
+28
View File
@@ -0,0 +1,28 @@
# Video calls
Allow video calls on empty chats.
## Target
<!-- Search markers used to locate this hook's symbols in newer Grindr APKs.
Sourced from `EmptyCalls.kt`. When porting this hook to a new
Grindr version, paste each snippet into JADX's search box to
locate the new obfuscated class / method name.
See `AGENTS.md` § 3 (Hook Authoring Rules) for the full convention. -->
```kotlin
// EmptyCalls.kt
val checkChattedBefore = "x65" // search for 'VideoCallHasNotChattedException'
// search for '<unique snippet>'
```
## Verified against
- Grindr 26.13.0 — initial port
- (latest verified Grindr version)
## Notes
<!-- Optional: feature flag gates, why a non-obvious method-body hook was needed,
behavior on rooted vs unrooted devices, etc. -->
+34
View File
@@ -0,0 +1,34 @@
# Enable unlimited
Enable Grindr Unlimited features.
## Target
<!-- Search markers used to locate this hook's symbols in newer Grindr APKs.
Sourced from `EnableUnlimited.kt`. When porting this hook to a new
Grindr version, paste each snippet into JADX's search box to
locate the new obfuscated class / method name.
See `AGENTS.md` § 3 (Hook Authoring Rules) for the full convention. -->
```kotlin
// EnableUnlimited.kt
val paywallUtils = "bya" // search for 'app_restart_required'
val persistentAdBannerContainer = "nb.d" // search for '(ComposeView) ViewBindings.findChildViewById(view,'
// search for '<unique snippet>'
// search for 'com.grindrapp.android.chat.presentation.ui.ChatActivityV2$subscribeToInterstitialAds$1$1$1'
// search for 'bind(Landroid/view/View;)Lcom/grindrapp/android/databinding/ProfileTagCascadeFragmentBinding;'
// search for '"bind(Landroid/view/View;)Lcom/grindrapp/android/databinding/DrawerProfileBinding;"'
// search for 'bind(Landroid/view/View;)Lcom/grindrapp/android/databinding/FragmentRadarBinding;'
// search for 'Intrinsics.checkNotNullParameter(roles, "roles");'
```
## Verified against
- Grindr 26.13.0 — initial port
- (latest verified Grindr version)
## Notes
<!-- Optional: feature flag gates, why a non-obvious method-body hook was needed,
behavior on rooted vs unrooted devices, etc. -->
+27
View File
@@ -0,0 +1,27 @@
# Expiring media
Allow unlimited photo/video viewing and save media permanently.
## Target
<!-- Search markers used to locate this hook's symbols in newer Grindr APKs.
Sourced from `ExpiringMedia.kt`. When porting this hook to a new
Grindr version, paste each snippet into JADX's search box to
locate the new obfuscated class / method name.
See `AGENTS.md` § 3 (Hook Authoring Rules) for the full convention. -->
```kotlin
// ExpiringMedia.kt
// search for '<unique snippet>'
```
## Verified against
- Grindr 26.13.0 — initial port
- (latest verified Grindr version)
## Notes
<!-- Optional: feature flag gates, why a non-obvious method-body hook was needed,
behavior on rooted vs unrooted devices, etc. -->
+31
View File
@@ -0,0 +1,31 @@
# Feature granting
Grant all Grindr features.
## Target
<!-- Search markers used to locate this hook's symbols in newer Grindr APKs.
Sourced from `FeatureGranting.kt`. When porting this hook to a new
Grindr version, paste each snippet into JADX's search box to
locate the new obfuscated class / method name.
See `AGENTS.md` § 3 (Hook Authoring Rules) for the full convention. -->
```kotlin
// FeatureGranting.kt
val isFeatureFlagEnabled = "xh6" // search for 'implements IsFeatureFlagEnabled {'
val settingDistanceVisibilityViewModel = "sfa" // search for 'UiState(distanceVisibility='
val alertParams = "P" // search for 'AlertController.AlertParams'
// search for '<unique snippet>'
// search for 'Assignment.Flag'
```
## Verified against
- Grindr 26.13.0 — initial port
- (latest verified Grindr version)
## Notes
<!-- Optional: feature flag gates, why a non-obvious method-body hook was needed,
behavior on rooted vs unrooted devices, etc. -->
+30
View File
@@ -0,0 +1,30 @@
# Local saved phrases
Save unlimited phrases locally.
## Target
<!-- Search markers used to locate this hook's symbols in newer Grindr APKs.
Sourced from `LocalSavedPhrases.kt`. When porting this hook to a new
Grindr version, paste each snippet into JADX's search box to
locate the new obfuscated class / method name.
See `AGENTS.md` § 3 (Hook Authoring Rules) for the full convention. -->
```kotlin
// LocalSavedPhrases.kt
val phrasesRestService = "com.grindrapp.android.api.PhrasesRestService" // search for 'v3/me/prefs'
val createSuccessResult = "xz3" // search for 'Success(successValue='
val chatRestService = "com.grindrapp.android.chat.data.datasource.api.service.ChatRestService" // search for '"v4/chat/conversation/{conversationId}"'
// search for '<unique snippet>'
```
## Verified against
- Grindr 26.13.0 — initial port
- (latest verified Grindr version)
## Notes
<!-- Optional: feature flag gates, why a non-obvious method-body hook was needed,
behavior on rooted vs unrooted devices, etc. -->
+27
View File
@@ -0,0 +1,27 @@
# Location spoofer
Spoof your location.
## Target
<!-- Search markers used to locate this hook's symbols in newer Grindr APKs.
Sourced from `LocationSpoofer.kt`. When porting this hook to a new
Grindr version, paste each snippet into JADX's search box to
locate the new obfuscated class / method name.
See `AGENTS.md` § 3 (Hook Authoring Rules) for the full convention. -->
```kotlin
// LocationSpoofer.kt
// search for '<unique snippet>'
```
## Verified against
- Grindr 26.13.0 — initial port
- (latest verified Grindr version)
## Notes
<!-- Optional: feature flag gates, why a non-obvious method-body hook was needed,
behavior on rooted vs unrooted devices, etc. -->
+28
View File
@@ -0,0 +1,28 @@
# Notification Alerts
Disable all Grindr warnings related to notifications.
## Target
<!-- Search markers used to locate this hook's symbols in newer Grindr APKs.
Sourced from `NotificationAlerts.kt`. When porting this hook to a new
Grindr version, paste each snippet into JADX's search box to
locate the new obfuscated class / method name.
See `AGENTS.md` § 3 (Hook Authoring Rules) for the full convention. -->
```kotlin
// NotificationAlerts.kt
val notificationManager = "cl5" // search for '0L, "notification_reminder_time"'
// search for '<unique snippet>'
```
## Verified against
- Grindr 26.13.0 — initial port
- (latest verified Grindr version)
## Notes
<!-- Optional: feature flag gates, why a non-obvious method-body hook was needed,
behavior on rooted vs unrooted devices, etc. -->
+29
View File
@@ -0,0 +1,29 @@
# Profile details
Add extra fields and details to profiles.
## Target
<!-- Search markers used to locate this hook's symbols in newer Grindr APKs.
Sourced from `ProfileDetails.kt`. When porting this hook to a new
Grindr version, paste each snippet into JADX's search box to
locate the new obfuscated class / method name.
See `AGENTS.md` § 3 (Hook Authoring Rules) for the full convention. -->
```kotlin
// ProfileDetails.kt
val blockedProfilesObserver = "or1" // search for 'Intrinsics.checkNotNullParameter(dataList, "dataList");'
val profileViewHolder = "eb6" // search for 'Intrinsics.checkNotNullParameter(individualUnblockActivityViewModel, "individualUnblockActivityViewModel");'
// search for '<unique snippet>'
```
## Verified against
- Grindr 26.13.0 — initial port
- (latest verified Grindr version)
## Notes
<!-- Optional: feature flag gates, why a non-obvious method-body hook was needed,
behavior on rooted vs unrooted devices, etc. -->
+27
View File
@@ -0,0 +1,27 @@
# Profile views
Don't let others know you viewed their profile.
## Target
<!-- Search markers used to locate this hook's symbols in newer Grindr APKs.
Sourced from `ProfileViews.kt`. When porting this hook to a new
Grindr version, paste each snippet into JADX's search box to
locate the new obfuscated class / method name.
See `AGENTS.md` § 3 (Hook Authoring Rules) for the full convention. -->
```kotlin
// ProfileViews.kt
// search for '<unique snippet>'
```
## Verified against
- Grindr 26.13.0 — initial port
- (latest verified Grindr version)
## Notes
<!-- Optional: feature flag gates, why a non-obvious method-body hook was needed,
behavior on rooted vs unrooted devices, etc. -->
+29
View File
@@ -0,0 +1,29 @@
# Quick block
Ability to block users quickly.
## Target
<!-- Search markers used to locate this hook's symbols in newer Grindr APKs.
Sourced from `QuickBlock.kt`. When porting this hook to a new
Grindr version, paste each snippet into JADX's search box to
locate the new obfuscated class / method name.
See `AGENTS.md` § 3 (Hook Authoring Rules) for the full convention. -->
```kotlin
// QuickBlock.kt
val blockViewModel = "ms0" // search for '("STATUS_BLOCK_DIALOG_SHOWN", 1)'
val profileViewHolder = "com.grindrapp.android.ui.profileV2.g" // search for 'com.grindrapp.android.ui.profileV2.ProfileViewHolder$onBind$3'
// search for '<unique snippet>'
```
## Verified against
- Grindr 26.13.0 — initial port
- (latest verified Grindr version)
## Notes
<!-- Optional: feature flag gates, why a non-obvious method-body hook was needed,
behavior on rooted vs unrooted devices, etc. -->
+38
View File
@@ -0,0 +1,38 @@
# Signature spoofer
Forces Firebase / Facebook SDKs to report the *original* Grindr APK
signature instead of the LSPatch-repackaged one. Without this, Firebase
Installations + Facebook Login reject mismatched signatures.
Unlike most hooks in this package, this file is a top-level function
called directly from `XposedLoader.handleLoadPackage` — it is not
registered with `HookManager` and never appears in the in-app settings
UI.
## Target
```kotlin
// SignatureSpoofer.kt
findAndHookMethod("com.google.firebase.installations.remote.FirebaseInstallationServiceClient", …, "getFingerprintHashForPackage", …)
findAndHookMethod("com.google.firebase.remoteconfig.internal.ConfigRealtimeHttpClient", …, "getFingerprintHashForPackage", …)
findAndHookMethod("com.google.firebase.remoteconfig.internal.ConfigFetchHttpClient", …, "getFingerprintHashForPackage", …)
findAndHookMethod("ly.img.android.c", …, "d", …) // getPackageName
findAndHookMethod("com.facebook.login.KatanaProxyLoginMethodHandler", …, "tryAuthorize", …)
```
## Verified against
- Grindr 26.13.0 — stable (targets public Firebase / Facebook SDK)
- (latest verified Grindr version)
## Notes
- Targets fully-qualified classes from Firebase / Facebook / Ly — the
obfuscation-marker convention doesn't apply because these symbols are
stable across Grindr releases.
- `ly.img.android.c::d` is an obfuscated symbol (Ly Image SDK), not a
Grindr symbol. It maps to `getPackageName`; if Ly renames it in a
future release, re-search JADX for the equivalent.
- The Firebase / Facebook `getFingerprintHashForPackage` hook always
returns `823f5a17c33b16b4775480b31607e7df35d67af8` — the original
Grindr signing-cert SHA-1 fingerprint.
+31
View File
@@ -0,0 +1,31 @@
# SSL unpinning
Disables TLS certificate pinning in the host app so Mitmproxy / HTTP Toolkit
can intercept HTTPS traffic during development.
Unlike most hooks in this package, this file is a top-level function
called directly from `XposedLoader.handleLoadPackage` under
`BuildConfig.DEBUG` — it is not registered with `HookManager` and never
appears in the in-app settings UI.
## Target
```kotlin
// SSLUnpinning.kt
findAndHookConstructor("okhttp3.OkHttpClient$Builder", …)
findAndHookMethod("okhttp3.OkHttpClient$Builder", …, "certificatePinner", …)
findAndHookMethod("com.android.org.conscrypt.TrustManagerImpl", …, "verifyChain", …)
```
## Verified against
- Grindr 26.13.0 — stable (targets public OkHttp / Conscrypt API)
- (latest verified Grindr version)
## Notes
- Targets fully-qualified classes from OkHttp / Conscrypt / Firebase —
the obfuscation-marker convention doesn't apply because these symbols
are stable across Grindr releases.
- Only runs under `BuildConfig.DEBUG` (see `XposedLoader`); release
builds don't unpin.
+27
View File
@@ -0,0 +1,27 @@
# Status Dialog
Check whether GrindrPlus is alive or not.
## Target
<!-- Search markers used to locate this hook's symbols in newer Grindr APKs.
Sourced from `StatusDialog.kt`. When porting this hook to a new
Grindr version, paste each snippet into JADX's search box to
locate the new obfuscated class / method name.
See `AGENTS.md` § 3 (Hook Authoring Rules) for the full convention. -->
```kotlin
// StatusDialog.kt
// search for '<unique snippet>'
```
## Verified against
- Grindr 26.13.0 — initial port
- (latest verified Grindr version)
## Notes
<!-- Optional: feature flag gates, why a non-obvious method-body hook was needed,
behavior on rooted vs unrooted devices, etc. -->
+27
View File
@@ -0,0 +1,27 @@
# Timber Logging
Forces Timber to log messages even if no tree is planted.
## Target
<!-- Search markers used to locate this hook's symbols in newer Grindr APKs.
Sourced from `TimberLogging.kt`. When porting this hook to a new
Grindr version, paste each snippet into JADX's search box to
locate the new obfuscated class / method name.
See `AGENTS.md` § 3 (Hook Authoring Rules) for the full convention. -->
```kotlin
// TimberLogging.kt
// search for '<unique snippet>'
```
## Verified against
- Grindr 26.13.0 — initial port
- (latest verified Grindr version)
## Notes
<!-- Optional: feature flag gates, why a non-obvious method-body hook was needed,
behavior on rooted vs unrooted devices, etc. -->
+28
View File
@@ -0,0 +1,28 @@
# Unlimited albums
Allow to be able to view unlimited albums.
## Target
<!-- Search markers used to locate this hook's symbols in newer Grindr APKs.
Sourced from `UnlimitedAlbums.kt`. When porting this hook to a new
Grindr version, paste each snippet into JADX's search box to
locate the new obfuscated class / method name.
See `AGENTS.md` § 3 (Hook Authoring Rules) for the full convention. -->
```kotlin
// UnlimitedAlbums.kt
val albumsService = "com.grindrapp.android.api.albums.AlbumsRestService" // search for 'v1/albums/red-dot'
// search for '<unique snippet>'
```
## Verified against
- Grindr 26.13.0 — initial port
- (latest verified Grindr version)
## Notes
<!-- Optional: feature flag gates, why a non-obvious method-body hook was needed,
behavior on rooted vs unrooted devices, etc. -->
+29
View File
@@ -0,0 +1,29 @@
# Unlimited profiles
Allow unlimited profiles.
## Target
<!-- Search markers used to locate this hook's symbols in newer Grindr APKs.
Sourced from `UnlimitedProfiles.kt`. When porting this hook to a new
Grindr version, paste each snippet into JADX's search box to
locate the new obfuscated class / method name.
See `AGENTS.md` § 3 (Hook Authoring Rules) for the full convention. -->
```kotlin
// UnlimitedProfiles.kt
val onProfileClicked = "com.grindrapp.android.ui.browse.e0" // search for 'com.grindrapp.android.ui.browse.ServerDrivenCascadeViewModel$onProfileClicked$1'
// search for '<unique snippet>'
// search for 'new StringBuilder("cascadeClickEvent/position=");'
```
## Verified against
- Grindr 26.13.0 — initial port
- (latest verified Grindr version)
## Notes
<!-- Optional: feature flag gates, why a non-obvious method-body hook was needed,
behavior on rooted vs unrooted devices, etc. -->
+27
View File
@@ -0,0 +1,27 @@
# Unlock Explorer
Unlock all profiles in Explorer.
## Target
<!-- Search markers used to locate this hook's symbols in newer Grindr APKs.
Sourced from `UnlockExplorer.kt`. When porting this hook to a new
Grindr version, paste each snippet into JADX's search box to
locate the new obfuscated class / method name.
See `AGENTS.md` § 3 (Hook Authoring Rules) for the full convention. -->
```kotlin
// UnlockExplorer.kt
// search for '<unique snippet>'
```
## Verified against
- Grindr 26.13.0 — initial port
- (latest verified Grindr version)
## Notes
<!-- Optional: feature flag gates, why a non-obvious method-body hook was needed,
behavior on rooted vs unrooted devices, etc. -->
+27
View File
@@ -0,0 +1,27 @@
# Keep Alive WebSocket
Prevents WebSocket disconnections when app goes to background. Causes battery drain, use with caution.
## Target
<!-- Search markers used to locate this hook's symbols in newer Grindr APKs.
Sourced from `WebSocketAlive.kt`. When porting this hook to a new
Grindr version, paste each snippet into JADX's search box to
locate the new obfuscated class / method name.
See `AGENTS.md` § 3 (Hook Authoring Rules) for the full convention. -->
```kotlin
// WebSocketAlive.kt
// search for '<unique snippet>'
```
## Verified against
- Grindr 26.13.0 — initial port
- (latest verified Grindr version)
## Notes
<!-- Optional: feature flag gates, why a non-obvious method-body hook was needed,
behavior on rooted vs unrooted devices, etc. -->
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+270
View File
@@ -0,0 +1,270 @@
#!/usr/bin/env python3
"""
check-obfuscated-symbols.py — walk every hook file in --hooks, extract the
`// search for '<snippet>'` markers, and grep a JADX-decompiled APK for them.
Outputs a Markdown table to STDOUT:
| Status | Marker | Old symbol | Candidates | Confidence |
Status ∈ {OK, AMBIGUOUS, STALE}.
- OK — exactly one candidate file matched.
- AMBIGUOUS — multiple candidate files matched; human must disambiguate.
- STALE — no candidate file matched; the snippet may be obsolete.
By default the script exits 0 even on STALE markers (grep-friendly).
Pass --strict to exit non-zero on STALE (useful in CI when you want
to force a maintainer to investigate before merging).
Pure stdlib — no third-party deps so it can run anywhere with Python 3.
"""
from __future__ import annotations
import argparse
import os
import re
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable
MARKER_RE = re.compile(
r"""val\s+(?P<name>\w+)\s*=\s*"(?P<symbol>[^"]+)"\s*//\s*search for\s+'(?P<snippet>[^']+)'""",
re.MULTILINE,
)
# Variant for comments above hook calls — capture the next line's symbol
# and method body. We only extract snippet text in that case.
BODY_MARKER_RE = re.compile(
r"""//\s*search for\s+'(?P<snippet>[^']+)'""",
)
@dataclass
class MarkerHit:
"""A single (snippet, old_symbol) tuple we found in a hook file."""
file: Path
line: int
name: str | None # val name, if the marker is on a val line
symbol: str | None # captured obfuscated name (val RHS)
snippet: str
@dataclass
class Candidate:
"""A single JADX-output match for a snippet."""
path: Path
line_no: int
@dataclass
class Result:
marker: MarkerHit
candidates: list[Candidate] = field(default_factory=list)
@property
def status(self) -> str:
if not self.candidates:
return "STALE"
if len(self.candidates) == 1:
return "OK"
return "AMBIGUOUS"
@property
def confidence(self) -> str:
s = self.status
if s == "OK":
return "high"
if s == "AMBIGUOUS":
return "low"
return "n/a"
def walk_hook_files(hooks_dir: Path) -> Iterable[Path]:
for p in sorted(hooks_dir.glob("*.kt")):
yield p
def extract_markers(kt_file: Path) -> list[MarkerHit]:
"""Return every `val X = "y" // search for 'snippet'` line in the file."""
hits: list[MarkerHit] = []
text = kt_file.read_text(encoding="utf-8", errors="replace")
# First pass: val-line markers
for m in MARKER_RE.finditer(text):
line = text.count("\n", 0, m.start()) + 1
hits.append(
MarkerHit(
file=kt_file,
line=line,
name=m.group("name"),
symbol=m.group("symbol"),
snippet=m.group("snippet"),
)
)
# Second pass: body-comment markers (// search for '<snippet>' on its own line).
# These don't carry an `old_symbol` because they comment a hook call site.
for m in BODY_MARKER_RE.finditer(text):
snippet = m.group("snippet")
# Skip duplicates already captured by the val-line pass.
if any(h.snippet == snippet and h.file == kt_file for h in hits):
continue
line = text.count("\n", 0, m.start()) + 1
hits.append(
MarkerHit(
file=kt_file,
line=line,
name=None,
symbol=None,
snippet=snippet,
)
)
return hits
def grep_snippet(jadx_root: Path, snippet: str) -> list[Candidate]:
"""grep -rF -- '<snippet>' <jadx_root>/sources"""
sources = jadx_root / "sources"
if not sources.is_dir():
# Older JADX layouts just dump everything into the root.
sources = jadx_root
# Use subprocess so we don't have to read every file into Python.
try:
out = subprocess.run(
["grep", "-rF", "-n", "--", snippet, str(sources)],
capture_output=True,
text=True,
timeout=30,
check=False,
)
except subprocess.TimeoutExpired:
return []
candidates: list[Candidate] = []
for raw in out.stdout.splitlines():
if ":" not in raw:
continue
path_str, _, line_str = raw.partition(":")
try:
line_no = int(line_str.split(":", 1)[0])
except ValueError:
continue
candidates.append(Candidate(path=Path(path_str), line_no=line_no))
return candidates
def render_markdown(results: list[Result]) -> str:
out = [
"# Obfuscation-marker scan",
"",
f"Markers found: **{len(results)}**",
"",
"| Status | Marker | Old symbol | Candidates | Confidence | File |",
"|--------|--------|------------|------------|------------|------|",
]
for r in results:
marker = r.marker
# Truncate long snippets for the table.
snip = marker.snippet if len(marker.snippet) <= 60 else marker.snippet[:57] + "…"
sym = marker.symbol or "(body)"
if not r.candidates:
cands = "—"
else:
shown = []
for c in r.candidates[:3]:
rel = c.path
# Show as JADX-rooted path so the maintainer can locate it.
shown.append(f"`{rel}`:{c.line_no}")
cands = "; ".join(shown)
if len(r.candidates) > 3:
cands += f" (+{len(r.candidates) - 3} more)"
rel = marker.file.name
out.append(
f"| {r.status} | `{snip}` | `{sym}` | {cands} | {r.confidence} | `{rel}` |"
)
out.append("")
n_stale = sum(1 for r in results if r.status == "STALE")
n_ambig = sum(1 for r in results if r.status == "AMBIGUOUS")
n_ok = sum(1 for r in results if r.status == "OK")
out.append(
f"Summary: **{n_ok} OK**, **{n_ambig} AMBIGUOUS**, **{n_stale} STALE**"
)
return "\n".join(out) + "\n"
def render_json(results: list[Result]) -> str:
import json
return json.dumps(
[
{
"file": str(r.marker.file),
"line": r.marker.line,
"val_name": r.marker.name,
"old_symbol": r.marker.symbol,
"snippet": r.marker.snippet,
"status": r.status,
"confidence": r.confidence,
"candidates": [
{"path": str(c.path), "line": c.line_no}
for c in r.candidates
],
}
for r in results
],
indent=2,
)
def main(argv: list[str]) -> int:
p = argparse.ArgumentParser(
description="Scan // search-for markers in hook files against a JADX-out dir."
)
p.add_argument(
"--jadx",
required=True,
type=Path,
help="Path to JADX decompilation output (the dir that contains sources/).",
)
p.add_argument(
"--hooks",
type=Path,
default=Path("app/src/main/java/com/grindrplus/hooks"),
help="Path to the hooks/ source directory.",
)
p.add_argument(
"--format",
choices=("markdown", "json"),
default="markdown",
)
p.add_argument(
"--strict",
action="store_true",
help="Exit non-zero if any STALE markers are found.",
)
args = p.parse_args(argv)
if not args.hooks.is_dir():
print(f"::error::hooks dir not found: {args.hooks}", file=sys.stderr)
return 2
if not args.jadx.exists():
print(f"::error::JADX dir not found: {args.jadx}", file=sys.stderr)
return 2
results: list[Result] = []
for kt in walk_hook_files(args.hooks):
for marker in extract_markers(kt):
cands = grep_snippet(args.jadx, marker.snippet)
results.append(Result(marker=marker, candidates=cands))
if args.format == "json":
print(render_json(results))
else:
print(render_markdown(results))
if args.strict and any(r.status == "STALE" for r in results):
return 1
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
#
# check-patch-docs.sh — every hook in --hooks-dir must have a corresponding
# docs/patches/<kebab>.md. Exits 1 and lists the missing docs if any are absent.
#
# Mapping: AntiBlock.kt -> anti_block.md (snake_case, lowercase, .md).
#
# Defaults assume the standard repo layout. Override with --hooks-dir / --docs-dir.
#
set -euo pipefail
HOOKS_DIR="app/src/main/java/com/grindrplus/hooks"
DOCS_DIR="docs/patches"
while [[ $# -gt 0 ]]; do
case "$1" in
--hooks-dir)
HOOKS_DIR="$2"
shift 2
;;
--docs-dir)
DOCS_DIR="$2"
shift 2
;;
-h|--help)
sed -n '2,12p' "$0"
exit 0
;;
*)
echo "Unknown flag: $1" >&2
exit 1
;;
esac
done
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$REPO_ROOT"
if [[ ! -d "$HOOKS_DIR" ]]; then
echo "::error::hooks dir not found: $HOOKS_DIR" >&2
exit 2
fi
if [[ ! -d "$DOCS_DIR" ]]; then
echo "::error::docs dir not found: $DOCS_DIR" >&2
exit 2
fi
# Convert PascalCase.kt -> snake_case.md via Python (more portable than bash).
# Handles acronyms gracefully: `SSLUnpinning` -> `ssl_unpinning`, not `s_s_l_unpinning`.
kebab() {
python3 - "$1" <<'PY'
import re, sys
name = sys.argv[1]
stem = name[:-3] if name.endswith(".kt") else name
# Step 1: only insert an underscore when an uppercase letter follows a lowercase.
# (This is what most editors do for snake-case conversion.)
stem = re.sub(r'(?<=[a-z])(?=[A-Z])', '_', stem)
# Step 2: an all-caps cluster of length >= 2 followed by an uppercase + lowercase
# pair is treated as an acronym; insert the boundary underscore there.
# e.g. `SSLUnpinning` -> `SSL_Unpinning`.
stem = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1_\2', stem)
# Step 3: lowercase.
print(stem.lower())
PY
}
missing=()
present=()
shopt -s nullglob
for kt in "$HOOKS_DIR"/*.kt; do
base="$(basename "$kt")"
doc_name="$(kebab "$base").md"
if [[ -f "$DOCS_DIR/$doc_name" ]]; then
present+=("$doc_name")
else
missing+=("$base -> $doc_name")
fi
done
shopt -u nullglob
echo "Hooks scanned: $(( ${#present[@]} + ${#missing[@]} ))"
echo "Docs present: ${#present[@]}"
echo "Docs missing: ${#missing[@]}"
if [[ ${#missing[@]} -gt 0 ]]; then
echo ""
echo "Missing patch docs:"
for m in "${missing[@]}"; do
echo " - $m"
done
echo ""
echo "::error::Add docs/patches/<kebab>.md for each hook above."
echo "::error::See .claude/skills/patch-doc-coverage/ for the stub template."
exit 1
fi
echo "All hooks have patch docs."
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
#
# sync-grindr-version.sh — refresh version.json + patch the two listOf(...) lines
# in app/build.gradle.kts in one pass.
#
# Usage:
# bash scripts/sync-grindr-version.sh # apply for real
# bash scripts/sync-grindr-version.sh --dry-run # print the diff, don't write
# bash scripts/sync-grindr-version.sh --gradle-kts path/to/build.gradle.kts
#
# Exit codes:
# 0 — success (or "already up to date")
# 1 — APKMirror unreachable / fetch_version.py failed
# 2 — could not parse fetched version
# 3 — gradle-kts file not found
#
set -euo pipefail
GRADLE_KTS="app/build.gradle.kts"
DRY_RUN="false"
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run)
DRY_RUN="true"
shift
;;
--gradle-kts)
GRADLE_KTS="$2"
shift 2
;;
-h|--help)
sed -n '2,18p' "$0"
exit 0
;;
*)
echo "Unknown flag: $1" >&2
exit 1
;;
esac
done
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$REPO_ROOT"
if [[ ! -f "$GRADLE_KTS" ]]; then
echo "::error::Gradle build file not found: $GRADLE_KTS" >&2
exit 3
fi
VERSION_JSON="version.json"
TMP_JSON="version.json.tmp"
echo ">>> Fetching latest Grindr version via fetch_version.py"
if ! python3 fetch_version.py -o "$TMP_JSON"; then
echo "::error::fetch_version.py failed (APKMirror unreachable?)" >&2
rm -f "$TMP_JSON"
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "::error::jq is required to parse version.json" >&2
rm -f "$TMP_JSON"
exit 2
fi
NEW_NAME="$(jq -r '.versionName' "$TMP_JSON")"
NEW_CODE="$(jq -r '.versionCode' "$TMP_JSON")"
if [[ -z "$NEW_NAME" || -z "$NEW_CODE" || "$NEW_NAME" == "null" || "$NEW_CODE" == "null" ]]; then
echo "::error::Could not parse versionName/versionCode from fetch output" >&2
rm -f "$TMP_JSON"
exit 2
fi
CURRENT_NAME="$(grep -E '^[[:space:]]*val grindrVersionName' "$GRADLE_KTS" \
| sed -E 's/.*listOf\("([^"]+)".*/\1/' | head -n1)"
CURRENT_CODE="$(grep -E '^[[:space:]]*val grindrVersionCode' "$GRADLE_KTS" \
| sed -E 's/.*listOf\(([0-9]+).*/\1/' | head -n1)"
if [[ "$CURRENT_NAME" == "$NEW_NAME" && "$CURRENT_CODE" == "$NEW_CODE" ]]; then
echo "Already up to date: versionName=$NEW_NAME versionCode=$NEW_CODE"
cp "$TMP_JSON" "$VERSION_JSON"
rm -f "$TMP_JSON" "${GRADLE_KTS}.bak"
exit 0
fi
echo "Bumping: versionName $CURRENT_NAME -> $NEW_NAME; versionCode $CURRENT_CODE -> $NEW_CODE"
if [[ "$DRY_RUN" == "true" ]]; then
echo "--- diff (dry-run; nothing written) ---"
echo "--- version.json ---"
diff -u "$VERSION_JSON" "$TMP_JSON" || true
echo "--- $GRADLE_KTS ---"
sed -E \
-e "s/(grindrVersionName = listOf\\(\")[^\"]+(\"\\))/\1${NEW_NAME}\2/" \
-e "s/(grindrVersionCode = listOf\\()[0-9]+(\\))/\1${NEW_CODE}\2/" \
"$GRADLE_KTS" \
| diff -u "$GRADLE_KTS" - || true
rm -f "$TMP_JSON"
exit 0
fi
# Apply version.json
cp "$TMP_JSON" "$VERSION_JSON"
rm -f "$TMP_JSON"
# Patch the two listOf(...) lines in build.gradle.kts
sed -i.bak -E \
-e "s/(grindrVersionName = listOf\\(\")[^\"]+(\"\\))/\1${NEW_NAME}\2/" \
-e "s/(grindrVersionCode = listOf\\()[0-9]+(\\))/\1${NEW_CODE}\2/" \
"$GRADLE_KTS"
echo "Wrote $VERSION_JSON and patched $GRADLE_KTS"
echo "Verify with: ./gradlew printVersionInfo --quiet"
+1
View File
@@ -1,4 +1,5 @@
{
"$schema": "./version.schema.json",
"versionName": "26.13.0",
"versionCode": 170510
}
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://github.com/Heretek-AI/GrindrPlus/version.schema.json",
"title": "GrindrPlus target Grindr version metadata",
"description": "The single source of truth for which Grindr (com.grindrapp.android) version this module targets. Updated by fetch_version.py; read by app/build.gradle.kts to set grindrVersionName/grindrVersionCode.",
"type": "object",
"required": ["versionName", "versionCode"],
"additionalProperties": false,
"properties": {
"versionName": {
"type": "string",
"description": "Grindr's semantic version (X.Y.Z). Mirrors the value APKMirror exposes for the latest com.grindrapp.android release.",
"pattern": "^\\d+\\.\\d+\\.\\d+$",
"examples": ["26.13.0", "25.21.1"]
},
"versionCode": {
"type": "integer",
"description": "Grindr's build number (positive integer). Mirrors the value APKMirror exposes for the latest com.grindrapp.android release.",
"minimum": 1,
"examples": [170510, 148541]
}
}
}