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 {