From cfd10b963dd457771eae01ea8987d33b8a2513d8 Mon Sep 17 00:00:00 2001 From: "Shane B." Date: Sun, 6 Sep 2026 11:33:39 +0200 Subject: [PATCH 1/3] Stop asking for a bug report when Apple is just declining Issue #176, and it is the same 503 as #168 at two call sites nothing covered. That log is worth reading, because half of it is the previous fix working. Apple refused the 2FA submit; the exporter waited, requested a new code, and signed in - GSA authentication successful, LOGGED_IN. Then open_client called request_pet one second later, got another 503, and that one reached the wizard's catch-all: an exception type, a log path, and a link to the issue tracker. The next attempt a minute later was refused at login and did the same. So the user filed an issue, because the program asked them to, about Apple having a bad three minutes. The obstacle was that FindMy.py folded every non-OK status into UnhandledProtocolError, whose meaning is "Apple said something this library does not model" - which is a bug and is worth reporting. Nothing downstream could tell that apart from a refusal without parsing the status back out of the message. So the fork gained AppleServiceUnavailableError, carrying the status and subclassing UnhandledProtocolError so existing handlers keep working, and raises it for 429 and 5xx from both sign-in requests. FindMy.py is re-pinned in all four places per rule 14, verified by installing the new pin and running the bridge suite against it. Here, apple_is_declining walks the cause chain, because the two call sites arrive differently: log_in raises it plainly and open_client raises it several frames down, wrapped. Only the second reached the catch-all, which is why it was the one that produced the issue. The wizard catches the plain case by type and the wrapped case in _report_unexpected; the CLI catches it ahead of its UnhandledProtocolError handler. Nothing retries. The 2FA path can, because it knows what to re-do - the code is spent and a new one can be requested - and that is left alone because #176 shows it working. A refused login or request_pet has nothing to re-do but the whole sign-in, so it says what happened and stops. Twelve tests here, nineteen in the fork. All verified failable: matching by type without walking the chain turns two red, and putting the issue link back in the message turns two more. Co-Authored-By: Claude Opus 5 (1M context) --- app/build.gradle.kts | 2 +- app/src/test/python/requirements.txt | 2 +- python/exporter/cli.py | 8 +- python/exporter/icloud.py | 42 +++++++++- python/exporter/wizard.py | 52 +++++++++--- python/pyproject.toml | 2 +- python/test/test_apple_declining.py | 117 +++++++++++++++++++++++++++ python/uv.lock | 4 +- 8 files changed, 213 insertions(+), 16 deletions(-) create mode 100644 python/test/test_apple_declining.py diff --git a/app/build.gradle.kts b/app/build.gradle.kts index afd1dff8..4f0353e6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -404,7 +404,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@ddc7f2342fc9f32ebe315b85c22a4554ce419f6d") + install("git+https://github.com/parawanderer/FindMy.py@3c2b4926252193e9cd265b39fa52252adcbaad4e") install("NSKeyedUnArchiver==1.5") diff --git a/app/src/test/python/requirements.txt b/app/src/test/python/requirements.txt index a365cb9e..84952e1d 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@ddc7f2342fc9f32ebe315b85c22a4554ce419f6d +git+https://github.com/parawanderer/FindMy.py@3c2b4926252193e9cd265b39fa52252adcbaad4e NSKeyedUnArchiver==1.5 PyYAML==6.0.2 diff --git a/python/exporter/cli.py b/python/exporter/cli.py index 113f30a1..245a8ef6 100644 --- a/python/exporter/cli.py +++ b/python/exporter/cli.py @@ -32,7 +32,7 @@ from typing import Sequence from findmy import InvalidCredentialsError, LoginState, MobileMeDelegateError, TermsError -from findmy.errors import UnhandledProtocolError +from findmy.errors import AppleServiceUnavailableError, UnhandledProtocolError from findmy.keychain.recovery import RecoveryError from exporter import icloud, localsource, prompts, secrets, source, terms @@ -949,6 +949,12 @@ def _run_and_return(arguments: argparse.Namespace) -> int: ) as e: print(f"\n{e}", file=sys.stderr) return 1 + except AppleServiceUnavailableError as e: + # **Before the UnhandledProtocolError handler below**, which asks for a bug report and a + # -vv rerun. A 503 is Apple declining rather than a response this program cannot read; + # issue #176 is somebody filing one because nothing said otherwise. + print(f"\n{icloud.describe_apple_declining(e)}", file=sys.stderr) + return 1 except RecoveryError as e: # **Before the handler below, and deliberately not through it.** RecoveryError is an # UnhandledProtocolError by inheritance, but it is not an unmodelled shape: it is a diff --git a/python/exporter/icloud.py b/python/exporter/icloud.py index b232d69d..ef39f560 100644 --- a/python/exporter/icloud.py +++ b/python/exporter/icloud.py @@ -36,7 +36,7 @@ SmsSecondFactorMethod, TrustedDeviceSecondFactorMethod, ) -from findmy.errors import UnhandledProtocolError +from findmy.errors import AppleServiceUnavailableError, UnhandledProtocolError from findmy.accessory import _extract_serial_from_stable_id # noqa: PLC2701 - see _candidate from findmy.cloudkit.beacons import ( AsyncBeaconStore, @@ -562,6 +562,46 @@ async def _wait_then_send_a_new_code(chosen, seconds: int, announce=None) -> Non await chosen.request() +def apple_is_declining(error: BaseException) -> AppleServiceUnavailableError | None: + """ + The `AppleServiceUnavailableError` in this failure, if there is one. + + **A 503 from Apple is not a bug in this program, and until FindMy.py could say so there was + no way to tell.** Every non-OK status arrived as `UnhandledProtocolError`, whose meaning is + "Apple said something this library does not model" - so the wizard's catch-all offered the + issue tracker, and people took it up. Issue #176 is one account meeting the same 503 at three + different call sites in three minutes, and reporting it. + + Searches the cause chain because a failure from inside `open_client` arrives wrapped. + + Only the 2FA path recovers on its own, in :func:`_wait_then_send_a_new_code`, because that is + the one place the program knows what to retry: the code has been spent and a new one can be + requested. There is no equivalent for a refused `login` or `request_pet` - the caller has + nothing to re-do but the whole sign-in - so those say what happened and stop. + """ + seen = set() + cause: BaseException | None = error + while cause is not None and id(cause) not in seen: + if isinstance(cause, AppleServiceUnavailableError): + return cause + seen.add(id(cause)) + cause = cause.__cause__ or cause.__context__ + + return None + + +def describe_apple_declining(error: AppleServiceUnavailableError) -> str: + """What to tell somebody whose sign-in was refused by Apple rather than by their password.""" + return ( + f"Apple's sign-in service refused the request with HTTP {error.status_code}.\n\n" + "This is a fault on Apple's side rather than anything you did. Your Apple ID, your" + " password and your verification code are all fine, and nothing was changed or sent." + " It usually clears on its own within a few minutes.\n\n" + "Wait a little and sign in again. If it is still refusing after ten minutes or so, it" + " is worth reporting - but a run of these over a few minutes is ordinary." + ) + + 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/exporter/wizard.py b/python/exporter/wizard.py index 7341a373..f02e1117 100644 --- a/python/exporter/wizard.py +++ b/python/exporter/wizard.py @@ -54,6 +54,7 @@ MobileMeDelegateError, TermsError, ) +from findmy.errors import AppleServiceUnavailableError from findmy.keychain.recovery import RecoveryError from exporter.icloud import Candidate, ExportSourceError @@ -395,18 +396,22 @@ def _load(self) -> None: f"{e}\n\nNothing was changed. You can try again whenever you like.", ) return - except Exception as e: # noqa: BLE001 - anything else is still the user's problem to see - logger.exception("Reading accessories failed") + except AppleServiceUnavailableError as e: + # **Before the catch-all below, which offers the issue tracker.** A 503 from Apple is + # not a bug in this program, and somebody took that offer up - issue #176, one account + # meeting the same 503 at three call sites in three minutes. This catches the failures + # raised directly by `log_in`; the ones raised deeper arrive wrapped and are handled + # in `_report_unexpected`. + logger.info("Apple declined the request: %s", e) messagebox.showerror( - "Could not read your accessories", - # The type, not just the message: "[Errno 2] No such file or directory" with - # nothing else is a real message this produced, and it says nothing about what - # was being opened or by whom. - f"{type(e).__name__}: {e}\n\n" - f"The details are in:\n{log_file()}\n\n" - f"If this looks like a bug, please report it with that file:\n{GITHUB_ISSUES_LINK}", + "Apple is not accepting sign-ins right now", + icloud.describe_apple_declining(e), ) return + except Exception as e: # noqa: BLE001 - anything else is still the user's problem to see + logger.exception("Reading accessories failed") + self._report_unexpected(e) + return self.candidates = fetched.candidates self.undecryptable = fetched.undecryptable @@ -415,6 +420,35 @@ def _load(self) -> None: self.read_button.configure(state="disabled") self._show(fetched.skipped) + def _report_unexpected(self, error: BaseException) -> None: + """ + Say what went wrong when nothing above recognised it. + + **The 503 check here is not redundant with the handler in `_load`.** One raised directly + by `log_in` is caught there by type. One raised inside `open_client` is several frames + down and arrives wrapped, so it reaches the catch-all instead - which is how issue #176's + `request_pet` failure came to offer the issue tracker. + + Its own method because `_load` was already at flake8's complexity limit. + """ + declining = icloud.apple_is_declining(error) + if declining is not None: + messagebox.showerror( + "Apple is not accepting sign-ins right now", + icloud.describe_apple_declining(declining), + ) + return + + messagebox.showerror( + "Could not read your accessories", + # The type, not just the message: "[Errno 2] No such file or directory" with + # nothing else is a real message this produced, and it says nothing about what + # was being opened or by whom. + f"{type(error).__name__}: {error}\n\n" + f"The details are in:\n{log_file()}\n\n" + f"If this looks like a bug, please report it with that file:\n{GITHUB_ISSUES_LINK}", + ) + async def _read_local(self, _asker: Asker): """The local route needs nothing from the user that macOS does not ask for itself.""" return localsource.fetch(localsource.read_key()) diff --git a/python/pyproject.toml b/python/pyproject.toml index f01ddc0f..a80d929b 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 = "ddc7f2342fc9f32ebe315b85c22a4554ce419f6d" } +FindMy = { git = "https://github.com/parawanderer/FindMy.py", rev = "3c2b4926252193e9cd265b39fa52252adcbaad4e" } [dependency-groups] # Only the release build installs this, with `uv sync --no-default-groups --group build`. It is diff --git a/python/test/test_apple_declining.py b/python/test/test_apple_declining.py new file mode 100644 index 00000000..10222c22 --- /dev/null +++ b/python/test/test_apple_declining.py @@ -0,0 +1,117 @@ +""" +A 503 from Apple is not a bug report. + +Issue #176. One account met the same 503 three times in three minutes, at `td_2fa_submit`, at +`request_pet` a second later, and at `login` a minute after that. Only the first had any handling, +so the other two reached the wizard's catch-all - the one that names an exception type and links +the issue tracker - and the user filed an issue, because the program asked them to. + +The 2FA path recovers on its own and that half works: the same log shows it waiting, requesting a +new code, and signing in. This is about the other two, where there is nothing to retry but the +whole sign-in. +""" + +from __future__ import annotations + +import pytest +from findmy.errors import AppleServiceUnavailableError, UnhandledProtocolError + +from exporter import icloud + + +def a_503() -> AppleServiceUnavailableError: + return AppleServiceUnavailableError(503, "The Grand Slam request") + + +class TestFindingItInWhateverWrappedIt: + def test_itisFoundWhenRaisedDirectly(self): + """What `log_in` does: the error arrives as itself.""" + error = a_503() + + assert icloud.apple_is_declining(error) is error + + def test_itisFoundThroughACauseChain(self): + """ + What `open_client` does. + + `request_pet` fails several frames down and the failure is re-raised wrapped, which is + exactly the path that reached the bug-report dialog in #176. + """ + declining = a_503() + wrapped = RuntimeError("could not open the client") + wrapped.__cause__ = declining + + assert icloud.apple_is_declining(wrapped) is declining + + def test_itisFoundThroughImplicitChaining(self): + """`raise X` inside an `except` sets __context__ rather than __cause__.""" + declining = a_503() + try: + try: + raise declining + except AppleServiceUnavailableError: + raise RuntimeError("while handling") # noqa: B904, TRY200 + except RuntimeError as raised: + assert icloud.apple_is_declining(raised) is declining + + def test_anordinaryProtocolErrorIsNotThis(self): + """ + The distinction the whole change turns on. + + `UnhandledProtocolError` still means "report this". Only the subclass means "wait". + """ + assert icloud.apple_is_declining( + UnhandledProtocolError("Error response for GSA request: 418")) is None + + def test_nothingAtAllIsNotThis(self): + assert icloud.apple_is_declining(ValueError("unrelated")) is None + + def test_acycleDoesNotHangIt(self): + """ + A self-referencing chain must terminate. + + Contrived, but this walks `__cause__` and `__context__` on exceptions this program did + not construct, and an infinite loop here would hang a wizard rather than fail it. + """ + first = RuntimeError("a") + second = RuntimeError("b") + first.__cause__ = second + second.__cause__ = first + + assert icloud.apple_is_declining(first) is None + + +class TestWhatTheUserIsTold: + @pytest.fixture + def message(self) -> str: + return icloud.describe_apple_declining(a_503()) + + def test_itcarriesTheStatus(self, message): + """503 is what makes a report answerable if this ever turns out not to be weather.""" + assert "503" in message + + def test_itsaysTheFaultIsApples(self, message): + assert "Apple's side" in message + assert "rather than anything you did" in message + + def test_itclearsThePasswordAndTheCode(self, message): + """Otherwise the next thing tried is a password reset, which cannot help.""" + assert "password" in message + assert "verification code" in message + + def test_itsaysNothingWasChanged(self, message): + """The first question after a failed sign-in to your own Apple account.""" + assert "nothing was changed" in message.lower() + + def test_itsaysWhenReportingWouldBeReasonable(self, message): + """ + Not "never report this". + + A 503 that persists for an hour is worth hearing about; the point is that a run of them + over a few minutes is not. + """ + assert "worth reporting" in message + + def test_itdoesNotAskForABugReportOutright(self, message): + """The behaviour being fixed.""" + assert "github.com" not in message.lower() diff --git a/python/uv.lock b/python/uv.lock index 241917a1..c03d1477 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=ddc7f2342fc9f32ebe315b85c22a4554ce419f6d#ddc7f2342fc9f32ebe315b85c22a4554ce419f6d" } +source = { git = "https://github.com/parawanderer/FindMy.py?rev=3c2b4926252193e9cd265b39fa52252adcbaad4e#3c2b4926252193e9cd265b39fa52252adcbaad4e" } dependencies = [ { name = "aiohttp" }, { name = "anisette" }, @@ -1032,7 +1032,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "findmy", git = "https://github.com/parawanderer/FindMy.py?rev=ddc7f2342fc9f32ebe315b85c22a4554ce419f6d" }, + { name = "findmy", git = "https://github.com/parawanderer/FindMy.py?rev=3c2b4926252193e9cd265b39fa52252adcbaad4e" }, { name = "pycryptodome", specifier = "==3.22.0" }, { name = "pyyaml", specifier = "==6.0.2" }, { name = "pyzipper", specifier = "==0.4.0" }, From 95f376715932e92baa5fa3adf2d46ff4e7959ec6 Mon Sep 17 00:00:00 2001 From: "Shane B." Date: Sun, 6 Sep 2026 12:02:42 +0200 Subject: [PATCH 2/3] Tell the app that Apple declining is not a rejected sign-in The exporter half of this landed with the pin bump; the app reaches the same library and had nothing for the new type. It also had a regression waiting, which is the part worth reading first. **ACodeAppleAlreadyTook.spentIt matched the string "UnhandledProtocolError" in the bridged message.** Its own docstring says there is nothing better to match on "until the fork grows a transient-failure type". The fork now has one, and it is a subclass - so a real 503 arrives as AppleServiceUnavailableError, the match fails, and the 2FA wait-and-retry silently stops firing. Every test in that file writes the old name into its own fixture, so all eight checks stayed green over it. A test that supplies the string it is looking for cannot notice the string changing. It now matches both names, and three tests cover the one that is real. Beyond that, a 503 outside the 2FA path had no classification at all: - classifyLoginFailure sent it to REASON_UNKNOWN, so the login screen echoed "The Grand Slam request was refused with HTTP 503" verbatim. Correct, unreadable, and indistinguishable from a bug here, which is how issue #176 came to be filed. - icloud_bridge._unexpected did the same, so the fetch screen fell to its default branch and put the same text on screen. Both now report apple_declined, which the Java side maps to ICloudFailure.APPLE_DECLINED and PythonAccountLoginException's matching reason. Deliberately not CREDENTIALS_REJECTED: that ends in a forced sign-out, and signing somebody out over a fault that clears in minutes is rule 15's mistake in the direction that costs a working session. Also deliberately not REASON_NETWORK, because the advice differs - a network failure is usually the phone's and worth checking, and this one is not. The login screen gets a sentence saying it is Apple's fault and the password is fine. The fetch screen reaches the retry container it already had, with wording that says which of the two failures it is rather than the raw detail. Fourteen tests. Three JVM for the regression, five JVM for the mapping and the sign-out predicate, six Python across both classifiers, and two Espresso - one per screen - asserting the sentence is shown and that the status code and "Grand Slam" are not. Three strings in ten locales via add_strings.py. Not verified locally: no Android SDK here, so the JVM and emulator suites are CI's to run. The Python bridge suite passes at 239. Co-Authored-By: Claude Opus 5 (1M context) --- .../opentagviewer/AppleLoginFlowTest.java | 33 +++++++++ .../FetchFromICloudFlowTest.java | 29 ++++++++ .../python/FakeAppleAuthService.java | 18 +++++ .../python/icloud/FakeICloudService.java | 16 +++++ .../opentagviewer/AppleLoginActivity.java | 7 ++ .../FetchFromICloudActivity.java | 10 +++ .../python/PythonAccountLoginException.java | 8 +++ .../python/icloud/ICloudFailure.java | 16 +++++ .../util/rx/ACodeAppleAlreadyTook.java | 20 ++++-- app/src/main/python/icloud_bridge.py | 21 +++++- app/src/main/python/main.py | 23 +++++++ app/src/main/res/values-de/strings.xml | 3 + app/src/main/res/values-en/strings.xml | 3 + app/src/main/res/values-fr/strings.xml | 3 + app/src/main/res/values-ja/strings.xml | 3 + app/src/main/res/values-ko/strings.xml | 3 + app/src/main/res/values-nl/strings.xml | 3 + app/src/main/res/values-ru/strings.xml | 3 + app/src/main/res/values-zh-rCN/strings.xml | 3 + app/src/main/res/values-zh-rTW/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + .../AppleDecliningIsItsOwnFailureTest.java | 69 +++++++++++++++++++ .../util/rx/ACodeAppleAlreadyTookTest.java | 40 +++++++++++ app/src/test/python/test_icloud_bridge.py | 43 ++++++++++++ app/src/test/python/test_main.py | 38 ++++++++++ 25 files changed, 415 insertions(+), 6 deletions(-) create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/python/icloud/AppleDecliningIsItsOwnFailureTest.java 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 2bf086f7..f56a3231 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/AppleLoginFlowTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/AppleLoginFlowTest.java @@ -13,6 +13,7 @@ import static androidx.test.espresso.matcher.ViewMatchers.withText; import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; @@ -309,6 +310,38 @@ public void anunrecognisedFailureStillShowsItsDetail() { .check(matches(withText(containsString("Bad password"))))); } + /** + * Apple declining is told apart from a wrong password and from a dead network. + * + *

Issue #176. The screen echoed the failure verbatim, so somebody entering correct + * credentials during a Grand Slam outage was shown a sentence about HTTP 503 and an Apple + * internal service name. It reads as a bug in this app, and was filed as one. + * + *

Asserted on all three halves, because each is a different wrong turn: that it does not + * blame the password, that it does not send them to check a working connection, and that the + * raw protocol text is gone from the screen. + */ + @Test + public void appleDecliningIsNotBlamedOnThePasswordOrTheNetwork() { + this.apple = FakeAppleAuthService.appleIsDeclining(); + AppDependencies.replaceAuthService(this.apple); + + launch(); + signIn(); + + final android.content.Context context = getInstrumentation().getTargetContext(); + + Eventually.check(() -> onView(withId(R.id.login_error_message_text)).check(matches( + withText(context.getString(R.string.login_failed_apple_declined))))); + + onView(withId(R.id.login_error_message_text)).check(matches( + not(withText(context.getString(R.string.login_failed_network))))); + onView(withId(R.id.login_error_message_text)).check(matches( + not(withText(containsString("503"))))); + onView(withId(R.id.login_error_message_text)).check(matches( + not(withText(containsString("Grand Slam"))))); + } + /** A rejected code says so, and gives the boxes back rather than stranding them. */ @Test public void aWrongCodeIsReportedAndTheBoxesComeBack() { diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudFlowTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudFlowTest.java index 48c1f945..c45bcbc8 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudFlowTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudFlowTest.java @@ -331,6 +331,35 @@ public void aserviceHavingABadDayOffersARetryInstead() { TestPace.afterAStep(); } + /** + * Apple declining reaches the retry screen, saying which of the two it is. + * + *

Issue #176. Before this it fell through to the default branch, which puts the + * failure's own detail on screen - an HTTP status and Apple's internal service name. Correct, + * unreadable, and indistinguishable from a bug in this app. + * + *

Asserted on the raw text being absent as well as the sentence being present, because a + * branch that showed both would pass a check for only the second. + */ + @Test + public void appleDecliningSaysSoRatherThanShowingTheStatusCode() { + this.open(FakeICloudService.whereAppleIsDeclining()); + + final android.content.Context context = + androidx.test.platform.app.InstrumentationRegistry + .getInstrumentation().getTargetContext(); + + Eventually.check(() -> onView(withId(R.id.icloud_retry_container)) + .check(matches(isDisplayed()))); + Eventually.check(() -> onView(withId(R.id.icloud_retry_body)).check(matches( + withText(context.getString(R.string.icloud_apple_declined_body))))); + + onView(withId(R.id.icloud_retry_body)).check(matches( + not(withText(org.hamcrest.Matchers.containsString("503"))))); + onView(withId(R.id.icloud_no_tags_container)).check(matches(not(isDisplayed()))); + TestPace.afterAStep(); + } + /** Retrying starts a fresh session rather than reusing the one that failed. */ @Test public void retryingAsksAgain() { 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 7d54e723..26bd37e7 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 @@ -118,6 +118,24 @@ public static FakeAppleAuthService cannotReachApple() { return fake; } + /** + * Apple answered and refused to serve, which is neither a wrong password nor a dead network. + * + *

Its own state because the message is the giveaway: the real one is a sentence about the + * Grand Slam request and an HTTP status, which the screen used to echo verbatim. That reads + * as a bug in this app, and issue #176 is somebody reporting it as one. + */ + public static FakeAppleAuthService appleIsDeclining() { + final FakeAppleAuthService fake = + 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.", + PythonAccountLoginException.REASON_APPLE_DECLINED); + 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/androidTest/java/dev/wander/android/opentagviewer/python/icloud/FakeICloudService.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/FakeICloudService.java index df64c2d9..f9e0196d 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/FakeICloudService.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/python/icloud/FakeICloudService.java @@ -139,6 +139,22 @@ public static FakeICloudService whereTheServiceIsUnsure() { return fake; } + /** + * Apple answered and refused to serve, rather than reporting nothing usable. + * + *

Kept apart from {@link #whereTheServiceIsUnsure()} because both reach the retry screen + * and they are not the same fact. That one is the keychain service having a bad day; this is + * an endpoint returning a 5xx, and it was reaching the screen as UNKNOWN with the HTTP + * status on it - issue #176. + */ + public static FakeICloudService whereAppleIsDeclining() { + final FakeICloudService fake = new FakeICloudService(); + fake.optionsFailsWith = new ICloudException( + ICloudFailure.APPLE_DECLINED, + "The Grand Slam request was refused with HTTP 503."); + return fake; + } + /** An account with a Mac on it and no tags - the empty fetch, one step later. */ public static FakeICloudService withNoTagsOnTheAccount() { final FakeICloudService fake = new FakeICloudService(); 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 b02f4a69..49b09958 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java @@ -729,6 +729,13 @@ private String describeLoginFailure(final Throwable error) { return this.getString(R.string.login_failed_network); } + // **Before the fallback, which would show "The Grand Slam request was refused with HTTP + // 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)) { + return this.getString(R.string.login_failed_apple_declined); + } + // Reached when the terms path was tried and produced nothing to accept, so the sentence // says what Apple said and then that accepting terms will not fix something else - // rather than asserting a cause that has not been established. diff --git a/app/src/main/java/dev/wander/android/opentagviewer/FetchFromICloudActivity.java b/app/src/main/java/dev/wander/android/opentagviewer/FetchFromICloudActivity.java index bf192976..181c7758 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/FetchFromICloudActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/FetchFromICloudActivity.java @@ -694,6 +694,16 @@ private void showFailure(final Throwable error) { SignInAgain.from(this); break; + case APPLE_DECLINED: + // The retry screen, like SERVICE_UNSURE, but saying which of the two it is. + // Falling through to `default` would put the raw detail on screen - an HTTP + // status and a sentence about Grand Slam - which reads as a bug here. + this.showOnly(R.id.icloud_retry_container, + R.string.icloud_apple_declined_title, Direction.FORWARD); + ((TextView) this.findViewById(R.id.icloud_retry_body)) + .setText(R.string.icloud_apple_declined_body); + break; + case SERVICE_UNSURE: default: // Everything unrecognised lands here on purpose: "try again later" is the safe 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 016d78bb..fd2676c2 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 @@ -34,6 +34,14 @@ public class PythonAccountLoginException extends RuntimeException { public static final String REASON_TERMS = "terms"; /** Anything not recognised. The detail is shown as-is rather than guessed at. */ + /** + * Apple answered and refused to serve. Matches {@code REASON_APPLE_DECLINED}. + * + *

Kept apart from {@link #REASON_NETWORK} because the advice differs: a network failure is + * usually the phone's and worth checking, and this one is Apple's and is not. + */ + public static final String REASON_APPLE_DECLINED = "apple_declined"; + public static final String REASON_UNKNOWN = "unknown"; private final String reason; diff --git a/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudFailure.java b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudFailure.java index 3c3154f0..c9c56705 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudFailure.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/python/icloud/ICloudFailure.java @@ -100,6 +100,21 @@ public enum ICloudFailure { */ NOT_AN_ACCESSORY, + /** + * Apple answered and refused to serve. Worth waiting out. + * + *

Not {@link #CREDENTIALS_REJECTED} and not {@link #UNKNOWN}, and the difference is + * expensive in both directions. Treated as rejected credentials it signs somebody out + * over a 503, destroying a working session for nothing. Left unknown it reaches a screen + * showing an HTTP status and a sentence about Grand Slam, which reads as a bug in this app - + * see issue #176, where that is what somebody reported. + * + *

Closest to {@link #SERVICE_UNSURE}, which also means "try later", and kept apart + * because that one is about the keychain service reporting nothing usable rather than about + * an endpoint declining. + */ + APPLE_DECLINED, + /** Anything else. The detail carries what there is to say. */ UNKNOWN; @@ -123,6 +138,7 @@ public static ICloudFailure fromWire(final String reason) { case "no_such_record": return NO_SUCH_RECORD; case "not_unlocked": return NOT_UNLOCKED; case "credentials_rejected": return CREDENTIALS_REJECTED; + case "apple_declined": return APPLE_DECLINED; case "membership_unusable": return MEMBERSHIP_UNUSABLE; case "no_such_accessory": return NO_SUCH_ACCESSORY; case "not_an_accessory": return NOT_AN_ACCESSORY; diff --git a/app/src/main/java/dev/wander/android/opentagviewer/util/rx/ACodeAppleAlreadyTook.java b/app/src/main/java/dev/wander/android/opentagviewer/util/rx/ACodeAppleAlreadyTook.java index aeaccd0f..40cfab36 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/util/rx/ACodeAppleAlreadyTook.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/rx/ACodeAppleAlreadyTook.java @@ -61,15 +61,25 @@ private ACodeAppleAlreadyTook() { * code, which is inconvenient and correct. * *

Matched on the name because that is what survives the bridge: the failure arrives from - * Chaquopy as a {@code PyException} whose message carries the Python class. FindMy.py folds - * every non-OK status into {@code UnhandledProtocolError} with only the number in it, so - * there is nothing better to match on until the fork grows a transient-failure type - see the - * handover note, which explains why that was left out of the first fix. + * Chaquopy as a {@code PyException} whose message carries the Python class. + * + *

Two names, and the second one is why this had to change. FindMy.py used to fold + * every non-OK status into {@code UnhandledProtocolError}; the fork now raises + * {@code AppleServiceUnavailableError} for a 429 or a 5xx, which is a subclass and therefore + * invisible to a match on the parent's name. Matching only the old name would have made this + * quietly stop firing the moment the pin moved - the recovery would still be here, still + * tested, and never reached, because every test in this file writes the old name into its + * own fixture. + * + *

The old name is kept alongside it. It still covers a protocol failure after the submit + * that is not a 5xx, which is the wide net the paragraph above is about. */ public static boolean spentIt(final Throwable error) { for (Throwable cause = error; cause != null; cause = cause.getCause()) { final String message = cause.getMessage(); - if (message != null && message.contains("UnhandledProtocolError")) { + if (message != null + && (message.contains("UnhandledProtocolError") + || message.contains("AppleServiceUnavailableError"))) { return true; } if (cause.getCause() == cause) { diff --git a/app/src/main/python/icloud_bridge.py b/app/src/main/python/icloud_bridge.py index 88b0de37..cbee16d9 100644 --- a/app/src/main/python/icloud_bridge.py +++ b/app/src/main/python/icloud_bridge.py @@ -43,7 +43,7 @@ import identity as app_identity from exporter import icloud -from findmy.errors import InvalidCredentialsError +from findmy.errors import AppleServiceUnavailableError, InvalidCredentialsError from findmy.keychain.enrolment import DeviceDescription from findmy.keychain.join import JoinedPeer from findmy.keychain.recovery import RecoveryError @@ -125,6 +125,20 @@ naming record as its single source of truth. """ +REASON_APPLE_DECLINED = "apple_declined" +""" +Apple answered and refused to serve. Worth waiting out, and not the user's fault. + +**Separate from :data:`REASON_UNKNOWN` because the screen written for unknown says the wrong +thing.** Unknown shows whatever detail there is, which here is a sentence about Grand Slam and an +HTTP status - accurate, unreadable, and indistinguishable from a bug in this app. See +OpenTagViewer#176, where somebody filed exactly that. + +Separate from :data:`REASON_CREDENTIALS_REJECTED` for the opposite reason: that one is permanent +and ends in a forced sign-out. Signing somebody out over a 503 destroys a working session for +nothing, which is rule 15's failure mode in the expensive direction. +""" + REASON_UNKNOWN = "unknown" """Anything else, with the exception text carried through so a report can be answered.""" @@ -176,6 +190,11 @@ def _unexpected(what: str) -> str: if _needsAFreshSignIn(sys.exc_info()[1]): return _failure(REASON_CREDENTIALS_REJECTED, _lastLineOf(detail) or what + " was refused") + # After the credentials check and before the fallback. Apple declining is neither a dead + # session nor a mystery, and it is the one failure here that is worth simply waiting out. + if isinstance(sys.exc_info()[1], AppleServiceUnavailableError): + return _failure(REASON_APPLE_DECLINED, _lastLineOf(detail) or what + " was declined") + # `str(e)` is empty for several of these - TimeoutError most of all - so the last line of # the traceback stands in. It names the exception type, which is not a good message but is # infinitely better than a colon with nothing after it. diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py index 9ff035ce..302a95ee 100644 --- a/app/src/main/python/main.py +++ b/app/src/main/python/main.py @@ -9,6 +9,7 @@ import NSKeyedUnArchiver from findmy import FindMyAccessory, MobileMeDelegateError +from findmy.errors import AppleServiceUnavailableError from findmy.accessory import FixedRollingKeyPairAccessory from findmy.reports import ( RemoteAnisetteProvider, @@ -456,6 +457,21 @@ def assertAnisetteIsSupported(serializedAccountData: str) -> str | None: screen that keeps refusing them for a reason nothing tells them. """ +REASON_APPLE_DECLINED = "apple_declined" +""" +Apple answered, and refused to serve the request. Not the password, and not the network. + +**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. + +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 +end is wasted effort. +""" + REASON_UNKNOWN = "unknown" """Anything else. The detail is shown as-is, because a wrong guess is worse than raw text.""" @@ -482,6 +498,13 @@ def classifyLoginFailure(error: BaseException) -> str: if isinstance(error, MobileMeDelegateError): return REASON_TERMS + # Before the network checks, and not because of ordering hazards - it is a RuntimeError and + # collides with none of them. It is here because it reads as the same thing to a user and is + # not: Apple answered. Telling somebody to check their connection when their connection is + # fine sends them to reset a router over a 503. + if isinstance(error, AppleServiceUnavailableError): + return REASON_APPLE_DECLINED + if isinstance(error, (asyncio.TimeoutError, asyncio.CancelledError)): return REASON_NETWORK if isinstance(error, _NETWORK_ERRORS): diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index eb846b7f..55f82a92 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -313,4 +313,7 @@ Du kannst das jetzt einrichten oder jederzeit später in den Einstellungen.Apple hat deinen Code angenommen und danach die Anmeldung nicht abschließen können. Der Code ist damit verbraucht, es wird also ein neuer gebraucht – wir warten kurz, bevor wir ihn anfordern, denn sofortiges Anfordern wird abgelehnt. Neuer Code wird in %1$d s bei Apple angefordert … Apple schließt die Anmeldung weiterhin nicht ab. Das liegt an Apple, nicht an dir und nicht an deinem Code.\n\nVersuche es in ein paar Minuten erneut. Dein Passwort wird dabei möglicherweise einmal abgelehnt – das gehört zum selben Fehler, gib es also einfach noch einmal ein, statt es für falsch zu halten. + Apple nimmt derzeit keine Anmeldungen an. Das liegt an Apple und nicht an deiner Apple-ID oder deinem Passwort. Es klärt sich meist innerhalb weniger Minuten – warte kurz und versuche es erneut. + Apple antwortet gerade nicht + Apple hat die Anfrage abgelehnt, statt gar nicht zu antworten. Mit deinem Account ist alles in Ordnung, und es wurde nichts geändert. Das klärt sich meist innerhalb weniger Minuten. \ 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 7672e7b4..c1941c2a 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -313,4 +313,7 @@ You can set this up now, or any time later from Settings. Apple accepted your code and then had a problem finishing the sign-in. The code is used up, so a new one is needed — waiting a moment before asking for it, because asking straight away is refused. Asking Apple for a new code in %1$d s… Apple is still not finishing the sign-in. This is a fault on Apple\'s side, not something you did, and not your code.\n\nTry again in a few minutes. Your password may be refused once when you do — that is part of the same fault, so enter it again rather than assuming it is wrong. + Apple is not accepting sign-ins at the moment. This is a problem on Apple\'s side, not with your Apple ID or password. It usually clears within a few minutes, so wait a little and try again. + Apple is not answering right now + Apple refused the request rather than failing to answer it. Nothing is wrong with your account, and nothing was changed. This usually clears within a few minutes. \ 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 c09e863a..86a07ffa 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -313,4 +313,7 @@ Vous pouvez configurer cela maintenant, ou à tout moment depuis les réglages.< Apple a accepté votre code puis n’a pas pu terminer la connexion. Le code est donc utilisé et il en faut un nouveau — nous patientons un instant avant de le demander, car une demande immédiate est refusée. Nouveau code demandé à Apple dans %1$d s… Apple ne termine toujours pas la connexion. C’est une panne du côté d’Apple, pas quelque chose que vous avez fait, ni votre code.\n\nRéessayez dans quelques minutes. Votre mot de passe pourra être refusé une fois à ce moment-là : cela fait partie de la même panne, saisissez-le à nouveau plutôt que de le croire erroné. + Apple n\'accepte pas les connexions pour le moment. Le problème vient d\'Apple, pas de votre identifiant ou de votre mot de passe. Cela se règle généralement en quelques minutes : patientez un peu et réessayez. + Apple ne répond pas pour le moment + Apple a refusé la requête plutôt que de ne pas y répondre. Votre compte n\'a aucun problème et rien n\'a été modifié. Cela se règle généralement en quelques minutes. \ 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 652a6804..6c477e5a 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -313,4 +313,7 @@ Apple はコードを受け付けたあと、サインインを完了できませんでした。コードは使用済みなので新しいものが必要です。すぐに要求しても拒否されるため、少し待ってから要求します。 %1$d 秒後に Apple へ新しいコードを要求します… Apple はまだサインインを完了できていません。これは Apple 側の障害であり、あなたの操作やコードのせいではありません。\n\n数分後にもう一度お試しください。そのときパスワードが一度だけ拒否されることがありますが、これも同じ障害の一部です。間違っていると考えず、もう一度入力してください。 + 現在、Appleがサインインを受け付けていません。Apple ID やパスワードではなく、Apple 側の問題です。通常は数分で解消しますので、少し待ってからもう一度お試しください。 + 現在 Apple が応答していません + 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 ca9b23ba..0e10e85b 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -313,4 +313,7 @@ Apple이 코드를 받은 뒤 로그인을 끝내지 못했습니다. 코드는 이미 사용되었으므로 새 코드가 필요합니다. 바로 요청하면 거부되기 때문에 잠시 기다린 뒤 요청합니다. %1$d초 후에 Apple에 새 코드를 요청합니다… Apple이 아직 로그인을 마치지 못하고 있습니다. 이는 Apple 쪽 장애이며, 사용자의 잘못도 코드 문제도 아닙니다.\n\n몇 분 뒤에 다시 시도하세요. 그때 비밀번호가 한 번 거부될 수 있는데, 이것도 같은 장애의 일부이므로 틀렸다고 생각하지 말고 다시 입력하세요. + 현재 Apple이 로그인을 처리하지 않고 있습니다. Apple ID나 비밀번호가 아니라 Apple 쪽 문제입니다. 보통 몇 분이면 해결되니 잠시 기다렸다가 다시 시도하세요. + 지금은 Apple이 응답하지 않습니다 + 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 3139a056..e32d9e4a 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -313,4 +313,7 @@ Je kunt dit nu instellen, of later altijd nog via Instellingen. Apple heeft je code geaccepteerd en kon daarna het inloggen niet afronden. De code is dus opgebruikt en er is een nieuwe nodig — we wachten even voordat we die aanvragen, want meteen aanvragen wordt geweigerd. Over %1$d s wordt een nieuwe code bij Apple aangevraagd… Apple rondt het inloggen nog steeds niet af. Dit ligt aan Apple, niet aan jou en niet aan je code.\n\nProbeer het over een paar minuten opnieuw. Je wachtwoord kan dan één keer worden geweigerd — dat hoort bij dezelfde storing, dus voer het gewoon nog een keer in in plaats van aan te nemen dat het fout is. + Apple accepteert op dit moment geen aanmeldingen. Dit ligt aan Apple, niet aan je Apple ID of wachtwoord. Meestal is het binnen enkele minuten voorbij; wacht even en probeer het opnieuw. + Apple reageert momenteel niet + Apple heeft het verzoek geweigerd in plaats van er niet op te antwoorden. Er is niets mis met je account en er is niets gewijzigd. Dit is meestal binnen enkele minuten voorbij. \ 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 6b0fd930..b4fee225 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -313,4 +313,7 @@ Apple приняла ваш код, а затем не смогла завершить вход. Код уже использован, поэтому нужен новый — подождём немного перед запросом, потому что сразу запрашивать бесполезно. Запросим новый код у Apple через %1$d с… Apple по-прежнему не завершает вход. Это сбой на стороне Apple — не ваша вина и не проблема кода.\n\nПопробуйте снова через несколько минут. Пароль при этом может быть отклонён один раз: это часть того же сбоя, поэтому введите его ещё раз, а не считайте неверным. + Apple сейчас не принимает входы. Проблема на стороне Apple, а не в вашем Apple ID или пароле. Обычно это проходит за несколько минут — подождите немного и попробуйте снова. + Apple сейчас не отвечает + Apple отклонила запрос, а не осталась без ответа. С вашей учётной записью всё в порядке, и ничего не изменилось. Обычно это проходит за несколько минут. \ 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 1a85cf5e..f4ba5f54 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -313,4 +313,7 @@ Apple 已接受你的验证码,随后未能完成登录。该验证码已被用掉,需要一个新的——我们会先等一会儿再申请,因为立刻申请会被拒绝。 将在 %1$d 秒后向 Apple 申请新验证码… Apple 仍未完成登录。这是 Apple 一侧的故障,不是你的操作问题,也不是验证码的问题。\n\n请过几分钟再试。届时你的密码可能会被拒绝一次——这属于同一个故障,请再输入一次,而不要以为密码错了。 + Apple 目前不接受登录。这是 Apple 一侧的问题,与你的 Apple ID 或密码无关。通常几分钟内就会恢复,请稍候再试。 + Apple 目前没有响应 + Apple 拒绝了该请求,而不是没有响应。你的账户没有问题,也没有任何更改。通常几分钟内即可恢复。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index a0792b1d..b1ff1c30 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -313,4 +313,7 @@ Apple 已接受你的驗證碼,隨後未能完成登入。該驗證碼已被用掉,需要一個新的——我們會先等一下再申請,因為立刻申請會被拒絕。 將在 %1$d 秒後向 Apple 申請新驗證碼… Apple 仍未完成登入。這是 Apple 一側的故障,不是你的操作問題,也不是驗證碼的問題。\n\n請過幾分鐘再試。屆時你的密碼可能會被拒絕一次——這屬於同一個故障,請再輸入一次,而不要以為密碼錯了。 + Apple 目前不接受登入。這是 Apple 端的問題,與你的 Apple ID 或密碼無關。通常幾分鐘內就會恢復,請稍候再試。 + Apple 目前沒有回應 + Apple 拒絕了該請求,而不是沒有回應。你的帳號沒有問題,也沒有任何變更。通常幾分鐘內即可恢復。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0ce454d4..65e582d6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -345,4 +345,7 @@ You can set this up now, or any time later from Settings. Apple accepted your code and then had a problem finishing the sign-in. The code is used up, so a new one is needed — waiting a moment before asking for it, because asking straight away is refused. Asking Apple for a new code in %1$d s… Apple is still not finishing the sign-in. This is a fault on Apple\'s side, not something you did, and not your code.\n\nTry again in a few minutes. Your password may be refused once when you do — that is part of the same fault, so enter it again rather than assuming it is wrong. + Apple is not accepting sign-ins at the moment. This is a problem on Apple\'s side, not with your Apple ID or password. It usually clears within a few minutes, so wait a little and try again. + Apple is not answering right now + Apple refused the request rather than failing to answer it. Nothing is wrong with your account, and nothing was changed. This usually clears within a few minutes. diff --git a/app/src/test/java/dev/wander/android/opentagviewer/python/icloud/AppleDecliningIsItsOwnFailureTest.java b/app/src/test/java/dev/wander/android/opentagviewer/python/icloud/AppleDecliningIsItsOwnFailureTest.java new file mode 100644 index 00000000..84cf8c11 --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/python/icloud/AppleDecliningIsItsOwnFailureTest.java @@ -0,0 +1,69 @@ +package dev.wander.android.opentagviewer.python.icloud; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; + +import org.junit.Test; + +/** + * A 503 from Apple is its own answer, and specifically not the two it used to be mistaken for. + * + *

Issue #176: one account met the same 503 at three call sites in three minutes. Nothing + * distinguished it, so it arrived as {@code UNKNOWN} and the screen showed an HTTP status and a + * sentence about Grand Slam - which reads as a bug in this app, and was reported as one. + * + *

Pure mapping and a pure predicate, so JVM - rule 13. + */ +public class AppleDecliningIsItsOwnFailureTest { + + @Test + public void thebridgesWireValueMaps() { + assertEquals(ICloudFailure.APPLE_DECLINED, ICloudFailure.fromWire("apple_declined")); + } + + /** + * The expensive mistake, in the direction that costs a working session. + * + *

{@code CREDENTIALS_REJECTED} ends in {@code SignInAgain}. Reaching it over a fault that + * clears itself in minutes signs somebody out for nothing. + */ + @Test + public void itisNotRejectedCredentials() { + assertNotEquals(ICloudFailure.CREDENTIALS_REJECTED, ICloudFailure.fromWire("apple_declined")); + } + + /** + * And it must not reach the sign-out path through the shared predicate either. + * + *

Rule 15's whole point is that one question decides this for every screen. A new failure + * that answered it wrongly would take every caller with it. + */ + @Test + public void itdoesNotAskAnybodyToSignInAgain() { + assertFalse(ICloudFailures.meansSignInAgain( + new ICloudException(ICloudFailure.APPLE_DECLINED, "refused with HTTP 503"))); + } + + /** + * The other direction: it is no longer UNKNOWN. + * + *

Which is what it was when #176 was filed. + */ + @Test + public void itisNoLongerUnknown() { + assertNotEquals(ICloudFailure.UNKNOWN, ICloudFailure.fromWire("apple_declined")); + } + + /** + * An unrecognised reason still degrades rather than throwing. + * + *

A reason added in Python and not yet known here has to become a poor screen, never a + * crash on the screen that reports failures. + */ + @Test + public void areasonThisBuildHasNeverHeardOfIsStillUnknown() { + assertEquals(ICloudFailure.UNKNOWN, ICloudFailure.fromWire("something_invented_later")); + assertEquals(ICloudFailure.UNKNOWN, ICloudFailure.fromWire(null)); + } +} diff --git a/app/src/test/java/dev/wander/android/opentagviewer/util/rx/ACodeAppleAlreadyTookTest.java b/app/src/test/java/dev/wander/android/opentagviewer/util/rx/ACodeAppleAlreadyTookTest.java index 1b6fde2d..86dfaa6e 100644 --- a/app/src/test/java/dev/wander/android/opentagviewer/util/rx/ACodeAppleAlreadyTookTest.java +++ b/app/src/test/java/dev/wander/android/opentagviewer/util/rx/ACodeAppleAlreadyTookTest.java @@ -103,4 +103,44 @@ public void thereAreExactlyTwoAttemptsAndThenItGivesUp() { public void aNonsenseAttemptNumberDoesNotWait() { assertEquals(-1, ACodeAppleAlreadyTook.waitBefore(-1)); } + + /** + * The name the fork raises now, which is the one a real 503 arrives as. + * + *

Every other test here writes the old name into its own fixture, so all of them + * kept passing when FindMy.py started raising a subclass and this stopped matching. A test + * that supplies the string it is looking for cannot notice the string changing. + */ + @Test + public void a503ArrivingAsTheForksTransientTypeIsRecognised() { + assertTrue(ACodeAppleAlreadyTook.spentIt(fromTheBridge( + "findmy.errors.AppleServiceUnavailableError: 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."))); + } + + /** + * And through a wrapper, because that is how it reaches the screen. + */ + @Test + public void theforksTypeIsFoundThroughAWrappingException() { + final Throwable wrapped = new RuntimeException( + "submitCode failed", + fromTheBridge("findmy.errors.AppleServiceUnavailableError: refused with HTTP 503")); + + assertTrue(ACodeAppleAlreadyTook.spentIt(wrapped)); + } + + /** + * The old name still counts. + * + *

It covers a protocol failure after the submit that is not a 5xx, which the fork still + * reports as the parent type. + */ + @Test + public void theolderNameIsStillRecognisedSoTheWideNetSurvives() { + assertTrue(ACodeAppleAlreadyTook.spentIt(fromTheBridge( + "UnhandledProtocolError: Error response for GSA request: 418"))); + } } diff --git a/app/src/test/python/test_icloud_bridge.py b/app/src/test/python/test_icloud_bridge.py index 7fee4ffd..f1fb5618 100644 --- a/app/src/test/python/test_icloud_bridge.py +++ b/app/src/test/python/test_icloud_bridge.py @@ -997,6 +997,49 @@ def test_an_unrelated_value_error_is_not_a_dead_session(self): assert answer["reason"] == icloud_bridge.REASON_UNKNOWN + def test_apple_declining_is_its_own_reason(self): + """ + Issue #176. Neither a dead session nor a mystery. + + Classified in `_unexpected` so all eight entry points get it - the same argument the + credentials check above is here for. + """ + from findmy.errors import AppleServiceUnavailableError + + try: + raise AppleServiceUnavailableError(503, "The Grand Slam request") + except AppleServiceUnavailableError: + answer = json.loads(icloud_bridge._unexpected("opening the Find My client")) + + assert answer["reason"] == icloud_bridge.REASON_APPLE_DECLINED + + def test_apple_declining_does_not_sign_anybody_out(self): + """ + The expensive half of getting this wrong. + + CREDENTIALS_REJECTED ends in a forced sign-out. Reaching it over a 503 destroys a working + session for a fault that clears itself in minutes. + """ + from findmy.errors import AppleServiceUnavailableError + + try: + raise AppleServiceUnavailableError(503, "The Grand Slam request") + except AppleServiceUnavailableError: + answer = json.loads(icloud_bridge._unexpected("reading the account's accessories")) + + assert answer["reason"] != icloud_bridge.REASON_CREDENTIALS_REJECTED + + def test_an_ordinary_protocol_error_is_still_unknown(self): + """A response this library cannot read is still worth reporting, so it stays UNKNOWN.""" + from findmy.errors import UnhandledProtocolError + + try: + raise UnhandledProtocolError("Error response for GSA request: 418") + except UnhandledProtocolError: + answer = json.loads(icloud_bridge._unexpected("opening the Find My client")) + + assert answer["reason"] == icloud_bridge.REASON_UNKNOWN + def test_an_unauthorized_error_is_deliberately_left_alone(self): """ **Two meanings, so it stays unclassified until they can be told apart.** diff --git a/app/src/test/python/test_main.py b/app/src/test/python/test_main.py index facdff4a..9ffa322e 100644 --- a/app/src/test/python/test_main.py +++ b/app/src/test/python/test_main.py @@ -834,6 +834,44 @@ def test_anything_else_is_left_unclassified(self): """ assert main.classifyLoginFailure(ValueError("bad password")) == main.REASON_UNKNOWN + def test_apple_declining_is_not_a_network_failure(self): + """ + Issue #176. Apple answered, and refused. + + Told to check their connection, somebody with a perfectly good connection resets a + router over a 503. The two failures look identical on screen and want opposite advice. + """ + from findmy.errors import AppleServiceUnavailableError + + declined = AppleServiceUnavailableError(503, "The Grand Slam request") + + assert main.classifyLoginFailure(declined) == main.REASON_APPLE_DECLINED + assert main.classifyLoginFailure(declined) != main.REASON_NETWORK + + def test_apple_declining_is_not_left_unclassified(self): + """ + UNKNOWN shows the detail verbatim, which here is a sentence about Grand Slam and an HTTP + status. Accurate, and it reads as a bug in this app - which is what got reported. + """ + from findmy.errors import AppleServiceUnavailableError + + assert main.classifyLoginFailure( + AppleServiceUnavailableError(429, "The two-factor request"), + ) != main.REASON_UNKNOWN + + def test_an_ordinary_protocol_error_is_still_unclassified(self): + """ + The subclass is the signal, not the parent. + + A response this library genuinely cannot read is still a bug worth reporting, and must + not be swept into "wait a few minutes". + """ + from findmy.errors import UnhandledProtocolError + + assert main.classifyLoginFailure( + UnhandledProtocolError("Error response for GSA request: 418"), + ) == main.REASON_UNKNOWN + def test_a_library_error_is_recognised_by_its_module(self): class ClientConnectorError(Exception): pass From 9b7cfb94f1830f5189bc68589cca53dc16e8f4c4 Mon Sep 17 00:00:00 2001 From: "Shane B." Date: Sat, 12 Sep 2026 13:56:58 +0200 Subject: [PATCH 3/3] Stop promising the 503 will clear, because for some people it does not Five people have now reported this and only @parawanderer has confirmed it resolving. crishpeen on #168 says the opposite: every attempt, every 2FA method, device-identity.json cleared, still refused - and their log shows login succeeding and only request_pet getting the 503. The message said it "usually clears on its own within a few minutes", which was one confirmed recovery stated as a rule. Somebody it does not clear for would read that and wait. It now says trying again shortly is worth doing, that it has been seen to last, and asks for a report with the log when it keeps refusing. The 2FA message said the same thing and said it immediately after demonstrating that two waits had not cleared it, which was incoherent as well as unsupported. Both Android strings reworded through --show and --replace across ten locales. The test asserting no bug report is mentioned is narrowed to what it was actually protecting: the dialog must not present this as a defect here, which is what linking the issue tracker did. Asking for a report when it persists is correct and now asserted. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/main/res/values-de/strings.xml | 4 +-- app/src/main/res/values-en/strings.xml | 4 +-- app/src/main/res/values-fr/strings.xml | 4 +-- app/src/main/res/values-ja/strings.xml | 4 +-- app/src/main/res/values-ko/strings.xml | 4 +-- app/src/main/res/values-nl/strings.xml | 4 +-- app/src/main/res/values-ru/strings.xml | 4 +-- app/src/main/res/values-zh-rCN/strings.xml | 4 +-- app/src/main/res/values-zh-rTW/strings.xml | 4 +-- app/src/main/res/values/strings.xml | 4 +-- python/exporter/icloud.py | 18 +++++++------ python/test/test_apple_declining.py | 25 +++++++++++++------ .../test/test_sign_in_interrupted_dialog.py | 15 ++++++++--- 13 files changed, 59 insertions(+), 39 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 55f82a92..1a5febe2 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -313,7 +313,7 @@ Du kannst das jetzt einrichten oder jederzeit später in den Einstellungen.Apple hat deinen Code angenommen und danach die Anmeldung nicht abschließen können. Der Code ist damit verbraucht, es wird also ein neuer gebraucht – wir warten kurz, bevor wir ihn anfordern, denn sofortiges Anfordern wird abgelehnt. Neuer Code wird in %1$d s bei Apple angefordert … Apple schließt die Anmeldung weiterhin nicht ab. Das liegt an Apple, nicht an dir und nicht an deinem Code.\n\nVersuche es in ein paar Minuten erneut. Dein Passwort wird dabei möglicherweise einmal abgelehnt – das gehört zum selben Fehler, gib es also einfach noch einmal ein, statt es für falsch zu halten. - Apple nimmt derzeit keine Anmeldungen an. Das liegt an Apple und nicht an deiner Apple-ID oder deinem Passwort. Es klärt sich meist innerhalb weniger Minuten – warte kurz und versuche es erneut. + Apple hat die Anmeldung abgelehnt. Das ist eine Ablehnung durch Apple und kein Problem mit deiner Apple-ID oder deinem Passwort. Ein erneuter Versuch nach kurzer Zeit lohnt sich, aber wenn es weiterhin abgelehnt wird, melde es bitte mit einem Protokoll, statt zu warten. Apple antwortet gerade nicht - Apple hat die Anfrage abgelehnt, statt gar nicht zu antworten. Mit deinem Account ist alles in Ordnung, und es wurde nichts geändert. Das klärt sich meist innerhalb weniger Minuten. + Apple hat die Anfrage abgelehnt, statt gar nicht zu antworten. Mit deinem Account ist alles in Ordnung, und es wurde nichts geändert. Versuche es bald erneut; wenn es weiterhin abgelehnt wird, melde es mit einem Protokoll. \ 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 c1941c2a..8cbb2c03 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -313,7 +313,7 @@ You can set this up now, or any time later from Settings. Apple accepted your code and then had a problem finishing the sign-in. The code is used up, so a new one is needed — waiting a moment before asking for it, because asking straight away is refused. Asking Apple for a new code in %1$d s… Apple is still not finishing the sign-in. This is a fault on Apple\'s side, not something you did, and not your code.\n\nTry again in a few minutes. Your password may be refused once when you do — that is part of the same fault, so enter it again rather than assuming it is wrong. - Apple is not accepting sign-ins at the moment. This is a problem on Apple\'s side, not with your Apple ID or password. It usually clears within a few minutes, so wait a little and try again. + Apple refused the sign-in. This is Apple declining, not a problem with your Apple ID or password. Trying again shortly is worth doing, but if it keeps refusing please report it with a log rather than waiting it out. Apple is not answering right now - Apple refused the request rather than failing to answer it. Nothing is wrong with your account, and nothing was changed. This usually clears within a few minutes. + Apple refused the request rather than failing to answer it. Nothing is wrong with your account, and nothing was changed. Try again shortly; if it keeps refusing, report it with a log. \ 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 86a07ffa..c85cf458 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -313,7 +313,7 @@ Vous pouvez configurer cela maintenant, ou à tout moment depuis les réglages.< Apple a accepté votre code puis n’a pas pu terminer la connexion. Le code est donc utilisé et il en faut un nouveau — nous patientons un instant avant de le demander, car une demande immédiate est refusée. Nouveau code demandé à Apple dans %1$d s… Apple ne termine toujours pas la connexion. C’est une panne du côté d’Apple, pas quelque chose que vous avez fait, ni votre code.\n\nRéessayez dans quelques minutes. Votre mot de passe pourra être refusé une fois à ce moment-là : cela fait partie de la même panne, saisissez-le à nouveau plutôt que de le croire erroné. - Apple n\'accepte pas les connexions pour le moment. Le problème vient d\'Apple, pas de votre identifiant ou de votre mot de passe. Cela se règle généralement en quelques minutes : patientez un peu et réessayez. + Apple a refusé la connexion. C\'est un refus d\'Apple, pas un problème avec votre identifiant ou votre mot de passe. Réessayer un peu plus tard vaut la peine, mais si le refus persiste, signalez-le avec un journal plutôt que d\'attendre. Apple ne répond pas pour le moment - Apple a refusé la requête plutôt que de ne pas y répondre. Votre compte n\'a aucun problème et rien n\'a été modifié. Cela se règle généralement en quelques minutes. + Apple a refusé la requête plutôt que de ne pas y répondre. Votre compte n\'a aucun problème et rien n\'a été modifié. Réessayez bientôt ; si le refus persiste, signalez-le avec un journal. \ 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 6c477e5a..4d8799b6 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -313,7 +313,7 @@ Apple はコードを受け付けたあと、サインインを完了できませんでした。コードは使用済みなので新しいものが必要です。すぐに要求しても拒否されるため、少し待ってから要求します。 %1$d 秒後に Apple へ新しいコードを要求します… Apple はまだサインインを完了できていません。これは Apple 側の障害であり、あなたの操作やコードのせいではありません。\n\n数分後にもう一度お試しください。そのときパスワードが一度だけ拒否されることがありますが、これも同じ障害の一部です。間違っていると考えず、もう一度入力してください。 - 現在、Appleがサインインを受け付けていません。Apple ID やパスワードではなく、Apple 側の問題です。通常は数分で解消しますので、少し待ってからもう一度お試しください。 + Apple がサインインを拒否しました。Apple ID やパスワードの問題ではなく、Apple 側の拒否です。少し時間をおいて再試行する価値はありますが、拒否され続ける場合は待たずにログを添えて報告してください。 現在 Apple が応答していません - Apple は応答しなかったのではなく、要求を拒否しました。アカウントに問題はなく、何も変更されていません。通常は数分で解消します。 + 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 0e10e85b..185cdfc5 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -313,7 +313,7 @@ Apple이 코드를 받은 뒤 로그인을 끝내지 못했습니다. 코드는 이미 사용되었으므로 새 코드가 필요합니다. 바로 요청하면 거부되기 때문에 잠시 기다린 뒤 요청합니다. %1$d초 후에 Apple에 새 코드를 요청합니다… Apple이 아직 로그인을 마치지 못하고 있습니다. 이는 Apple 쪽 장애이며, 사용자의 잘못도 코드 문제도 아닙니다.\n\n몇 분 뒤에 다시 시도하세요. 그때 비밀번호가 한 번 거부될 수 있는데, 이것도 같은 장애의 일부이므로 틀렸다고 생각하지 말고 다시 입력하세요. - 현재 Apple이 로그인을 처리하지 않고 있습니다. Apple ID나 비밀번호가 아니라 Apple 쪽 문제입니다. 보통 몇 분이면 해결되니 잠시 기다렸다가 다시 시도하세요. + Apple이 로그인을 거부했습니다. Apple ID나 비밀번호의 문제가 아니라 Apple 쪽의 거부입니다. 잠시 후 다시 시도해 볼 만하지만, 계속 거부된다면 기다리지 말고 로그와 함께 신고해 주세요. 지금은 Apple이 응답하지 않습니다 - Apple이 응답하지 못한 것이 아니라 요청을 거부했습니다. 계정에는 문제가 없으며 변경된 것도 없습니다. 보통 몇 분이면 해결됩니다. + 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 e32d9e4a..c5218663 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -313,7 +313,7 @@ Je kunt dit nu instellen, of later altijd nog via Instellingen. Apple heeft je code geaccepteerd en kon daarna het inloggen niet afronden. De code is dus opgebruikt en er is een nieuwe nodig — we wachten even voordat we die aanvragen, want meteen aanvragen wordt geweigerd. Over %1$d s wordt een nieuwe code bij Apple aangevraagd… Apple rondt het inloggen nog steeds niet af. Dit ligt aan Apple, niet aan jou en niet aan je code.\n\nProbeer het over een paar minuten opnieuw. Je wachtwoord kan dan één keer worden geweigerd — dat hoort bij dezelfde storing, dus voer het gewoon nog een keer in in plaats van aan te nemen dat het fout is. - Apple accepteert op dit moment geen aanmeldingen. Dit ligt aan Apple, niet aan je Apple ID of wachtwoord. Meestal is het binnen enkele minuten voorbij; wacht even en probeer het opnieuw. + Apple heeft de aanmelding geweigerd. Dit is een weigering van Apple, geen probleem met je Apple ID of wachtwoord. Het is de moeite waard het zo weer te proberen, maar blijft het geweigerd worden, meld het dan met een logbestand in plaats van af te wachten. Apple reageert momenteel niet - Apple heeft het verzoek geweigerd in plaats van er niet op te antwoorden. Er is niets mis met je account en er is niets gewijzigd. Dit is meestal binnen enkele minuten voorbij. + Apple heeft het verzoek geweigerd in plaats van er niet op te antwoorden. Er is niets mis met je account en er is niets gewijzigd. Probeer het zo opnieuw; blijft het geweigerd worden, meld het dan met een logbestand. \ 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 b4fee225..54b10e58 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -313,7 +313,7 @@ Apple приняла ваш код, а затем не смогла завершить вход. Код уже использован, поэтому нужен новый — подождём немного перед запросом, потому что сразу запрашивать бесполезно. Запросим новый код у Apple через %1$d с… Apple по-прежнему не завершает вход. Это сбой на стороне Apple — не ваша вина и не проблема кода.\n\nПопробуйте снова через несколько минут. Пароль при этом может быть отклонён один раз: это часть того же сбоя, поэтому введите его ещё раз, а не считайте неверным. - Apple сейчас не принимает входы. Проблема на стороне Apple, а не в вашем Apple ID или пароле. Обычно это проходит за несколько минут — подождите немного и попробуйте снова. + Apple отклонила вход. Это отказ со стороны Apple, а не проблема с вашим Apple ID или паролем. Стоит повторить попытку чуть позже, но если отказы продолжаются, сообщите об этом с журналом, а не ждите. Apple сейчас не отвечает - Apple отклонила запрос, а не осталась без ответа. С вашей учётной записью всё в порядке, и ничего не изменилось. Обычно это проходит за несколько минут. + Apple отклонила запрос, а не осталась без ответа. С вашей учётной записью всё в порядке, и ничего не изменилось. Повторите попытку чуть позже; если отказы продолжаются, сообщите об этом с журналом. \ 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 f4ba5f54..fec8a1cf 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -313,7 +313,7 @@ Apple 已接受你的验证码,随后未能完成登录。该验证码已被用掉,需要一个新的——我们会先等一会儿再申请,因为立刻申请会被拒绝。 将在 %1$d 秒后向 Apple 申请新验证码… Apple 仍未完成登录。这是 Apple 一侧的故障,不是你的操作问题,也不是验证码的问题。\n\n请过几分钟再试。届时你的密码可能会被拒绝一次——这属于同一个故障,请再输入一次,而不要以为密码错了。 - Apple 目前不接受登录。这是 Apple 一侧的问题,与你的 Apple ID 或密码无关。通常几分钟内就会恢复,请稍候再试。 + Apple 拒绝了此次登录。这是 Apple 的拒绝,不是你的 Apple ID 或密码有问题。稍后重试是值得的,但如果持续被拒绝,请附上日志报告,而不要一直等待。 Apple 目前没有响应 - Apple 拒绝了该请求,而不是没有响应。你的账户没有问题,也没有任何更改。通常几分钟内即可恢复。 + Apple 拒绝了该请求,而不是没有响应。你的账户没有问题,也没有任何更改。请稍后重试;如果持续被拒绝,请附上日志报告。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index b1ff1c30..762e9dea 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -313,7 +313,7 @@ Apple 已接受你的驗證碼,隨後未能完成登入。該驗證碼已被用掉,需要一個新的——我們會先等一下再申請,因為立刻申請會被拒絕。 將在 %1$d 秒後向 Apple 申請新驗證碼… Apple 仍未完成登入。這是 Apple 一側的故障,不是你的操作問題,也不是驗證碼的問題。\n\n請過幾分鐘再試。屆時你的密碼可能會被拒絕一次——這屬於同一個故障,請再輸入一次,而不要以為密碼錯了。 - Apple 目前不接受登入。這是 Apple 端的問題,與你的 Apple ID 或密碼無關。通常幾分鐘內就會恢復,請稍候再試。 + Apple 拒絕了此次登入。這是 Apple 的拒絕,不是你的 Apple ID 或密碼有問題。稍後重試是值得的,但如果持續被拒絕,請附上紀錄檔回報,而不要一直等待。 Apple 目前沒有回應 - Apple 拒絕了該請求,而不是沒有回應。你的帳號沒有問題,也沒有任何變更。通常幾分鐘內即可恢復。 + Apple 拒絕了該請求,而不是沒有回應。你的帳號沒有問題,也沒有任何變更。請稍後重試;如果持續被拒絕,請附上紀錄檔回報。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 65e582d6..aaf80a85 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -345,7 +345,7 @@ You can set this up now, or any time later from Settings. Apple accepted your code and then had a problem finishing the sign-in. The code is used up, so a new one is needed — waiting a moment before asking for it, because asking straight away is refused. Asking Apple for a new code in %1$d s… Apple is still not finishing the sign-in. This is a fault on Apple\'s side, not something you did, and not your code.\n\nTry again in a few minutes. Your password may be refused once when you do — that is part of the same fault, so enter it again rather than assuming it is wrong. - Apple is not accepting sign-ins at the moment. This is a problem on Apple\'s side, not with your Apple ID or password. It usually clears within a few minutes, so wait a little and try again. + Apple refused the sign-in. This is Apple declining, not a problem with your Apple ID or password. Trying again shortly is worth doing, but if it keeps refusing please report it with a log rather than waiting it out. Apple is not answering right now - Apple refused the request rather than failing to answer it. Nothing is wrong with your account, and nothing was changed. This usually clears within a few minutes. + Apple refused the request rather than failing to answer it. Nothing is wrong with your account, and nothing was changed. Try again shortly; if it keeps refusing, report it with a log. diff --git a/python/exporter/icloud.py b/python/exporter/icloud.py index ef39f560..91bba515 100644 --- a/python/exporter/icloud.py +++ b/python/exporter/icloud.py @@ -594,11 +594,12 @@ def describe_apple_declining(error: AppleServiceUnavailableError) -> str: """What to tell somebody whose sign-in was refused by Apple rather than by their password.""" return ( f"Apple's sign-in service refused the request with HTTP {error.status_code}.\n\n" - "This is a fault on Apple's side rather than anything you did. Your Apple ID, your" - " password and your verification code are all fine, and nothing was changed or sent." - " It usually clears on its own within a few minutes.\n\n" - "Wait a little and sign in again. If it is still refusing after ten minutes or so, it" - " is worth reporting - but a run of these over a few minutes is ordinary." + "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" + "Trying again shortly is worth doing - it has cleared by itself for some people. It has" + " also been reported as lasting across many attempts, so if it keeps refusing, please" + " report it with this log rather than waiting it out. Why it persists for some accounts" + " and not others is not yet known." ) @@ -624,13 +625,14 @@ def _apple_failed_after_taking_the_code(error: BaseException) -> SignInInterrupt """ return SignInInterrupted( f"Apple accepted your verification code and then failed to finish signing in: {error}\n\n" - "This is a fault on Apple's side rather than anything you did, and it clears on its own." - " Nothing was changed and nothing was sent.\n\n" + "This is a fault on Apple's side rather than anything you did. Nothing was changed and" + " nothing was sent.\n\n" f"It was given {len(SPENT_CODE_WAITS)} chances to settle - waiting" f" {' and then '.join(f'{s}s' for s in SPENT_CODE_WAITS)} - and did not.\n\n" "Leave it a few minutes and sign in again. Apple may also refuse the password once while" " it settles - that is part of the same hiccup rather than a second problem, and trying" - " once more is the answer.", + " once more is the answer. If it keeps refusing, please report it with your log: this" + " has been seen to last for some accounts, and why is not yet known.", ) diff --git a/python/test/test_apple_declining.py b/python/test/test_apple_declining.py index 10222c22..0150c350 100644 --- a/python/test/test_apple_declining.py +++ b/python/test/test_apple_declining.py @@ -91,8 +91,8 @@ def test_itcarriesTheStatus(self, message): assert "503" in message def test_itsaysTheFaultIsApples(self, message): - assert "Apple's side" in message - assert "rather than anything you did" in message + assert "Apple declined it" in message + assert "rather than anything being wrong with your Apple ID" in message def test_itclearsThePasswordAndTheCode(self, message): """Otherwise the next thing tried is a password reset, which cannot help.""" @@ -103,14 +103,25 @@ def test_itsaysNothingWasChanged(self, message): """The first question after a failed sign-in to your own Apple account.""" assert "nothing was changed" in message.lower() - def test_itsaysWhenReportingWouldBeReasonable(self, message): + def test_itasksForAReportWhenItKeepsHappening(self, message): """ - Not "never report this". + Not "wait it out", because for at least one person it never cleared. - A 503 that persists for an hour is worth hearing about; the point is that a run of them - over a few minutes is not. + Five people have reported this and only @parawanderer has confirmed it resolving. + crishpeen on #168 says the opposite: every attempt, every 2FA method, device-identity.json + cleared. A message promising it passes would have them wait on something that does not. """ - assert "worth reporting" in message + assert "report it with this log" in message + assert "not yet known" in message + + def test_itdoesNotPromiseItWillClear(self, message): + """ + The overclaim this replaced. + + The first version said it "usually clears on its own within a few minutes", which was one + confirmed recovery presented as a rule. + """ + assert "usually clears" not in message def test_itdoesNotAskForABugReportOutright(self, message): """The behaviour being fixed.""" diff --git a/python/test/test_sign_in_interrupted_dialog.py b/python/test/test_sign_in_interrupted_dialog.py index 3280f096..ad53d18e 100644 --- a/python/test/test_sign_in_interrupted_dialog.py +++ b/python/test/test_sign_in_interrupted_dialog.py @@ -86,16 +86,23 @@ def test_everything_else_keeps_the_heading_it_had(self, window): class TestTheBodySaysWhoseFaultItIsAndWhatToDo: - def test_it_does_not_ask_for_a_bug_report(self, window): + def test_it_does_not_present_itself_as_a_bug_in_this_program(self, window): """ - The complaint, asserted. + The complaint, asserted - and narrower than it first looked. - This is what the catch-all handler adds, and reaching it is what produced issue #168. + What produced #168 was the catch-all handler naming an exception type and linking the + issue tracker, so the failure read as a defect here. That is what must not happen. + + **Asking for a report when it keeps happening is a different thing, and is now correct.** + Five people have reported this 503 and only one has confirmed it clearing; crishpeen on + #168 says every attempt fails, across every 2FA method. A message that only ever says + "wait" would have them wait on something that does not pass. """ _title, body = load_failing_with(window, THE_REAL_ONE) - assert "report" not in body.lower() assert "github.com" not in body.lower() + assert "please report it with your log" in body.lower(), ( + "it has to leave a route open for the people it does not clear for") def test_it_says_the_fault_is_apples(self, window): _title, body = load_failing_with(window, THE_REAL_ONE)