diff --git a/app/build.gradle b/app/build.gradle
index 1aa3515..c2c0330 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -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.
diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java
index cc225e7..3fe59c7 100644
--- a/app/src/main/java/com/evsuite/abrp/MainActivity.java
+++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java
@@ -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 {
diff --git a/app/src/main/java/com/evsuite/abrp/PairingClient.java b/app/src/main/java/com/evsuite/abrp/PairingClient.java
index 29c0fd8..e349dca 100644
--- a/app/src/main/java/com/evsuite/abrp/PairingClient.java
+++ b/app/src/main/java/com/evsuite/abrp/PairingClient.java
@@ -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);
}
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index cd1beb8..f942d80 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -66,6 +66,11 @@
The code expired — press Pair with phone again
Pairing is not enabled on the server (no APPROVAL_PASSWORD)
Pairing failed: %1$s
+ Close
+ The server has no pairing (HTTP 404). It needs server version 2.1.0 or newer.
+ Server name %1$s not found. Is it in public DNS?
+ Server %1$s not reachable
+ Server answered HTTP %1$d
Cancel
QR code to approve this car on your phone
The token must be at least 24 characters, without spaces
diff --git a/app/src/test/java/com/evsuite/abrp/PairingClientTest.java b/app/src/test/java/com/evsuite/abrp/PairingClientTest.java
index 0bc8006..c9b6844 100644
--- a/app/src/test/java/com/evsuite/abrp/PairingClientTest.java
+++ b/app/src/test/java/com/evsuite/abrp/PairingClientTest.java
@@ -27,6 +27,7 @@ public class PairingClientTest {
private String url;
private final Deque 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;
diff --git a/k8s/secrets.example.yaml b/k8s/secrets/secrets.example.yaml
similarity index 100%
rename from k8s/secrets.example.yaml
rename to k8s/secrets/secrets.example.yaml