Files
apk-reverse/references/updates-and-forced-upgrade.md
T
inliver233 e10fb2d308 Add observation-window hygiene and non-Java update gates
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.
2026-09-20 00:53:25 +08:00

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:

  1. 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.
  2. 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.
  3. 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 / minimum flag.
  • 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.md P5): 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:

  1. 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.
  2. 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.
  3. 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".
  4. Exercise the manual path if the app has a "check for updates" entry in settings — that is the caller Layer 2 exists for.
  5. 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:

  1. 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.
  2. 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.
  3. 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_PACKAGES or a download service finds nothing. That is not evidence the dialog is harmless; the browser does the install.
  • Logcat truncates the URL. ActivityTaskManager prints dat=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