From a8a17d5452c23ebbadbbd6916650ca0b777de675 Mon Sep 17 00:00:00 2001 From: "Shane B." Date: Sun, 11 Oct 2026 14:46:17 +0200 Subject: [PATCH 1/2] Log the fetch and session outcomes a stale-map report needs Three places a "map goes stale with no error anywhere" capture had to be read around silences, seen across #238, #239 and #240: - A fetch refused while the session answers as healthy (rate limiting, flagging) and a session check suppressed by the latch produced the same empty log. Both branches now say which one happened. - A successful fetch logged only a report count, so "the network genuinely holds nothing newer" was indistinguishable from "returned something fresh". The newest report's age is now logged per accessory, on the periodic path the reports come from. - A restore stalled inside Python (anisette, network) looked exactly like one that never started. Start and return are now logged, so a cold-start capture shows which side of the call it sits on. Co-Authored-By: Claude Code --- .../android/opentagviewer/MapsActivity.java | 21 ++++++++++++++++--- .../python/PythonAuthService.java | 6 ++++++ app/src/main/python/main.py | 12 +++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java index 0d740daf..3b63a8bc 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/MapsActivity.java @@ -1967,9 +1967,16 @@ private void askForACodeIfTheSessionNeedsOne() { // reason when a session goes stale, and each failure calls this - so without the latch a // six-tag account would ask Apple for six codes, or sign out six times over. The check // and the set are both on the main thread, which is why a plain boolean is enough. - if (this.appleService == null - || this.alreadyAskingAboutTheSession + if (this.appleService == null) { + Log.d(TAG, "Not checking the session: there is no Apple service"); + return; + } + if (this.alreadyAskingAboutTheSession || (this.twoFactorAgain != null && this.twoFactorAgain.isShowing())) { + // **Logged, because a silent return here used to be indistinguishable from + // "checked, the session is fine".** Both left a failed fetch with no follow-up + // line in the log, and only one of them says anything about the session. + Log.d(TAG, "Not re-checking the session: a check is already in flight or on screen"); return; } this.alreadyAskingAboutTheSession = true; @@ -1979,7 +1986,15 @@ private void askForACodeIfTheSessionNeedsOne() { .observeOn(AndroidSchedulers.mainThread()) .subscribe(needed -> { if (needed.isEmpty()) { - return; // nothing wrong with this session + // **Logged, because this answer is half the diagnosis.** A fetch that + // was refused while the session answers as healthy is not a dead + // session - the reports endpoint itself is refusing (rate limiting, + // flagging), and no re-login fixes that. Without the line, this + // outcome and a check suppressed by the latch above produce the same + // empty log. + Log.i(TAG, "The session answers as healthy after a failed fetch, so the" + + " refusal is specific to fetching rather than a dead session"); + return; } final List methods = needed.get(); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAuthService.java b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAuthService.java index a912d778..1cc220da 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAuthService.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAuthService.java @@ -290,6 +290,11 @@ public static Observable retrieveAuthData(@NonNull PythonAuthResponse au public static Observable restoreAccount( final AppleUserData appleUserData, final AnisetteSource localAnisette) { return Observable.fromCallable(() -> { + // **The two lines bracket the Python call so a stall is visible.** A capture that + // shows the first and never the second means the restore is sitting inside Python + // (anisette provisioning, the network) rather than failed - and a failure is the + // only one of those the exception below distinguishes today. + Log.i(TAG, "Restoring the stored account"); var data = AppCryptographyUtil.AppEncryptedData.fromFlattened(appleUserData.getData()); var account = new AppCryptographyUtil().decrypt(data, KEYSTORE_ALIAS_ACCOUNT); @@ -308,6 +313,7 @@ public static Observable restoreAccount( throw new PythonAccountLoginException("Error occurred while restoring account! Check python logs for more details"); } + Log.i(TAG, "Account restore returned from Python"); return new PythonAppleAccount(returned); }).subscribeOn(Schedulers.io()); } diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py index 2328bf28..094b7dcb 100644 --- a/app/src/main/python/main.py +++ b/app/src/main/python/main.py @@ -1945,6 +1945,18 @@ def getLastReports( print(f"Got {len(reports)} raw reports for {beaconId}") + # Log the age of the newest one, not only the count. A fetch that succeeds while + # Apple's newest report is hours old means the network genuinely holds nothing + # newer - the "stale map with no error anywhere" case - and a count alone cannot + # tell that from a fetch that returned something fresh. + if reports: + newest_ms = max(int(r.timestamp.timestamp() * 1000) for r in reports) + age_min = max(0, (now_ms - newest_ms) // 60000) + age = f"{age_min} min" if age_min < 180 else f"{age_min / 60:.1f} h" + newest_iso = datetime.fromtimestamp( + newest_ms / 1000, tz=timezone.utc).isoformat(timespec="seconds") + print(f" -> newest report is {age} old ({newest_iso})") + # Measured here because this is the one place both halves are in scope: what the # alignment said before anything was fetched, and what the fetch made of it. aligned_after = _alignmentOf(airtag) From 5380da4561825c639b5fe884de9b70031ba21757 Mon Sep 17 00:00:00 2001 From: "Shane B." Date: Sun, 11 Oct 2026 14:46:44 +0200 Subject: [PATCH 2/2] update python README as it was outdated --- python/README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/python/README.md b/python/README.md index 89b3c9db..bae0199e 100644 --- a/python/README.md +++ b/python/README.md @@ -45,12 +45,6 @@ uv run python -m exporter.cli # the same thing in a terminal uv run python -m exporter.cli --help ``` -> [!IMPORTANT] -> **Pass `--no-password` to the CLI for now**, or use the window, which does not lock bundles. -> The CLI encrypts by default and no released version of the Android app can open an encrypted -> zip — its reader cannot decrypt anything. See -> [the handover doc](../docs/android-import-handover.md). - **tkinter** is what draws the window, and it is not part of a stock Python on macOS or Linux: ```shell