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
@@ -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 {
@@ -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();
}
}
}
@@ -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;
}
}
+2
View File
@@ -6,6 +6,8 @@
<com.google.android.material.textfield.TextInputLayout android:id="@+id/server_url_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_server_url" app:helperText="@string/helper_server_url">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/server_url_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="textUri" android:singleLine="true" android:textSize="20sp" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/server_pair_button" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/btn_pair" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/pair_hint" android:textAppearance="@style/Text.EV.Secondary" />
<com.google.android.material.textfield.TextInputLayout android:id="@+id/server_token_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_server_token" app:endIconMode="password_toggle" app:endIconMinSize="72dp" app:helperText="@string/helper_server_token">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/server_token_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="textPassword" android:singleLine="true" android:textSize="20sp" />
</com.google.android.material.textfield.TextInputLayout>
+13
View File
@@ -55,6 +55,19 @@
<string name="hint_installation_id">Installation ID</string>
<string name="helper_installation_id">Your data is stored under this ID. Keep it when reinstalling.</string>
<string name="server_err_url">Enter an https:// URL</string>
<string name="btn_pair">Pair with phone</string>
<string name="pair_hint">Shows a QR code: scan it with your phone and approve with the approval password. The token below is then filled in for you.</string>
<string name="pair_title">Pair with phone</string>
<string name="pair_scan">Scan with your phone, or open %1$s and enter this code:</string>
<string name="pair_waiting">Waiting for approval…</string>
<string name="pair_starting">Contacting the server…</string>
<string name="pair_ok">Paired — this car now has its own token</string>
<string name="pair_denied">Pairing was denied on the phone</string>
<string name="pair_expired">The code expired — press Pair with phone again</string>
<string name="pair_not_enabled">Pairing is not enabled on the server (no APPROVAL_PASSWORD)</string>
<string name="pair_error">Pairing failed: %1$s</string>
<string name="pair_cancel">Cancel</string>
<string name="pair_qr_description">QR code to approve this car on your phone</string>
<string name="server_err_token">The token must be at least 24 characters, without spaces</string>
<string name="server_err_installation_id">Not a valid ID (UUID)</string>
<string name="server_saved">Server settings saved</string>
@@ -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
}
}
}