add update-suppression and account-gate references; fix two script defects
- references/updates-and-forced-upgrade.md: keeping a patched build alive (locating the version check, the two-layer patch, what not to touch, self-update and hot-update channels, verifying no version request is issued) - references/account-gates.md: client-side sign-in/binding gates vs account-scoped resources, why forging a session is worse than being signed out, session loss after reinstall - SKILL.md / README.md: wire both into the decision list, workflow and indexes - scripts/dex_classdiff.py: had no argparse, so --help crashed with IndexError; added proper CLI, non-zero exit on structural difference, removed a dead duplicate read - scripts/dex_strings.py: --find compiled the regex as bytes but matched decoded text, raising TypeError on the main path; split into text and byte patterns, added --max-print output cap
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
# Account gates: sign-in walls, forced binding, guest mode
|
||||
|
||||
Load this when the task mentions "no login required", "don't force me to sign in", "skip phone
|
||||
binding", "guest mode", or when a screen is unreachable without an account — and also **before**
|
||||
promising that any account- or membership-derived feature will work offline.
|
||||
|
||||
This file is mostly about **telling apart two things that look identical in the UI and are completely
|
||||
different in reality**:
|
||||
|
||||
- a **client-side gate** — code that refuses to proceed. Patchable.
|
||||
- an **account-scoped resource** — the screen is empty because the server sent nothing, because the
|
||||
request was made without a valid identity. **Not** patchable.
|
||||
|
||||
Confusing the two is how a task ends up "done" while the user stares at an empty page.
|
||||
|
||||
## Step 1: classify what you are looking at
|
||||
|
||||
| Symptom | Most likely cause | Can a client patch help? |
|
||||
|---|---|---|
|
||||
| A dismissible dialog / bottom sheet asking to sign in, then the app works | client-side gate | **yes** |
|
||||
| A full-screen wall with no dismiss, app unusable | client-side gate | **yes** |
|
||||
| Screen renders its frame but the list is empty and a "log in to see" hint is drawn | **account-scoped data** | no — the data does not exist locally |
|
||||
| A specific action (comment, favourite, follow, download) refuses | client-side gate | usually yes |
|
||||
| The action succeeds locally but reverts on restart / never reaches the server | **server-side identity** | no |
|
||||
| "Binding required" before an action | client-side gate | **yes** |
|
||||
| An entitlement appears then disappears | server-side authority | no |
|
||||
|
||||
**The deciding question is never "is there a login screen?" — it is "does the thing I want exist
|
||||
without an identity?"**
|
||||
|
||||
Ask it before patching:
|
||||
|
||||
- If the content is **fetched per-account** (subscriptions, favourites, reading history, purchased
|
||||
items), a patch changes the gate and nothing else. The list stays empty because the server was asked
|
||||
for *this account's* items and there is no account.
|
||||
- If the content is **public** and merely hidden behind a prompt (a reader, a catalogue, a settings
|
||||
page), the patch is real and the feature becomes usable.
|
||||
|
||||
Reading the code tells you which: follow the "sign in" prompt to what it guards, then look at whether
|
||||
that guarded path issues a request carrying a token, or touches only local state.
|
||||
|
||||
## Step 2: find the convergence point
|
||||
|
||||
Sign-in checks are usually concentrated, because a UI cannot meaningfully enforce login at thirty
|
||||
call sites. Look for a small number of helpers with names in this family:
|
||||
|
||||
```
|
||||
checkLogin* ensureLogin* requireLogin* needLogin* isLogined* isLoggedIn*
|
||||
checkBind* ensureBind* requireBind* needBind*
|
||||
showLogin* openLogin* loginSheet* loginDialog*
|
||||
```
|
||||
|
||||
and, on the state side, a **login-state field or stream** that every helper reads
|
||||
(`isLogin`, `loginedStream`, an `Rx<bool>`, an auth-state enum, a credential store wrapper).
|
||||
|
||||
**Rule: patch the predicate, not the prompt.** Making the sheet not appear is cosmetic — the caller
|
||||
still treats the answer as "not signed in" and takes its else-branch, which is usually where the real
|
||||
feature got skipped. The durable fix is to make the *verdict* come back as "signed in / no sign-in
|
||||
needed", so the caller takes its happy path.
|
||||
|
||||
Two useful shapes, both better than editing the dialog:
|
||||
|
||||
| Target | Change | Effect |
|
||||
|---|---|---|
|
||||
| the gate predicate used by many callers | return "ok / already signed in" unconditionally | every caller proceeds; no new code path |
|
||||
| the "should I prompt?" decision inside the helper | return "nothing to do" | the prompt never appears and callers proceed |
|
||||
|
||||
Prefer the first when callers branch on the result; prefer the second when the helper is fire-and-forget.
|
||||
|
||||
**Count the callers before choosing** (`scripts/find_refs.py`). A predicate with one caller is a
|
||||
surgical target; a predicate with forty callers is a behavioural change you must reason about — it will
|
||||
also release flows you were not asked to touch (settings, purchase, upload). That may be fine, but it is
|
||||
a decision, not a side effect.
|
||||
|
||||
### Forced binding specifically
|
||||
|
||||
Phone binding is the same pattern with its own helper and usually **two** state checks in series
|
||||
(bound-phone? and email-or-other-recovery?), because either satisfies the requirement. Patch the
|
||||
combined verdict, not the first condition — a patch that only satisfies one branch still prompts on the
|
||||
other.
|
||||
|
||||
Watch for a **"skip"/"later" path the app already has**, and for **which actions the binding actually
|
||||
gates**. Binding is often required for a subset (password change, deletion, payment) while the rest of
|
||||
the app is unaffected; releasing it globally changes what the app believes about account safety.
|
||||
|
||||
## Step 3: what NOT to do
|
||||
|
||||
These are the tempting moves that produce a worse artifact than doing nothing:
|
||||
|
||||
- **Do not fabricate a session.** Writing a dummy token, a fake credential blob, or a placeholder
|
||||
identity into the credential store makes the client *believe* it is signed in. It then calls
|
||||
identity-scoped endpoints with garbage, and the app enters a state that is **worse than signed out**:
|
||||
screens show an authentication-failure message, or silently return empty data with no explanation.
|
||||
A signed-out app that says "please sign in" is better than one that claims a session and fails.
|
||||
*(If you touch stored credentials at all, keep a backup and expect to have to restore it.)*
|
||||
- **Do not delete the sign-in UI.** Removing the entry point does not change the verdict; it only makes
|
||||
the failure unexplainable when a caller does branch on it. Keep the entry point working — it is
|
||||
normally an explicit requirement ("signing in must still be possible").
|
||||
- **Do not release gates that protect server-side operations.** If the action's result is an
|
||||
entitlement the server must grant (a purchase, a quota, a subscription), releasing the client gate
|
||||
only lies to the UI. See `references/membership-and-limits.md`.
|
||||
- **Do not assume "guest can browse" means "guest can do everything".** Establish per feature which of
|
||||
the three states you are in: public, gate-only, or identity-required.
|
||||
|
||||
## Step 4: the session-loss problem (this one is unavoidable)
|
||||
|
||||
Any change to the signing identity — including a plain reinstall of *your own* build — can invalidate
|
||||
stored credentials, because they may be sealed with a key held by the platform keystore rather than by
|
||||
the app.
|
||||
|
||||
**What you observe:** the app opens fine, then one subsystem reports it cannot decrypt or unwrap
|
||||
something, or the user is silently signed out.
|
||||
|
||||
**What it means:** the app's private data was carried across an uninstall/reinstall (or restored from a
|
||||
backup), but the encryption key was destroyed with the app. The ciphertext is now unopenable by anyone.
|
||||
|
||||
**What to do:** treat "the user signs in once on the new build" as the expected, documented outcome — not
|
||||
as a defect to fix. Put it in the delivery notes in one line, together with the fact that a later
|
||||
upgrade over the *same* signing key can be installed without uninstalling, and therefore will not lose
|
||||
the session.
|
||||
|
||||
**Do not** chase this into the keystore. Restoring the old key is not part of packaging a patched build,
|
||||
and any workaround that keeps the *old* ciphertext alive while the key is gone is a dead end.
|
||||
|
||||
Related and worth knowing: an app may keep the same logical account in **two places** — a plain
|
||||
preference blob and a sealed store. After a reinstall the plain one survives and the sealed one does
|
||||
not, which shows up as an account that is half-present (identity fields populated, token empty). That
|
||||
half-state is a useful diagnostic: it tells you the app reads its identity from the sealed store, which
|
||||
is exactly the thing you must not try to forge.
|
||||
|
||||
## Step 5: verify
|
||||
|
||||
The claim to support is narrow and should be stated narrowly.
|
||||
|
||||
1. **The prompt does not appear** on a cold start and on the screens that used to show it.
|
||||
2. **The feature behind it actually works**, not merely "no longer blocked": open the reader, the
|
||||
catalogue, the settings page — whatever the gate guarded — and confirm real content or real function.
|
||||
3. **The sign-in entry still exists and is usable.** Signing in with a real account must still work; that
|
||||
is usually part of the request, and it is the thing that proves you patched a gate rather than broke
|
||||
authentication.
|
||||
4. **Signed-in behaviour still works** for a real account, so you can tell the difference between
|
||||
"released the gate" and "broke the auth path".
|
||||
5. **No fake-session artefact is left behind** — check that you did not write anything into the
|
||||
credential store or preferences. If you did, remove it and re-verify from a clean install.
|
||||
|
||||
State the result as: *"the gate no longer blocks X, from a signed-out state; **the content behind it is
|
||||
public / the content behind it is per-account and therefore still empty when signed out**."* That second
|
||||
clause is what stops a client patch from being mistaken for an account.
|
||||
|
||||
## Reporting template
|
||||
|
||||
```
|
||||
Gate: <sign-in sheet | binding prompt | action-scoped check>
|
||||
Class: client-side gate / account-scoped data (not patchable)
|
||||
Patch: <predicate or decision, not the prompt>
|
||||
Signed-out state: <what works now, per screen>
|
||||
Signed-in state: <still works, listing what still requires an account>
|
||||
Not achievable: <per-account lists, entitlements — with the reason>
|
||||
Session note: sign in once after install; upgrade over the same key keeps the session
|
||||
```
|
||||
@@ -0,0 +1,180 @@
|
||||
# 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.
|
||||
|
||||
## 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
|
||||
Reference in New Issue
Block a user