From ac2b988e9fac1e1225393af9fd61a00568191364 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sun, 30 Aug 2026 22:13:26 +0200 Subject: [PATCH] Stop asking for a code Apple has already spent FindMy.py's 2FA submit does two things: it sends the code, which passes, and then runs a full Grand Slam re-authentication, which can 503 on its own. By then Apple has consumed the code - so the screen's response, "Two-Factor Authentication failed", an empty box and try again, was the one action guaranteed to fail. It compounds. The retry comes back as InvalidCredentialsError, a different error that reads as a typo, and failed2FAAttemptCount climbs until the screen advises changing the Anisette server. Anisette had no part in it, and changing it forces a re-login against a different machine identity (rule 4): somebody is sent to fix something that was never broken. Reported as #168 and reproduced since. The desktop exporter fixed its half through the same library in #169; this is reasoned the same way rather than invented differently, from the handover note. So: classify before counting, say Apple took the code, wait, then request a *new* one - after the wait, never before, because Apple's codes expire and one fetched first is two minutes stale by the time it is typed. No prompt: there is no question worth asking when re-typing cannot work and waiting is the only option. The wait counts down on screen, because two minutes of a still screen on a phone is indistinguishable from a hang. Two goes, then it says plainly that this is Apple's fault - and warns the password may be refused once, which happened in the one observed recovery and otherwise reads as a second, unrelated problem. The Apple ID and password are not asked for again: the account is still in its second-factor state, so requesting on the chosen method is all that is needed. The waits are 60s then 120s, and ACodeAppleAlreadyTookTest goes red if either drops. They are a measurement, not round numbers - the observed recovery was still being refused about a minute after the 503 and worked about two rounds out - and the test carries the reasoning so lowering them has to be a decision rather than a tidy-up. Checked both ways on a device: two of the three screen tests go red with the classification disabled. Co-Authored-By: Claude Opus 5 --- .../python/FakeAppleAuthService.java | 17 ++ .../ACodeAppleTookAndThenFailedOnTest.java | 190 ++++++++++++++++++ .../opentagviewer/AppleLoginActivity.java | 110 ++++++++++ .../util/rx/ACodeAppleAlreadyTook.java | 90 +++++++++ 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 + .../util/rx/ACodeAppleAlreadyTookTest.java | 106 ++++++++++ 15 files changed, 543 insertions(+) create mode 100644 app/src/androidTest/java/dev/wander/android/opentagviewer/ui/login/ACodeAppleTookAndThenFailedOnTest.java create mode 100644 app/src/main/java/dev/wander/android/opentagviewer/util/rx/ACodeAppleAlreadyTook.java create mode 100644 app/src/test/java/dev/wander/android/opentagviewer/util/rx/ACodeAppleAlreadyTookTest.java 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 59db67e2..7d54e723 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 @@ -124,6 +124,23 @@ public FakeAppleAuthService thatRejectsTheCode(final String message) { return this; } + /** + * Apple takes the code and then fails to finish - so the code is spent. + * + *

FindMy.py's submit does two calls: the check, which passed, and a Grand Slam + * re-authentication, which 503s. The message is the one that actually arrives across the + * bridge, because that string is what the app has to pattern-match on - FindMy.py folds + * every non-OK status into {@code UnhandledProtocolError} carrying only the number. + * + *

Reported as issue #168; the desktop exporter fixed its half in #169. + */ + public FakeAppleAuthService whereAppleTakesTheCodeThenFails() { + this.codeFailsWith = new RuntimeException( + "com.chaquo.python.PyException: findmy.errors.UnhandledProtocolError:" + + " Error response for GSA request: 503"); + return this; + } + @Override public Observable login( final String emailOrPhone, final String password, final String anisetteServerUrl, diff --git a/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/login/ACodeAppleTookAndThenFailedOnTest.java b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/login/ACodeAppleTookAndThenFailedOnTest.java new file mode 100644 index 00000000..560e157e --- /dev/null +++ b/app/src/androidTest/java/dev/wander/android/opentagviewer/ui/login/ACodeAppleTookAndThenFailedOnTest.java @@ -0,0 +1,190 @@ +package dev.wander.android.opentagviewer.ui.login; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +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 android.app.Activity; +import android.app.Instrumentation.ActivityResult; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.espresso.intent.Intents; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import dev.wander.android.opentagviewer.AppleLoginActivity; +import dev.wander.android.opentagviewer.Eventually; +import dev.wander.android.opentagviewer.MapsActivity; +import dev.wander.android.opentagviewer.R; +import dev.wander.android.opentagviewer.anisette.FakeAnisetteSource; +import dev.wander.android.opentagviewer.db.AccountBeaconsForTests; +import dev.wander.android.opentagviewer.python.AppDependencies; +import dev.wander.android.opentagviewer.python.FakeAppleAuthService; + +/** + * The 503 that arrives after Apple has accepted the code. + * + *

The submit is two calls, and only the second failed. FindMy.py checks the code - which + * passes - and then runs a Grand Slam re-authentication, which can 503 on its own. By then Apple + * has consumed the code. + * + *

So the screen's old behaviour was the one thing guaranteed to fail. It cleared the box + * and asked for the code again; the code is spent, so the next attempt returns + * {@code InvalidCredentialsError} - which reads as a typo - and the failure counter climbs until + * the screen advises changing the Anisette server. Anisette had no part in it, and changing it + * forces a re-login against a different machine identity (AGENTS.md rule 4): somebody is sent to + * fix something that was never broken. + * + *

Reported as + * #168. The desktop + * exporter fixed the same bug through the same library in #169, and this is deliberately reasoned + * the same way rather than invented differently. + * + *

The arithmetic of the waits lives in {@code ACodeAppleAlreadyTookTest} on the JVM, including + * the test that goes red if either is shortened. This covers what the screen does. + */ +@LargeTest +@RunWith(AndroidJUnit4.class) +public class ACodeAppleTookAndThenFailedOnTest { + + private static final String EMAIL = "someone@example.com"; + private static final String PASSWORD = "hunter2"; + private static final String A_CODE = "222222"; + + private FakeAppleAuthService apple; + private ActivityScenario scenario; + + @Before + public void replaceApple() { + AccountBeaconsForTests.forgetThemAll(); + + this.apple = FakeAppleAuthService.wantsTwoFactor().whereAppleTakesTheCodeThenFails(); + AppDependencies.replaceAuthService(this.apple); + AppDependencies.replaceAnisette(settings -> FakeAnisetteSource.ready()); + + Intents.init(); + Intents.intending(androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent( + MapsActivity.class.getName())) + .respondWith(new ActivityResult(Activity.RESULT_OK, null)); + + this.scenario = ActivityScenario.launch(AppleLoginActivity.class); + } + + @After + public void putItBack() { + if (this.scenario != null) { + this.scenario.close(); + } + Intents.release(); + AppDependencies.reset(); + getInstrumentation().waitForIdleSync(); + AccountBeaconsForTests.forgetThemAll(); + } + + /** + * It says Apple took the code, rather than blaming the code. + * + *

The distinction the whole change turns on: this is not a wrong code, and a screen that + * says "Two-Factor Authentication failed" over an empty box invites the one action that + * cannot work. + */ + @Test + public void itSaysAppleTookTheCodeRatherThanBlamingTheUser() { + this.getToTheCodeBoxAndSubmit(); + + Eventually.check(() -> onView(withId(R.id.verification_code_error_message)) + .check(matches(isDisplayed()))); + + // **Asserted against the old wording, not merely the absence of "Anisette".** The old + // message does not mention Anisette on a first attempt either, so a test that only + // checked for that passed with the fix removed - it proved nothing about the case it was + // written for. What must be gone is the sentence that blames the code. + final String blamesTheCode = getInstrumentation().getTargetContext() + .getString(R.string.twofactor_failed_x, "").trim(); + + Eventually.check(() -> onView(withId(R.id.verification_code_error_message)) + .check(matches(not(withText(containsString(blamesTheCode)))))); + onView(withId(R.id.verification_code_error_message)) + .check(matches(not(withText(containsString("Anisette"))))); + } + + /** + * And it does not count as a failed attempt. + * + *

This is the assertion with teeth. The counter is what eventually produces the + * change-your-Anisette-server advice, so a fault on Apple's side reaching it turns one bad + * afternoon into a re-login against a different machine identity. + */ + @Test + public void itDoesNotCountTowardsTheAnisetteAdvice() { + this.getToTheCodeBoxAndSubmit(); + for (int attempt = 0; attempt < 3; attempt++) { + this.submitTheCode(); + } + + // Four goes is past HINT_DIFFERENT_ANISETTE_SERVER_AFTER_FAILED_2FACODES, so if this were + // being counted as a rejected code the hint would be on screen by now. + onView(withId(R.id.verification_code_error_message)) + .check(matches(not(withText(containsString("Anisette"))))); + } + + /** + * Nobody is asked to sign in again. + * + *

The account is still in its second-factor state, so a new code needs only a request on + * the chosen method. Sending them back to the email and password screen would be a second + * thing that looks broken. + */ + @Test + public void theAppleIdAndPasswordAreNotAskedForAgain() { + this.getToTheCodeBoxAndSubmit(); + + Eventually.check(() -> onView(withId(R.id.login_2fa_container)) + .check(matches(isDisplayed()))); + onView(withId(R.id.login_maininfo_container)).check(matches(not(isDisplayed()))); + + assertEquals("signing in again was not needed and must not happen", + 1, this.apple.timesCalled("login")); + } + + private void getToTheCodeBoxAndSubmit() { + onView(withId(R.id.email_or_phone_input_field)).perform(replaceText(EMAIL)); + onView(withId(R.id.password_input_field)) + .perform(replaceText(PASSWORD), closeSoftKeyboard()); + + Eventually.perform("the sign in button", () -> this.apple.timesCalled("login") > 0, + () -> onView(withId(R.id.login_button_main)).perform(click())); + + Eventually.check(() -> onView(withText(containsString(FakeAppleAuthService.PHONE_ONE))) + .check(matches(isDisplayed()))); + onView(withText(containsString(FakeAppleAuthService.PHONE_ONE))).perform(click()); + + this.submitTheCode(); + } + + private void submitTheCode() { + final long before = this.apple.timesCalled("submitCode"); + + Eventually.check(() -> onView(withId(R.id.twofactorauth_textinput_1)) + .check(matches(isDisplayed()))); + + // replaceText rather than typeText: the boxes move focus as they fill, and a paste is + // what people actually do with a code. See AGENTS.md on Espresso. + Eventually.perform("the code", () -> this.apple.timesCalled("submitCode") > before, + () -> onView(withId(R.id.twofactorauth_textinput_1)).perform(replaceText(A_CODE))); + } +} 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 5a47b334..b02f4a69 100644 --- a/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java +++ b/app/src/main/java/dev/wander/android/opentagviewer/AppleLoginActivity.java @@ -72,6 +72,7 @@ import dev.wander.android.opentagviewer.ui.settings.SharedMainSettingsManager; import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil; import dev.wander.android.opentagviewer.util.android.PropertiesUtil; +import dev.wander.android.opentagviewer.util.rx.ACodeAppleAlreadyTook; import dev.wander.android.opentagviewer.viewmodel.AppleLoginViewModel; import dev.wander.android.opentagviewer.viewmodel.LoginActivityState; import dev.wander.android.opentagviewer.viewmodel.LoginActivityState.PAGE; @@ -1013,6 +1014,104 @@ public void onClickBackTo2FAMethodChoice(View view) { this.show2FAChoiceScreen(Direction.BACK); } + /** + * How many times a spent code has been waited out on this screen. Never reset on purpose: + * two goes at it is the budget for one sign-in, not per code. + */ + private int spentCodeRecoveries = 0; + + /** Cancelled if the screen goes away mid-wait, so a dead activity is not written to. */ + private final Handler waitingForApple = new Handler(Looper.getMainLooper()); + + /** + * Apple took the code and then failed. Wait, then ask for a new one - without a prompt. + * + *

There is no question worth asking, so none is asked. Re-typing cannot work and + * waiting is the only option, so a dialog would offer a choice between one real answer and a + * wrong one. The exporter reached the same conclusion and has a test asserting the user is + * never prompted. + * + *

The wait is shown counting down. Two minutes of a still screen on a phone is + * indistinguishable from a hang, and gets force-quit. + * + *

And the new code is requested after the wait, never before. Both orders look + * right in a diff; Apple's codes expire, so one fetched first is two minutes stale by the + * time it is typed. + * + *

The Apple ID and password are not asked for again. The account is still in its + * second-factor state, so requesting on the chosen method is all that is needed. + */ + private void recoverFromApppleTakingTheCode( + final FrameLayout errorBox, final TextView errorText) { + + final long wait = ACodeAppleAlreadyTook.waitBefore(this.spentCodeRecoveries); + this.spentCodeRecoveries++; + + this.hideLoading(); + this.showPage(R.id.login_2fa_container, Direction.BACK); + this.twoFactorEntryManager.clear(); + this.twoFactorAuthChoiceBackButton.setEnabled(true); + errorBox.setVisibility(VISIBLE); + + if (wait < 0) { + // Out of goes. Say whose fault it is, and warn about the password refusal - it + // happened in the one observed recovery, and unwarned it reads as a second, + // unrelated problem. + Log.w(TAG, "Apple did not recover after waiting it out twice"); + errorText.setText(R.string.twofactor_apple_did_not_recover); + return; + } + + Log.i(TAG, "Apple took the code and then failed; waiting " + wait + + "ms before asking for a new one"); + errorText.setText(R.string.twofactor_apple_took_the_code); + this.countDownThenAskForANewCode(wait, errorBox, errorText); + } + + private void countDownThenAskForANewCode( + final long remaining, final FrameLayout errorBox, final TextView errorText) { + if (this.isFinishing() || this.isDestroyed()) { + return; + } + + if (remaining <= 0) { + final var chosen = this.getUiState().getChosenAuthMethod(); + if (chosen == null) { + errorText.setText(R.string.twofactor_apple_did_not_recover); + return; + } + + var async = this.authService.requestCode(chosen) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + () -> { + // The box is already empty and the prompt above it still says + // where the code was sent, so the error line's job is done. + Log.i(TAG, "Asked Apple for a fresh code after the wait"); + errorBox.setVisibility(GONE); + }, + error -> { + Log.e(TAG, "Asking for a fresh code failed too", error); + errorText.setText(R.string.twofactor_apple_did_not_recover); + }); + return; + } + + errorText.setText(this.getString( + R.string.twofactor_waiting_seconds, (int) Math.ceil(remaining / 1000.0))); + + this.waitingForApple.postDelayed( + () -> this.countDownThenAskForANewCode(remaining - 1000L, errorBox, errorText), + 1000L); + } + + @Override + protected void onDestroy() { + // Or a countdown outlives the screen and writes to views that are gone. + this.waitingForApple.removeCallbacksAndMessages(null); + super.onDestroy(); + } + private void on2FAAuthCodeFilled(final String authCode) { if (!REGEX_2FA_CODE.matcher(authCode).matches()) { Log.w(TAG, "2FA Auth code from callback was invalid: " + authCode); @@ -1055,6 +1154,17 @@ private void on2FAAuthCodeFilled(final String authCode) { }, error -> { // I really would like to handle this error separately from the one above, hence the nesting above. Log.e(TAG, "Failed to authenticate using auth code " + authCode, error); + + // **Apple taking the code and then failing is not a wrong code**, and must be told + // apart before anything counts it as one. See ACodeAppleAlreadyTook: the submit does + // two calls, the first succeeded, and the code is spent - so returning to the code + // box is the one action guaranteed to fail, and the attempt counter would eventually + // advise changing the Anisette server for a fault Anisette had no part in. + if (ACodeAppleAlreadyTook.spentIt(error)) { + this.recoverFromApppleTakingTheCode(twoFactorErrorMessage, errorMessageText); + return; + } + var state = this.getUiState(); final int failedLoginAttemptCount = state.getFailed2FAAttemptCount() + 1; state.setFailed2FAAttemptCount(failedLoginAttemptCount); 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 new file mode 100644 index 00000000..aeaccd0f --- /dev/null +++ b/app/src/main/java/dev/wander/android/opentagviewer/util/rx/ACodeAppleAlreadyTook.java @@ -0,0 +1,90 @@ +package dev.wander.android.opentagviewer.util.rx; + +import java.util.concurrent.TimeUnit; + +/** + * The 503 that arrives after Apple has accepted a verification code. + * + *

Two calls hide behind one submit. FindMy.py's {@code td_2fa_submit} first sends the + * code - that is the check, and it passes - and then runs a full Grand Slam re-authentication. + * The second half can fail on its own, and when it does the code has already been consumed. + * + *

So the one thing the screen used to do is the one thing that cannot work. It sent the + * user back to an empty code box holding a code Apple has already spent. Typing it again returns + * {@code InvalidCredentialsError} - a different error, which reads as "you typed it wrong" + * - and the attempt counter climbs until the screen advises changing the Anisette server. That + * advice is wrong here and expensive: Anisette had nothing to do with it, and changing it forces + * a re-login against a different machine identity (AGENTS.md rule 4). Somebody is sent to fix + * something that was never broken. + * + *

Reported as + * #168 and reproduced + * since. The desktop exporter fixed the same bug through the same library in #169; this is the + * app's half, deliberately reasoned the same way rather than invented differently. + */ +public final class ACodeAppleAlreadyTook { + + /** + * How long to wait before asking for a new code, first time and second. + * + *

These are a measurement, not round numbers, and they are the part most likely to be + * quietly lowered. The one observed recovery went: the 503; then a whole manual round - + * re-typing the Apple ID and password, choosing delivery, waiting for a code, typing it - + * which was refused at the password step; then another round, which worked. A manual + * round is the better part of a minute, so roughly a minute after the 503 the account was + * still being refused, and what eventually worked was about two rounds out. + * + *

A first wait materially under a minute is known to be too short. {@code + * ACodeAppleAlreadyTookTest} goes red if either number drops, with the reasoning attached, so + * that lowering them has to be a decision rather than a tidy-up. + * + *

The honest caveat, carried over from the exporter's write-up: that middle refusal was on + * the password call rather than the 2FA call, so it does not strictly prove a new code would + * have been rejected at that instant. It is the only measurement there is, and it points one + * way. + */ + public static final long[] WAITS_MS = { + TimeUnit.SECONDS.toMillis(60), + TimeUnit.SECONDS.toMillis(120), + }; + + private ACodeAppleAlreadyTook() { + } + + /** + * Whether this failure spent the user's code. + * + *

A wide net, on purpose. Everything it catches happened after the submit + * returned, so the code is gone in all of them - and the cost of being wrong runs one way. + * Treating a spent code as a typo sends somebody back to type it again, which cannot work and + * ends in bad advice about Anisette; treating a typo as a spent code costs a wait and a fresh + * 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. + */ + 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")) { + return true; + } + if (cause.getCause() == cause) { + break; + } + } + return false; + } + + /** + * @param attempt how many times this has already been waited out, from zero. + * @return how long to wait before asking Apple for a new code, or -1 when there is no attempt + * left and the user should be told plainly that it did not clear. + */ + public static long waitBefore(final int attempt) { + return attempt >= 0 && attempt < WAITS_MS.length ? WAITS_MS[attempt] : -1; + } +} diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index e3262fa4..eb846b7f 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -310,4 +310,7 @@ Du kannst das jetzt einrichten oder jederzeit später in den Einstellungen.Auf dem Stand deines Apple-Kontos Dein Apple-Konto war gerade nicht erreichbar. An deinen Tags hat sich nichts geändert. Dieses Protokoll ist zu groß zum Kopieren. Speichere es stattdessen als Datei und hänge diese an. + 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. \ 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 54ecf193..7672e7b4 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -310,4 +310,7 @@ You can set this up now, or any time later from Settings. Up to date with your Apple account Could not reach your Apple account just now. Your tags are unchanged. This log is too large to copy. Save it as a file and attach that instead. + 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. \ 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 40f2678a..c09e863a 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -310,4 +310,7 @@ Vous pouvez configurer cela maintenant, ou à tout moment depuis les réglages.< À jour avec votre compte Apple Impossible de joindre votre compte Apple pour le moment. Vos tags sont inchangés. Ce journal est trop volumineux pour être copié. Enregistrez-le plutôt sous forme de fichier et joignez celui-ci. + 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é. \ 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 04e33b91..652a6804 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -310,4 +310,7 @@ Apple アカウントと同じ状態になりました いま Apple アカウントに接続できませんでした。タグはそのままです。 このログはコピーするには大きすぎます。代わりにファイルとして保存して添付してください。 + Apple はコードを受け付けたあと、サインインを完了できませんでした。コードは使用済みなので新しいものが必要です。すぐに要求しても拒否されるため、少し待ってから要求します。 + %1$d 秒後に Apple へ新しいコードを要求します… + Apple はまだサインインを完了できていません。これは Apple 側の障害であり、あなたの操作やコードのせいではありません。\n\n数分後にもう一度お試しください。そのときパスワードが一度だけ拒否されることがありますが、これも同じ障害の一部です。間違っていると考えず、もう一度入力してください。 \ 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 996727d8..ca9b23ba 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -310,4 +310,7 @@ Apple 계정과 동기화되었습니다 지금은 Apple 계정에 연결하지 못했습니다. 태그는 그대로입니다. 이 로그는 너무 커서 복사할 수 없습니다. 대신 파일로 저장한 뒤 첨부하세요. + Apple이 코드를 받은 뒤 로그인을 끝내지 못했습니다. 코드는 이미 사용되었으므로 새 코드가 필요합니다. 바로 요청하면 거부되기 때문에 잠시 기다린 뒤 요청합니다. + %1$d초 후에 Apple에 새 코드를 요청합니다… + Apple이 아직 로그인을 마치지 못하고 있습니다. 이는 Apple 쪽 장애이며, 사용자의 잘못도 코드 문제도 아닙니다.\n\n몇 분 뒤에 다시 시도하세요. 그때 비밀번호가 한 번 거부될 수 있는데, 이것도 같은 장애의 일부이므로 틀렸다고 생각하지 말고 다시 입력하세요. \ 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 296e0b7a..3139a056 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -310,4 +310,7 @@ Je kunt dit nu instellen, of later altijd nog via Instellingen. Bijgewerkt met je Apple-account Je Apple-account was even niet bereikbaar. Je tags zijn ongewijzigd. Dit logbestand is te groot om te kopiëren. Sla het op als bestand en voeg dat toe. + 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. \ 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 c82e9acb..6b0fd930 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -310,4 +310,7 @@ Данные соответствуют учётной записи Apple Сейчас не удалось связаться с учётной записью Apple. Метки не изменились. Этот журнал слишком велик для копирования. Сохраните его в файл и приложите его. + Apple приняла ваш код, а затем не смогла завершить вход. Код уже использован, поэтому нужен новый — подождём немного перед запросом, потому что сразу запрашивать бесполезно. + Запросим новый код у Apple через %1$d с… + Apple по-прежнему не завершает вход. Это сбой на стороне Apple — не ваша вина и не проблема кода.\n\nПопробуйте снова через несколько минут. Пароль при этом может быть отклонён один раз: это часть того же сбоя, поэтому введите его ещё раз, а не считайте неверным. \ 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 e6dccba7..1a85cf5e 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -310,4 +310,7 @@ 已与你的 Apple 账户同步 此刻无法连接你的 Apple 账户。标签没有变化。 此日志太大,无法复制。请改为保存为文件并附上该文件。 + Apple 已接受你的验证码,随后未能完成登录。该验证码已被用掉,需要一个新的——我们会先等一会儿再申请,因为立刻申请会被拒绝。 + 将在 %1$d 秒后向 Apple 申请新验证码… + Apple 仍未完成登录。这是 Apple 一侧的故障,不是你的操作问题,也不是验证码的问题。\n\n请过几分钟再试。届时你的密码可能会被拒绝一次——这属于同一个故障,请再输入一次,而不要以为密码错了。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 6fbea036..a0792b1d 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -310,4 +310,7 @@ 已與你的 Apple 帳戶同步 此刻無法連線到你的 Apple 帳戶。標籤沒有變化。 此紀錄檔太大,無法複製。請改為儲存成檔案並附上該檔案。 + Apple 已接受你的驗證碼,隨後未能完成登入。該驗證碼已被用掉,需要一個新的——我們會先等一下再申請,因為立刻申請會被拒絕。 + 將在 %1$d 秒後向 Apple 申請新驗證碼… + Apple 仍未完成登入。這是 Apple 一側的故障,不是你的操作問題,也不是驗證碼的問題。\n\n請過幾分鐘再試。屆時你的密碼可能會被拒絕一次——這屬於同一個故障,請再輸入一次,而不要以為密碼錯了。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8b41b3bf..0ce454d4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -342,4 +342,7 @@ You can set this up now, or any time later from Settings. Up to date with your Apple account Could not reach your Apple account just now. Your tags are unchanged. This log is too large to copy. Save it as a file and attach that instead. + 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. 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 new file mode 100644 index 00000000..1b6fde2d --- /dev/null +++ b/app/src/test/java/dev/wander/android/opentagviewer/util/rx/ACodeAppleAlreadyTookTest.java @@ -0,0 +1,106 @@ +package dev.wander.android.opentagviewer.util.rx; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.concurrent.TimeUnit; + +/** + * Telling a code Apple already took from a code the user got wrong. + * + *

The difference decides whether somebody is sent back to retype a code that can never be + * accepted, and eventually told to change their Anisette server for a fault that had nothing to + * do with Anisette. + */ +public class ACodeAppleAlreadyTookTest { + + /** How the failure actually arrives: a Chaquopy PyException carrying the Python class name. */ + private static Throwable fromTheBridge(final String message) { + return new RuntimeException("com.chaquo.python.PyException: " + message); + } + + @Test + public void a503AfterTheCodeWasTakenIsRecognised() { + assertTrue(ACodeAppleAlreadyTook.spentIt(fromTheBridge( + "UnhandledProtocolError: Error response for GSA request: 503"))); + } + + @Test + public void itIsFoundThroughAWrappingException() { + final Throwable wrapped = new IllegalStateException("submitting the code failed", + fromTheBridge("UnhandledProtocolError: Error response for GSA request: 503")); + + assertTrue("the bridge's exception is usually wrapped by the time a screen sees it", + ACodeAppleAlreadyTook.spentIt(wrapped)); + } + + /** + * The one it must not claim. A rejected code is a typo, and the screen's existing + * behaviour - clear the box, let them try again - is exactly right for it. + */ + @Test + public void aRejectedCodeIsNotThis() { + assertFalse(ACodeAppleAlreadyTook.spentIt(fromTheBridge( + "InvalidCredentialsError: The verification code was not accepted"))); + } + + @Test + public void nothingAtAllIsNotThis() { + assertFalse(ACodeAppleAlreadyTook.spentIt(null)); + assertFalse(ACodeAppleAlreadyTook.spentIt(new RuntimeException())); + } + + /** A cycle in the cause chain must not hang the screen. See ICloudFailures, same guard. */ + @Test(timeout = 2000) + public void aSelfReferencingCauseDoesNotSpin() { + final Throwable loop = new RuntimeException("something") { + @Override + public synchronized Throwable getCause() { + return this; + } + }; + + assertFalse(ACodeAppleAlreadyTook.spentIt(loop)); + } + + // ---------------------------------------------------------------- the waits + + /** + * These numbers are a measurement, and this test exists so lowering them is a decision. + * + *

The one observed recovery: the 503, then a full manual round - Apple ID, password, + * choose delivery, wait, type the code - which was refused at the password step, then another + * round that worked. A round is the better part of a minute, so the account was still + * refusing about a minute after the 503, and what worked was roughly two rounds out. + * + *

So a first wait materially under a minute is known to be too short. If this test is in + * the way, the thing to change is the evidence, not the constant. + */ + @Test + public void theFirstWaitIsNotShorterThanAMinute() { + assertTrue("a wait under a minute is known to be too short - see the class comment", + ACodeAppleAlreadyTook.waitBefore(0) >= TimeUnit.SECONDS.toMillis(60)); + } + + @Test + public void theSecondWaitIsLongerStill() { + assertTrue("the second attempt has to reach further out than the first", + ACodeAppleAlreadyTook.waitBefore(1) >= TimeUnit.SECONDS.toMillis(120)); + } + + @Test + public void thereAreExactlyTwoAttemptsAndThenItGivesUp() { + assertEquals(2, ACodeAppleAlreadyTook.WAITS_MS.length); + assertEquals("past the last wait there is nothing left to try", + -1, ACodeAppleAlreadyTook.waitBefore(2)); + assertEquals(-1, ACodeAppleAlreadyTook.waitBefore(99)); + } + + @Test + public void aNonsenseAttemptNumberDoesNotWait() { + assertEquals(-1, ACodeAppleAlreadyTook.waitBefore(-1)); + } +}