App 2.12.0, server 2.5.0: enter settings on the phone

The ABRP credentials are long to type on the car. "Enter on phone"
(Live data tab, and Settings next to the charging price) shows a QR
code; it opens a form on the server where the phone sends the ABRP API
key and user token, charging price and usable capacity, confirmed with
the approval password. The car polls with its own token and collects
the values once, then saves them through the same checks as the fields
on screen.

Same device-flow shape as pairing. The server keeps the values in memory
only, at most ten minutes, hands them out once with no-store, and never
logs them or puts them back into the page; a paired token only reaches
its own installation's request. Pairing's QR dialog is now shared
(PhoneQrDialog), and the phone pages share one stylesheet.

The config file also takes charging_price and usable_kwh.

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-18 12:59:03 +02:00
co-authored by Claude Opus 5.5
parent 9f184362f0
commit d711e0cfda
22 changed files with 918 additions and 100 deletions
@@ -531,71 +531,8 @@ public class MainActivity extends AppCompatActivity {
String installationId = ServerConfig.isValidInstallationId(typedId)
? typedId : ServerConfig.get(this).installationId;
float density = getResources().getDisplayMetrics().density;
int pad = (int) (24 * density);
// On the head unit's wide, low screen the QR code sits left of the text: stacked, the
// code and status below a 240 dp QR code were cut off. Scrollable either way, so
// nothing can be clipped on any screen.
android.util.DisplayMetrics metrics = getResources().getDisplayMetrics();
boolean wide = metrics.widthPixels > metrics.heightPixels;
android.widget.LinearLayout panel = new android.widget.LinearLayout(this);
panel.setOrientation(wide ? android.widget.LinearLayout.HORIZONTAL : android.widget.LinearLayout.VERTICAL);
panel.setGravity(wide ? android.view.Gravity.CENTER_VERTICAL : android.view.Gravity.CENTER_HORIZONTAL);
panel.setPadding(pad, pad, pad, pad);
android.widget.LinearLayout column = new android.widget.LinearLayout(this);
column.setOrientation(android.widget.LinearLayout.VERTICAL);
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);
android.widget.LinearLayout.LayoutParams qrParams = new android.widget.LinearLayout.LayoutParams(qrSize, qrSize);
android.widget.LinearLayout.LayoutParams columnParams;
if (wide) {
qrParams.rightMargin = pad;
columnParams = new android.widget.LinearLayout.LayoutParams(
0, android.view.ViewGroup.LayoutParams.WRAP_CONTENT, 1f);
panel.addView(qr, qrParams);
panel.addView(column, columnParams);
column.addView(instructions);
} else {
qrParams.topMargin = pad / 2;
qrParams.bottomMargin = pad / 2;
columnParams = new android.widget.LinearLayout.LayoutParams(
android.view.ViewGroup.LayoutParams.MATCH_PARENT, android.view.ViewGroup.LayoutParams.WRAP_CONTENT);
panel.addView(instructions);
panel.addView(qr, qrParams);
panel.addView(column, columnParams);
}
android.widget.LinearLayout.LayoutParams codeParams = new android.widget.LinearLayout.LayoutParams(
android.view.ViewGroup.LayoutParams.WRAP_CONTENT, android.view.ViewGroup.LayoutParams.WRAP_CONTENT);
codeParams.topMargin = pad / 2;
codeParams.bottomMargin = pad / 2;
column.addView(code, codeParams);
column.addView(status);
android.widget.ScrollView scroll = new android.widget.ScrollView(this);
scroll.addView(panel);
final boolean[] cancelled = {false};
androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(this)
.setTitle(R.string.pair_title)
.setView(scroll)
.setNegativeButton(R.string.pair_cancel, null)
.create();
// Shown only once the server has answered, so a failure leaves no empty QR frame.
qr.setVisibility(View.GONE);
code.setVisibility(View.GONE);
dialog.setOnDismissListener(d -> cancelled[0] = true);
dialog.show();
PhoneQrDialog ui = new PhoneQrDialog(R.string.pair_title, R.string.pair_starting);
final boolean[] cancelled = ui.cancelled;
new Thread(() -> {
String outcome;
@@ -603,18 +540,7 @@ public class MainActivity extends AppCompatActivity {
try {
PairingClient.Request request = PairingClient.start(url, installationId);
android.graphics.Bitmap bitmap = QrCode.generate(request.verificationUriComplete, 416);
runOnUiThread(() -> {
// The code is in the sentence as well as below it, so it is readable
// wherever the text is.
instructions.setText(getString(R.string.pair_scan, request.verificationUri, request.userCode));
if (bitmap != null) {
qr.setImageBitmap(bitmap);
qr.setVisibility(View.VISIBLE);
}
code.setText(request.userCode);
code.setVisibility(View.VISIBLE);
status.setText(R.string.pair_waiting);
});
runOnUiThread(() -> ui.showRequest(request, bitmap, R.string.pair_scan, R.string.pair_waiting));
PairingClient.PollResult result = null;
while (!cancelled[0] && System.currentTimeMillis() < request.expiresAtMs) {
Thread.sleep(request.intervalMs);
@@ -655,23 +581,201 @@ public class MainActivity extends AppCompatActivity {
showServerConfig();
serverTokenLayout.setError(null);
refreshStatus();
if (!cancelled[0]) dialog.dismiss();
if (!cancelled[0]) ui.dialog.dismiss();
return;
}
if (cancelled[0]) return;
// Keep the dialog up with the reason, instead of closing it on a failure the
// driver would otherwise only see as a flicker.
qr.setVisibility(View.GONE);
code.setVisibility(View.GONE);
instructions.setVisibility(View.GONE);
status.setText(message);
status.setTextColor(c);
android.widget.Button close = dialog.getButton(android.content.DialogInterface.BUTTON_NEGATIVE);
if (close != null) close.setText(R.string.pair_close);
if (!cancelled[0]) ui.finish(message, c);
});
}, "server-pairing").start();
}
/**
* Settings typed on a phone instead of the car's keyboard: the server opens a request for
* this car, the dialog shows it as a QR code, and the values the phone sends (ABRP
* credentials, charging price, usable capacity) are applied through the same checks as the
* fields here. Needs the server set up, since the request is made with this car's token.
*/
private void startPhoneSettings() {
ServerConfig config = ServerConfig.get(this);
PhoneQrDialog ui = new PhoneQrDialog(R.string.phone_settings_title, R.string.pair_starting);
if (!config.isConfigured()) {
ui.finish(getString(R.string.phone_settings_needs_server), COLOR_ERROR);
return;
}
new Thread(() -> {
String outcome;
int color = COLOR_ERROR;
PhoneSettingsClient.Settings received = null;
try {
PairingClient.Request request = PhoneSettingsClient.start(config);
android.graphics.Bitmap bitmap = QrCode.generate(request.verificationUriComplete, 416);
runOnUiThread(() -> ui.showRequest(request, bitmap, R.string.phone_settings_scan,
R.string.phone_settings_waiting));
while (!ui.cancelled[0] && System.currentTimeMillis() < request.expiresAtMs) {
Thread.sleep(request.intervalMs);
if (ui.cancelled[0]) return;
try {
received = PhoneSettingsClient.poll(config, request.deviceCode);
} catch (java.io.IOException e) {
continue; // a dropped connection mid-wait is not a failure
}
if (received != null) break;
}
if (ui.cancelled[0]) return;
outcome = received == null ? getString(R.string.pair_expired_settings) : null;
} catch (PairingClient.NotEnabledException e) {
outcome = getString(R.string.phone_settings_not_enabled);
} catch (PairingClient.HttpStatusException e) {
outcome = e.status == 410 ? getString(R.string.pair_expired_settings)
: getString(R.string.err_http, e.status);
} catch (InterruptedException e) {
return;
} catch (Exception e) {
outcome = describeNetworkError(e, config.url);
}
final PhoneSettingsClient.Settings settings = received;
final String failure = outcome;
runOnUiThread(() -> {
if (ui.cancelled[0]) return;
if (settings == null) {
ui.finish(failure, COLOR_ERROR);
return;
}
String applied = applyPhoneSettings(settings);
ui.finish(applied, applied.equals(getString(R.string.phone_settings_applied)) ? COLOR_OK : COLOR_ERROR);
});
}, "phone-settings").start();
}
/**
* Puts what the phone sent into the fields and saves each through its own Save, so the
* same limits apply as when typed here. A credential sent alone is paired with the one
* already stored. Returns the message for the dialog. UI thread.
*/
private String applyPhoneSettings(PhoneSettingsClient.Settings s) {
boolean ok = true;
if (s.abrpApiKey != null || s.abrpToken != null) {
AbrpConfig current = AbrpConfig.get(this);
ok &= saveAbrpConfig(s.abrpApiKey != null ? s.abrpApiKey : current.apiKey,
s.abrpToken != null ? s.abrpToken : current.userToken);
}
if (s.pricePerKwh != null) {
chargePriceInput.setText(String.format(java.util.Locale.US, "%.2f", s.pricePerKwh));
saveChargePrice();
ok &= chargePriceLayout.getError() == null;
}
if (s.usableKwh != null) {
usableCapacityInput.setText(String.format(java.util.Locale.US, "%.1f", s.usableKwh));
saveUsableCapacity();
ok &= usableCapacityLayout.getError() == null;
}
return getString(ok ? R.string.phone_settings_applied : R.string.phone_settings_partly);
}
/**
* The dialog of both phone flows (pairing, settings): a QR code beside the code to type
* and a status line. The QR code and code stay hidden until the server has answered.
*/
private final class PhoneQrDialog {
final TextView instructions, code, status;
final ImageView qr;
final androidx.appcompat.app.AlertDialog dialog;
/** Set when the driver closes the dialog; the polling thread then stops. */
final boolean[] cancelled = {false};
PhoneQrDialog(int title, int startingText) {
float density = getResources().getDisplayMetrics().density;
int pad = (int) (24 * density);
// On the head unit's wide, low screen the QR code sits left of the text: stacked, the
// code and status below a 240 dp QR code were cut off. Scrollable either way, so
// nothing can be clipped on any screen.
android.util.DisplayMetrics metrics = getResources().getDisplayMetrics();
boolean wide = metrics.widthPixels > metrics.heightPixels;
android.widget.LinearLayout panel = new android.widget.LinearLayout(MainActivity.this);
panel.setOrientation(wide ? android.widget.LinearLayout.HORIZONTAL : android.widget.LinearLayout.VERTICAL);
panel.setGravity(wide ? android.view.Gravity.CENTER_VERTICAL : android.view.Gravity.CENTER_HORIZONTAL);
panel.setPadding(pad, pad, pad, pad);
android.widget.LinearLayout column = new android.widget.LinearLayout(MainActivity.this);
column.setOrientation(android.widget.LinearLayout.VERTICAL);
instructions = new TextView(MainActivity.this);
instructions.setTextAppearance(R.style.Text_EV_Secondary);
qr = new ImageView(MainActivity.this);
qr.setContentDescription(getString(R.string.pair_qr_description));
int qrSize = (int) (240 * density);
code = new TextView(MainActivity.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));
status = new TextView(MainActivity.this);
status.setTextAppearance(R.style.Text_EV_Body);
status.setText(startingText);
android.widget.LinearLayout.LayoutParams qrParams = new android.widget.LinearLayout.LayoutParams(qrSize, qrSize);
android.widget.LinearLayout.LayoutParams columnParams;
if (wide) {
qrParams.rightMargin = pad;
columnParams = new android.widget.LinearLayout.LayoutParams(
0, android.view.ViewGroup.LayoutParams.WRAP_CONTENT, 1f);
panel.addView(qr, qrParams);
panel.addView(column, columnParams);
column.addView(instructions);
} else {
qrParams.topMargin = pad / 2;
qrParams.bottomMargin = pad / 2;
columnParams = new android.widget.LinearLayout.LayoutParams(
android.view.ViewGroup.LayoutParams.MATCH_PARENT, android.view.ViewGroup.LayoutParams.WRAP_CONTENT);
panel.addView(instructions);
panel.addView(qr, qrParams);
panel.addView(column, columnParams);
}
android.widget.LinearLayout.LayoutParams codeParams = new android.widget.LinearLayout.LayoutParams(
android.view.ViewGroup.LayoutParams.WRAP_CONTENT, android.view.ViewGroup.LayoutParams.WRAP_CONTENT);
codeParams.topMargin = pad / 2;
codeParams.bottomMargin = pad / 2;
column.addView(code, codeParams);
column.addView(status);
android.widget.ScrollView scroll = new android.widget.ScrollView(MainActivity.this);
scroll.addView(panel);
dialog = new MaterialAlertDialogBuilder(MainActivity.this)
.setTitle(title)
.setView(scroll)
.setNegativeButton(R.string.pair_cancel, null)
.create();
// Shown only once the server has answered, so a failure leaves no empty QR frame.
qr.setVisibility(View.GONE);
code.setVisibility(View.GONE);
dialog.setOnDismissListener(d -> cancelled[0] = true);
dialog.show();
}
/** Shows the request: QR code, the code in the sentence and below it. UI thread. */
void showRequest(PairingClient.Request request, android.graphics.Bitmap bitmap, int scanText, int waitingText) {
instructions.setText(getString(scanText, request.verificationUri, request.userCode));
if (bitmap != null) {
qr.setImageBitmap(bitmap);
qr.setVisibility(View.VISIBLE);
}
code.setText(request.userCode);
code.setVisibility(View.VISIBLE);
status.setText(waitingText);
}
/** Keeps the dialog up with the outcome instead of closing it. UI thread. */
void finish(String message, int color) {
qr.setVisibility(View.GONE);
code.setVisibility(View.GONE);
instructions.setVisibility(View.GONE);
status.setText(message);
status.setTextColor(color);
android.widget.Button close = dialog.getButton(android.content.DialogInterface.BUTTON_NEGATIVE);
if (close != null) close.setText(R.string.pair_close);
}
}
/**
* A network failure in words the driver can act on: a name that does not resolve, a
* server that does not answer, or a status. Shared by Test connection and pairing.
@@ -845,6 +949,15 @@ public class MainActivity extends AppCompatActivity {
return false;
}
}
if (config.chargingPrice != null || config.usableKwh != null) {
// Through the same Save as the fields (and the phone form), with the same limits.
String applied = applyPhoneSettings(new PhoneSettingsClient.Settings(
null, null, config.chargingPrice, config.usableKwh));
if (!applied.equals(getString(R.string.phone_settings_applied))) {
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_values));
return false;
}
}
SharedPreferences.Editor edit = prefs.edit();
if (config.intervalSec != null) edit.putInt(UploadSettings.KEY_INTERVAL_SEC, config.intervalSec);
if (config.boostLowSoc != null) edit.putBoolean(UploadSettings.KEY_BOOST_LOW_SOC, config.boostLowSoc);
@@ -898,6 +1011,7 @@ public class MainActivity extends AppCompatActivity {
servicePane.findViewById(R.id.abrp_save_button).setOnClickListener(v ->
saveAbrpConfig(text(abrpApiKeyInput), text(abrpTokenInput)));
servicePane.findViewById(R.id.abrp_test_button).setOnClickListener(v -> testAbrpConfig());
servicePane.findViewById(R.id.abrp_phone_button).setOnClickListener(v -> startPhoneSettings());
}
private static String text(TextInputEditText input) {
@@ -1266,6 +1380,7 @@ public class MainActivity extends AppCompatActivity {
chargePriceInput.setText(String.format(java.util.Locale.US, "%.2f",
chargeSessionTracker.pricePerKwh()));
settingsPane.findViewById(R.id.charge_price_save).setOnClickListener(v -> saveChargePrice());
settingsPane.findViewById(R.id.settings_phone_button).setOnClickListener(v -> startPhoneSettings());
}
private void saveChargePrice() {