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
+ }
+ }
+}