App 2.11.0, server 2.3.0: live data straight to ABRP; settings regrouped

ABRP keeps getting live data while the car server is down: with an ABRP
API key and user token set in the app (Live data tab, or abrp_api_key /
abrp_token in the config file), the car sends the newest sample to ABRP
itself at every upload. It tells the server the newest sample ABRP took
(abrpSentUtc), and the server forwards only newer ones, so ABRP gets
each sample once. Without credentials in the app the server forwards
everything, as before. The credentials sit in encrypted storage, go in
the request body, and neither they nor ABRP's answer are logged.

Settings are grouped by what they are for:
- Live data (was Upload): on/off, ABRP credentials, upload frequency.
- Settings (replaces the Wi-Fi tab): full screen and voice key,
  usable capacity and manual SoH (were on Consumption), charging price
  (was on Charging), Wi-Fi.
Charging and Live data get icons of their own.

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:15:21 +02:00
co-authored by Claude Opus 5.5
parent 087f193f8c
commit ca2f899403
36 changed files with 636 additions and 163 deletions
@@ -42,16 +42,16 @@ public class MainActivity extends AppCompatActivity {
/** Navigation destinations in page order. Parallel to {@link #panes}. */
private static final int[] TAB_IDS =
{ R.id.tabVehicle, R.id.tabConsumption, R.id.tabCharging, R.id.tabService, R.id.tabServer,
R.id.tabWifi, R.id.tabLog };
R.id.tabSettings, R.id.tabLog };
private static final int[] PAGE_TITLES = {
R.string.design_overview_title, R.string.consumption_title, R.string.charging_title,
R.string.design_service_title, R.string.design_server_title, R.string.design_wifi_title,
R.string.design_service_title, R.string.design_server_title, R.string.design_settings_title,
R.string.design_log_title
};
private static final int[] PAGE_SUBTITLES = {
R.string.design_overview_subtitle, R.string.consumption_subtitle, R.string.charging_subtitle,
R.string.design_service_subtitle, R.string.design_server_subtitle, R.string.design_wifi_subtitle,
R.string.design_service_subtitle, R.string.design_server_subtitle, R.string.design_settings_subtitle,
R.string.design_log_subtitle
};
@@ -70,7 +70,8 @@ public class MainActivity extends AppCompatActivity {
private View vehiclePane;
private View consumptionPane;
private View chargingPane;
private View wifiPane;
/** Display, battery, charging price and Wi-Fi. */
private View settingsPane;
/** The pages in navigation order. Parallel to {@link #TAB_IDS}. */
private View[] panes;
@@ -241,10 +242,10 @@ public class MainActivity extends AppCompatActivity {
vehiclePane = inflatePane(R.layout.pane_vehicle);
consumptionPane = inflatePane(R.layout.pane_consumption);
chargingPane = inflatePane(R.layout.pane_charging);
wifiPane = inflatePane(R.layout.pane_wifi);
settingsPane = inflatePane(R.layout.pane_settings);
logPane = inflatePane(R.layout.pane_log);
panes = new View[] { vehiclePane, consumptionPane, chargingPane, servicePane, serverPane,
wifiPane, logPane };
settingsPane, logPane };
bindVehiclePane();
bindConsumptionPane();
@@ -836,6 +837,14 @@ public class MainActivity extends AppCompatActivity {
return false;
}
}
if (config.abrpApiKey != null || config.abrpToken != null) {
AbrpConfig abrp = AbrpConfig.get(this);
if (!saveAbrpConfig(config.abrpApiKey != null ? config.abrpApiKey : abrp.apiKey,
config.abrpToken != null ? config.abrpToken : abrp.userToken)) {
setConnectionStatus(COLOR_ERROR, getString(R.string.abrp_invalid));
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);
@@ -850,11 +859,11 @@ public class MainActivity extends AppCompatActivity {
// ---------- Upload page ----------
private void bindServicePane() {
voiceKeySwitch = servicePane.findViewById(R.id.voice_key_switch);
voiceKeySwitch = settingsPane.findViewById(R.id.voice_key_switch);
voiceKeySwitch.setChecked(UploadSettings.voiceKeyOpensApp(prefs));
voiceKeySwitch.setOnCheckedChangeListener((btn, checked) ->
prefs.edit().putBoolean(UploadSettings.KEY_VOICE_KEY_OPENS, checked).apply());
fullScreenSwitch = servicePane.findViewById(R.id.full_screen_switch);
fullScreenSwitch = settingsPane.findViewById(R.id.full_screen_switch);
fullScreenSwitch.setChecked(UploadSettings.fullScreen(prefs));
fullScreenSwitch.setOnCheckedChangeListener((btn, checked) -> {
prefs.edit().putBoolean(UploadSettings.KEY_FULL_SCREEN, checked).apply();
@@ -871,6 +880,66 @@ public class MainActivity extends AppCompatActivity {
refreshStatus();
});
bindCadenceControls();
bindAbrpControls();
}
// ---------- ABRP, directly from the car ----------
private TextInputEditText abrpApiKeyInput, abrpTokenInput;
private TextView abrpStatusText;
private void bindAbrpControls() {
abrpApiKeyInput = servicePane.findViewById(R.id.abrp_api_key_input);
abrpTokenInput = servicePane.findViewById(R.id.abrp_token_input);
abrpStatusText = servicePane.findViewById(R.id.abrp_status_text);
AbrpConfig current = AbrpConfig.get(this);
abrpApiKeyInput.setText(current.apiKey);
abrpTokenInput.setText(current.userToken);
servicePane.findViewById(R.id.abrp_save_button).setOnClickListener(v ->
saveAbrpConfig(text(abrpApiKeyInput), text(abrpTokenInput)));
servicePane.findViewById(R.id.abrp_test_button).setOnClickListener(v -> testAbrpConfig());
}
private static String text(TextInputEditText input) {
CharSequence raw = input.getText();
return raw == null ? "" : raw.toString().trim();
}
/** Both set and plausible, or both empty (direct sending off). Returns whether it saved. */
private boolean saveAbrpConfig(String apiKey, String token) {
boolean clear = apiKey.isEmpty() && token.isEmpty();
if (!clear && !(AbrpConfig.isPlausible(apiKey) && AbrpConfig.isPlausible(token))) {
abrpStatusText.setText(R.string.abrp_invalid);
return false;
}
AbrpConfig.save(this, apiKey, token);
abrpApiKeyInput.setText(apiKey);
abrpTokenInput.setText(token);
abrpStatusText.setText(clear ? R.string.abrp_cleared : R.string.abrp_saved);
refreshStatus();
return true;
}
/** Read-only check against ABRP; never sends telemetry. Off the main thread. */
private void testAbrpConfig() {
AbrpConfig config = new AbrpConfig(text(abrpApiKeyInput), text(abrpTokenInput));
if (!config.isConfigured()) {
abrpStatusText.setText(R.string.abrp_not_set);
return;
}
abrpStatusText.setText(R.string.abrp_testing);
new Thread(() -> {
String message;
try {
ServerHttp.Response response = AbrpApi.verify(config);
message = response.ok() ? getString(R.string.abrp_test_ok)
: getString(R.string.abrp_test_failed, response.status);
} catch (Exception e) {
message = getString(R.string.abrp_test_offline, e.getClass().getSimpleName());
}
String shown = message;
runOnUiThread(() -> abrpStatusText.setText(shown));
}, "abrp-test").start();
}
/** The service reads these settings on every tick, so a change applies without a restart. */
@@ -946,16 +1015,16 @@ public class MainActivity extends AppCompatActivity {
private void bindWifiPane() {
wifi = new WifiAutoConnect(getApplicationContext());
wifiSsid = wifiPane.findViewById(R.id.wifi_ssid);
wifiState = wifiPane.findViewById(R.id.wifi_state);
wifiSavedList = wifiPane.findViewById(R.id.wifi_saved_list);
wifiSsid = settingsPane.findViewById(R.id.wifi_ssid);
wifiState = settingsPane.findViewById(R.id.wifi_state);
wifiSavedList = settingsPane.findViewById(R.id.wifi_saved_list);
SwitchMaterial auto = wifiPane.findViewById(R.id.wifi_auto_switch);
SwitchMaterial auto = settingsPane.findViewById(R.id.wifi_auto_switch);
auto.setChecked(prefs.getBoolean(WIFI_AUTO_KEY, true));
auto.setOnCheckedChangeListener((btn, checked) ->
prefs.edit().putBoolean(WIFI_AUTO_KEY, checked).apply());
wifiPane.findViewById(R.id.wifi_connect_button).setOnClickListener(v -> {
settingsPane.findViewById(R.id.wifi_connect_button).setOnClickListener(v -> {
// force: the driver is asking now, so the rate limit does not apply.
WifiAutoConnect.Result r = wifi.ensureConnected(true);
wifiState.setText(r.message);
@@ -1099,16 +1168,16 @@ public class MainActivity extends AppCompatActivity {
consumptionDateButton.setOnClickListener(v -> showConsumptionDatePicker());
consumptionPane.findViewById(R.id.consumption_refresh).setOnClickListener(v -> refreshFromCloud());
consumptionReset = consumptionPane.findViewById(R.id.consumption_reset);
consumptionSohLayout = consumptionPane.findViewById(R.id.consumption_soh_layout);
consumptionSohInput = consumptionPane.findViewById(R.id.consumption_soh_input);
consumptionSohLayout = settingsPane.findViewById(R.id.consumption_soh_layout);
consumptionSohInput = settingsPane.findViewById(R.id.consumption_soh_input);
Float savedSoh = consumptionTracker.sohPercent();
if (savedSoh != null) consumptionSohInput.setText(String.format(
java.util.Locale.US, "%.1f", savedSoh));
consumptionPane.findViewById(R.id.consumption_soh_save).setOnClickListener(v -> saveSoh());
usableCapacityLayout = consumptionPane.findViewById(R.id.usable_capacity_layout);
usableCapacityInput = consumptionPane.findViewById(R.id.usable_capacity_input);
settingsPane.findViewById(R.id.consumption_soh_save).setOnClickListener(v -> saveSoh());
usableCapacityLayout = settingsPane.findViewById(R.id.usable_capacity_layout);
usableCapacityInput = settingsPane.findViewById(R.id.usable_capacity_input);
usableCapacityInput.setText(String.format(java.util.Locale.US, "%.1f", usableKwh()));
consumptionPane.findViewById(R.id.usable_capacity_save).setOnClickListener(v -> saveUsableCapacity());
settingsPane.findViewById(R.id.usable_capacity_save).setOnClickListener(v -> saveUsableCapacity());
bindPeriodButton(R.id.consumption_lifetime, ConsumptionTracker.Period.LIFETIME);
bindPeriodButton(R.id.consumption_week, ConsumptionTracker.Period.WEEK);
bindPeriodButton(R.id.consumption_month, ConsumptionTracker.Period.MONTH);
@@ -1192,11 +1261,11 @@ public class MainActivity extends AppCompatActivity {
chargeTotalCost = chargingPane.findViewById(R.id.charge_total_cost);
chargeGraph = chargingPane.findViewById(R.id.charge_graph);
chargeCurveTable = chargingPane.findViewById(R.id.charge_curve_table);
chargePriceLayout = chargingPane.findViewById(R.id.charge_price_layout);
chargePriceInput = chargingPane.findViewById(R.id.charge_price_input);
chargePriceLayout = settingsPane.findViewById(R.id.charge_price_layout);
chargePriceInput = settingsPane.findViewById(R.id.charge_price_input);
chargePriceInput.setText(String.format(java.util.Locale.US, "%.2f",
chargeSessionTracker.pricePerKwh()));
chargingPane.findViewById(R.id.charge_price_save).setOnClickListener(v -> saveChargePrice());
settingsPane.findViewById(R.id.charge_price_save).setOnClickListener(v -> saveChargePrice());
}
private void saveChargePrice() {
@@ -1446,9 +1515,13 @@ public class MainActivity extends AppCompatActivity {
break;
case RUNNING:
case STARTING:
if (!ServerConfig.get(this).isConfigured()) {
boolean server = ServerConfig.get(this).isConfigured();
if (!server && !AbrpConfig.get(this).isConfigured()) {
statusText.setText(R.string.server_not_configured);
statusText.setTextColor(COLOR_ERROR);
} else if (!server && UploadSettings.from(prefs).abrpUploadEnabled) {
statusText.setText(R.string.state_abrp_only);
statusText.setTextColor(COLOR_PENDING);
} else if (!UploadSettings.from(prefs).abrpUploadEnabled) {
statusText.setText(R.string.state_tracking_locally);
statusText.setTextColor(COLOR_PENDING);