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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user