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) 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