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.
This commit is contained in:
inliver233
2026-09-20 00:53:25 +08:00
parent 0fc2a30408
commit e10fb2d308
2 changed files with 96 additions and 0 deletions
+33
View File
@@ -193,6 +193,39 @@ Three rules that follow:
- **Prefer a bounded observable over a fixed sleep.** Poll the state you actually care about, with a - **Prefer a bounded observable over a fixed sleep.** Poll the state you actually care about, with a
cap — and prefer a sample you can inspect over a duration you hope is right. cap — and prefer a sample you can inspect over a duration you hope is right.
## Keep the observation window clean
Every conclusion in this skill rests on "the build I am looking at is the build I made". That
assumption is silently false more often than any other, and a contaminated window does not look
contaminated — it looks like a result.
Three ways it happens, all of which have produced confidently wrong findings:
- **Something else touched the target while you were observing.** Another process, another agent,
another window of your own work installed, reverted, restored or cleared the app. Your screenshots
and logs are then a mixture of two states and describe neither.
- **You are looking at a stale process.** The app was never actually restarted, so the "before" and
"after" captures come from the same run.
- **The artifact on the device is not the artifact on disk.** An install that reported success, or was
skipped because the version matched, leaves the previous build running.
Guards, in order of cost:
1. **Pin the identity, not the filename.** Hash the artifact you built, hash the artifact you intend
to test, and hash what is actually installed (pull the installed APK or read its digest) — the
filename proves nothing. Do this immediately before the observation window, not hours earlier.
2. **Take the window deliberately.** Before a capture sequence that a conclusion will rest on, state
(to yourself or in the record) that the next N seconds are for this observation only, and do not
run another install/uninstall/clear inside it.
3. **Timestamp the window.** Record the wall-clock start and end. It costs one line and it is the only
way to later notice that a teammate, a background job or your own earlier command landed inside it.
4. **Prefer one continuous capture over several short ones.** A restarted capture invites a restarted
state; a single sequence cannot straddle an install it did not perform.
When you discover a window was contaminated, **the correct action is to discard it and re-capture**,
not to salvage it. A re-run costs minutes; a wrong conclusion costs the rest of the task, and it will
be re-derived from the same bad evidence because the record says it was observed.
## Handover ## Handover
A handover is the record plus three things, written for someone with **no** memory of the task: A handover is the record plus three things, written for someone with **no** memory of the task:
+63
View File
@@ -165,6 +165,69 @@ Update suppression changes behaviour a user can observe ("it never tells me abou
in the delivery notes, in one line, together with anything else you neutralised. If the app also has a 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. 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 ## Checklist
- [ ] Update vocabulary searched in **both** string encodings, in the right layer for the runtime - [ ] Update vocabulary searched in **both** string encodings, in the right layer for the runtime