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:
2026-10-17 20:56:02 +02:00
co-authored by Claude Opus 5.5
parent 6f57351866
commit 8a5813fa16
6 changed files with 84 additions and 11 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4"
minSdk 27
targetSdk 36
versionCode 59
versionName "2.4.1"
versionCode 60
versionName "2.4.2"
// 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.
@@ -455,7 +455,7 @@ public class MainActivity extends AppCompatActivity {
message = getString(R.string.conn_err_timeout);
color = COLOR_ERROR;
} catch (Exception e) {
message = getString(R.string.conn_err_no_internet);
message = describeNetworkError(e, url);
color = COLOR_ERROR;
}
final String m = message;
@@ -514,6 +514,9 @@ public class MainActivity extends AppCompatActivity {
.setView(panel)
.setNegativeButton(R.string.pair_cancel, null)
.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.show();
@@ -525,8 +528,12 @@ public class MainActivity extends AppCompatActivity {
android.graphics.Bitmap bitmap = QrCode.generate(request.verificationUriComplete, 416);
runOnUiThread(() -> {
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.setVisibility(View.VISIBLE);
status.setText(R.string.pair_waiting);
});
PairingClient.PollResult result = null;
@@ -552,26 +559,58 @@ public class MainActivity extends AppCompatActivity {
}
} catch (PairingClient.NotEnabledException e) {
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) {
return;
} catch (Exception e) {
outcome = getString(R.string.pair_error, e.getClass().getSimpleName());
outcome = describeNetworkError(e, url);
}
final String message = outcome;
final int c = color;
runOnUiThread(() -> {
if (!cancelled[0]) dialog.dismiss();
setConnectionStatus(c, message);
if (c == COLOR_OK) {
if (consumptionCloudClient != null) consumptionCloudClient.invalidateHistory();
showServerConfig();
serverTokenLayout.setError(null);
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();
}
/**
* 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 {
java.net.HttpURLConnection connection = (java.net.HttpURLConnection) new java.net.URL(url).openConnection();
try {
@@ -52,13 +52,28 @@ final class PairingClient {
/** Thrown when pairing is switched off on the server (no APPROVAL_PASSWORD). */
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() { }
static Request start(String serverUrl, String installationId) throws Exception {
Response r = post(serverUrl + "/v1/pair/start",
new JSONObject().put("installationId", installationId));
if (r.status == 404) throw new NotEnabledException();
if (r.status != 201) throw new java.io.IOException("HTTP " + r.status);
// Only the server's own answer means "switched off". A 404 without it is a server (or
// 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);
long now = System.currentTimeMillis();
return new Request(
@@ -77,7 +92,7 @@ final class PairingClient {
case 202: return new PollResult(Outcome.PENDING, null);
case 403: return new PollResult(Outcome.DENIED, null);
case 410: return new PollResult(Outcome.EXPIRED, null);
default: throw new java.io.IOException("HTTP " + r.status);
default: throw new HttpStatusException(r.status);
}
}
+5
View File
@@ -66,6 +66,11 @@
<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_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_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>
@@ -27,6 +27,7 @@ public class PairingClientTest {
private String url;
private final Deque<Integer> pollStatuses = new ArrayDeque<>();
private volatile int startStatus = 201;
private volatile String startErrorBody = "{\"error\":\"pairing is not enabled\"}";
private volatile String lastStartBody;
@Before
@@ -73,7 +74,7 @@ public class PairingClientTest {
response = status == 201
? "{\"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}"
: "{\"error\":\"pairing is not enabled\"}";
: startErrorBody;
} else {
Integer next;
synchronized (pollStatuses) { next = pollStatuses.poll(); }
@@ -119,6 +120,19 @@ public class PairingClientTest {
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
public void pairingSwitchedOffOnTheServerIsReportedAsSuch() throws Exception {
startStatus = 404;