App 2.4.2: pairing dialog shows errors instead of closing
- On a failure the pairing dialog stays open and shows the reason in red (Cancel becomes Close); before, it closed at once and the reason was only in the Server tab's status line. - "Pairing is not enabled" only when the server says so. Any other 404 (a server older than 2.1.0, or a proxy's page) is reported as a server without pairing. - Pairing and Test connection name the problem: a server name that does not resolve (Test connection used to say "No internet connection"), a server that does not answer, or the HTTP status. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
This commit is contained in:
+2
-2
@@ -34,8 +34,8 @@ android {
|
|||||||
applicationId "com.stinnesbeck.nilsmg4"
|
applicationId "com.stinnesbeck.nilsmg4"
|
||||||
minSdk 27
|
minSdk 27
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 59
|
versionCode 60
|
||||||
versionName "2.4.1"
|
versionName "2.4.2"
|
||||||
|
|
||||||
// Nothing about the server ships in the APK: its URL and ingest token are entered in
|
// Nothing about the server ships in the APK: its URL and ingest token are entered in
|
||||||
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
|
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
message = getString(R.string.conn_err_timeout);
|
message = getString(R.string.conn_err_timeout);
|
||||||
color = COLOR_ERROR;
|
color = COLOR_ERROR;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
message = getString(R.string.conn_err_no_internet);
|
message = describeNetworkError(e, url);
|
||||||
color = COLOR_ERROR;
|
color = COLOR_ERROR;
|
||||||
}
|
}
|
||||||
final String m = message;
|
final String m = message;
|
||||||
@@ -514,6 +514,9 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
.setView(panel)
|
.setView(panel)
|
||||||
.setNegativeButton(R.string.pair_cancel, null)
|
.setNegativeButton(R.string.pair_cancel, null)
|
||||||
.create();
|
.create();
|
||||||
|
// Shown only once the server has answered, so a failure leaves no empty QR frame.
|
||||||
|
qr.setVisibility(View.GONE);
|
||||||
|
code.setVisibility(View.GONE);
|
||||||
dialog.setOnDismissListener(d -> cancelled[0] = true);
|
dialog.setOnDismissListener(d -> cancelled[0] = true);
|
||||||
dialog.show();
|
dialog.show();
|
||||||
|
|
||||||
@@ -525,8 +528,12 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
android.graphics.Bitmap bitmap = QrCode.generate(request.verificationUriComplete, 416);
|
android.graphics.Bitmap bitmap = QrCode.generate(request.verificationUriComplete, 416);
|
||||||
runOnUiThread(() -> {
|
runOnUiThread(() -> {
|
||||||
instructions.setText(getString(R.string.pair_scan, request.verificationUri));
|
instructions.setText(getString(R.string.pair_scan, request.verificationUri));
|
||||||
if (bitmap != null) qr.setImageBitmap(bitmap);
|
if (bitmap != null) {
|
||||||
|
qr.setImageBitmap(bitmap);
|
||||||
|
qr.setVisibility(View.VISIBLE);
|
||||||
|
}
|
||||||
code.setText(request.userCode);
|
code.setText(request.userCode);
|
||||||
|
code.setVisibility(View.VISIBLE);
|
||||||
status.setText(R.string.pair_waiting);
|
status.setText(R.string.pair_waiting);
|
||||||
});
|
});
|
||||||
PairingClient.PollResult result = null;
|
PairingClient.PollResult result = null;
|
||||||
@@ -552,26 +559,58 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
}
|
}
|
||||||
} catch (PairingClient.NotEnabledException e) {
|
} catch (PairingClient.NotEnabledException e) {
|
||||||
outcome = getString(R.string.pair_not_enabled);
|
outcome = getString(R.string.pair_not_enabled);
|
||||||
|
} catch (PairingClient.HttpStatusException e) {
|
||||||
|
outcome = e.status == 404 ? getString(R.string.pair_err_old_server)
|
||||||
|
: getString(R.string.err_http, e.status);
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
return;
|
return;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
outcome = getString(R.string.pair_error, e.getClass().getSimpleName());
|
outcome = describeNetworkError(e, url);
|
||||||
}
|
}
|
||||||
final String message = outcome;
|
final String message = outcome;
|
||||||
final int c = color;
|
final int c = color;
|
||||||
runOnUiThread(() -> {
|
runOnUiThread(() -> {
|
||||||
if (!cancelled[0]) dialog.dismiss();
|
setConnectionStatus(c, message);
|
||||||
if (c == COLOR_OK) {
|
if (c == COLOR_OK) {
|
||||||
if (consumptionCloudClient != null) consumptionCloudClient.invalidateHistory();
|
if (consumptionCloudClient != null) consumptionCloudClient.invalidateHistory();
|
||||||
showServerConfig();
|
showServerConfig();
|
||||||
serverTokenLayout.setError(null);
|
serverTokenLayout.setError(null);
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
|
if (!cancelled[0]) dialog.dismiss();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
setConnectionStatus(c, message);
|
if (cancelled[0]) return;
|
||||||
|
// Keep the dialog up with the reason, instead of closing it on a failure the
|
||||||
|
// driver would otherwise only see as a flicker.
|
||||||
|
qr.setVisibility(View.GONE);
|
||||||
|
code.setVisibility(View.GONE);
|
||||||
|
instructions.setVisibility(View.GONE);
|
||||||
|
status.setText(message);
|
||||||
|
status.setTextColor(c);
|
||||||
|
android.widget.Button close = dialog.getButton(android.content.DialogInterface.BUTTON_NEGATIVE);
|
||||||
|
if (close != null) close.setText(R.string.pair_close);
|
||||||
});
|
});
|
||||||
}, "server-pairing").start();
|
}, "server-pairing").start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A network failure in words the driver can act on: a name that does not resolve, a
|
||||||
|
* server that does not answer, or a status. Shared by Test connection and pairing.
|
||||||
|
*/
|
||||||
|
private String describeNetworkError(Exception e, String url) {
|
||||||
|
String host = Uri.parse(url).getHost();
|
||||||
|
if (host == null) host = url;
|
||||||
|
if (e instanceof java.net.UnknownHostException) return getString(R.string.err_dns, host);
|
||||||
|
if (e instanceof java.net.SocketTimeoutException || e instanceof java.net.ConnectException
|
||||||
|
|| e instanceof java.net.NoRouteToHostException) {
|
||||||
|
return getString(R.string.err_unreachable, host);
|
||||||
|
}
|
||||||
|
if (e instanceof PairingClient.HttpStatusException) {
|
||||||
|
return getString(R.string.err_http, ((PairingClient.HttpStatusException) e).status);
|
||||||
|
}
|
||||||
|
return getString(R.string.pair_error, e.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
|
||||||
private static int httpGet(String url, String token) throws java.io.IOException {
|
private static int httpGet(String url, String token) throws java.io.IOException {
|
||||||
java.net.HttpURLConnection connection = (java.net.HttpURLConnection) new java.net.URL(url).openConnection();
|
java.net.HttpURLConnection connection = (java.net.HttpURLConnection) new java.net.URL(url).openConnection();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -52,13 +52,28 @@ final class PairingClient {
|
|||||||
/** Thrown when pairing is switched off on the server (no APPROVAL_PASSWORD). */
|
/** Thrown when pairing is switched off on the server (no APPROVAL_PASSWORD). */
|
||||||
static final class NotEnabledException extends Exception { }
|
static final class NotEnabledException extends Exception { }
|
||||||
|
|
||||||
|
/** The server answered with a status the flow does not expect. */
|
||||||
|
static final class HttpStatusException extends java.io.IOException {
|
||||||
|
final int status;
|
||||||
|
|
||||||
|
HttpStatusException(int status) {
|
||||||
|
super("HTTP " + status);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the server says when APPROVAL_PASSWORD is unset; any other 404 is a missing route. */
|
||||||
|
static final String NOT_ENABLED_MESSAGE = "pairing is not enabled";
|
||||||
|
|
||||||
private PairingClient() { }
|
private PairingClient() { }
|
||||||
|
|
||||||
static Request start(String serverUrl, String installationId) throws Exception {
|
static Request start(String serverUrl, String installationId) throws Exception {
|
||||||
Response r = post(serverUrl + "/v1/pair/start",
|
Response r = post(serverUrl + "/v1/pair/start",
|
||||||
new JSONObject().put("installationId", installationId));
|
new JSONObject().put("installationId", installationId));
|
||||||
if (r.status == 404) throw new NotEnabledException();
|
// Only the server's own answer means "switched off". A 404 without it is a server (or
|
||||||
if (r.status != 201) throw new java.io.IOException("HTTP " + r.status);
|
// proxy) that has no pairing route at all, e.g. one older than 2.1.0.
|
||||||
|
if (r.status == 404 && r.body.contains(NOT_ENABLED_MESSAGE)) throw new NotEnabledException();
|
||||||
|
if (r.status != 201) throw new HttpStatusException(r.status);
|
||||||
JSONObject json = new JSONObject(r.body);
|
JSONObject json = new JSONObject(r.body);
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
return new Request(
|
return new Request(
|
||||||
@@ -77,7 +92,7 @@ final class PairingClient {
|
|||||||
case 202: return new PollResult(Outcome.PENDING, null);
|
case 202: return new PollResult(Outcome.PENDING, null);
|
||||||
case 403: return new PollResult(Outcome.DENIED, null);
|
case 403: return new PollResult(Outcome.DENIED, null);
|
||||||
case 410: return new PollResult(Outcome.EXPIRED, null);
|
case 410: return new PollResult(Outcome.EXPIRED, null);
|
||||||
default: throw new java.io.IOException("HTTP " + r.status);
|
default: throw new HttpStatusException(r.status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
<string name="pair_expired">The code expired — press Pair with phone again</string>
|
<string name="pair_expired">The code expired — press Pair with phone again</string>
|
||||||
<string name="pair_not_enabled">Pairing is not enabled on the server (no APPROVAL_PASSWORD)</string>
|
<string name="pair_not_enabled">Pairing is not enabled on the server (no APPROVAL_PASSWORD)</string>
|
||||||
<string name="pair_error">Pairing failed: %1$s</string>
|
<string name="pair_error">Pairing failed: %1$s</string>
|
||||||
|
<string name="pair_close">Close</string>
|
||||||
|
<string name="pair_err_old_server">The server has no pairing (HTTP 404). It needs server version 2.1.0 or newer.</string>
|
||||||
|
<string name="err_dns">Server name %1$s not found. Is it in public DNS?</string>
|
||||||
|
<string name="err_unreachable">Server %1$s not reachable</string>
|
||||||
|
<string name="err_http">Server answered HTTP %1$d</string>
|
||||||
<string name="pair_cancel">Cancel</string>
|
<string name="pair_cancel">Cancel</string>
|
||||||
<string name="pair_qr_description">QR code to approve this car on your phone</string>
|
<string name="pair_qr_description">QR code to approve this car on your phone</string>
|
||||||
<string name="server_err_token">The token must be at least 24 characters, without spaces</string>
|
<string name="server_err_token">The token must be at least 24 characters, without spaces</string>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public class PairingClientTest {
|
|||||||
private String url;
|
private String url;
|
||||||
private final Deque<Integer> pollStatuses = new ArrayDeque<>();
|
private final Deque<Integer> pollStatuses = new ArrayDeque<>();
|
||||||
private volatile int startStatus = 201;
|
private volatile int startStatus = 201;
|
||||||
|
private volatile String startErrorBody = "{\"error\":\"pairing is not enabled\"}";
|
||||||
private volatile String lastStartBody;
|
private volatile String lastStartBody;
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
@@ -73,7 +74,7 @@ public class PairingClientTest {
|
|||||||
response = status == 201
|
response = status == 201
|
||||||
? "{\"deviceCode\":\"dev-code\",\"userCode\":\"K7F-3QX\",\"verificationUri\":\"https://car.example.com/pair\","
|
? "{\"deviceCode\":\"dev-code\",\"userCode\":\"K7F-3QX\",\"verificationUri\":\"https://car.example.com/pair\","
|
||||||
+ "\"verificationUriComplete\":\"https://car.example.com/pair?code=K7F-3QX\",\"expiresIn\":600,\"interval\":5}"
|
+ "\"verificationUriComplete\":\"https://car.example.com/pair?code=K7F-3QX\",\"expiresIn\":600,\"interval\":5}"
|
||||||
: "{\"error\":\"pairing is not enabled\"}";
|
: startErrorBody;
|
||||||
} else {
|
} else {
|
||||||
Integer next;
|
Integer next;
|
||||||
synchronized (pollStatuses) { next = pollStatuses.poll(); }
|
synchronized (pollStatuses) { next = pollStatuses.poll(); }
|
||||||
@@ -119,6 +120,19 @@ public class PairingClientTest {
|
|||||||
assertEquals(PairingClient.Outcome.EXPIRED, PairingClient.poll(url, "dev-code").outcome);
|
assertEquals(PairingClient.Outcome.EXPIRED, PairingClient.poll(url, "dev-code").outcome);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aServerWithoutPairingIsNotMistakenForPairingSwitchedOff() throws Exception {
|
||||||
|
// e.g. server 2.0.0, or a proxy's own 404 page
|
||||||
|
startStatus = 404;
|
||||||
|
startErrorBody = "404 page not found";
|
||||||
|
try {
|
||||||
|
PairingClient.start(url, "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b");
|
||||||
|
fail("expected HttpStatusException");
|
||||||
|
} catch (PairingClient.HttpStatusException expected) {
|
||||||
|
assertEquals(404, expected.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void pairingSwitchedOffOnTheServerIsReportedAsSuch() throws Exception {
|
public void pairingSwitchedOffOnTheServerIsReportedAsSuch() throws Exception {
|
||||||
startStatus = 404;
|
startStatus = 404;
|
||||||
|
|||||||
Reference in New Issue
Block a user