From c578a882bddd371313ee0052a2cca62f7466344b Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 17 Oct 2026 16:16:52 +0200 Subject: [PATCH] App 2.4.0: pair with the server by QR code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Server tab gets "Pair with phone" right under the server URL, which is now pre-filled with https://car.stinnesbeck.com. It opens a dialog with a QR code and the short code, polls the server, and on approval saves this car's own token like Save would — nothing to type in the car. Denied, expired, and pairing switched off on the server are each reported. PairingClient is tested against a stand-in for the server. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki --- app/build.gradle | 4 +- .../java/com/evsuite/abrp/MainActivity.java | 109 +++++++++++++++ .../java/com/evsuite/abrp/PairingClient.java | 122 ++++++++++++++++ .../java/com/evsuite/abrp/ServerConfig.java | 5 +- app/src/main/res/layout/pane_server.xml | 2 + app/src/main/res/values/strings.xml | 13 ++ .../com/evsuite/abrp/PairingClientTest.java | 132 ++++++++++++++++++ 7 files changed, 384 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/evsuite/abrp/PairingClient.java create mode 100644 app/src/test/java/com/evsuite/abrp/PairingClientTest.java diff --git a/app/build.gradle b/app/build.gradle index 0c0fc65..da13714 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 57 - versionName "2.3.0" + versionCode 58 + versionName "2.4.0" // 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 7df5cbb..cc225e7 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -391,6 +391,7 @@ public class MainActivity extends AppCompatActivity { } }); serverPane.findViewById(R.id.server_test_button).setOnClickListener(v -> testConnection()); + serverPane.findViewById(R.id.server_pair_button).setOnClickListener(v -> startPairing()); serverPane.findViewById(R.id.import_button).setOnClickListener(v -> startConfigImport()); } @@ -463,6 +464,114 @@ public class MainActivity extends AppCompatActivity { }, "server-test").start(); } + /** + * Pairs this car with the server through a phone: the server opens a pairing request, the + * dialog shows its code as a QR code, and a background thread polls until the request is + * approved, denied or expired. On approval the car's own token is saved with the URL from + * the field and the installation id, exactly as Save would. + */ + private void startPairing() { + String url = ServerConfig.normalizeUrl(textOf(serverUrlInput)); + if (url == null) { + serverUrlLayout.setError(getString(R.string.server_err_url)); + return; + } + serverUrlLayout.setError(null); + String typedId = textOf(installationIdInput).toLowerCase(java.util.Locale.US); + String installationId = ServerConfig.isValidInstallationId(typedId) + ? typedId : ServerConfig.get(this).installationId; + + float density = getResources().getDisplayMetrics().density; + int pad = (int) (24 * density); + android.widget.LinearLayout panel = new android.widget.LinearLayout(this); + panel.setOrientation(android.widget.LinearLayout.VERTICAL); + panel.setGravity(android.view.Gravity.CENTER_HORIZONTAL); + panel.setPadding(pad, pad, pad, pad); + TextView instructions = new TextView(this); + instructions.setTextAppearance(R.style.Text_EV_Secondary); + ImageView qr = new ImageView(this); + qr.setContentDescription(getString(R.string.pair_qr_description)); + int qrSize = (int) (240 * density); + TextView code = new TextView(this); + code.setTextAppearance(R.style.Text_EV_Title); + code.setTypeface(android.graphics.Typeface.MONOSPACE, android.graphics.Typeface.BOLD); + code.setTextSize(36); + code.setLetterSpacing(0.15f); + code.setTextColor(getColor(R.color.ev_cyan)); + TextView status = new TextView(this); + status.setTextAppearance(R.style.Text_EV_Body); + status.setText(R.string.pair_starting); + panel.addView(instructions); + android.widget.LinearLayout.LayoutParams qrParams = new android.widget.LinearLayout.LayoutParams(qrSize, qrSize); + qrParams.topMargin = pad / 2; + panel.addView(qr, qrParams); + panel.addView(code); + panel.addView(status); + + final boolean[] cancelled = {false}; + androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(this) + .setTitle(R.string.pair_title) + .setView(panel) + .setNegativeButton(R.string.pair_cancel, null) + .create(); + dialog.setOnDismissListener(d -> cancelled[0] = true); + dialog.show(); + + new Thread(() -> { + String outcome; + int color = COLOR_ERROR; + try { + PairingClient.Request request = PairingClient.start(url, installationId); + 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); + code.setText(request.userCode); + status.setText(R.string.pair_waiting); + }); + PairingClient.PollResult result = null; + while (!cancelled[0] && System.currentTimeMillis() < request.expiresAtMs) { + Thread.sleep(request.intervalMs); + if (cancelled[0]) break; + try { + result = PairingClient.poll(url, request.deviceCode); + } catch (java.io.IOException e) { + continue; // a dropped connection mid-wait is not a failed pairing + } + if (result.outcome != PairingClient.Outcome.PENDING) break; + } + if (cancelled[0]) return; + if (result != null && result.outcome == PairingClient.Outcome.APPROVED) { + ServerConfig.save(this, url, result.token, installationId); + outcome = getString(R.string.pair_ok); + color = COLOR_OK; + } else if (result != null && result.outcome == PairingClient.Outcome.DENIED) { + outcome = getString(R.string.pair_denied); + } else { + outcome = getString(R.string.pair_expired); + } + } catch (PairingClient.NotEnabledException e) { + outcome = getString(R.string.pair_not_enabled); + } catch (InterruptedException e) { + return; + } catch (Exception e) { + outcome = getString(R.string.pair_error, e.getClass().getSimpleName()); + } + final String message = outcome; + final int c = color; + runOnUiThread(() -> { + if (!cancelled[0]) dialog.dismiss(); + if (c == COLOR_OK) { + if (consumptionCloudClient != null) consumptionCloudClient.invalidateHistory(); + showServerConfig(); + serverTokenLayout.setError(null); + refreshStatus(); + } + setConnectionStatus(c, message); + }); + }, "server-pairing").start(); + } + 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 new file mode 100644 index 0000000..29c0fd8 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/PairingClient.java @@ -0,0 +1,122 @@ +package com.evsuite.abrp; + +import org.json.JSONObject; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +/** + * The car's side of pairing with the server (an OAuth device-authorization flow): ask for a + * pairing request, show its code as a QR code, then poll until a phone has approved it and + * collect this car's own token. Blocking calls — run them off the main thread. + */ +final class PairingClient { + + private static final int TIMEOUT_MS = 8_000; + + /** A pairing request as the server opened it. */ + static final class Request { + final String deviceCode; + final String userCode; + final String verificationUri; + final String verificationUriComplete; + final long expiresAtMs; + final long intervalMs; + + Request(String deviceCode, String userCode, String verificationUri, String verificationUriComplete, + long expiresAtMs, long intervalMs) { + this.deviceCode = deviceCode; + this.userCode = userCode; + this.verificationUri = verificationUri; + this.verificationUriComplete = verificationUriComplete; + this.expiresAtMs = expiresAtMs; + this.intervalMs = intervalMs; + } + } + + enum Outcome { PENDING, APPROVED, DENIED, EXPIRED } + + static final class PollResult { + final Outcome outcome; + final String token; + + PollResult(Outcome outcome, String token) { + this.outcome = outcome; + this.token = token; + } + } + + /** Thrown when pairing is switched off on the server (no APPROVAL_PASSWORD). */ + static final class NotEnabledException extends Exception { } + + 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); + JSONObject json = new JSONObject(r.body); + long now = System.currentTimeMillis(); + return new Request( + json.getString("deviceCode"), + json.getString("userCode"), + json.getString("verificationUri"), + json.getString("verificationUriComplete"), + now + json.optLong("expiresIn", 600) * 1000L, + Math.max(2, json.optLong("interval", 5)) * 1000L); + } + + static PollResult poll(String serverUrl, String deviceCode) throws Exception { + Response r = post(serverUrl + "/v1/pair/poll", new JSONObject().put("deviceCode", deviceCode)); + switch (r.status) { + case 200: return new PollResult(Outcome.APPROVED, new JSONObject(r.body).getString("token")); + 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); + } + } + + private static final class Response { + final int status; + final String body; + + Response(int status, String body) { + this.status = status; + this.body = body; + } + } + + private static Response post(String url, JSONObject json) throws Exception { + byte[] body = json.toString().getBytes(StandardCharsets.UTF_8); + HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + try { + connection.setRequestMethod("POST"); + connection.setConnectTimeout(TIMEOUT_MS); + connection.setReadTimeout(TIMEOUT_MS); + connection.setDoOutput(true); + connection.setFixedLengthStreamingMode(body.length); + connection.setRequestProperty("Content-Type", "application/json"); + try (OutputStream out = connection.getOutputStream()) { out.write(body); } + int status = connection.getResponseCode(); + InputStream in = status < 400 ? connection.getInputStream() : connection.getErrorStream(); + String text = ""; + if (in != null) { + try (InputStream stream = in) { + java.io.ByteArrayOutputStream buf = new java.io.ByteArrayOutputStream(); + byte[] chunk = new byte[2048]; + int n; + while ((n = stream.read(chunk)) != -1 && buf.size() < 16 * 1024) buf.write(chunk, 0, n); + text = buf.toString(StandardCharsets.UTF_8.name()); + } + } + return new Response(status, text); + } finally { + connection.disconnect(); + } + } +} diff --git a/app/src/main/java/com/evsuite/abrp/ServerConfig.java b/app/src/main/java/com/evsuite/abrp/ServerConfig.java index af36f73..555685a 100644 --- a/app/src/main/java/com/evsuite/abrp/ServerConfig.java +++ b/app/src/main/java/com/evsuite/abrp/ServerConfig.java @@ -27,6 +27,9 @@ final class ServerConfig { "^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", Pattern.CASE_INSENSITIVE); + /** Pre-filled so pairing needs no typing in the car; can still be changed. */ + static final String DEFAULT_URL = "https://car.stinnesbeck.com"; + /** The server refuses shorter ingest tokens outright. */ static final int MIN_TOKEN_LENGTH = 24; @@ -58,7 +61,7 @@ final class ServerConfig { id = UUID.randomUUID().toString(); prefs.edit().putString(KEY_INSTALLATION_ID, id).apply(); } - current = new ServerConfig(prefs.getString(KEY_URL, ""), prefs.getString(KEY_TOKEN, ""), id); + current = new ServerConfig(prefs.getString(KEY_URL, DEFAULT_URL), prefs.getString(KEY_TOKEN, ""), id); return current; } } diff --git a/app/src/main/res/layout/pane_server.xml b/app/src/main/res/layout/pane_server.xml index 455efa2..7eb14d7 100644 --- a/app/src/main/res/layout/pane_server.xml +++ b/app/src/main/res/layout/pane_server.xml @@ -6,6 +6,8 @@ + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ef89248..67b4320 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -55,6 +55,19 @@ Installation ID Your data is stored under this ID. Keep it when reinstalling. Enter an https:// URL + Pair with phone + Shows a QR code: scan it with your phone and approve with the approval password. The token below is then filled in for you. + Pair with phone + Scan with your phone, or open %1$s and enter this code: + Waiting for approval… + Contacting the server… + Paired — this car now has its own token + Pairing was denied on the phone + The code expired — press Pair with phone again + Pairing is not enabled on the server (no APPROVAL_PASSWORD) + Pairing failed: %1$s + Cancel + QR code to approve this car on your phone The token must be at least 24 characters, without spaces Not a valid ID (UUID) Server settings saved diff --git a/app/src/test/java/com/evsuite/abrp/PairingClientTest.java b/app/src/test/java/com/evsuite/abrp/PairingClientTest.java new file mode 100644 index 0000000..0bc8006 --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/PairingClientTest.java @@ -0,0 +1,132 @@ +package com.evsuite.abrp; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.Deque; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** The car's pairing client against a stand-in that answers like the Go server. */ +public class PairingClientTest { + + private ServerSocket socket; + private Thread thread; + private String url; + private final Deque pollStatuses = new ArrayDeque<>(); + private volatile int startStatus = 201; + private volatile String lastStartBody; + + @Before + public void setUp() throws Exception { + socket = new ServerSocket(0, 10, InetAddress.getLoopbackAddress()); + url = "http://127.0.0.1:" + socket.getLocalPort(); + thread = new Thread(() -> { + while (!socket.isClosed()) { + try (Socket client = socket.accept()) { + handle(client); + } catch (Exception ignored) { + // socket closed in tearDown + } + } + }); + thread.start(); + } + + @After + public void tearDown() throws Exception { + socket.close(); + thread.join(2000); + } + + /** Minimal HTTP/1.1: one request per connection, body by Content-Length. */ + private void handle(Socket client) throws Exception { + BufferedReader in = new BufferedReader(new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8)); + String requestLine = in.readLine(); + int length = 0; + for (String line = in.readLine(); line != null && !line.isEmpty(); line = in.readLine()) { + if (line.toLowerCase(java.util.Locale.US).startsWith("content-length:")) { + length = Integer.parseInt(line.substring(15).trim()); + } + } + char[] body = new char[length]; + int read = 0; + while (read < length) read += in.read(body, read, length - read); + String path = requestLine.split(" ")[1]; + int status; + String response; + if (path.equals("/v1/pair/start")) { + lastStartBody = new String(body); + status = startStatus; + 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\"}"; + } else { + Integer next; + synchronized (pollStatuses) { next = pollStatuses.poll(); } + status = next == null ? 410 : next; + response = status == 200 ? "{\"token\":\"car-token-0123456789abcdefghij\",\"deviceId\":\"d1\"}" + : status == 202 ? "{\"status\":\"authorization_pending\"}" : "{\"error\":\"x\"}"; + } + byte[] bytes = response.getBytes(StandardCharsets.UTF_8); + OutputStream out = client.getOutputStream(); + out.write(("HTTP/1.1 " + status + " X\r\nContent-Type: application/json\r\nContent-Length: " + bytes.length + + "\r\nConnection: close\r\n\r\n").getBytes(StandardCharsets.UTF_8)); + out.write(bytes); + out.flush(); + } + + @Test + public void startSendsTheInstallationAndReadsTheCodes() throws Exception { + long before = System.currentTimeMillis(); + PairingClient.Request request = PairingClient.start(url, "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b"); + assertTrue(lastStartBody, lastStartBody.contains("6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b")); + assertEquals("dev-code", request.deviceCode); + assertEquals("K7F-3QX", request.userCode); + assertEquals("https://car.example.com/pair?code=K7F-3QX", request.verificationUriComplete); + assertEquals(5000L, request.intervalMs); + assertTrue(request.expiresAtMs >= before + 600_000L); + } + + @Test + public void pollMapsEveryServerAnswer() throws Exception { + synchronized (pollStatuses) { + pollStatuses.add(202); + pollStatuses.add(200); + pollStatuses.add(403); + pollStatuses.add(410); + } + PairingClient.PollResult pending = PairingClient.poll(url, "dev-code"); + assertEquals(PairingClient.Outcome.PENDING, pending.outcome); + assertNull(pending.token); + PairingClient.PollResult approved = PairingClient.poll(url, "dev-code"); + assertEquals(PairingClient.Outcome.APPROVED, approved.outcome); + assertEquals("car-token-0123456789abcdefghij", approved.token); + assertEquals(PairingClient.Outcome.DENIED, PairingClient.poll(url, "dev-code").outcome); + assertEquals(PairingClient.Outcome.EXPIRED, PairingClient.poll(url, "dev-code").outcome); + } + + @Test + public void pairingSwitchedOffOnTheServerIsReportedAsSuch() throws Exception { + startStatus = 404; + try { + PairingClient.start(url, "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b"); + fail("expected NotEnabledException"); + } catch (PairingClient.NotEnabledException expected) { + // the dialog says pairing is off instead of a generic failure + } + } +}