diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 175b36aa..f7c484b1 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@ddc7f2342fc9f32ebe315b85c22a4554ce419f6d") + install("git+https://github.com/parawanderer/FindMy.py@3c2b4926252193e9cd265b39fa52252adcbaad4e") install("NSKeyedUnArchiver==1.5") 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 9be60d1c..b15adf03 100644 --- a/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudFlowTest.java +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/FetchFromICloudFlowTest.java @@ -353,6 +353,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 ed59f270..24045d64 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java @@ -734,6 +734,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 55c895f4..73225ea9 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/FetchFromICloudActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/FetchFromICloudActivity.java @@ -698,6 +698,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 2341818a..cd5555fd 100644 --- a/app/src/main/python/main.py +++ b/app/src/main/python/main.py @@ -10,6 +10,7 @@ import NSKeyedUnArchiver from findmy import FindMyAccessory, MobileMeDelegateError +from findmy.errors import AppleServiceUnavailableError from findmy.accessory import FixedRollingKeyPairAccessory from findmy.keys import KeyPairType from findmy.reports import ( @@ -458,6 +459,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.""" @@ -484,6 +500,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 9dc1ddd8..cfbb0154 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -366,6 +366,9 @@ Deine Tags und ihr Standortverlauf sind davon nicht betroffen, und aus deinem Ap 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 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. Versuche es bald erneut; wenn es weiterhin abgelehnt wird, melde es mit einem Protokoll. Verlauf importieren Verlaufsimport abgeschlossen Gelesene Zeilen: %1$d\nHinzugefügte Zeilen: %2$d\nBereits vorhanden: %3$d\nFehlerhaft: %4$d\nÜbersprungen (Tag nicht importiert): %5$d diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 2fee314f..c01dbdf7 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -366,6 +366,9 @@ Your tags and their location history are not affected, and nothing was removed f 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 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. Try again shortly; if it keeps refusing, report it with a log. Import History History import complete Rows read: %1$d\nRows added: %2$d\nAlready present: %3$d\nMalformed: %4$d\nSkipped (tag not imported): %5$d diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 80849080..615a8cd0 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -366,6 +366,9 @@ Vos tags et leur historique de position ne sont pas touchés, et rien n’a ét 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 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é. Réessayez bientôt ; si le refus persiste, signalez-le avec un journal. Importer l’historique Importation de l’historique terminée Lignes lues : %1$d\nLignes ajoutées : %2$d\nDéjà présentes : %3$d\nIncorrectes : %4$d\nIgnorées (balise non importée) : %5$d diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 7ef39d77..c913842d 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -366,6 +366,9 @@ Apple はコードを受け付けたあと、サインインを完了できませんでした。コードは使用済みなので新しいものが必要です。すぐに要求しても拒否されるため、少し待ってから要求します。 %1$d 秒後に Apple へ新しいコードを要求します… Apple はまだサインインを完了できていません。これは Apple 側の障害であり、あなたの操作やコードのせいではありません。\n\n数分後にもう一度お試しください。そのときパスワードが一度だけ拒否されることがありますが、これも同じ障害の一部です。間違っていると考えず、もう一度入力してください。 + Apple がサインインを拒否しました。Apple ID やパスワードの問題ではなく、Apple 側の拒否です。少し時間をおいて再試行する価値はありますが、拒否され続ける場合は待たずにログを添えて報告してください。 + 現在 Apple が応答していません + Apple は応答しなかったのではなく、要求を拒否しました。アカウントに問題はなく、何も変更されていません。少し後に再試行してください。拒否が続く場合はログを添えて報告してください。 履歴をインポート 履歴のインポートが完了しました 読み込んだ行: %1$d\n追加した行: %2$d\n既存の行: %3$d\n不正な行: %4$d\nスキップ (タグ未インポート): %5$d diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 2b4a04eb..20b3796f 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -366,6 +366,9 @@ Apple이 코드를 받은 뒤 로그인을 끝내지 못했습니다. 코드는 이미 사용되었으므로 새 코드가 필요합니다. 바로 요청하면 거부되기 때문에 잠시 기다린 뒤 요청합니다. %1$d초 후에 Apple에 새 코드를 요청합니다… Apple이 아직 로그인을 마치지 못하고 있습니다. 이는 Apple 쪽 장애이며, 사용자의 잘못도 코드 문제도 아닙니다.\n\n몇 분 뒤에 다시 시도하세요. 그때 비밀번호가 한 번 거부될 수 있는데, 이것도 같은 장애의 일부이므로 틀렸다고 생각하지 말고 다시 입력하세요. + Apple이 로그인을 거부했습니다. Apple ID나 비밀번호의 문제가 아니라 Apple 쪽의 거부입니다. 잠시 후 다시 시도해 볼 만하지만, 계속 거부된다면 기다리지 말고 로그와 함께 신고해 주세요. + 지금은 Apple이 응답하지 않습니다 + Apple이 응답하지 못한 것이 아니라 요청을 거부했습니다. 계정에는 문제가 없으며 변경된 것도 없습니다. 잠시 후 다시 시도하고, 계속 거부되면 로그와 함께 신고해 주세요. 기록 가져오기 기록 가져오기 완료 읽은 행: %1$d\n추가된 행: %2$d\n이미 존재함: %3$d\n잘못된 행: %4$d\n건너뜀(태그를 가져오지 않음): %5$d diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index e6c492e9..bfa3c6d2 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -366,6 +366,9 @@ Je tags en hun locatiegeschiedenis blijven ongemoeid, en er is niets uit je Appl 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 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. Probeer het zo opnieuw; blijft het geweigerd worden, meld het dan met een logbestand. Geschiedenis importeren Geschiedenis geïmporteerd Gelezen rijen: %1$d\nToegevoegde rijen: %2$d\nAl aanwezig: %3$d\nOngeldig: %4$d\nOvergeslagen (tag niet geïmporteerd): %5$d diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 9b7316aa..fbd9e9ef 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -366,6 +366,9 @@ Apple приняла ваш код, а затем не смогла завершить вход. Код уже использован, поэтому нужен новый — подождём немного перед запросом, потому что сразу запрашивать бесполезно. Запросим новый код у Apple через %1$d с… Apple по-прежнему не завершает вход. Это сбой на стороне Apple — не ваша вина и не проблема кода.\n\nПопробуйте снова через несколько минут. Пароль при этом может быть отклонён один раз: это часть того же сбоя, поэтому введите его ещё раз, а не считайте неверным. + Apple отклонила вход. Это отказ со стороны Apple, а не проблема с вашим Apple ID или паролем. Стоит повторить попытку чуть позже, но если отказы продолжаются, сообщите об этом с журналом, а не ждите. + Apple сейчас не отвечает + Apple отклонила запрос, а не осталась без ответа. С вашей учётной записью всё в порядке, и ничего не изменилось. Повторите попытку чуть позже; если отказы продолжаются, сообщите об этом с журналом. Импортировать историю Импорт истории завершён Прочитано строк: %1$d\nДобавлено строк: %2$d\nУже есть: %3$d\nПовреждено: %4$d\nПропущено (метка не импортирована): %5$d diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e3e93695..d7b640ca 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -366,6 +366,9 @@ Apple 已接受你的验证码,随后未能完成登录。该验证码已被用掉,需要一个新的——我们会先等一会儿再申请,因为立刻申请会被拒绝。 将在 %1$d 秒后向 Apple 申请新验证码… Apple 仍未完成登录。这是 Apple 一侧的故障,不是你的操作问题,也不是验证码的问题。\n\n请过几分钟再试。届时你的密码可能会被拒绝一次——这属于同一个故障,请再输入一次,而不要以为密码错了。 + Apple 拒绝了此次登录。这是 Apple 的拒绝,不是你的 Apple ID 或密码有问题。稍后重试是值得的,但如果持续被拒绝,请附上日志报告,而不要一直等待。 + Apple 目前没有响应 + Apple 拒绝了该请求,而不是没有响应。你的账户没有问题,也没有任何更改。请稍后重试;如果持续被拒绝,请附上日志报告。 导入历史记录 历史记录导入完成 读取的行数:%1$d\n新增的行数:%2$d\n已存在:%3$d\n格式错误:%4$d\n已跳过(标签未导入):%5$d diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 09d28f1f..8db5db9a 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -366,6 +366,9 @@ Apple 已接受你的驗證碼,隨後未能完成登入。該驗證碼已被用掉,需要一個新的——我們會先等一下再申請,因為立刻申請會被拒絕。 將在 %1$d 秒後向 Apple 申請新驗證碼… Apple 仍未完成登入。這是 Apple 一側的故障,不是你的操作問題,也不是驗證碼的問題。\n\n請過幾分鐘再試。屆時你的密碼可能會被拒絕一次——這屬於同一個故障,請再輸入一次,而不要以為密碼錯了。 + Apple 拒絕了此次登入。這是 Apple 的拒絕,不是你的 Apple ID 或密碼有問題。稍後重試是值得的,但如果持續被拒絕,請附上紀錄檔回報,而不要一直等待。 + Apple 目前沒有回應 + Apple 拒絕了該請求,而不是沒有回應。你的帳號沒有問題,也沒有任何變更。請稍後重試;如果持續被拒絕,請附上紀錄檔回報。 匯入歷史記錄 歷史記錄匯入完成 讀取的列數:%1$d\n新增的列數:%2$d\n已存在:%3$d\n格式錯誤:%4$d\n已略過(標籤未匯入):%5$d diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1b0f5a80..88b57366 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -399,6 +399,9 @@ Your tags and their location history are not affected, and nothing was removed f 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 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. Try again shortly; if it keeps refusing, report it with a log. Import History History import complete Rows read: %1$d\nRows added: %2$d\nAlready present: %3$d\nMalformed: %4$d\nSkipped (tag not imported): %5$d 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/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/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 db46614e..94bd2624 100644 --- a/app/src/test/python/test_main.py +++ b/app/src/test/python/test_main.py @@ -1192,6 +1192,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 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..91bba515 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,47 @@ 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" + "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." + ) + + def _apple_failed_after_taking_the_code(error: BaseException) -> SignInInterrupted: """ What to tell somebody whose code was accepted and whose sign-in failed anyway. @@ -584,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/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..0150c350 --- /dev/null +++ b/python/test/test_apple_declining.py @@ -0,0 +1,128 @@ +""" +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 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.""" + 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_itasksForAReportWhenItKeepsHappening(self, message): + """ + Not "wait it out", because for at least one person it never cleared. + + 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 "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.""" + assert "github.com" not in message.lower() 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) 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" },