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