references/long-task-discipline.md
New section: "Keep the observation window clean". Every conclusion rests on "the
build I am looking at is the build I made", and that assumption fails silently:
something else installs/reverts/clears the target mid-capture, the process was never
actually restarted, or the installed artifact is not the one on disk. Guards, cheapest
first: pin identity by hash (not filename, and immediately before the window, not
hours earlier); declare a capture window and run no install/uninstall/clear inside it;
timestamp it; prefer one continuous capture over several short ones. When a window
turns out to be contaminated, discard and re-capture rather than salvage - a re-run
costs minutes, a wrong conclusion costs the rest of the task and gets re-derived from
the same bad evidence.
references/updates-and-forced-upgrade.md
New Step 6: "when the check is not where you expect", covering three shapes that break
the flip-the-comparison plan.
- The check is native: a declared update entry point with zero Java callers means the
invocation lives in a .so. Ordered remedies, starting with the one that needs no
native work at all - attack the response, or feed the check a version it likes. "No
Java caller" is not "unpatchable".
- The dialog hands off to a browser or WebView: there is no installer call to find,
and logcat truncates the URL to `dat=https://host/...`, so a grep gives you a host
and no path. Approach table for recovering the real URL, with hooking intent
construction as the reliable one.
- Server-issued gate with a local fallback: read the failure branch first. If failure
defaults to "no update", failing the request is the whole fix and also gives you an
offline verification signal; if it defaults to "must update", it makes things worse.
14 KiB
Updates and forced upgrade
Load this whenever the deliverable is a patched build that must keep working over time — which is almost always. A build that is correct on the day you ship it and dead a week later has failed, and the cause is nearly always an update path you did not neutralise.
This is the file that decides whether a patch is durable, and it is cheap: the whole job is usually one or two edits.
Why this is not optional
Three independent failure modes, all of which present as "the mod stopped working" with no obvious link to updates:
- Forced upgrade. The app checks a version endpoint, sees a newer build, and blocks its UI behind a non-dismissible dialog until the user installs the official (unpatched) package. Your patch is gone.
- Self-update install. The app downloads an APK and hands it to the package installer. Because the downloaded build is signed differently, the install either fails or — worse — succeeds and replaces your work.
- Silent "compatibility" downgrade. The server decides the client version is too old and starts omitting fields or returning an error page. No local code changed, so nothing local looks broken.
A fourth case is a resource / hot-update channel: the app fetches a bundle (JS, a Dart/RN patch, a config blob, a plugin APK) that can reintroduce the behaviour you removed. That is a separate code path from version checking — handle it explicitly if the app has one (see §hot-update below).
Step 1: locate the check
There is usually exactly one update service. Find it before patching anything else.
From the client's own vocabulary (works on any runtime):
update / upgrade / new version / check version / force update
版本更新 / 检查更新 / 发现新版本 / 立即更新 / 强制更新
Search those in the app's string table and in class names; on heavy runtimes search the string pool,
remembering its encoding (see framework-runtimes.md §string encoding — searching only one encoding
misses half your hits, and "nothing found" is usually the encoding, not an absence).
Signals that a version check exists even if you cannot read the strings:
- A network call early in startup whose response contains a version number, a download URL, and
sometimes a
force/must/minimumflag. - A stored "last update check time" preference (throttling a check implies one exists).
- The app ships the package installer path or requests
REQUEST_INSTALL_PACKAGES. - A permissions/manifest entry for an "unknown sources" install intent.
Also look for the coupling. Update checks are frequently run from the same startup routine as ad loading and splash dismissal, and a blocking update dialog often suspends those siblings — you may find logging that literally says so ("update dialog showing, skipping X"). That tells you both where the check lives and that your patch must not leave the dialog state machine half-finished.
Step 2: classify the shape
| Shape | How it behaves | Where to cut |
|---|---|---|
| Optional update | a dismissible prompt with a download button | suppressing the dialog is enough |
| Forced update | non-dismissible; blocks the app | must be cut at the decision, not the dialog |
| Silent version gate | no dialog; features degrade or the server 400s | client-side is not fixable — see §the honest answer |
| Self-update installer | downloads and installs an APK | cut the check, and ideally the install path too |
| Hot-update / resource patch | fetches a bundle that can restore behaviour | separate route; treat as a data channel |
Assume forced until you have evidence otherwise, because that is the case that costs the user the most when missed.
Step 3: patch the decision, not the dialog
Prefer the earliest safe exit over every downstream surface. Supressing the dialog only hides it; the download may still start, and a later code path may still act on the "new version" verdict.
Two layers, in this order:
Layer 1 — make the check itself a no-op. Replace the entry of the update routine so it returns immediately. Nothing is requested, nothing is compared, no dialog state is created. This is the root fix and it removes the network call entirely, which also makes verification trivial (Step 4).
Layer 2 — neutralise the comparison. Somewhere the client compares the server version against its own and branches on the result. Force that branch to the "already current" side. This catches any path that reaches the comparison without going through the entry you patched (a second caller, a manual "check for updates" button, a settings screen).
Layer 2 alone is a valid minimal fix if Layer 1 is awkward; Layer 1 alone is valid if you have confirmed a single entry. Doing both is cheap insurance and is what makes the claim "this build cannot be upgraded into uselessness" defensible.
target routine: <updateChecker>(...) -> return immediately (no request, no state)
target branch: <versionCompare> result -> force the "no update" side
Do not patch the dialog itself as your primary fix, and do not patch the comparison to lie in the other direction (claiming an update when there is none) — that produces exactly the blocking dialog you are trying to avoid.
What not to touch
- Do not rewrite the app's own version number in the manifest or in a version string. It is the cheapest-looking fix and it breaks things you are not looking at: server-side feature negotiation, analytics, cached asset keys, and the app's own self-comparison. Change the decision, not the identity.
- Do not strip the installer permission unless you have checked nothing else uses it.
- Do not block the update hostname at the transport layer. That is the same class of mistake as
blocking ad hosts (
pitfalls.mdP5): it fails offline/online transitions, needs a helper on the machine, and is not a property of the artifact. It is also not required — the check is local code.
Step 4: verify
Update paths are checked once at launch, so verification is a single clean cold start:
- Clear logs, cold start, and grep for the update vocabulary. Expect zero hits. Not "quieter" — absent. If the routine still runs, the string will still appear.
- Watch the screen through startup. The failure mode is a modal, so a sample you did not look at is
not evidence (
environment.md§look at the screen). A blocking dialog is unmistakable and it will be on screen for a long time. - Check the network. With the check disabled there should be no request to the version/update endpoint at startup. This is the strongest signal: it distinguishes "dialog suppressed" from "check never happened".
- Exercise the manual path if the app has a "check for updates" entry in settings — that is the caller Layer 2 exists for.
- Confirm what you disabled is only updates. The update routine sometimes shares a bootstrap with other startup work. If you no-op'd a routine rather than its decision, verify that splash dismissal, first-run setup, and the ad/consent flow still complete.
Two-sample rule for the durability claim. If you are asked whether the build survives a server-side version bump, you cannot test that directly. What you can honestly claim is: (a) no version request is issued at startup, and (b) the comparison no longer reaches its "newer" branch. State it in those terms instead of asserting the future.
Step 5: hot-update and resource channels
If the app can fetch a bundle at runtime, version checking is not the only way your change can be undone.
- What to look for: a startup fetch of a signed bundle/zip/config with a version or hash, a local unpack directory, a dynamic plugin loader, or a JS/Dart/RN patch channel.
- Why it matters: even with updates disabled, a remotely fetched config can re-enable a feature, and a fetched bundle can overwrite patched logic in the parts it owns.
- Handle it like a data channel, not like an update: identify what is fetched, whether the patched behaviour is inside that payload, and cut the consumption point if it is. If the patched logic lives in the shipped artifact and the fetched payload cannot override it, say so and move on — that is a finding, not a gap.
Note the flip side: a server-issued configuration is the same category. If the thing you removed was
driven by remote config, the client patch holds only while the server keeps sending that config — see
references/ad-removal.md §server-driven and references/membership-and-limits.md for the general
rule that client-side enforcement can be patched, server-side authority cannot.
The honest answer for silent version gates
If the app degrades because the server decides the client is too old — no local dialog, just missing data or errors on some screens — then there is no client-side fix. The only honest report is:
- the version the client reports to the server (with evidence of where it is read),
- the endpoint that makes the decision (with the observed response),
- and a clear statement that this is server-side authority.
Do not paper over it by forcing the version string at random read sites. If you do change a reported version as an experiment, label it experimental, and remember that a version number is used in more places than the one you are looking at.
Delivery note: always state what was disabled
Update suppression changes behaviour a user can observe ("it never tells me about new versions"). Put it in the delivery notes, in one line, together with anything else you neutralised. If the app also has a legitimate reason to update (security fixes), say that the build will not pick them up automatically.
Step 6: when the check is not where you expect
Three shapes that break the "find the Java comparison and flip it" plan. Recognise them early, because each has a different cheapest remedy.
The check is native
Symptom: you grep the whole dex tree for the version-comparison routine, the update endpoint, or the
dialog text and find the method declared, and zero callers. A static native update entry point
with no Java caller means the invocation lives in a .so (registerNatives from a static block is
the usual giveaway — see code-virtualization-and-custom-linkers.md).
Options, cheapest first:
- Attack the response, not the check. Block or rewrite the update endpoint at the network layer, or empty the version/config response. The native code still runs and still decides "no update". This needs no native work at all and survives a rebuild — check this before reaching for a disassembler.
- Send the check a version it likes. If it compares against a value the app itself supplies (a config key, a stored preference, a build field), setting that value is a one-line change.
- Patch the native comparison. Last, because it is the most expensive and the most fragile.
Do not conclude "unpatchable" from "no Java caller". The gate usually reads a value you can control.
The update hands off to a browser or a WebView
Very common in this category: the dialog's button fires an ACTION_VIEW intent at a download URL,
or loads it in an in-app WebView. Two consequences:
- You will not find an installer call in the app, so searching for
PackageInstaller,REQUEST_INSTALL_PACKAGESor a download service finds nothing. That is not evidence the dialog is harmless; the browser does the install. - Logcat truncates the URL.
ActivityTaskManagerprintsdat=https://host/...and elides the rest, so a naive grep gives you a host and no path. Do not build a plan on a truncated URL.
Ways to get the real URL, cheapest first:
| Approach | Notes |
|---|---|
Hook Intent construction / Context.startActivity and log intent.getDataString() |
Most reliable. A truncated log line is a logging limit, not a limit on what you can read |
dumpsys activity activities / recents while the browser is still in the stack |
Free; works only if the activity is still alive |
| The browser's own history database (root) | Survives the browser being closed; needs the right DB, and modern browsers may encrypt or prune it |
| Read the update response instead | Often easier: the URL came from a JSON field you can fetch or hook directly |
Deliberately tapping the button is a legitimate probe — but treat it as a measurement, not a side effect: capture the intent (or the screen) in the same window, and know that on some devices a second tap resumes an already-started download rather than re-issuing the intent.
The gate is server-issued with a local fallback
fetchEnabled()-style helpers frequently default to a local value on any failure (non-200,
timeout, malformed body, signature mismatch). Two things follow:
- Read the failure branch before you touch anything. If failure ⇒ "no update" or "no ad", then merely making the request fail is a complete fix, and it is far cheaper and more robust than patching the comparison.
- The fallback also tells you how to test offline. Disconnect the device and relaunch: if the gate disappears, the fallback is benign and you have a zero-code workaround as well as a verification signal.
Conversely, if failure ⇒ "must update", failing the request makes things worse, and you must patch the decision instead.
Checklist
- Update vocabulary searched in both string encodings, in the right layer for the runtime
- The single update entry point identified (or its absence established, with evidence)
- Shape classified: optional / forced / silent / installer / hot-update
- Decision-level patch applied, not just the dialog
- Comparison branch neutralised as a second layer
- Manifest version and identity left alone
- Cold start: zero update log lines, no blocking modal, no version request on the wire
- Manual "check for updates" entry exercised
- Startup siblings (splash dismissal, ads, consent, first-run) still complete
- Hot-update / remote-config channel considered explicitly
- Delivery note states what is now disabled