Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,7 @@ chaquopy {
// wheel for desktop platforms and a pure-Python `py3-none-any` one as well.
// There is no Android wheel, so pip falls back to the pure-Python build - which
// is correct but markedly slower. The messages here are small enough not to care.
install("git+https://github.com/parawanderer/FindMy.py@b6f544bc673b66adeac9e8315f8ef499c4956036")
install("git+https://github.com/parawanderer/FindMy.py@bcb078761085c078eadac477da39a4c85b1f3a44")

install("NSKeyedUnArchiver==1.5")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
import dev.wander.android.opentagviewer.python.AppDependencies;
import dev.wander.android.opentagviewer.ui.error.ErrorReportActivity;
import dev.wander.android.opentagviewer.python.PythonAuthService.AuthMethodPhone;
import dev.wander.android.opentagviewer.util.HowLongToWait;
import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil;

/**
Expand Down Expand Up @@ -347,6 +348,37 @@ public void appleDecliningIsNotBlamedOnThePasswordOrTheNetwork() {
not(withText(containsString("Grand Slam")))));
}

/**
* <b>When Apple names a wait, the screen gives it rather than "try again shortly".</b>
*
* <p>A {@code Retry-After} on the refusal, carried across the bridge. The test above is the
* other half - no header, which is every refusal observed so far - and it pins that no number
* is invented then.
*/
@Test
public void aWaitAppleNamedIsShownRoundedUp() {
this.apple = FakeAppleAuthService.appleAsksToWait(90);
AppDependencies.replaceAuthService(this.apple);

launch();
signIn();

final android.content.Context context = getInstrumentation().getTargetContext();
final java.util.Locale locale = context.getResources().getConfiguration().getLocales().get(0);

// The device's ICU, not a string this test composed: 90 seconds is two minutes, not one.
assertEquals("2 minutes", HowLongToWait.of(90).describe(java.util.Locale.ENGLISH));

Eventually.check(() -> onView(withId(R.id.login_error_message_text)).check(matches(
withText(context.getString(R.string.login_failed_apple_asked_to_wait,
HowLongToWait.of(90).describe(locale))))));

onView(withId(R.id.login_error_message_text)).check(matches(
not(withText(context.getString(R.string.login_failed_apple_declined)))));
onView(withId(R.id.login_error_message_text)).check(matches(
not(withText(containsString("429")))));
}

/**
* <b>A failed sign-in can produce a log without a second machine.</b>
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,30 @@ public static FakeAppleAuthService appleIsDeclining() {
new FakeAppleAuthService(LOGIN_STATE.LOGGED_OUT, null);
fake.loginFailsWith = new PythonAccountLoginException(
"The Grand Slam request was refused with HTTP 503. This is Apple declining to"
+ " serve the request rather than a response this library cannot read,"
+ " and it usually clears on its own -- wait and try again.",
+ " serve the request rather than a response this library cannot read."
+ " Waiting and trying again may help.",
PythonAccountLoginException.REASON_APPLE_DECLINED);
return fake;
}

/**
* Apple declining, and saying how long to leave it - a {@code Retry-After} on the refusal.
*
* <p><b>Not yet seen from Grand Slam</b>, which is why {@link #appleIsDeclining()} carries no
* wait: that is the case people actually meet. This is the one where Apple names a time.
*/
public static FakeAppleAuthService appleAsksToWait(final double seconds) {
final FakeAppleAuthService fake =
new FakeAppleAuthService(LOGIN_STATE.LOGGED_OUT, null);
fake.loginFailsWith = new PythonAccountLoginException(
"The Grand Slam request was refused with HTTP 429. This is Apple declining to"
+ " serve the request rather than a response this library cannot read.",
PythonAccountLoginException.REASON_APPLE_DECLINED,
null,
seconds);
return fake;
}

/** Signing in works, but the code that gets typed is refused. */
public FakeAppleAuthService thatRejectsTheCode(final String message) {
this.codeFailsWith = new PythonAccountLoginException(message);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
import dev.wander.android.opentagviewer.ui.login.StepTransition.Direction;
import dev.wander.android.opentagviewer.ui.settings.AmapApiKeyDialog;
import dev.wander.android.opentagviewer.ui.settings.SharedMainSettingsManager;
import dev.wander.android.opentagviewer.util.HowLongToWait;
import dev.wander.android.opentagviewer.util.android.AppCryptographyUtil;
import dev.wander.android.opentagviewer.util.android.PropertiesUtil;
import dev.wander.android.opentagviewer.util.rx.ACodeAppleAlreadyTook;
Expand Down Expand Up @@ -762,6 +763,15 @@ private String describeLoginFailure(final Throwable error) {
// 503" verbatim.** That is accurate and reads as a bug in this app, which is how issue
// #176 came to be filed. Nothing about the Apple ID or the password is wrong here.
if (PythonAccountLoginException.REASON_APPLE_DECLINED.equals(reason)) {
// Apple's own wait replaces the "try again shortly" advice when it named one - it is
// the only reliable answer to "how long". It usually has not, and then nothing here
// invents a number: a guess too short is refused again.
final Double wait = ((PythonAccountLoginException) error).getRetryAfterSeconds();
if (wait != null) {
return this.getString(R.string.login_failed_apple_asked_to_wait,
HowLongToWait.of(wait).describe(
this.getResources().getConfiguration().getLocales().get(0)));
}
return this.getString(R.string.login_failed_apple_declined);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ public class PythonAccountLoginException extends RuntimeException {
*/
private final transient PyObject account;

/**
* Seconds Apple asked for before another attempt, or null when it did not say.
*
* <p><b>Null is the usual value, and means unknown rather than "retry now".</b> It comes from
* a {@code Retry-After} header, and no refusal from Grand Slam has been seen carrying one -
* so nothing may substitute a number for it. When present, it is the only reliable advice
* there is about how long to leave it.
*/
private final Double retryAfterSeconds;

public PythonAccountLoginException(String message) {
this(message, REASON_UNKNOWN);
}
Expand All @@ -64,21 +74,29 @@ public PythonAccountLoginException(String message, String reason) {
}

public PythonAccountLoginException(String message, String reason, PyObject account) {
this(message, reason, account, null);
}

public PythonAccountLoginException(
String message, String reason, PyObject account, Double retryAfterSeconds) {
super(message);
this.reason = reason == null || reason.isBlank() ? REASON_UNKNOWN : reason;
this.account = account;
this.retryAfterSeconds = retryAfterSeconds;
}

public PythonAccountLoginException(String message, Throwable cause) {
super(message, cause);
this.reason = REASON_UNKNOWN;
this.account = null;
this.retryAfterSeconds = null;
}

public PythonAccountLoginException(Throwable cause) {
super(cause);
this.reason = REASON_UNKNOWN;
this.account = null;
this.retryAfterSeconds = null;
}

/** Which kind of failure this was, for choosing what to show. Never null. */
Expand All @@ -91,6 +109,11 @@ public PyObject getAccount() {
return this.account;
}

/** How long Apple asked the user to wait, in seconds, or null. See the field. */
public Double getRetryAfterSeconds() {
return this.retryAfterSeconds;
}

/** Whether this is a terms failure that can actually be recovered from in the app. */
public boolean hasTermsToAccept() {
return REASON_TERMS.equals(this.reason) && this.account != null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,14 @@ public static Observable<PythonAuthResponse> pythonLogin(
// agreeing needs. Null for every other reason, which is what stops it being
// stored in a state that fails every later fetch (issues #43 and #119).
final var pendingAccount = resultMap.get("account");
// Present only when Apple named a wait, which it has not yet been seen to do.
// Absent is carried as null, never as zero - zero would read as "retry now".
final var retryAfter = resultMap.get("retryAfterSeconds");
throw new PythonAccountLoginException(
errorMessage,
reason == null ? null : reason.toString(),
pendingAccount);
pendingAccount,
retryAfter == null ? null : retryAfter.toDouble());
}

// need to do an annoying conversion here...
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package dev.wander.android.opentagviewer.util;

import android.icu.text.MeasureFormat;
import android.icu.util.Measure;
import android.icu.util.MeasureUnit;

import java.util.Locale;

/**
* A wait Apple asked for, as a person would say it.
*
* <p><b>Always rounded up.</b> Somebody told to come back too early is refused again, and then
* trusts the number less - so 61 seconds is "2 minutes", never "1 minute".
*
* <p>Seconds below a minute, minutes below two hours, hours after that. The desktop exporter's
* {@code _a_wait_a_person_reads} draws the same lines, so the two say the same thing about the
* same header.
*
* <p>The rounding is here and plain Java so the JVM suite can pin it; the wording comes from
* {@code android.icu}, which knows every locale's plural forms and so needs no string per unit.
*/
public final class HowLongToWait {

public enum Unit { SECONDS, MINUTES, HOURS }

private static final long MINUTE = 60;
private static final long HOUR = 60 * MINUTE;

private final long amount;
private final Unit unit;

private HowLongToWait(final long amount, final Unit unit) {
this.amount = amount;
this.unit = unit;
}

public static HowLongToWait of(final double seconds) {
final long whole = Math.max(0, (long) Math.ceil(seconds));
if (whole < MINUTE) {
return new HowLongToWait(whole, Unit.SECONDS);
}
if (whole < 2 * HOUR) {
return new HowLongToWait(ceilDiv(whole, MINUTE), Unit.MINUTES);
}
return new HowLongToWait(ceilDiv(whole, HOUR), Unit.HOURS);
}

public long getAmount() {
return this.amount;
}

public Unit getUnit() {
return this.unit;
}

/** "2 minutes", "2 Minuten", "2 分钟" - in the given locale, with its own plural rules. */
public String describe(final Locale locale) {
final MeasureUnit measured;
switch (this.unit) {
case SECONDS: measured = MeasureUnit.SECOND; break;
case MINUTES: measured = MeasureUnit.MINUTE; break;
default: measured = MeasureUnit.HOUR; break;
}
return MeasureFormat.getInstance(locale, MeasureFormat.FormatWidth.WIDE)
.format(new Measure(this.amount, measured));
}

private static long ceilDiv(final long x, final long y) {
return (x + y - 1) / y;
}
}
29 changes: 26 additions & 3 deletions app/src/main/python/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,11 @@ def loginSync(email: str, password: str, anisetteServerUrl: str,
"reason": reason,
}

# Absent rather than zero when Apple did not say, because zero reads as "retry now".
wait = appleAskedToWait(e)
if wait is not None:
failure["retryAfterSeconds"] = wait

# **The account survives a terms failure, and only a terms failure.**
#
# Authentication itself worked here - it is the delegate exchange after it that did not
Expand Down Expand Up @@ -465,9 +470,13 @@ def assertAnisetteIsSupported(serializedAccountData: str) -> str | None:

**A 503 from Grand Slam reads as a rejected sign-in to everybody who meets it**, because the only
thing on screen is a failure at the moment a password was entered. It is neither: nothing was
wrong with the credentials, nothing was reached and refused on their merits, and it clears on its
own within minutes. Reported as OpenTagViewer#176, where one account met the same 503 three times
in three minutes at three different call sites.
wrong with the credentials, and nothing was reached and refused on their merits. Reported as
OpenTagViewer#176, where one account met the same 503 three times in three minutes at three
different call sites.

**It does not reliably clear on its own**, so nothing on screen promises that. The 503s of
September 2026 were Apple refusing the Xcode client identifier, and lasted until the client
changed; a 429 has been seen to outlast a reinstall.

Distinguished from :data:`REASON_NETWORK` because the advice differs. A network failure is
usually the phone's - check the connection. This one is Apple's, and checking anything at this
Expand Down Expand Up @@ -521,6 +530,20 @@ def classifyLoginFailure(error: BaseException) -> str:
return REASON_UNKNOWN


def appleAskedToWait(error: BaseException) -> float | None:
"""
Seconds Apple asked for before another attempt, or None when it did not say.

**None is the usual answer.** It comes from a `Retry-After` header, and no refusal from Grand
Slam has been seen carrying one - so the screen treats None as "unknown" and says nothing
about a wait, rather than inventing one. When Apple does name a time it is the only reliable
advice available, which is why it is carried at all.
"""
if isinstance(error, AppleServiceUnavailableError):
return error.retry_after
return None


def describeLoginFailure(error: BaseException) -> str:
"""
A detail string that is **never empty**.
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -387,4 +387,5 @@ Es wurde kein Verlauf importiert und bereits gespeicherte Daten wurden nicht ge
<string name="error_report_body_login">Die Anmeldung ist nicht durchgekommen. Das Protokoll unten nennt den fehlgeschlagenen Schritt und die Antwort darauf – meist genau der Teil, der es erklärt.

Wenn Apple die Anfrage lediglich abgelehnt hat, lohnt es sich, zu warten und es erneut zu versuchen. Schlägt es weiterhin fehl, macht ein Bericht mit diesem Protokoll es behebbar.</string>
<string name="login_failed_apple_asked_to_wait">Apple hat die Anmeldung abgelehnt und bittet, %1$s bis zum nächsten Versuch zu warten. Das ist eine Ablehnung durch Apple und kein Problem mit deiner Apple-ID oder deinem Passwort.</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-en/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -387,4 +387,5 @@ No history was imported and nothing already stored was changed.</string>
<string name="error_report_body_login">Signing in did not get through. The log below says which step failed and what came back, which is almost always the part that explains it.

If Apple simply refused the request, waiting and trying again is worth doing first. If it keeps failing, a report with this log attached is what makes it fixable.</string>
<string name="login_failed_apple_asked_to_wait">Apple refused the sign-in and asked for %1$s before trying again. This is Apple declining, not a problem with your Apple ID or password.</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-fr/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -387,4 +387,5 @@ Aucun historique n’a été importé et les données déjà enregistrées n’o
<string name="error_report_body_login">La connexion n\'a pas abouti. Le journal ci-dessous indique quelle étape a échoué et ce qui a été renvoyé, ce qui explique presque toujours le problème.

Si Apple a simplement refusé la requête, il vaut mieux attendre et réessayer. Si l\'échec persiste, un rapport accompagné de ce journal permet de corriger le problème.</string>
<string name="login_failed_apple_asked_to_wait">Apple a refusé la connexion et demande d\'attendre %1$s avant de réessayer. C\'est un refus d\'Apple, pas un problème avec votre identifiant ou votre mot de passe.</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-ja/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -387,4 +387,5 @@
<string name="error_report_body_login">サインインが完了しませんでした。下のログには、どの手順で失敗し、何が返されたかが記録されています。原因はたいていそこにあります。

Appleが要求を拒否しただけなら、少し待ってからもう一度お試しください。それでも失敗する場合は、このログを添えて報告すると修正できます。</string>
<string name="login_failed_apple_asked_to_wait">Apple がサインインを拒否し、再試行まで %1$s 待つよう求めています。Apple ID やパスワードの問題ではなく、Apple 側の拒否です。</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-ko/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -387,4 +387,5 @@
<string name="error_report_body_login">로그인이 완료되지 않았습니다. 아래 로그에 어느 단계에서 실패했고 무엇이 반환되었는지 나와 있으며, 대개 그 부분이 원인입니다.

Apple이 요청을 거부한 것뿐이라면 잠시 기다렸다가 다시 시도해 보세요. 계속 실패한다면 이 로그를 첨부해 신고하면 해결할 수 있습니다.</string>
<string name="login_failed_apple_asked_to_wait">Apple이 로그인을 거부했으며, 다시 시도하기 전에 %1$s 기다려 달라고 요청했습니다. Apple ID나 비밀번호의 문제가 아니라 Apple 쪽의 거부입니다.</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-nl/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -387,4 +387,5 @@ Er is geen geschiedenis geïmporteerd en eerder opgeslagen gegevens zijn niet ge
<string name="error_report_body_login">Aanmelden is niet gelukt. Het logboek hieronder vermeldt welke stap is mislukt en wat er terugkwam; dat is bijna altijd het deel dat het verklaart.

Als Apple het verzoek simpelweg weigerde, is even wachten en opnieuw proberen het eerste om te doen. Blijft het mislukken, dan maakt een melding met dit logboek het oplosbaar.</string>
<string name="login_failed_apple_asked_to_wait">Apple heeft de aanmelding geweigerd en vraagt %1$s te wachten voor je het opnieuw probeert. Dit is een weigering van Apple, geen probleem met je Apple ID of wachtwoord.</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-ru/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -387,4 +387,5 @@
<string name="error_report_body_login">Вход не выполнен. В журнале ниже указано, какой шаг не удался и что вернулось, — обычно именно это всё и объясняет.

Если Apple просто отклонила запрос, сначала стоит подождать и попробовать снова. Если ошибка повторяется, отчёт с этим журналом позволит её исправить.</string>
<string name="login_failed_apple_asked_to_wait">Apple отклонила вход и просит подождать %1$s, прежде чем повторить попытку. Это отказ со стороны Apple, а не проблема с вашим Apple ID или паролем.</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values-zh-rCN/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -387,4 +387,5 @@
<string name="error_report_body_login">登录未能完成。下面的日志会说明哪一步失败以及返回了什么,原因通常就在其中。

如果只是 Apple 拒绝了请求,先等一会儿再试。若持续失败,附上此日志的报告才能让问题得到修复。</string>
<string name="login_failed_apple_asked_to_wait">Apple 拒绝了此次登录,并要求等待 %1$s 后再试。这是 Apple 的拒绝,不是你的 Apple ID 或密码有问题。</string>
</resources>
Loading
Loading