App 2.4.0: pair with the server by QR code

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
This commit is contained in:
2026-10-17 16:16:52 +02:00
co-authored by Claude Opus 5.5
parent d5f68e1bd9
commit c578a882bd
7 changed files with 384 additions and 3 deletions
@@ -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<Integer> 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
}
}
}