Skip to content

Re-read the account when somebody would notice, and explain the device once - #153

Merged
parawanderer merged 3 commits into
mainfrom
feat/reread-the-account-when-it-matters
Aug 23, 2026
Merged

Re-read the account when somebody would notice, and explain the device once#153
parawanderer merged 3 commits into
mainfrom
feat/reread-the-account-when-it-matters

Conversation

@parawanderer

Copy link
Copy Markdown
Owner

Six hours to notice a rename, when an iPad takes seconds

Renaming an AirTag on your own iPad shows up there almost immediately. This app re-read the
account every six hours, so the old name sat on screen for the rest of the afternoon — which
reads as the app being broken rather than patient.

Four ways it updates now, and the difference between them is who asked:

Route When A refused sign-in
Settings → read all devices on demand → sign in again (#151)
My Devices → overflow menu on demand → sign in again
On resume 60-second floor logs, changes nothing
Every 15 minutes while open was 6 hours logs, changes nothing

Fifteen minutes is not reckless: the location fetch beside it runs every minute, so four
account reads an hour is not the expensive thing here. The floor on resume exists because
resuming happens on every task switch, and a Python call for flicking between two apps is waste.

The menu item replaces "Link an account" once linking is done — only one of the two is ever what
somebody wants, and offering both reads as three ways in rather than two.

The device-registration page appeared every time

The page explaining what this app put on your Apple account — a row titled after a Mac you do not
own, next to a button offering to remove things you do not recognise, where removing it breaks
your session — was shown on every run of the iCloud flow.

It describes an event. Re-reading a linked account causes none of it, so the page was explaining
something that had not happened, to somebody who read it the first time. A page that turns up
when nothing has happened is one people learn to tap past, including on the run where it matters.

It now shows only when the connection actually registered a device. A fresh install still gets it,
because each one really does add another row.

A design flaw the tests found

AccountRefresher swallowed a refused sign-in into an empty list — so the manual refresh's
onError never fired, and a read that failed looked identical to a successful read of nothing.
The "who asked" decision was sitting inside the one component that cannot know the answer.

It now reports, and each caller decides. Found only because the test drove the menu item rather
than the refresher directly.

Testing

AccountReadPolicyTest moves to the JVM, where arithmetic and three booleans always belonged
(rule 13). That move is also what exposed the gap: adding a parameter to the constructor broke
nothing in the JVM suite, because the only test of the class lived somewhere the JVM suite does
not look.

New: RefreshingFromTheAccountTest (the menu item, including that an outage must not cost a
sign-in) and TheDeviceNoteIsShownOnceTest (shown on a first connection, absent on a re-read, and
the button reads Done rather than Next).

One test hung the suite before it worked: a Toast matched with inRoot(withDecorView(not(...)))
sends Espresso's root picker hunting for a window that is not up yet, retrying for seconds per
attempt inside a retry loop. Killed after ten minutes at three tests of seven. A call count on the
fake answers instantly.

Also

AGENTS.md rule 14 — every call reaching the account has to answer "is this session dead?",
through the shared decision rather than its own, with the who-asked line written down.

Stacked on #151

Branched from fix/a-dead-icloud-session-says-so, because a manual refresh that meets a dead
session needs the classification that PR adds. Merge #151 first.

🤖 Generated with Claude Code

@parawanderer
parawanderer changed the base branch from fix/a-dead-icloud-session-says-so to main August 23, 2026 18:58
parawanderer and others added 3 commits August 23, 2026 21:00
An iPad picks up a renamed AirTag in seconds. This app read the account every six
hours, so a tag renamed on the owner's own iPad kept its old name here for the
rest of the afternoon - which reads as broken rather than patient.

Four ways it updates now, and the difference between them is who asked:

- Settings, which opens the whole iCloud flow and reads everything
- **My Devices, from the overflow menu** - new, and the one that fits "did the
  name I just changed arrive". It replaces the link item once linking is done,
  since only one of the two is ever what somebody wants
- **on resume**, with a one-minute floor. The moment a stale name is noticed is
  the moment the app is opened, and resuming already refetched where the tags
  were while never asking what they were called
- every fifteen minutes while the screen stays open, down from six hours

Fifteen is not reckless: the location fetch beside it runs every *minute*, so
four account reads an hour is not the expensive thing here. The floor on resume
exists because resuming happens on every task switch, and a Python call for
flicking between two apps is waste.

**A refused sign-in is handled by who asked, per rule 14.** The menu item sends
the user to sign in again - they are watching, and a tap that does nothing is
indistinguishable from a broken button. Resume and the timer only log: deleting
somebody's session out from under them, from a job nobody requested, the instant
they open the app is its own bug.

AccountReadPolicyTest moves to the JVM, where arithmetic and three booleans
always belonged. That move is also what exposed the gap: adding a parameter to
the constructor broke nothing in the JVM suite, because the only test of this
class lived somewhere the JVM suite does not look.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The page describing what this app put on somebody's Apple account was shown every
time the iCloud flow ran. It describes an event - a row appearing in their device
list, titled after a Mac they do not own, beside a button offering to remove
things they do not recognise, where removing it breaks their session. Worth a
screen on the run that caused it.

Re-reading a linked account causes none of that. The same page then explains
something that did not happen, to somebody who read it the first time - and a
page that appears when nothing has happened is one people learn to tap past,
including on the run where it matters.

So the results step ends in Done rather than Next when the app was already a
member when the screen opened. The flag is set where that branch is taken,
because from there on the flow is identical to a first connection and afterwards
there is no way to tell which one this was.

Its javadoc claimed "shown on every connect" and is corrected rather than left:
a fresh install does need telling again, since each one adds another row with the
same title and model, and that is a connect. A read is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AccountRefresher swallowed the refusal into an empty list, so the manual refresh
could never act on it: its onError never fired and a read that failed looked
exactly like a successful read of nothing. The "who asked" decision was sitting
inside the one component that cannot know the answer.

So it reports and the caller decides. The menu item somebody pressed sends them
to sign in again; the periodic and on-resume reads log and touch nothing. Same
failure, different callers, which is rule 14.

The test that found it is the one asserting the menu item routes - it failed for
the right reason, and only because it drove the real path rather than the
refresher directly.

Also drops a Toast matcher that hung this class. `inRoot(withDecorView(not(...)))`
sends Espresso's root picker hunting for a window that is not up yet, retrying
internally for seconds per attempt inside a retry loop - the same shape that took
UnlinkTheAccountSettingTest to 6m33s. The run had to be killed after ten minutes
at three tests of seven. A call count on the fake answers instantly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@parawanderer
parawanderer force-pushed the feat/reread-the-account-when-it-matters branch from e488122 to 36be09b Compare August 23, 2026 19:02
@parawanderer
parawanderer merged commit 34864bc into main Aug 23, 2026
3 checks passed
ubrt pushed a commit to ubrt/OpenTagViewer that referenced this pull request Aug 24, 2026
Every workflow filters on `pull_request: branches: ["main"]`, and that matches
the base of the PR rather than the branch the work is on. A PR opened against
another PR's branch therefore runs nothing.

Worth writing down because of how it presents: the checks section is absent,
not red and not pending, which reads as "nothing to run here" rather than
"nothing ran". parawanderer#153 sat like that and looked fine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant