From e10fb2d3086e4dd8304c4f7f5089bcd4e40b237f Mon Sep 17 00:00:00 2001 From: inliver233 Date: Sun, 20 Sep 2026 00:53:25 +0800 Subject: [PATCH] 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. --- references/long-task-discipline.md | 33 +++++++++++++ references/updates-and-forced-upgrade.md | 63 ++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/references/long-task-discipline.md b/references/long-task-discipline.md index 711b63d..fc2609e 100644 --- a/references/long-task-discipline.md +++ b/references/long-task-discipline.md @@ -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 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 A handover is the record plus three things, written for someone with **no** memory of the task: diff --git a/references/updates-and-forced-upgrade.md b/references/updates-and-forced-upgrade.md index e8a6971..dd87b0b 100644 --- a/references/updates-and-forced-upgrade.md +++ b/references/updates-and-forced-upgrade.md @@ -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 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