From c0fe796d05576ece87806ea67b8a50a84ac6c8bb Mon Sep 17 00:00:00 2001 From: "Shane B." Date: Sun, 13 Sep 2026 20:49:45 +0200 Subject: [PATCH 1/3] Pin the FindMy.py fork at bcb0787, which reads Retry-After AppleServiceUnavailableError gains retry_after (None when Apple did not say, which is every refusal seen so far), and a refusal now logs the request headers minus the secrets and the response headers in full. Its message no longer promises the refusal clears on its own. Co-Authored-By: Claude Opus 5 (1M context) --- app/build.gradle.kts | 2 +- app/src/test/python/requirements.txt | 2 +- python/pyproject.toml | 2 +- python/uv.lock | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e8864292..29c82b4b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -407,7 +407,7 @@ chaquopy { // wheel for desktop platforms and a pure-Python `py3-none-any` one as well. // There is no Android wheel, so pip falls back to the pure-Python build - which // is correct but markedly slower. The messages here are small enough not to care. - install("git+https://github.com/parawanderer/FindMy.py@b6f544bc673b66adeac9e8315f8ef499c4956036") + install("git+https://github.com/parawanderer/FindMy.py@bcb078761085c078eadac477da39a4c85b1f3a44") install("NSKeyedUnArchiver==1.5") diff --git a/app/src/test/python/requirements.txt b/app/src/test/python/requirements.txt index f43b0747..3d9b2cec 100644 --- a/app/src/test/python/requirements.txt +++ b/app/src/test/python/requirements.txt @@ -9,7 +9,7 @@ # `FindMy==0.9.8` here for as long as the app built the fork, so every bridge test # ran against a library the app does not ship - which is not a small difference: # the fork's Anisette providers take `serial=` and PyPI's do not. -git+https://github.com/parawanderer/FindMy.py@b6f544bc673b66adeac9e8315f8ef499c4956036 +git+https://github.com/parawanderer/FindMy.py@bcb078761085c078eadac477da39a4c85b1f3a44 NSKeyedUnArchiver==1.5 PyYAML==6.0.2 diff --git a/python/pyproject.toml b/python/pyproject.toml index 7ac98da4..b3581dfa 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -69,7 +69,7 @@ constraint-dependencies = [ ] [tool.uv.sources] -FindMy = { git = "https://github.com/parawanderer/FindMy.py", rev = "b6f544bc673b66adeac9e8315f8ef499c4956036" } +FindMy = { git = "https://github.com/parawanderer/FindMy.py", rev = "bcb078761085c078eadac477da39a4c85b1f3a44" } [dependency-groups] # Only the release build installs this, with `uv sync --no-default-groups --group build`. It is diff --git a/python/uv.lock b/python/uv.lock index 0a543aa1..e1635ec9 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -659,7 +659,7 @@ wheels = [ [[package]] name = "findmy" version = "0.10.1" -source = { git = "https://github.com/parawanderer/FindMy.py?rev=b6f544bc673b66adeac9e8315f8ef499c4956036#b6f544bc673b66adeac9e8315f8ef499c4956036" } +source = { git = "https://github.com/parawanderer/FindMy.py?rev=bcb078761085c078eadac477da39a4c85b1f3a44#bcb078761085c078eadac477da39a4c85b1f3a44" } dependencies = [ { name = "aiohttp" }, { name = "anisette" }, @@ -1032,7 +1032,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "findmy", git = "https://github.com/parawanderer/FindMy.py?rev=b6f544bc673b66adeac9e8315f8ef499c4956036" }, + { name = "findmy", git = "https://github.com/parawanderer/FindMy.py?rev=bcb078761085c078eadac477da39a4c85b1f3a44" }, { name = "pycryptodome", specifier = "==3.22.0" }, { name = "pyyaml", specifier = "==6.0.2" }, { name = "pyzipper", specifier = "==0.4.0" }, From 5f94b99b294551bbb27db0ef03692c40053bfe45 Mon Sep 17 00:00:00 2001 From: "Shane B." Date: Sun, 13 Sep 2026 20:49:45 +0200 Subject: [PATCH 2/3] Tell exporter users how long Apple asked them to wait, when it says A refusal carrying Retry-After now names the wait, rounded up, in place of "try again shortly". Without the header - every refusal observed so far - the message is unchanged and names no number. Co-Authored-By: Claude Opus 5 (1M context) --- python/exporter/icloud.py | 30 +++++++++++++++++- python/test/test_apple_declining.py | 49 +++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/python/exporter/icloud.py b/python/exporter/icloud.py index 8af7032f..f0b72628 100644 --- a/python/exporter/icloud.py +++ b/python/exporter/icloud.py @@ -20,6 +20,7 @@ import asyncio import base64 import logging +import math import uuid from io import BytesIO from pathlib import Path @@ -615,7 +616,22 @@ def apple_is_declining(error: BaseException) -> AppleServiceUnavailableError | N def describe_apple_declining(error: AppleServiceUnavailableError) -> str: - """What to tell somebody whose sign-in was refused by Apple rather than by their password.""" + """ + What to tell somebody whose sign-in was refused by Apple rather than by their password. + + **Apple's own wait comes first when it named one**, from a `Retry-After` header, because it + is the only reliable advice there is. No refusal from Grand Slam has been seen carrying one, + so the usual message is the one without it - and that one invents no number. + """ + if error.retry_after is not None: + return ( + f"Apple's sign-in service refused the request with HTTP {error.status_code}, and asked" + f" for {_a_wait_a_person_reads(error.retry_after)} before trying again.\n\n" + "Apple declined it rather than anything being wrong with your Apple ID, your password" + " or your verification code. Nothing was changed and nothing was sent.\n\n" + "Signing in again sooner than that is likely to be refused the same way." + ) + return ( f"Apple's sign-in service refused the request with HTTP {error.status_code}.\n\n" "Apple declined it rather than anything being wrong with your Apple ID, your password or" @@ -627,6 +643,18 @@ def describe_apple_declining(error: AppleServiceUnavailableError) -> str: ) +def _a_wait_a_person_reads(seconds: float) -> str: + """Seconds, minutes or hours, rounded up so nobody is told to come back too early.""" + whole = max(0, math.ceil(seconds)) + if whole < 60: + amount, unit = whole, "second" + elif whole < 2 * 60 * 60: + amount, unit = math.ceil(whole / 60), "minute" + else: + amount, unit = math.ceil(whole / (60 * 60)), "hour" + return f"{amount} {unit}{'' if amount == 1 else 's'}" + + def _apple_failed_after_taking_the_code(error: BaseException) -> SignInInterrupted: """ What to tell somebody whose code was accepted and whose sign-in failed anyway. diff --git a/python/test/test_apple_declining.py b/python/test/test_apple_declining.py index 0150c350..5ce17339 100644 --- a/python/test/test_apple_declining.py +++ b/python/test/test_apple_declining.py @@ -126,3 +126,52 @@ def test_itdoesNotPromiseItWillClear(self, message): def test_itdoesNotAskForABugReportOutright(self, message): """The behaviour being fixed.""" assert "github.com" not in message.lower() + + +class TestWhenAppleNamesAWait: + """ + **Absent is the observed case and the one that has to stay honest.** No refusal from Grand + Slam has been seen with a `Retry-After` header, so everything above - the message without a + wait - is what people actually read. This is for the day Apple says how long. + """ + + @staticmethod + def told(seconds: float) -> str: + return icloud.describe_apple_declining( + AppleServiceUnavailableError(429, "The Grand Slam request", retry_after=seconds), + ) + + def test_itsaysHowLongAppleAskedFor(self): + assert "asked for 2 minutes before trying again" in self.told(90) + + def test_itkeepsTheStatusAndClearsTheCredentials(self): + message = self.told(90) + + assert "429" in message + assert "password" in message + assert "nothing was changed" in message.lower() + + def test_itdropsTheAdviceToTryAgainShortly(self): + """Apple has just said when. "Shortly" would contradict it.""" + assert "shortly" not in self.told(90) + + @pytest.mark.parametrize(("seconds", "said"), [ + (0, "0 seconds"), + (1, "1 second"), + (59.2, "1 minute"), + (60, "1 minute"), + (61, "2 minutes"), + (7199, "120 minutes"), + (7200, "2 hours"), + (7201, "3 hours"), + ]) + def test_itroundsUpNeverDown(self, seconds, said): + """Told to come back too early, a person is refused again and trusts the number less.""" + assert f"asked for {said} before" in self.told(seconds) + + def test_withoutOneItNamesNoWaitAtAll(self): + """No invented number when the header was absent.""" + message = icloud.describe_apple_declining(a_503()) + + assert "asked for" not in message + assert "minute" not in message From 3c556e3c9ad1e70da7e4671d716077e507567b4d Mon Sep 17 00:00:00 2001 From: "Shane B." Date: Sun, 13 Sep 2026 20:49:46 +0200 Subject: [PATCH 3/3] Show Apple's requested wait on the sign-in screen, when it gives one The bridge carries retryAfterSeconds only when Apple sent Retry-After, and the screen replaces "try again shortly" with the wait, rounded up and worded by ICU for the locale. Absent stays absent all the way across: zero would read as "retry now". The loginSync failure harness moves to conftest.py so the new bridge tests can drive it. Co-Authored-By: Claude Opus 5 (1M context) --- .../opentagviewer/AppleLoginFlowTest.java | 32 +++++++++ .../python/FakeAppleAuthService.java | 22 +++++- .../opentagviewer/AppleLoginActivity.java | 10 +++ .../python/PythonAccountLoginException.java | 23 ++++++ .../python/PythonAuthService.java | 6 +- .../opentagviewer/util/HowLongToWait.java | 71 +++++++++++++++++++ app/src/main/python/main.py | 29 +++++++- app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-en/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-nl/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../opentagviewer/util/HowLongToWaitTest.java | 50 +++++++++++++ app/src/test/python/conftest.py | 37 ++++++++++ .../test/python/test_apple_asked_to_wait.py | 57 +++++++++++++++ app/src/test/python/test_terms_flow.py | 27 ------- 21 files changed, 341 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/util/HowLongToWait.java create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/util/HowLongToWaitTest.java create mode 100644 app/src/test/python/test_apple_asked_to_wait.py diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/AppleLoginFlowTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/AppleLoginFlowTest.java index 03567a97..8fb9ed21 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/AppleLoginFlowTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/AppleLoginFlowTest.java @@ -48,6 +48,7 @@ import dev.wander.android.opentagviewer.python.AppDependencies; import dev.wander.android.opentagviewer.ui.error.ErrorReportActivity; import dev.wander.android.opentagviewer.python.PythonAuthService.AuthMethodPhone; +import dev.wander.android.opentagviewer.util.HowLongToWait; import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; /** @@ -347,6 +348,37 @@ public void appleDecliningIsNotBlamedOnThePasswordOrTheNetwork() { not(withText(containsString("Grand Slam"))))); } + /** + * When Apple names a wait, the screen gives it rather than "try again shortly". + * + *

A {@code Retry-After} on the refusal, carried across the bridge. The test above is the + * other half - no header, which is every refusal observed so far - and it pins that no number + * is invented then. + */ + @Test + public void aWaitAppleNamedIsShownRoundedUp() { + this.apple = FakeAppleAuthService.appleAsksToWait(90); + AppDependencies.replaceAuthService(this.apple); + + launch(); + signIn(); + + final android.content.Context context = getInstrumentation().getTargetContext(); + final java.util.Locale locale = context.getResources().getConfiguration().getLocales().get(0); + + // The device's ICU, not a string this test composed: 90 seconds is two minutes, not one. + assertEquals("2 minutes", HowLongToWait.of(90).describe(java.util.Locale.ENGLISH)); + + Eventually.check(() -> onView(withId(R.id.login_error_message_text)).check(matches( + withText(context.getString(R.string.login_failed_apple_asked_to_wait, + HowLongToWait.of(90).describe(locale)))))); + + onView(withId(R.id.login_error_message_text)).check(matches( + not(withText(context.getString(R.string.login_failed_apple_declined))))); + onView(withId(R.id.login_error_message_text)).check(matches( + not(withText(containsString("429"))))); + } + /** * A failed sign-in can produce a log without a second machine. * diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/python/FakeAppleAuthService.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/python/FakeAppleAuthService.java index 26bd37e7..416ec404 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/python/FakeAppleAuthService.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/python/FakeAppleAuthService.java @@ -130,12 +130,30 @@ public static FakeAppleAuthService appleIsDeclining() { new FakeAppleAuthService(LOGIN_STATE.LOGGED_OUT, null); fake.loginFailsWith = new PythonAccountLoginException( "The Grand Slam request was refused with HTTP 503. This is Apple declining to" - + " serve the request rather than a response this library cannot read," - + " and it usually clears on its own -- wait and try again.", + + " serve the request rather than a response this library cannot read." + + " Waiting and trying again may help.", PythonAccountLoginException.REASON_APPLE_DECLINED); return fake; } + /** + * Apple declining, and saying how long to leave it - a {@code Retry-After} on the refusal. + * + *

Not yet seen from Grand Slam, which is why {@link #appleIsDeclining()} carries no + * wait: that is the case people actually meet. This is the one where Apple names a time. + */ + public static FakeAppleAuthService appleAsksToWait(final double seconds) { + final FakeAppleAuthService fake = + new FakeAppleAuthService(LOGIN_STATE.LOGGED_OUT, null); + fake.loginFailsWith = new PythonAccountLoginException( + "The Grand Slam request was refused with HTTP 429. This is Apple declining to" + + " serve the request rather than a response this library cannot read.", + PythonAccountLoginException.REASON_APPLE_DECLINED, + null, + seconds); + return fake; + } + /** Signing in works, but the code that gets typed is refused. */ public FakeAppleAuthService thatRejectsTheCode(final String message) { this.codeFailsWith = new PythonAccountLoginException(message); diff --git a/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java index c76a9d7c..c0d2d6b7 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java @@ -73,6 +73,7 @@ import dev.wander.android.opentagviewer.ui.login.StepTransition.Direction; import dev.wander.android.opentagviewer.ui.settings.AmapApiKeyDialog; import dev.wander.android.opentagviewer.ui.settings.SharedMainSettingsManager; +import dev.wander.android.opentagviewer.util.HowLongToWait; import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; import dev.wander.android.opentagviewer.util.android.PropertiesUtil; import dev.wander.android.opentagviewer.util.rx.ACodeAppleAlreadyTook; @@ -762,6 +763,15 @@ private String describeLoginFailure(final Throwable error) { // 503" verbatim.** That is accurate and reads as a bug in this app, which is how issue // #176 came to be filed. Nothing about the Apple ID or the password is wrong here. if (PythonAccountLoginException.REASON_APPLE_DECLINED.equals(reason)) { + // Apple's own wait replaces the "try again shortly" advice when it named one - it is + // the only reliable answer to "how long". It usually has not, and then nothing here + // invents a number: a guess too short is refused again. + final Double wait = ((PythonAccountLoginException) error).getRetryAfterSeconds(); + if (wait != null) { + return this.getString(R.string.login_failed_apple_asked_to_wait, + HowLongToWait.of(wait).describe( + this.getResources().getConfiguration().getLocales().get(0))); + } return this.getString(R.string.login_failed_apple_declined); } diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAccountLoginException.java b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAccountLoginException.java index fd2676c2..4c67849a 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAccountLoginException.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/PythonAccountLoginException.java @@ -55,6 +55,16 @@ public class PythonAccountLoginException extends RuntimeException { */ private final transient PyObject account; + /** + * Seconds Apple asked for before another attempt, or null when it did not say. + * + *

Null is the usual value, and means unknown rather than "retry now". It comes from + * a {@code Retry-After} header, and no refusal from Grand Slam has been seen carrying one - + * so nothing may substitute a number for it. When present, it is the only reliable advice + * there is about how long to leave it. + */ + private final Double retryAfterSeconds; + public PythonAccountLoginException(String message) { this(message, REASON_UNKNOWN); } @@ -64,21 +74,29 @@ public PythonAccountLoginException(String message, String reason) { } public PythonAccountLoginException(String message, String reason, PyObject account) { + this(message, reason, account, null); + } + + public PythonAccountLoginException( + String message, String reason, PyObject account, Double retryAfterSeconds) { super(message); this.reason = reason == null || reason.isBlank() ? REASON_UNKNOWN : reason; this.account = account; + this.retryAfterSeconds = retryAfterSeconds; } public PythonAccountLoginException(String message, Throwable cause) { super(message, cause); this.reason = REASON_UNKNOWN; this.account = null; + this.retryAfterSeconds = null; } public PythonAccountLoginException(Throwable cause) { super(cause); this.reason = REASON_UNKNOWN; this.account = null; + this.retryAfterSeconds = null; } /** Which kind of failure this was, for choosing what to show. Never null. */ @@ -91,6 +109,11 @@ public PyObject getAccount() { return this.account; } + /** How long Apple asked the user to wait, in seconds, or null. See the field. */ + public Double getRetryAfterSeconds() { + return this.retryAfterSeconds; + } + /** Whether this is a terms failure that can actually be recovered from in the app. */ public boolean hasTermsToAccept() { return REASON_TERMS.equals(this.reason) && this.account != null; 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 23c6ad4d..a912d778 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 @@ -69,10 +69,14 @@ public static Observable pythonLogin( // agreeing needs. Null for every other reason, which is what stops it being // stored in a state that fails every later fetch (issues #43 and #119). final var pendingAccount = resultMap.get("account"); + // Present only when Apple named a wait, which it has not yet been seen to do. + // Absent is carried as null, never as zero - zero would read as "retry now". + final var retryAfter = resultMap.get("retryAfterSeconds"); throw new PythonAccountLoginException( errorMessage, reason == null ? null : reason.toString(), - pendingAccount); + pendingAccount, + retryAfter == null ? null : retryAfter.toDouble()); } // need to do an annoying conversion here... diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/HowLongToWait.java b/app/src/main/java/dev/wander/android/opentagviewer/util/HowLongToWait.java new file mode 100644 index 00000000..2e6f52d3 --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/HowLongToWait.java @@ -0,0 +1,71 @@ +package dev.wander.android.opentagviewer.util; + +import android.icu.text.MeasureFormat; +import android.icu.util.Measure; +import android.icu.util.MeasureUnit; + +import java.util.Locale; + +/** + * A wait Apple asked for, as a person would say it. + * + *

Always rounded up. Somebody told to come back too early is refused again, and then + * trusts the number less - so 61 seconds is "2 minutes", never "1 minute". + * + *

Seconds below a minute, minutes below two hours, hours after that. The desktop exporter's + * {@code _a_wait_a_person_reads} draws the same lines, so the two say the same thing about the + * same header. + * + *

The rounding is here and plain Java so the JVM suite can pin it; the wording comes from + * {@code android.icu}, which knows every locale's plural forms and so needs no string per unit. + */ +public final class HowLongToWait { + + public enum Unit { SECONDS, MINUTES, HOURS } + + private static final long MINUTE = 60; + private static final long HOUR = 60 * MINUTE; + + private final long amount; + private final Unit unit; + + private HowLongToWait(final long amount, final Unit unit) { + this.amount = amount; + this.unit = unit; + } + + public static HowLongToWait of(final double seconds) { + final long whole = Math.max(0, (long) Math.ceil(seconds)); + if (whole < MINUTE) { + return new HowLongToWait(whole, Unit.SECONDS); + } + if (whole < 2 * HOUR) { + return new HowLongToWait(ceilDiv(whole, MINUTE), Unit.MINUTES); + } + return new HowLongToWait(ceilDiv(whole, HOUR), Unit.HOURS); + } + + public long getAmount() { + return this.amount; + } + + public Unit getUnit() { + return this.unit; + } + + /** "2 minutes", "2 Minuten", "2 分钟" - in the given locale, with its own plural rules. */ + public String describe(final Locale locale) { + final MeasureUnit measured; + switch (this.unit) { + case SECONDS: measured = MeasureUnit.SECOND; break; + case MINUTES: measured = MeasureUnit.MINUTE; break; + default: measured = MeasureUnit.HOUR; break; + } + return MeasureFormat.getInstance(locale, MeasureFormat.FormatWidth.WIDE) + .format(new Measure(this.amount, measured)); + } + + private static long ceilDiv(final long x, final long y) { + return (x + y - 1) / y; + } +} diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py index 1d97d8cd..197060b2 100644 --- a/app/src/main/python/main.py +++ b/app/src/main/python/main.py @@ -362,6 +362,11 @@ def loginSync(email: str, password: str, anisetteServerUrl: str, "reason": reason, } + # Absent rather than zero when Apple did not say, because zero reads as "retry now". + wait = appleAskedToWait(e) + if wait is not None: + failure["retryAfterSeconds"] = wait + # **The account survives a terms failure, and only a terms failure.** # # Authentication itself worked here - it is the delegate exchange after it that did not @@ -465,9 +470,13 @@ def assertAnisetteIsSupported(serializedAccountData: str) -> str | None: **A 503 from Grand Slam reads as a rejected sign-in to everybody who meets it**, because the only thing on screen is a failure at the moment a password was entered. It is neither: nothing was -wrong with the credentials, nothing was reached and refused on their merits, and it clears on its -own within minutes. Reported as OpenTagViewer#176, where one account met the same 503 three times -in three minutes at three different call sites. +wrong with the credentials, and nothing was reached and refused on their merits. Reported as +OpenTagViewer#176, where one account met the same 503 three times in three minutes at three +different call sites. + +**It does not reliably clear on its own**, so nothing on screen promises that. The 503s of +September 2026 were Apple refusing the Xcode client identifier, and lasted until the client +changed; a 429 has been seen to outlast a reinstall. Distinguished from :data:`REASON_NETWORK` because the advice differs. A network failure is usually the phone's - check the connection. This one is Apple's, and checking anything at this @@ -521,6 +530,20 @@ def classifyLoginFailure(error: BaseException) -> str: return REASON_UNKNOWN +def appleAskedToWait(error: BaseException) -> float | None: + """ + Seconds Apple asked for before another attempt, or None when it did not say. + + **None is the usual answer.** It comes from a `Retry-After` header, and no refusal from Grand + Slam has been seen carrying one - so the screen treats None as "unknown" and says nothing + about a wait, rather than inventing one. When Apple does name a time it is the only reliable + advice available, which is why it is carried at all. + """ + if isinstance(error, AppleServiceUnavailableError): + return error.retry_after + return None + + def describeLoginFailure(error: BaseException) -> str: """ A detail string that is **never empty**. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index bae2aab4..2e3beb15 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -387,4 +387,5 @@ Es wurde kein Verlauf importiert und bereits gespeicherte Daten wurden nicht ge Die Anmeldung ist nicht durchgekommen. Das Protokoll unten nennt den fehlgeschlagenen Schritt und die Antwort darauf – meist genau der Teil, der es erklärt. Wenn Apple die Anfrage lediglich abgelehnt hat, lohnt es sich, zu warten und es erneut zu versuchen. Schlägt es weiterhin fehl, macht ein Bericht mit diesem Protokoll es behebbar. + Apple hat die Anmeldung abgelehnt und bittet, %1$s bis zum nächsten Versuch zu warten. Das ist eine Ablehnung durch Apple und kein Problem mit deiner Apple-ID oder deinem Passwort. \ No newline at end of file diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index d26e586c..2a361fff 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -387,4 +387,5 @@ No history was imported and nothing already stored was changed. Signing in did not get through. The log below says which step failed and what came back, which is almost always the part that explains it. If Apple simply refused the request, waiting and trying again is worth doing first. If it keeps failing, a report with this log attached is what makes it fixable. + Apple refused the sign-in and asked for %1$s before trying again. This is Apple declining, not a problem with your Apple ID or password. \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 607ddc2a..fa253c9e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -387,4 +387,5 @@ Aucun historique n’a été importé et les données déjà enregistrées n’o La connexion n\'a pas abouti. Le journal ci-dessous indique quelle étape a échoué et ce qui a été renvoyé, ce qui explique presque toujours le problème. Si Apple a simplement refusé la requête, il vaut mieux attendre et réessayer. Si l\'échec persiste, un rapport accompagné de ce journal permet de corriger le problème. + Apple a refusé la connexion et demande d\'attendre %1$s avant de réessayer. C\'est un refus d\'Apple, pas un problème avec votre identifiant ou votre mot de passe. \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 9aff3573..0b0a06ba 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -387,4 +387,5 @@ サインインが完了しませんでした。下のログには、どの手順で失敗し、何が返されたかが記録されています。原因はたいていそこにあります。 Appleが要求を拒否しただけなら、少し待ってからもう一度お試しください。それでも失敗する場合は、このログを添えて報告すると修正できます。 + Apple がサインインを拒否し、再試行まで %1$s 待つよう求めています。Apple ID やパスワードの問題ではなく、Apple 側の拒否です。 \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 465a7668..da2a2ca2 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -387,4 +387,5 @@ 로그인이 완료되지 않았습니다. 아래 로그에 어느 단계에서 실패했고 무엇이 반환되었는지 나와 있으며, 대개 그 부분이 원인입니다. Apple이 요청을 거부한 것뿐이라면 잠시 기다렸다가 다시 시도해 보세요. 계속 실패한다면 이 로그를 첨부해 신고하면 해결할 수 있습니다. + Apple이 로그인을 거부했으며, 다시 시도하기 전에 %1$s 기다려 달라고 요청했습니다. Apple ID나 비밀번호의 문제가 아니라 Apple 쪽의 거부입니다. \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 200aff55..b10f2200 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -387,4 +387,5 @@ Er is geen geschiedenis geïmporteerd en eerder opgeslagen gegevens zijn niet ge Aanmelden is niet gelukt. Het logboek hieronder vermeldt welke stap is mislukt en wat er terugkwam; dat is bijna altijd het deel dat het verklaart. Als Apple het verzoek simpelweg weigerde, is even wachten en opnieuw proberen het eerste om te doen. Blijft het mislukken, dan maakt een melding met dit logboek het oplosbaar. + Apple heeft de aanmelding geweigerd en vraagt %1$s te wachten voor je het opnieuw probeert. Dit is een weigering van Apple, geen probleem met je Apple ID of wachtwoord. \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 80052227..83e99984 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -387,4 +387,5 @@ Вход не выполнен. В журнале ниже указано, какой шаг не удался и что вернулось, — обычно именно это всё и объясняет. Если Apple просто отклонила запрос, сначала стоит подождать и попробовать снова. Если ошибка повторяется, отчёт с этим журналом позволит её исправить. + Apple отклонила вход и просит подождать %1$s, прежде чем повторить попытку. Это отказ со стороны Apple, а не проблема с вашим Apple ID или паролем. \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 4cb4e839..2d4222bd 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -387,4 +387,5 @@ 登录未能完成。下面的日志会说明哪一步失败以及返回了什么,原因通常就在其中。 如果只是 Apple 拒绝了请求,先等一会儿再试。若持续失败,附上此日志的报告才能让问题得到修复。 + Apple 拒绝了此次登录,并要求等待 %1$s 后再试。这是 Apple 的拒绝,不是你的 Apple ID 或密码有问题。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index dc691128..c970f2ef 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -387,4 +387,5 @@ 登入未能完成。下方的日誌會說明哪一步失敗以及回傳了什麼,原因通常就在其中。 如果只是 Apple 拒絕了請求,先等一會兒再試。若持續失敗,附上此日誌的回報才能讓問題獲得修正。 + Apple 拒絕了此次登入,並要求等待 %1$s 後再試。這是 Apple 的拒絕,不是你的 Apple ID 或密碼有問題。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 51efc612..2cf1d02a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -420,4 +420,5 @@ No history was imported and nothing already stored was changed. Signing in did not get through. The log below says which step failed and what came back, which is almost always the part that explains it. If Apple simply refused the request, waiting and trying again is worth doing first. If it keeps failing, a report with this log attached is what makes it fixable. + Apple refused the sign-in and asked for %1$s before trying again. This is Apple declining, not a problem with your Apple ID or password. diff --git a/app/src/test/java/dev/wander/android/opentagviewer/util/HowLongToWaitTest.java b/app/src/test/java/dev/wander/android/opentagviewer/util/HowLongToWaitTest.java new file mode 100644 index 00000000..6c8c4c52 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/util/HowLongToWaitTest.java @@ -0,0 +1,50 @@ +package dev.wander.android.opentagviewer.util; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +/** + * Rounding Apple's {@code Retry-After} into something a person reads. + * + *

The cases are the exporter's, from {@code test_apple_declining.py}, so the phone and the + * desktop say the same thing about the same header. Pure arithmetic, so JVM - rule 13. + */ +public class HowLongToWaitTest { + + private static void assertWait(double seconds, long amount, HowLongToWait.Unit unit) { + final HowLongToWait wait = HowLongToWait.of(seconds); + assertEquals(seconds + "s", amount, wait.getAmount()); + assertEquals(seconds + "s", unit, wait.getUnit()); + } + + @Test + public void underAMinuteIsSeconds() { + assertWait(0, 0, HowLongToWait.Unit.SECONDS); + assertWait(1, 1, HowLongToWait.Unit.SECONDS); + assertWait(45, 45, HowLongToWait.Unit.SECONDS); + } + + /** Told to come back too early, a person is refused again and trusts the number less. */ + @Test + public void itRoundsUpNeverDown() { + assertWait(0.2, 1, HowLongToWait.Unit.SECONDS); + assertWait(59.2, 1, HowLongToWait.Unit.MINUTES); + assertWait(61, 2, HowLongToWait.Unit.MINUTES); + assertWait(90, 2, HowLongToWait.Unit.MINUTES); + assertWait(7201, 3, HowLongToWait.Unit.HOURS); + } + + @Test + public void minutesUntilTwoHoursThenHours() { + assertWait(60, 1, HowLongToWait.Unit.MINUTES); + assertWait(7199, 120, HowLongToWait.Unit.MINUTES); + assertWait(7200, 2, HowLongToWait.Unit.HOURS); + } + + /** A date already past arrives as 0 from Python; nothing should make it negative here. */ + @Test + public void aNegativeWaitIsNone() { + assertWait(-5, 0, HowLongToWait.Unit.SECONDS); + } +} diff --git a/app/src/test/python/conftest.py b/app/src/test/python/conftest.py index c5737097..f3f84e1f 100644 --- a/app/src/test/python/conftest.py +++ b/app/src/test/python/conftest.py @@ -8,6 +8,8 @@ import sys from pathlib import Path +import pytest + APP_PYTHON = Path(__file__).resolve().parents[2] / "main" / "python" RESOURCES = Path(__file__).resolve().parents[1] / "resources" @@ -27,3 +29,38 @@ def pytest_configure(config): config.addinivalue_line("markers", "fixture_required: needs a redacted beacon plist") + + +class FakeSigningIn: + """An account whose `login` raises, for driving `loginSync`'s failure path.""" + + def __init__(self, raising: BaseException) -> None: + from findmy.reports.state import LoginState + + self._raising = raising + self.login_state = LoginState.LOGGED_OUT + + def login(self, email: str, password: str): + raise self._raising + + +@pytest.fixture +def signingIn(monkeypatch): + """ + `loginSync`, with the Anisette and identity machinery stubbed out. + + Returns the account the attempt was made with and what `loginSync` handed Java. Shared here + because more than one module needs to ask what a particular failure looks like from Java. + """ + import main + + def attempt(raising: BaseException): + account = FakeSigningIn(raising) + monkeypatch.setattr(main, "AppleAccount", lambda *a, **k: account) + monkeypatch.setattr(main, "_anisetteProvider", lambda *a, **k: object()) + monkeypatch.setattr(main.app_identity, "identityForNewSession", lambda _: {}) + monkeypatch.setattr(main.app_identity, "deviceIdsForNewSession", lambda _: {}) + + return account, main.loginSync("someone@example.com", "hunter2", "https://ani.example") + + return attempt diff --git a/app/src/test/python/test_apple_asked_to_wait.py b/app/src/test/python/test_apple_asked_to_wait.py new file mode 100644 index 00000000..ab4a9ab1 --- /dev/null +++ b/app/src/test/python/test_apple_asked_to_wait.py @@ -0,0 +1,57 @@ +""" +Carrying Apple's own "try again in N" from a refused sign-in to the screen. + +**Absent is the case that matters most**, because it is the only one observed. No refusal from +Grand Slam has been seen with a `Retry-After` header - not the 503s of September 2026, not the +429 that followed them - so what these tests protect first is that a missing header stays +missing, all the way across, instead of becoming a zero that reads as "retry now". +""" + +from __future__ import annotations + +from findmy.errors import AppleServiceUnavailableError + +import main + + +class TestTheWaitCrossesTheBridge: + def test_a_wait_apple_named_reaches_java(self, signingIn): + _, answer = signingIn( + AppleServiceUnavailableError(429, "The Grand Slam request", retry_after=90.0), + ) + + assert answer["reason"] == main.REASON_APPLE_DECLINED + assert answer["retryAfterSeconds"] == 90.0 + + def test_no_wait_is_no_key_rather_than_zero(self, signingIn): + """Zero is a real answer, meaning "now", and Apple did not give it.""" + _, answer = signingIn(AppleServiceUnavailableError(429, "The Grand Slam request")) + + assert answer["reason"] == main.REASON_APPLE_DECLINED + assert "retryAfterSeconds" not in answer + + def test_a_wait_of_zero_is_still_carried(self, signingIn): + """A date already past arrives as 0, and is not the same as no answer.""" + _, answer = signingIn( + AppleServiceUnavailableError(503, "The Grand Slam request", retry_after=0.0), + ) + + assert answer["retryAfterSeconds"] == 0.0 + + def test_other_failures_carry_no_wait(self, signingIn): + _, answer = signingIn(TimeoutError()) + + assert "retryAfterSeconds" not in answer + + +class TestReadingTheWait: + def test_it_is_read_off_the_error(self): + error = AppleServiceUnavailableError(429, "The Grand Slam request", retry_after=45.0) + + assert main.appleAskedToWait(error) == 45.0 + + def test_it_is_none_when_apple_did_not_say(self): + assert main.appleAskedToWait(AppleServiceUnavailableError(429, "x")) is None + + def test_it_is_none_for_anything_else(self): + assert main.appleAskedToWait(RuntimeError("Retry-After: 30")) is None diff --git a/app/src/test/python/test_terms_flow.py b/app/src/test/python/test_terms_flow.py index 31220a8e..a967eb97 100644 --- a/app/src/test/python/test_terms_flow.py +++ b/app/src/test/python/test_terms_flow.py @@ -308,33 +308,6 @@ def test_a_second_fetch_forgets_the_first(self): assert not json.loads(main.acceptTerms(account, "iCloud"))["ok"] -class FakeSigningIn: - """An account whose `login` raises, for driving `loginSync`'s failure path.""" - - def __init__(self, raising: BaseException) -> None: - self._raising = raising - self.login_state = LoginState.LOGGED_OUT - - def login(self, email: str, password: str): - raise self._raising - - -@pytest.fixture -def signingIn(monkeypatch): - """`loginSync`, with the Anisette and identity machinery stubbed out.""" - - def attempt(raising: BaseException): - account = FakeSigningIn(raising) - monkeypatch.setattr(main, "AppleAccount", lambda *a, **k: account) - monkeypatch.setattr(main, "_anisetteProvider", lambda *a, **k: object()) - monkeypatch.setattr(main.app_identity, "identityForNewSession", lambda _: {}) - monkeypatch.setattr(main.app_identity, "deviceIdsForNewSession", lambda _: {}) - - return account, main.loginSync("someone@example.com", "hunter2", "https://ani.example") - - return attempt - - class TestTheAccountSurvivesLongEnoughToAgree: """ The seam the screen depends on, and the one that is easy to leave out.