App 2.3.0: Server tab, live telemetry to the car server

- New Server tab: server URL, ingest token and installation id, with
  Save, Test connection (health, then a read-only token check) and the
  config-file import that upstream removed (app folder / USB stick first,
  document picker as fallback). Settings are kept encrypted and read on
  every use, so the service picks up a change without a restart. Each
  installation generates its own id; the inherited upstream UUID and
  the baked-in CONSUMPTION_API_* build fields are gone.
- Live telemetry restored from before ea8032a (TelemetryPayload,
  UploadCadence, UploadSettings, GPS) and sent in batches to
  POST /v1/telemetry instead of ABRP; the server forwards to ABRP. Adds
  ext_temp, voltage and current. Unsent samples queue in memory (about
  8 h) while offline.
- Upload tab: the live-telemetry switch and cadence controls work again;
  the dead service and autostart switches are removed.
- Manifest: location permissions and foreground service types.
- Unit tests compile again: 114 (stable) / 136 (unstable) pass. Tests
  for the deleted AbrpApi and HeadUnitWeather removed; new tests for
  ServerConfig and TelemetryQueue. Lint passes.

Not yet tried on the car.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 14:23:04 +02:00
co-authored by Claude Opus 5.5
parent c253876ed2
commit 6145736c97
27 changed files with 1590 additions and 287 deletions
@@ -16,6 +16,8 @@ import android.widget.TextView;
import java.time.LocalDate;
import java.util.List;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.app.AppCompatDelegate;
@@ -39,16 +41,18 @@ 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.tabWifi,
R.id.tabLog };
{ R.id.tabVehicle, R.id.tabConsumption, R.id.tabCharging, R.id.tabService, R.id.tabServer,
R.id.tabWifi, 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_wifi_title, R.string.design_log_title
R.string.design_service_title, R.string.design_server_title, R.string.design_wifi_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_wifi_subtitle, R.string.design_log_subtitle
R.string.design_service_subtitle, R.string.design_server_subtitle, R.string.design_wifi_subtitle,
R.string.design_log_subtitle
};
// Status colours come from the palette, not from the Material swatches: #4CAF50 and
@@ -61,13 +65,14 @@ public class MainActivity extends AppCompatActivity {
private TextView statusText;
private TextView callLogText;
private View servicePane;
private View serverPane;
private View logPane;
private View vehiclePane;
private View consumptionPane;
private View chargingPane;
private View wifiPane;
/** The six pages in navigation order. Parallel to {@link #TAB_IDS}. */
/** The pages in navigation order. Parallel to {@link #TAB_IDS}. */
private View[] panes;
/** Wi-Fi page. The helper is the same one the upload service uses on its own tick. */
@@ -102,6 +107,28 @@ public class MainActivity extends AppCompatActivity {
private ChargeSessionTracker chargeSessionTracker;
private ChargingGraphView chargeGraph;
/** Server page. */
private TextInputLayout serverUrlLayout, serverTokenLayout, installationIdLayout;
private TextInputEditText serverUrlInput, serverTokenInput, installationIdInput;
private View connectionStatusRow, connectionIndicator;
private TextView connectionStatusText;
/** Upload page: live telemetry switch and cadence. */
private SwitchMaterial liveTelemetrySwitch, boostSwitch;
private android.widget.Spinner intervalSpinner;
private TextInputLayout lowSocLayout;
private TextInputEditText lowSocInput;
/**
* Picks a config text file. Only reachable on devices that ship a document picker — the
* MG4 head unit does not, which is why {@link #startConfigImport()} scans the app's own
* folders first and only falls back to this.
*/
private final ActivityResultLauncher<String[]> configPicker =
registerForActivityResult(new ActivityResultContracts.OpenDocument(), uri -> {
if (uri != null) importConfig(uri);
});
/**
* Inflates a page with no parent, then gives it the MATCH_PARENT/MATCH_PARENT layout
* params ViewPager2 requires of its items — inflating detached leaves them null, and
@@ -201,17 +228,22 @@ public class MainActivity extends AppCompatActivity {
// in activity_main.xml — a recycled page would invalidate these references as
// soon as the user swiped away from it.
servicePane = inflatePane(R.layout.pane_service);
serverPane = inflatePane(R.layout.pane_server);
vehiclePane = inflatePane(R.layout.pane_vehicle);
consumptionPane = inflatePane(R.layout.pane_consumption);
chargingPane = inflatePane(R.layout.pane_charging);
wifiPane = inflatePane(R.layout.pane_wifi);
logPane = inflatePane(R.layout.pane_log);
panes = new View[] { vehiclePane, consumptionPane, chargingPane, servicePane, wifiPane, logPane };
panes = new View[] { vehiclePane, consumptionPane, chargingPane, servicePane, serverPane,
wifiPane, logPane };
bindVehiclePane();
bindConsumptionPane();
bindChargingPane();
bindWifiPane();
bindServerPane();
bindServicePane();
requestLocationPermissionIfNeeded();
statusText = servicePane.findViewById(R.id.status_text);
callLogText = logPane.findViewById(R.id.call_log_text);
@@ -225,7 +257,7 @@ public class MainActivity extends AppCompatActivity {
// a no-op and does not even contain the updater.
UpdateHook.checkInBackground(this);
// The service is always-on: it tracks consumption and syncs to MongoDB cloud.
// The service is always-on: it tracks consumption and sends everything to the server.
// startForegroundService is idempotent — if the service is already up this
// is a no-op aside from delivering a new intent.
startForegroundService(new Intent(this, AbrpUploadService.class));
@@ -337,6 +369,327 @@ public class MainActivity extends AppCompatActivity {
});
}
// ---------- Server page ----------
private void bindServerPane() {
serverUrlLayout = serverPane.findViewById(R.id.server_url_layout);
serverTokenLayout = serverPane.findViewById(R.id.server_token_layout);
installationIdLayout = serverPane.findViewById(R.id.installation_id_layout);
serverUrlInput = serverPane.findViewById(R.id.server_url_input);
serverTokenInput = serverPane.findViewById(R.id.server_token_input);
installationIdInput = serverPane.findViewById(R.id.installation_id_input);
connectionStatusRow = serverPane.findViewById(R.id.connection_status_row);
connectionIndicator = serverPane.findViewById(R.id.connection_indicator);
connectionStatusText = serverPane.findViewById(R.id.connection_status_text);
showServerConfig();
((TextView) serverPane.findViewById(R.id.import_hint))
.setText(getString(R.string.import_hint, configDirHint()));
serverPane.findViewById(R.id.server_save_button).setOnClickListener(v -> {
if (saveServerConfig(textOf(serverUrlInput), textOf(serverTokenInput), textOf(installationIdInput))) {
setConnectionStatus(COLOR_OK, getString(R.string.server_saved));
}
});
serverPane.findViewById(R.id.server_test_button).setOnClickListener(v -> testConnection());
serverPane.findViewById(R.id.import_button).setOnClickListener(v -> startConfigImport());
}
private void showServerConfig() {
ServerConfig config = ServerConfig.get(this);
serverUrlInput.setText(config.url);
serverTokenInput.setText(config.token);
installationIdInput.setText(config.installationId);
}
/** Validates, marks the bad fields, and saves only when all three are good. */
private boolean saveServerConfig(String rawUrl, String rawToken, String rawId) {
String url = ServerConfig.normalizeUrl(rawUrl);
String token = rawToken == null ? "" : rawToken.trim();
String id = rawId == null ? "" : rawId.trim().toLowerCase(java.util.Locale.US);
serverUrlLayout.setError(url == null ? getString(R.string.server_err_url) : null);
serverTokenLayout.setError(ServerConfig.isValidToken(token) ? null : getString(R.string.server_err_token));
installationIdLayout.setError(ServerConfig.isValidInstallationId(id)
? null : getString(R.string.server_err_installation_id));
if (url == null || !ServerConfig.isValidToken(token) || !ServerConfig.isValidInstallationId(id)) return false;
ServerConfig.save(this, url, token, id);
if (consumptionCloudClient != null) consumptionCloudClient.invalidateHistory();
showServerConfig();
refreshStatus();
return true;
}
/**
* Checks reachability, then the token, without writing anything: the health endpoint
* first, then the car's own history read, which needs the ingest token. Uses what is in
* the fields, saved or not, so a typo can be caught before it is stored.
*/
private void testConnection() {
String url = ServerConfig.normalizeUrl(textOf(serverUrlInput));
String token = textOf(serverTokenInput);
String id = textOf(installationIdInput).toLowerCase(java.util.Locale.US);
if (url == null) {
serverUrlLayout.setError(getString(R.string.server_err_url));
return;
}
if (!ServerConfig.isValidInstallationId(id)) {
installationIdLayout.setError(getString(R.string.server_err_installation_id));
return;
}
setConnectionStatus(COLOR_PENDING, getString(R.string.conn_testing));
new Thread(() -> {
String message;
int color;
try {
int health = httpGet(url + "/v1/health", null);
if (health != 200) {
message = getString(R.string.conn_err_http, health);
color = COLOR_ERROR;
} else {
int auth = httpGet(url + "/v1/consumption/history/" + id, token);
if (auth == 200) { message = getString(R.string.conn_ok); color = COLOR_OK; }
else if (auth == 401) { message = getString(R.string.conn_err_auth); color = COLOR_ERROR; }
else { message = getString(R.string.conn_err_http, auth); color = COLOR_ERROR; }
}
} catch (java.net.SocketTimeoutException e) {
message = getString(R.string.conn_err_timeout);
color = COLOR_ERROR;
} catch (Exception e) {
message = getString(R.string.conn_err_no_internet);
color = COLOR_ERROR;
}
final String m = message;
final int c = color;
runOnUiThread(() -> setConnectionStatus(c, m));
}, "server-test").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 {
connection.setConnectTimeout(8_000);
connection.setReadTimeout(8_000);
if (token != null) connection.setRequestProperty("Authorization", "Bearer " + token);
return connection.getResponseCode();
} finally {
connection.disconnect();
}
}
private void setConnectionStatus(int color, String message) {
connectionStatusRow.setVisibility(View.VISIBLE);
connectionIndicator.getBackground().mutate().setTint(color);
connectionStatusText.setText(message);
connectionStatusText.setTextColor(color);
}
// ---------- Config import ----------
/**
* The MG4 head unit ships no document picker — SAF answers "FileManagement is no supported
* on this device" — so the file is found by scanning instead. getExternalFilesDirs() returns
* this app's own folder on internal storage and on every mounted volume including a USB
* stick, and those need no storage permission at any API level. The picker stays as the
* fallback for phones and tablets, where the user may keep the file anywhere.
*/
private void startConfigImport() {
java.io.File found = findConfigFile();
if (found != null) {
importConfig(found);
return;
}
try {
// Any MIME: the MG4 Files app tags .txt inconsistently, so filtering by type hides
// the very file the user is trying to pick. They select it by name instead.
configPicker.launch(new String[]{"*/*"});
} catch (android.content.ActivityNotFoundException e) {
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_not_found, configDirHint()));
}
}
/**
* First readable file in an app-specific folder that parses as a config. Names are not
* filtered: the user copies one file to the folder, whatever they called it.
*/
private java.io.File findConfigFile() {
for (java.io.File dir : getExternalFilesDirs(null)) {
if (dir == null) continue;
java.io.File[] files = dir.listFiles();
if (files == null) continue;
for (java.io.File f : files) {
if (f.isFile() && f.canRead() && f.length() > 0 && f.length() <= 64 * 1024
&& !ConfigImport.parse(readText(f)).isEmpty()) {
return f;
}
}
}
return null;
}
/** File text, or "" when unreadable — callers treat that as "not a config". */
private String readText(java.io.File file) {
try (java.io.InputStream in = new java.io.FileInputStream(file)) {
return readCapped(in);
} catch (Exception e) {
return "";
}
}
/** Capped so a wrong file picked by mistake is not slurped whole into memory. */
private String readCapped(java.io.InputStream in) throws java.io.IOException {
java.io.ByteArrayOutputStream buf = new java.io.ByteArrayOutputStream();
byte[] chunk = new byte[4096];
int n;
while ((n = in.read(chunk)) != -1 && buf.size() < 64 * 1024) buf.write(chunk, 0, n);
return buf.toString(java.nio.charset.StandardCharsets.UTF_8.name());
}
/** Where the user should drop the file. */
private String configDirHint() {
java.io.File[] dirs = getExternalFilesDirs(null);
return dirs.length > 0 && dirs[0] != null ? dirs[0].getAbsolutePath() : "";
}
private void importConfig(java.io.File file) {
String text = readText(file);
if (text.isEmpty()) {
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_read));
return;
}
applyConfig(text);
}
private void importConfig(Uri uri) {
String text;
try (java.io.InputStream in = getContentResolver().openInputStream(uri)) {
if (in == null) throw new java.io.IOException("no stream");
text = readCapped(in);
} catch (Exception e) {
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_read));
return;
}
applyConfig(text);
}
/**
* Applies whatever the file sets; absent keys keep their current value, so a file with
* only the token does not wipe the URL. Server settings are validated and saved together;
* cadence keys go to the plain prefs the same way the Upload page writes them.
*/
private void applyConfig(String text) {
ConfigImport config = ConfigImport.parse(text);
if (config.isEmpty()) {
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_empty));
return;
}
ServerConfig current = ServerConfig.get(this);
if (config.serverUrl != null || config.token != null || config.installationId != null) {
boolean saved = saveServerConfig(
config.serverUrl != null ? config.serverUrl : current.url,
config.token != null ? config.token : current.token,
config.installationId != null ? config.installationId : current.installationId);
if (!saved) {
// Show what the file said, so the marked field is the one to fix in the file.
if (config.serverUrl != null) serverUrlInput.setText(config.serverUrl);
if (config.token != null) serverTokenInput.setText(config.token);
if (config.installationId != null) installationIdInput.setText(config.installationId);
return;
}
}
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);
if (config.lowSocPercent != null) edit.putInt(UploadSettings.KEY_LOW_SOC_PERCENT, config.lowSocPercent);
edit.apply();
bindCadenceControls();
setConnectionStatus(COLOR_OK, getString(R.string.import_ok));
refreshStatus();
}
// ---------- Upload page ----------
private void bindServicePane() {
liveTelemetrySwitch = servicePane.findViewById(R.id.abrp_upload_switch);
intervalSpinner = servicePane.findViewById(R.id.interval_spinner);
boostSwitch = servicePane.findViewById(R.id.boost_switch);
lowSocInput = servicePane.findViewById(R.id.low_soc_input);
lowSocLayout = servicePane.findViewById(R.id.low_soc_layout);
liveTelemetrySwitch.setChecked(UploadSettings.from(prefs).abrpUploadEnabled);
liveTelemetrySwitch.setOnCheckedChangeListener((btn, checked) -> {
prefs.edit().putBoolean(UploadSettings.KEY_ABRP_UPLOAD_ENABLED, checked).apply();
refreshStatus();
});
bindCadenceControls();
}
/** The service reads these settings on every tick, so a change applies without a restart. */
private void bindCadenceControls() {
UploadSettings current = UploadSettings.from(prefs);
String[] labels = new String[UploadSettings.INTERVAL_CHOICES_SEC.length];
int selected = 0;
for (int i = 0; i < UploadSettings.INTERVAL_CHOICES_SEC.length; i++) {
int sec = UploadSettings.INTERVAL_CHOICES_SEC[i];
labels[i] = sec >= 60
? getResources().getQuantityString(R.plurals.interval_minutes, sec / 60, sec / 60)
: getResources().getQuantityString(R.plurals.interval_seconds, sec, sec);
if (sec == current.intervalSec) selected = i;
}
android.widget.ArrayAdapter<String> adapter = new android.widget.ArrayAdapter<>(
this, R.layout.spinner_value, labels);
adapter.setDropDownViewResource(R.layout.spinner_dropdown);
intervalSpinner.setAdapter(adapter);
intervalSpinner.setSelection(selected);
intervalSpinner.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(android.widget.AdapterView<?> parent, View view, int position, long id) {
prefs.edit().putInt(UploadSettings.KEY_INTERVAL_SEC,
UploadSettings.INTERVAL_CHOICES_SEC[position]).apply();
}
@Override public void onNothingSelected(android.widget.AdapterView<?> parent) { }
});
boostSwitch.setOnCheckedChangeListener(null);
boostSwitch.setChecked(current.boostLowSoc);
lowSocLayout.setEnabled(current.boostLowSoc);
lowSocInput.setEnabled(current.boostLowSoc);
boostSwitch.setOnCheckedChangeListener((button, checked) -> {
prefs.edit().putBoolean(UploadSettings.KEY_BOOST_LOW_SOC, checked).apply();
lowSocLayout.setEnabled(checked);
lowSocInput.setEnabled(checked);
});
lowSocInput.setText(String.valueOf(current.lowSocPercent));
lowSocInput.setOnFocusChangeListener((view, hasFocus) -> {
if (!hasFocus) saveLowSocThreshold();
});
}
/** Clamped to 1-99: 0 would never trigger and 100 would boost permanently. */
private void saveLowSocThreshold() {
CharSequence raw = lowSocInput.getText();
int value;
try {
value = Integer.parseInt(raw == null ? "" : raw.toString().trim());
} catch (NumberFormatException e) {
value = UploadSettings.DEFAULT_LOW_SOC_PERCENT;
}
value = Math.max(1, Math.min(99, value));
lowSocInput.setText(String.valueOf(value));
prefs.edit().putInt(UploadSettings.KEY_LOW_SOC_PERCENT, value).apply();
}
/**
* GPS for the telemetry position. On the car the app runs as the system user and holds
* it already; this only asks where it does not, such as a phone running the stable build.
*/
private void requestLocationPermissionIfNeeded() {
if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION)
!= PackageManager.PERMISSION_GRANTED) {
requestPermissions(new String[]{android.Manifest.permission.ACCESS_FINE_LOCATION,
android.Manifest.permission.ACCESS_COARSE_LOCATION}, 1);
}
}
// ---------- Wi-Fi page ----------
private void bindWifiPane() {
@@ -764,8 +1117,16 @@ public class MainActivity extends AppCompatActivity {
break;
case RUNNING:
case STARTING:
statusText.setText("Consumption tracking active");
statusText.setTextColor(COLOR_OK);
if (!ServerConfig.get(this).isConfigured()) {
statusText.setText(R.string.server_not_configured);
statusText.setTextColor(COLOR_ERROR);
} else if (!UploadSettings.from(prefs).abrpUploadEnabled) {
statusText.setText(R.string.state_tracking_locally);
statusText.setTextColor(COLOR_PENDING);
} else {
statusText.setText(R.string.state_running);
statusText.setTextColor(COLOR_OK);
}
break;
case STOPPED:
default: