Files
mg4/app/src/main/java/com/evsuite/abrp/MainActivity.java
T
nilsandClaude Opus 5.5 851a4a34d4 App 2.17.1: tidier Settings and Logs
- Settings: the Wi-Fi and saved-networks cards are gone, and the rest
  sits in three columns (display and steering wheel | battery |
  charging price and app) instead of one tall column. The background
  Wi-Fi auto-connect stays on, without a switch.
- Logs: only the upload log. The steering-key reports, the car-colour
  probe and the screen line answered their questions and are removed,
  with their code (CarColorProbe, the key-report log) and strings.
- README lists the current tabs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 20:32:01 +02:00

1762 lines
88 KiB
Java

package com.evsuite.abrp;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.Toast;
import android.widget.ImageView;
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;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager2.widget.ViewPager2;
import com.google.android.material.switchmaterial.SwitchMaterial;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.textfield.TextInputEditText;
import com.google.android.material.textfield.TextInputLayout;
import com.evsuite.hardware.telemetry.EnergySnapshot;
import com.evsuite.hardware.telemetry.EnergyTelemetryReader;
public class MainActivity extends AppCompatActivity {
/**
* Rejoin a saved network (the phone hotspot) in the background; read by
* {@link AbrpUploadService} on every tick. On by default; the Wi-Fi card with its switch was
* removed from Settings (2.17.1), so it now stays on.
*/
static final String WIFI_AUTO_KEY = "wifi_auto_connect";
private static final String REPOSITORY_URL = "https://git.stinnesbeck.com/nils/mg4";
/** 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.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_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_settings_subtitle,
R.string.design_log_subtitle
};
// Status colours come from the palette, not from the Material swatches: #4CAF50 and
// #F44336 sit around 4:1 on this background, which disappears behind a sunlit
// reflection. The palette entries are the lightened variants (7:1 and above).
private static final int COLOR_OK = 0xFFA4DCC1;
private static final int COLOR_ERROR = 0xFFFFB4AB;
private static final int COLOR_PENDING = 0xFFBDC4C6;
private TextView statusText;
private TextView callLogText;
private View servicePane;
private View serverPane;
private View logPane;
private View vehiclePane;
private View consumptionPane;
private View chargingPane;
/** Display, battery, charging price and Wi-Fi. */
private View settingsPane;
/** The pages in navigation order. Parallel to {@link #TAB_IDS}. */
private View[] panes;
/**
* Value cells of the Vehicle page, looked up once and refreshed on the UI tick.
*
* Read straight from {@link EnergyTelemetryReader} rather than from the uploader: the
* page shows what the car reports, whether or not the upload service is running, and a
* signal the vehicle does not publish must show a dash rather than a zero.
*/
/** Used only on the overview-read thread. */
private EnergyTelemetryReader vehicleReader;
private TextView vehSoc, vehRange, vehPower, vehSpeed, vehOdometer,
vehCharging, vehHvac,
vehTireFl, vehTireFr, vehTireRl, vehTireRr,
vehRangeTrip, vehRangeTripNote, vehRangeAverage, vehRangeAverageNote,
vehUsageTrip, vehUsageTripNote, vehUsageAverage, vehUsageAverageNote,
vehEnergyTrip, vehEnergyTripNote, vehPowerAverage, vehPowerAverageNote,
vehSpeedAverage, vehSpeedAverageNote;
/** Usable pack capacity for the range estimates; the car does not publish it. */
static final String USABLE_KWH_KEY = "usable_kwh";
private TextInputLayout usableCapacityLayout;
private TextInputEditText usableCapacityInput;
private ConsumptionTracker consumptionTracker;
private ConsumptionCloudClient consumptionCloudClient;
private ConsumptionTracker.Period consumptionPeriod = ConsumptionTracker.Period.LIFETIME;
private TextView consumptionDistance, consumptionEnergy, consumptionAverage,
consumptionSpeed, consumptionTime, consumptionSoc, consumptionDailyHistory;
private Button consumptionReset;
private MaterialButton consumptionDateButton;
private TextInputLayout consumptionSohLayout;
private TextInputEditText consumptionSohInput;
private java.time.LocalDate selectedConsumptionDate = java.time.LocalDate.now();
private TextView chargeStatus, chargeSoc, chargeStartSoc, chargeVoltage, chargeCurrent, chargePower,
chargeEnergy, chargeGridEnergy, chargeDuration, chargeTotalCost, chargeCurveTable;
private TextInputLayout chargePriceLayout;
private TextInputEditText chargePriceInput;
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, voiceKeySwitch, fullScreenSwitch;
private com.google.android.material.textfield.MaterialAutoCompleteTextView intervalChoices;
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
* the pager throws rather than guessing.
*/
private View inflatePane(int layoutRes) {
View pane = getLayoutInflater().inflate(layoutRes, null, false);
pane.setLayoutParams(new android.view.ViewGroup.LayoutParams(
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
android.view.ViewGroup.LayoutParams.MATCH_PARENT));
return pane;
}
/**
* Wires the navigation to the pager: every tab is a page, and every page is a tab.
*
* Each page has its own view type, so the pager asks for it once and keeps it: these
* views are the activity's own, held in {@link #panes}, not rows to be recycled.
*/
private void setUpPager() {
ViewPager2 pager = findViewById(R.id.content);
pager.setAdapter(new RecyclerView.Adapter<RecyclerView.ViewHolder>() {
@Override public int getItemCount() { return panes.length; }
@Override public int getItemViewType(int position) { return position; }
@NonNull @Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull android.view.ViewGroup parent,
int viewType) {
return new RecyclerView.ViewHolder(panes[viewType]) {};
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
// Nothing to bind: the pages are built once in onCreate and keep their own
// state, exactly as they did when they were siblings in the layout.
}
});
// Keep all pages alive: the log and vehicle readings are refreshed
// by the tick even when their page is off screen.
pager.setOffscreenPageLimit(panes.length - 1);
pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
@Override public void onPageSelected(int position) { markCurrentPage(position); }
});
for (int i = 0; i < TAB_IDS.length; i++) {
final int index = i;
// Animated, so the button does the same thing the swipe does: the direction of
// travel is what tells the driver where they are in the row.
findViewById(TAB_IDS[i]).setOnClickListener(v -> pager.setCurrentItem(index, false));
}
markCurrentPage(pager.getCurrentItem());
}
/**
* Marks the destination and labels the page on screen.
*
* Only isSelected is set: fill, text, icon and stroke come from the
* res/color/nav_tab_*.xml selectors applied by the Widget.EV.NavTab style. isSelected
* also serves TalkBack, which announces the current destination.
*/
private void markCurrentPage(int position) {
for (int i = 0; i < TAB_IDS.length; i++) {
findViewById(TAB_IDS[i]).setSelected(i == position);
}
((TextView) findViewById(R.id.header_title)).setText(PAGE_TITLES[position]);
((TextView) findViewById(R.id.header_subtitle)).setText(PAGE_SUBTITLES[position]);
}
/** Refreshes state + call log while the screen is visible. */
private final android.os.Handler uiHandler = new android.os.Handler(android.os.Looper.getMainLooper());
private final Runnable uiRefresh = new Runnable() {
@Override public void run() {
refreshStatus();
refreshCallLog();
sampleTrackers();
refreshConsumption();
uiHandler.postDelayed(this, 2_000L);
}
};
private SharedPreferences prefs;
@Override
protected void onCreate(Bundle savedInstanceState) {
getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES);
super.onCreate(savedInstanceState);
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
// Before anything is inflated: every ?attr/evAccent… resolves against this overlay.
getTheme().applyStyle(AccentColor.fromPref(prefs.getString(AccentColor.PREF_KEY, null)).overlay, true);
setContentView(R.layout.activity_main);
// The pages are inflated here, once, and handed to the pager as fixed,
// non-recycled items. That is what lets every widget below be looked up now and
// held for the life of the activity, the way it was when the panes were siblings
// 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);
settingsPane = inflatePane(R.layout.pane_settings);
logPane = inflatePane(R.layout.pane_log);
panes = new View[] { vehiclePane, consumptionPane, chargingPane, servicePane, serverPane,
settingsPane, logPane };
bindVehiclePane();
bindConsumptionPane();
bindChargingPane();
bindServerPane();
bindServicePane();
requestLocationPermissionIfNeeded();
statusText = servicePane.findViewById(R.id.status_text);
callLogText = logPane.findViewById(R.id.call_log_text);
logPane.findViewById(R.id.flush_now_button).setOnClickListener(v -> manualFlush());
setUpPager();
// Back on the page that was open when the activity was recreated (accent colour change).
if (savedInstanceState != null) {
((ViewPager2) findViewById(R.id.content)).setCurrentItem(savedInstanceState.getInt(STATE_PAGE, 0), false);
}
((TextView) settingsPane.findViewById(R.id.settings_version))
.setText(getString(R.string.settings_version, BuildConfig.VERSION_NAME));
settingsPane.findViewById(R.id.settings_about_button).setOnClickListener(v -> showAbout());
settingsPane.findViewById(R.id.settings_update_button).setOnClickListener(v -> checkForUpdate(v));
// Unstable builds check for a newer pre-release; the stable flavor's UpdateHook is
// a no-op and does not even contain the updater.
UpdateHook.checkInBackground(this);
// 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));
}
/**
* Shows a dialog without bringing the car's bars back. A dialog is a window of its own, and
* the system shows the bars when a window without the full-screen flags takes focus. So it
* is shown unfocusable, given the activity's flags, and only then allowed focus. None of the
* dialogs here take typing, which an unfocusable window would block.
*/
@SuppressWarnings("deprecation") // the WindowInsetsController API starts at 30; this is 28
private void showKeepingFullScreen(android.app.Dialog dialog) {
android.view.Window window = dialog.getWindow();
if (window == null || !UploadSettings.fullScreen(prefs)) {
dialog.show();
return;
}
window.setFlags(android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE);
dialog.show();
window.getDecorView().setSystemUiVisibility(getWindow().getDecorView().getSystemUiVisibility());
window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE);
}
private static final String STATE_PAGE = "page";
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putInt(STATE_PAGE, ((ViewPager2) findViewById(R.id.content)).getCurrentItem());
}
private void showAbout() {
String version;
try {
version = getPackageManager().getPackageInfo(getPackageName(), 0).versionName;
} catch (PackageManager.NameNotFoundException e) {
version = getString(R.string.about_version_unknown);
}
View content = getLayoutInflater().inflate(R.layout.dialog_about, null);
content.<TextView>findViewById(R.id.about_version)
.setText(getString(R.string.about_version, version));
ImageView qr = content.findViewById(R.id.about_qr_code);
android.graphics.Bitmap bitmap = QrCode.generate(REPOSITORY_URL, 416);
if (bitmap != null) qr.setImageBitmap(bitmap);
content.findViewById(R.id.about_repository).setOnClickListener(v ->
startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(REPOSITORY_URL))));
androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(this)
.setView(content)
.create();
content.<MaterialButton>findViewById(R.id.about_close).setOnClickListener(v -> dialog.dismiss());
showKeepingFullScreen(dialog);
if (dialog.getWindow() != null) {
dialog.getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
android.util.DisplayMetrics metrics = getResources().getDisplayMetrics();
int width = Math.min((int) (760 * metrics.density),
metrics.widthPixels - (int) (32 * metrics.density));
dialog.getWindow().setLayout(width, android.view.ViewGroup.LayoutParams.WRAP_CONTENT);
}
}
@Override
protected void onResume() {
super.onResume();
// Poll state and log only while the screen is up; onPause cancels it.
uiHandler.post(uiRefresh);
uiHandler.post(overviewRefresh);
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
// The system shows its bars again when focus moves (a dialog, the bars swiped in);
// re-applying on regaining focus keeps the app full screen.
if (hasFocus) applyFullScreen();
}
/**
* Immersive full screen: the head unit's top and bottom bars are hidden while the app is
* in front, and a swipe from the edge shows them briefly. On the MG4 those bars are SAIC's
* own (climate strip, hazard and home shortcuts); if the platform keeps them, these flags
* simply have no effect.
*
* The content takes the space the bars leave: no LAYOUT_STABLE (it reports the bars' insets
* even while they are hidden), and the root stops padding itself for them. On the MG4 that
* padding stayed as a black band where the bars had been (2.8.1).
*/
@SuppressWarnings("deprecation") // the WindowInsetsController API starts at 30; this is 28
private void applyFullScreen() {
View decor = getWindow().getDecorView();
boolean full = UploadSettings.fullScreen(prefs);
View root = findViewById(R.id.app_root);
if (root != null) {
root.setFitsSystemWindows(!full);
if (full) root.setPadding(0, 0, 0, 0);
root.requestApplyInsets();
}
if (full) {
decor.setSystemUiVisibility(View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_FULLSCREEN);
} else {
decor.setSystemUiVisibility(View.SYSTEM_UI_FLAG_VISIBLE);
}
}
@Override
protected void onPause() {
super.onPause();
// Stop polling off-screen: this activity has no reason to spend cycles then.
uiHandler.removeCallbacks(uiRefresh);
uiHandler.removeCallbacks(overviewRefresh);
}
@Override
protected void onDestroy() {
carReader.shutdownNow();
super.onDestroy();
}
// ---------- Update check ----------
/**
* Checks for a newer build, and installs one if it is there.
*
* The button is disabled for the duration rather than guarded by a flag: the whole
* sequence — API call, download, installer staging — runs on a background thread, and
* a second press would start a second download of the same APK. It comes back on when
* the result lands, whatever that result is.
*
* A modal progress surface keeps the driver informed while the APK is downloaded and
* SHA-256 verified; the final result remains visible until dismissed.
*/
private void checkForUpdate(View button) {
button.setEnabled(false);
android.widget.LinearLayout panel = new android.widget.LinearLayout(this);
panel.setOrientation(android.widget.LinearLayout.VERTICAL);
int padding = (int) (24 * getResources().getDisplayMetrics().density);
panel.setPadding(padding, padding, padding, padding);
TextView status = new TextView(this);
status.setText(R.string.update_checking);
status.setTextAppearance(R.style.Text_EV_Body);
android.widget.ProgressBar progress = new android.widget.ProgressBar(this, null,
android.R.attr.progressBarStyleHorizontal);
progress.setIndeterminate(true);
android.widget.LinearLayout.LayoutParams progressParams =
new android.widget.LinearLayout.LayoutParams(
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
android.view.ViewGroup.LayoutParams.WRAP_CONTENT);
progressParams.topMargin = padding;
panel.addView(status);
panel.addView(progress, progressParams);
androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(this)
.setTitle(R.string.update_dialog_title)
.setView(panel)
.setCancelable(false)
.create();
showKeepingFullScreen(dialog);
UpdateHook.checkInBackground(this, new UpdateHook.Listener() {
@Override public void onProgress(int percent) {
if (percent < 0) {
progress.setIndeterminate(true);
status.setText(R.string.update_downloading);
} else {
progress.setIndeterminate(false);
progress.setMax(100);
progress.setProgress(percent);
status.setText(getString(R.string.update_downloading_percent, percent));
}
}
@Override public void onResult(String message) {
button.setEnabled(true);
dialog.dismiss();
showKeepingFullScreen(new MaterialAlertDialogBuilder(MainActivity.this)
.setTitle(R.string.update_dialog_title)
.setMessage(message)
.setPositiveButton(android.R.string.ok, null)
.create());
}
});
}
// ---------- 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.server_pair_button).setOnClickListener(v -> startPairing());
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 = describeNetworkError(e, url);
color = COLOR_ERROR;
}
final String m = message;
final int c = color;
runOnUiThread(() -> setConnectionStatus(c, m));
}, "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;
PhoneQrDialog ui = new PhoneQrDialog(R.string.pair_title, R.string.pair_starting);
final boolean[] cancelled = ui.cancelled;
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(() -> 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);
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 (PairingClient.HttpStatusException e) {
outcome = e.status == 404 ? getString(R.string.pair_err_old_server)
: getString(R.string.err_http, e.status);
} catch (InterruptedException e) {
return;
} catch (Exception e) {
outcome = describeNetworkError(e, url);
}
final String message = outcome;
final int c = color;
runOnUiThread(() -> {
setConnectionStatus(c, message);
if (c == COLOR_OK) {
if (consumptionCloudClient != null) consumptionCloudClient.invalidateHistory();
showServerConfig();
serverTokenLayout.setError(null);
refreshStatus();
if (!cancelled[0]) ui.dialog.dismiss();
return;
}
// Keep the dialog up with the reason, instead of closing it on a failure the
// driver would otherwise only see as a flicker.
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(AccentColor.resolve(MainActivity.this, R.attr.evAccentLight));
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);
showKeepingFullScreen(dialog);
}
/** 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.
*/
private String describeNetworkError(Exception e, String url) {
String host = Uri.parse(url).getHost();
if (host == null) host = url;
if (e instanceof java.net.UnknownHostException) return getString(R.string.err_dns, host);
if (e instanceof java.net.SocketTimeoutException || e instanceof java.net.ConnectException
|| e instanceof java.net.NoRouteToHostException) {
return getString(R.string.err_unreachable, host);
}
if (e instanceof PairingClient.HttpStatusException) {
return getString(R.string.err_http, ((PairingClient.HttpStatusException) e).status);
}
return getString(R.string.pair_error, e.getClass().getSimpleName());
}
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() : "";
}
/**
* Imports a scanned file and then deletes it: it holds the ingest token in plaintext, maybe
* on a USB stick, and the settings live in encrypted storage from now on.
*/
private void importConfig(java.io.File file) {
String text = readText(file);
if (text.isEmpty()) {
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_read));
return;
}
if (!applyConfig(text)) return;
boolean deleted;
try {
deleted = file.delete();
} catch (SecurityException e) {
deleted = false;
}
if (deleted) {
setConnectionStatus(COLOR_OK, getString(R.string.import_ok_deleted));
} else {
setConnectionStatus(COLOR_ERROR, getString(R.string.import_ok_not_deleted, file.getAbsolutePath()));
}
}
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 boolean applyConfig(String text) {
ConfigImport config = ConfigImport.parse(text);
if (config.isEmpty()) {
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_empty));
return false;
}
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 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;
}
}
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);
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();
return true;
}
// ---------- Upload page ----------
private void bindServicePane() {
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());
bindTireUnit();
bindAccentColor();
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();
applyFullScreen();
});
liveTelemetrySwitch = servicePane.findViewById(R.id.abrp_upload_switch);
intervalChoices = servicePane.findViewById(R.id.interval_choices);
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();
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());
servicePane.findViewById(R.id.abrp_phone_button).setOnClickListener(v -> startPhoneSettings());
}
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();
}
/**
* A single choice as a drop-down (Widget.EV.Dropdown). An exposed dropdown menu rather than
* a Spinner: a Spinner's list is a focusable window of its own without the full-screen
* flags, and the car showed its bars while it was open; this list leaves the focus with
* the activity. setSimpleItems keeps every option listed (a plain adapter would filter the
* list down to the current text). Rebinding is safe.
*/
private void bindChoices(com.google.android.material.textfield.MaterialAutoCompleteTextView view,
String[] labels, int selected, java.util.function.IntConsumer onPick) {
view.setOnItemClickListener(null);
view.setSimpleItems(labels);
view.setText(labels[selected], false);
view.setOnItemClickListener((parent, item, position, id) -> onPick.accept(position));
}
/** The accent colour; a change recreates the activity so every view takes it. */
private void bindAccentColor() {
AccentColor[] colors = AccentColor.values();
String[] labels = new String[colors.length];
for (int i = 0; i < colors.length; i++) labels[i] = colors[i].label;
AccentColor current = AccentColor.fromPref(prefs.getString(AccentColor.PREF_KEY, null));
bindChoices(settingsPane.findViewById(R.id.accent_choices), labels, current.ordinal(), position -> {
if (colors[position] == current) return;
prefs.edit().putString(AccentColor.PREF_KEY, colors[position].name()).apply();
recreate();
});
}
/** Tyre pressure unit: display only, read on every refresh of the Overview. */
private void bindTireUnit() {
PressureUnit[] units = PressureUnit.values();
String[] labels = new String[units.length];
for (int i = 0; i < units.length; i++) labels[i] = units[i].label;
int current = PressureUnit.fromPref(prefs.getString(PressureUnit.PREF_KEY, null)).ordinal();
bindChoices(settingsPane.findViewById(R.id.tire_unit_choices), labels, current, position ->
prefs.edit().putString(PressureUnit.PREF_KEY, units[position].name()).apply());
}
/** 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;
}
bindChoices(intervalChoices, labels, selected, position ->
prefs.edit().putInt(UploadSettings.KEY_INTERVAL_SEC,
UploadSettings.INTERVAL_CHOICES_SEC[position]).apply());
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);
}
}
// ---------- Vehicle page ----------
/** Looks up the value cells once; the labels are static and never touched from code. */
private void bindVehiclePane() {
vehSoc = vehiclePane.findViewById(R.id.vehicle_soc);
vehRange = vehiclePane.findViewById(R.id.vehicle_range);
vehPower = vehiclePane.findViewById(R.id.vehicle_power);
vehSpeed = vehiclePane.findViewById(R.id.vehicle_speed);
vehOdometer = vehiclePane.findViewById(R.id.vehicle_odometer);
vehCharging = vehiclePane.findViewById(R.id.vehicle_charging);
vehHvac = vehiclePane.findViewById(R.id.vehicle_hvac);
vehTireFl = vehiclePane.findViewById(R.id.vehicle_tire_fl);
vehTireFr = vehiclePane.findViewById(R.id.vehicle_tire_fr);
vehTireRl = vehiclePane.findViewById(R.id.vehicle_tire_rl);
vehTireRr = vehiclePane.findViewById(R.id.vehicle_tire_rr);
vehRangeTrip = vehiclePane.findViewById(R.id.vehicle_range_trip);
vehRangeTripNote = vehiclePane.findViewById(R.id.vehicle_range_trip_note);
vehRangeAverage = vehiclePane.findViewById(R.id.vehicle_range_average);
vehRangeAverageNote = vehiclePane.findViewById(R.id.vehicle_range_average_note);
vehUsageTrip = vehiclePane.findViewById(R.id.vehicle_usage_trip);
vehUsageTripNote = vehiclePane.findViewById(R.id.vehicle_usage_trip_note);
vehUsageAverage = vehiclePane.findViewById(R.id.vehicle_usage_average);
vehUsageAverageNote = vehiclePane.findViewById(R.id.vehicle_usage_average_note);
vehEnergyTrip = vehiclePane.findViewById(R.id.vehicle_energy_trip);
vehEnergyTripNote = vehiclePane.findViewById(R.id.vehicle_energy_trip_note);
vehPowerAverage = vehiclePane.findViewById(R.id.vehicle_power_average);
vehPowerAverageNote = vehiclePane.findViewById(R.id.vehicle_power_average_note);
vehSpeedAverage = vehiclePane.findViewById(R.id.vehicle_speed_average);
vehSpeedAverageNote = vehiclePane.findViewById(R.id.vehicle_speed_average_note);
}
private double usableKwh() {
return prefs.getFloat(USABLE_KWH_KEY, (float) RangeEstimator.DEFAULT_USABLE_KWH);
}
/**
* The range estimates since start and at the 5-minute average, each with its usage in a
* card below it. Since start needs the car to have moved; the average needs a minute or more
* of driving, which the service has usually collected already.
*/
private void refreshRangeEstimates(EnergySnapshot s) {
RangeEstimator estimator = RangeEstimator.shared();
long monotonicMs = android.os.SystemClock.elapsedRealtime();
estimator.add(monotonicMs, s.getBatteryPowerKw(), s.getSpeedKmh());
Double remaining = RangeEstimator.remainingKwh(s.getSocPercent(), usableKwh());
RangeEstimator.Estimate trip = estimator.sinceStart(remaining);
Double power = estimator.averagePowerKw(monotonicMs);
vehPowerAverage.setText(power == null ? "—" : getString(R.string.power_kw, power));
vehPowerAverageNote.setText(power == null ? R.string.power_waiting : R.string.usage_last_5_min);
Double speed = estimator.averageSpeedKmh(monotonicMs);
vehSpeedAverage.setText(speed == null ? "—" : getString(R.string.speed_kmh, speed));
vehSpeedAverageNote.setText(speed == null ? R.string.power_waiting : R.string.usage_last_5_min);
Double energy = estimator.energySinceStart();
vehEnergyTrip.setText(energy == null ? "—" : getString(R.string.energy_kwh, energy));
vehEnergyTripNote.setText(energy == null ? R.string.energy_trip_waiting : R.string.energy_trip_note);
showEstimate(vehRangeTrip, vehRangeTripNote, vehUsageTrip, vehUsageTripNote, trip,
trip == null ? "" : getString(R.string.usage_over_distance, trip.distanceKm),
R.string.range_trip_waiting);
showEstimate(vehRangeAverage, vehRangeAverageNote, vehUsageAverage, vehUsageAverageNote,
estimator.average(remaining, monotonicMs), getString(R.string.usage_last_5_min),
R.string.range_waiting);
}
private void showEstimate(TextView range, TextView rangeNote, TextView usage, TextView usageNote,
RangeEstimator.Estimate e, String basis, int whyNot) {
if (e == null) {
range.setText("—");
rangeNote.setText(whyNot);
usage.setText("—");
usageNote.setText(whyNot);
return;
}
// Recuperating, the pack gains energy: the range is unbounded. Coasting at speed costs
// almost nothing, which would read as thousands of km.
range.setText(e.kwhPer100Km <= 0 ? "∞"
: e.rangeKm > 999 ? getString(R.string.range_over, 999)
: String.format(java.util.Locale.US, "%.0f km", e.rangeKm));
rangeNote.setText(e.kwhPer100Km <= 0 ? getString(R.string.range_recuperating) : "");
usage.setText(getString(R.string.range_consumption, e.kwhPer100Km));
usageNote.setText(basis);
}
/**
* Reads one snapshot and writes it to the page.
*
* The reader is built lazily and kept: constructing it binds the vendor services, which
* is not work to repeat every two seconds. A read that throws leaves the cells as they
* were rather than blanking the page — a transient miss is not news worth showing.
*/
// ---------- Overview: read every half second, off the main thread ----------
/** How often the Overview reads the car while it is on screen. */
private static final long OVERVIEW_REFRESH_MS = 500L;
/** One reader thread; a read that is still running makes the next tick skip, not queue. */
private final java.util.concurrent.ExecutorService carReader =
java.util.concurrent.Executors.newSingleThreadExecutor(r -> new Thread(r, "overview-read"));
private final java.util.concurrent.atomic.AtomicBoolean reading =
new java.util.concurrent.atomic.AtomicBoolean();
/** The newest reading, for the slower work on the 2-second tick. */
private volatile EnergySnapshot latestSnapshot;
private final Runnable overviewRefresh = new Runnable() {
@Override public void run() {
readVehicle();
uiHandler.postDelayed(this, OVERVIEW_REFRESH_MS);
}
};
/**
* About twenty calls into the car's services: on a background thread, so the screen never
* waits for them; the values are shown on the main thread once they are in.
*/
private void readVehicle() {
if (!reading.compareAndSet(false, true)) return;
try {
carReader.execute(() -> {
try {
if (vehicleReader == null) {
vehicleReader = new EnergyTelemetryReader(getApplicationContext());
}
// The timestamp is passed explicitly: its Kotlin default is not visible from Java.
EnergySnapshot s = vehicleReader.read(System.currentTimeMillis());
latestSnapshot = s;
runOnUiThread(() -> {
if (!isDestroyed()) showVehicle(s);
});
} catch (Throwable ignored) {
// Leave the last good reading on screen.
} finally {
reading.set(false);
}
});
} catch (java.util.concurrent.RejectedExecutionException e) {
reading.set(false); // the activity is going away
}
}
/**
* The consumption and charging trackers stay on the 2-second tick: they integrate over
* time, so a faster rate adds nothing, and the charging tracker writes its session to
* storage on every sample while charging.
*/
private void sampleTrackers() {
EnergySnapshot s = latestSnapshot;
if (s == null) return;
try {
ConsumptionTracker.get(this).sample(s);
refreshCharging(s);
} catch (Throwable ignored) {
// A tracker failure must not stop the page.
}
}
private void showVehicle(EnergySnapshot s) {
try {
vehSoc.setText(fmt(s.getSocPercent(), "%.0f %%"));
vehRange.setText(fmt(s.getRangeKm(), "%.0f km"));
vehPower.setText(fmt(s.getBatteryPowerKw(), "%.1f kW"));
vehSpeed.setText(fmt(s.getSpeedKmh(), "%.0f km/h"));
vehOdometer.setText(fmt(s.getOdometerKm(), "%.0f km"));
vehHvac.setText(fmt(s.getClimate().getDriverTargetCelsius(), "%.0f °C"));
vehCharging.setText(chargingLabel(s));
refreshRangeEstimates(s);
PressureUnit unit = PressureUnit.fromPref(prefs.getString(PressureUnit.PREF_KEY, null));
vehTireFl.setText(unit.format(s.getTirePressures().getFrontLeftKpa()));
vehTireFr.setText(unit.format(s.getTirePressures().getFrontRightKpa()));
vehTireRl.setText(unit.format(s.getTirePressures().getRearLeftKpa()));
vehTireRr.setText(unit.format(s.getTirePressures().getRearRightKpa()));
} catch (Throwable ignored) {
// Leave the last good reading on screen.
}
}
private void bindConsumptionPane() {
consumptionTracker = ConsumptionTracker.get(this);
consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext());
consumptionDistance = consumptionPane.findViewById(R.id.consumption_distance);
consumptionEnergy = consumptionPane.findViewById(R.id.consumption_energy);
consumptionAverage = consumptionPane.findViewById(R.id.consumption_average);
consumptionSpeed = consumptionPane.findViewById(R.id.consumption_speed);
consumptionTime = consumptionPane.findViewById(R.id.consumption_time);
consumptionSoc = consumptionPane.findViewById(R.id.consumption_soc);
consumptionDailyHistory = consumptionPane.findViewById(R.id.consumption_daily_history);
consumptionDateButton = consumptionPane.findViewById(R.id.consumption_date_button);
consumptionDateButton.setText(selectedConsumptionDate.toString());
consumptionDateButton.setOnClickListener(v -> showConsumptionDatePicker());
consumptionPane.findViewById(R.id.consumption_refresh).setOnClickListener(v -> refreshFromCloud());
consumptionReset = consumptionPane.findViewById(R.id.consumption_reset);
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));
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()));
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);
bindPeriodButton(R.id.consumption_trip_a, ConsumptionTracker.Period.TRIP_A);
bindPeriodButton(R.id.consumption_trip_b, ConsumptionTracker.Period.TRIP_B);
selectPeriodButton(R.id.consumption_lifetime);
consumptionReset.setOnClickListener(v -> {
if (consumptionPeriod != ConsumptionTracker.Period.TRIP_A && consumptionPeriod != ConsumptionTracker.Period.TRIP_B) return;
String name = getString(consumptionPeriod == ConsumptionTracker.Period.TRIP_A
? R.string.consumption_trip_a : R.string.consumption_trip_b);
showKeepingFullScreen(new MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.trip_reset_title, name))
.setMessage(R.string.trip_reset_message)
.setNegativeButton(R.string.pair_cancel, null)
.setPositiveButton(R.string.trip_reset_confirm, (d, w) -> {
consumptionTracker.reset(consumptionPeriod);
refreshConsumption();
}).create());
});
}
private void saveUsableCapacity() {
try {
float value = Float.parseFloat(textOf(usableCapacityInput).replace(',', '.'));
if (!(value >= 10f && value <= 150f)) throw new NumberFormatException();
prefs.edit().putFloat(USABLE_KWH_KEY, value).apply();
usableCapacityLayout.setError(null);
usableCapacityInput.setText(String.format(java.util.Locale.US, "%.1f", value));
Toast.makeText(this, R.string.usable_capacity_saved, Toast.LENGTH_SHORT).show();
} catch (NumberFormatException e) {
usableCapacityLayout.setError(getString(R.string.usable_capacity_invalid));
}
}
private void saveSoh() {
String raw = textOf(consumptionSohInput).replace(',', '.');
try {
float value = Float.parseFloat(raw);
if (!consumptionTracker.setSohPercent(value)) throw new NumberFormatException();
consumptionSohLayout.setError(null);
consumptionSohInput.setText(String.format(java.util.Locale.US, "%.1f", value));
Toast.makeText(this, R.string.soh_saved, Toast.LENGTH_SHORT).show();
} catch (NumberFormatException e) {
consumptionSohLayout.setError(getString(R.string.soh_invalid));
}
}
private void bindPeriodButton(int id, ConsumptionTracker.Period period) {
View button = consumptionPane.findViewById(id);
button.setOnClickListener(v -> {
selectPeriodButton(id);
consumptionPeriod = period;
consumptionReset.setVisibility(period == ConsumptionTracker.Period.TRIP_A ||
period == ConsumptionTracker.Period.TRIP_B ? View.VISIBLE : View.GONE);
refreshConsumption();
});
}
private void selectPeriodButton(int selectedId) {
int[] ids = {R.id.consumption_lifetime, R.id.consumption_week,
R.id.consumption_month, R.id.consumption_trip_a, R.id.consumption_trip_b};
for (int id : ids) {
MaterialButton button = consumptionPane.findViewById(id);
boolean selected = id == selectedId;
button.setSelected(selected);
int accent = AccentColor.resolve(this, R.attr.evAccent);
button.setTextColor(selected ? accent : COLOR_PENDING);
button.setStrokeColor(android.content.res.ColorStateList.valueOf(
selected ? accent : 0xFF3A4143));
}
}
private void bindChargingPane() {
chargeSessionTracker = ChargeSessionTracker.get(this);
chargeStatus = chargingPane.findViewById(R.id.charge_status);
chargeSoc = chargingPane.findViewById(R.id.charge_soc);
chargeStartSoc = chargingPane.findViewById(R.id.charge_start_soc);
chargeVoltage = chargingPane.findViewById(R.id.charge_voltage);
chargeCurrent = chargingPane.findViewById(R.id.charge_current);
chargePower = chargingPane.findViewById(R.id.charge_power);
chargeEnergy = chargingPane.findViewById(R.id.charge_energy);
chargeGridEnergy = chargingPane.findViewById(R.id.charge_grid_energy);
chargeDuration = chargingPane.findViewById(R.id.charge_duration);
chargeTotalCost = chargingPane.findViewById(R.id.charge_total_cost);
chargeGraph = chargingPane.findViewById(R.id.charge_graph);
chargeCurveTable = chargingPane.findViewById(R.id.charge_curve_table);
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()));
settingsPane.findViewById(R.id.charge_price_save).setOnClickListener(v -> saveChargePrice());
settingsPane.findViewById(R.id.settings_phone_button).setOnClickListener(v -> startPhoneSettings());
}
private void saveChargePrice() {
String raw = textOf(chargePriceInput).replace(',', '.');
try {
double value = Double.parseDouble(raw);
if (!chargeSessionTracker.setPricePerKwh(value)) throw new NumberFormatException();
chargePriceLayout.setError(null);
chargePriceInput.setText(String.format(java.util.Locale.US, "%.2f", value));
Toast.makeText(this, R.string.price_saved, Toast.LENGTH_SHORT).show();
} catch (NumberFormatException e) {
chargePriceLayout.setError(getString(R.string.price_invalid));
}
}
private void refreshCharging(EnergySnapshot s) {
if (chargeStatus == null) return;
chargeSessionTracker.sample(s);
Integer code = s.getChargingStatus();
boolean charging = ChargingState.isCharging(
code, s.getChargePortConnected(), s.getBatteryPowerKw(), s.getSpeedKmh());
ChargeSessionTracker.Snapshot session = chargeSessionTracker.snapshot();
// Only DC is metered, so an AC charge must say so rather than let the readouts
// below — which then describe the previous DC session — be read as this charge.
boolean dc = ChargingState.isDcCharging(
code, s.getChargePortConnected(), s.getBatteryPowerKw(), s.getSpeedKmh());
chargeStatus.setText(dc ? R.string.charge_dc
: charging ? R.string.charge_ac_not_metered
: (session.startedAtMs > 0 ? R.string.charge_last : R.string.charging_not_charging));
chargeSoc.setText(fmt(s.getSocPercent(), "%.1f %%"));
chargeStartSoc.setText(fmt(session.startSocPercent, "%.1f %%"));
chargeVoltage.setText(fmt(s.getBatteryVoltageV(), "%.1f V"));
chargeCurrent.setText(fmt(s.getBatteryCurrentA(), "%.1f A"));
chargePower.setText(s.getBatteryPowerKw() == null ? "—" : String.format(java.util.Locale.getDefault(), "%.1f kW", Math.abs(s.getBatteryPowerKw())));
chargeEnergy.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "%.2f kWh", session.energyKwh) : "—");
chargeGridEnergy.setText(session.startedAtMs > 0 ? String.format(
java.util.Locale.getDefault(), "%.2f kWh", session.gridEnergyKwh) : "—");
long sec = session.durationSeconds;
chargeDuration.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "%02d:%02d:%02d", sec/3600,(sec%3600)/60,sec%60) : "—");
chargeTotalCost.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "€%.2f", session.totalCost) : "—");
chargeGraph.setPoints(session.points);
chargeCurveTable.setText(chargeGraph.tableText());
}
private void refreshConsumption() {
if (consumptionTracker == null) return;
// Trip counters are local-only (cloud has no trip concept); everything else reads
// back from the cloud so an APK wipe does not blank the display.
ConsumptionTracker.Totals t;
if (consumptionPeriod == ConsumptionTracker.Period.TRIP_A
|| consumptionPeriod == ConsumptionTracker.Period.TRIP_B) {
t = consumptionTracker.totals(consumptionPeriod);
} else {
t = cloudTotals(consumptionPeriod);
}
consumptionDistance.setText(String.format(java.util.Locale.getDefault(), "%.1f km", t.km));
consumptionEnergy.setText(String.format(java.util.Locale.getDefault(), "%.2f kWh", t.kwh));
consumptionAverage.setText(t.km < 0.1 ? "—" : String.format(java.util.Locale.getDefault(),
"%.1f kWh/100 km", t.kwh * 100d / t.km));
consumptionSpeed.setText(t.hours <= 0 ? "—" : String.format(java.util.Locale.getDefault(),
"%.1f km/h", t.km / t.hours));
long minutes = Math.round(t.hours * 60d);
consumptionTime.setText(String.format(java.util.Locale.getDefault(), "%d h %02d min",
minutes / 60, minutes % 60));
consumptionSoc.setText(String.format(java.util.Locale.getDefault(), "%.1f %%", t.soc));
// Daily history: cloud first, local fallback for today (not yet uploaded).
ConsumptionTracker.Totals day = cloudDay(selectedConsumptionDate);
if (day == null) day = consumptionTracker.totalsForDay(selectedConsumptionDate);
if (day.km == 0 && day.kwh == 0 && day.hours == 0 && day.soc == 0) {
consumptionDailyHistory.setText(R.string.consumption_no_records);
} else {
String average = day.km < 0.1 ? "—" : String.format(java.util.Locale.getDefault(),
"%.1f kWh/100 km", day.kwh * 100d / day.km);
long dayMinutes = Math.round(day.hours * 60d);
consumptionDailyHistory.setText(getString(R.string.consumption_day_summary,
day.km, day.kwh, average, dayMinutes / 60, dayMinutes % 60, day.soc));
}
}
/**
* Period totals from the cloud history. LIFETIME = the latest record's lifetime field;
* WEEK/MONTH = sum of the last 7/30 days' day-totals. Falls back to the local tracker
* when the cloud is empty or not yet fetched, so the UI is never blank on a cold start.
*/
private ConsumptionTracker.Totals cloudTotals(ConsumptionTracker.Period period) {
List<ConsumptionCloudClient.DayRecord> history = consumptionCloudClient != null
? consumptionCloudClient.fetchHistoryIfDue() : java.util.Collections.emptyList();
if (history.isEmpty()) return consumptionTracker.totals(period);
if (period == ConsumptionTracker.Period.LIFETIME) {
// history is sorted date-desc, so index 0 is the latest day carrying lifetime.
return history.get(0).lifetime;
}
int days = period == ConsumptionTracker.Period.WEEK ? 7 : 30;
double km = 0, kwh = 0, hours = 0, soc = 0;
LocalDate today = LocalDate.now();
for (int i = 0; i < days; i++) {
ConsumptionTracker.Totals d = cloudDay(today.minusDays(i), history);
if (d != null) { km += d.km; kwh += d.kwh; hours += d.hours; soc += d.soc; }
}
return new ConsumptionTracker.Totals(km, kwh, hours, soc);
}
/** Looks up one day in the cloud cache; null when that date is not present. */
private ConsumptionTracker.Totals cloudDay(LocalDate date) {
return cloudDay(date, consumptionCloudClient != null
? consumptionCloudClient.fetchHistoryIfDue() : java.util.Collections.emptyList());
}
private static ConsumptionTracker.Totals cloudDay(LocalDate date,
List<ConsumptionCloudClient.DayRecord> history) {
for (ConsumptionCloudClient.DayRecord r : history) {
if (r.date.equals(date)) return r.day;
}
return null;
}
private void showConsumptionDatePicker() {
java.time.LocalDate selected = selectedConsumptionDate;
android.app.DatePickerDialog dialog = new android.app.DatePickerDialog(this,
(view, year, month, day) -> {
selectedConsumptionDate = java.time.LocalDate.of(year, month + 1, day);
consumptionDateButton.setText(selectedConsumptionDate.toString());
refreshConsumption();
}, selected.getYear(), selected.getMonthValue() - 1, selected.getDayOfMonth());
java.time.ZoneId zone = java.time.ZoneId.systemDefault();
dialog.getDatePicker().setMaxDate(System.currentTimeMillis());
dialog.getDatePicker().setMinDate(java.time.LocalDate.now().minusMonths(4)
.atStartOfDay(zone).toInstant().toEpochMilli());
showKeepingFullScreen(dialog);
}
/**
* The charging row: the session state when the car reports one, the port otherwise.
*
* The codes are the vehicle's own — see EVHardware.PROP_VENDOR_CHARGE_STATUS. An
* unrecognised code is shown as itself rather than guessed at.
*/
private String chargingLabel(EnergySnapshot s) {
Integer status = s.getChargingStatus();
if (status != null) {
switch (status) {
case 1: return getString(R.string.charge_status_ac);
case 10: return getString(R.string.charge_status_dc);
case 5: return getString(R.string.charge_status_connecting);
case 7: return getString(R.string.charge_status_plugged);
case 8: return getString(R.string.charge_status_stopped);
case 0: return getString(R.string.charging_not_charging);
default: return getString(R.string.charge_status_code, status);
}
}
Boolean plugged = s.getChargePortConnected();
if (plugged != null) return getString(plugged ? R.string.charge_status_connected : R.string.charge_status_unplugged);
return "—";
}
/** A dash for a signal the car does not publish — never a zero. See pane_vehicle.xml. */
private static String fmt(Float value, String pattern) {
if (value == null || value.isNaN() || value.isInfinite()) return "—";
return String.format(java.util.Locale.US, pattern, value);
}
// ---------- Call log ----------
/**
* "Upload now" button — forces a cache drain + fresh chunk on a background thread,
* ignoring the 2-minute throttle. The result lands in the upload log (Logs tab).
*/
private void manualFlush() {
if (consumptionCloudClient == null || consumptionTracker == null) return;
AbrpUploadService.log().record(new UploadLog.Entry(
System.currentTimeMillis(), 0, true, "Manual sync started…"));
new Thread(() -> {
String result = consumptionCloudClient.flushNow(
consumptionTracker, System.currentTimeMillis());
runOnUiThread(() -> Toast.makeText(this, result, Toast.LENGTH_SHORT).show());
}, "manual-flush").start();
}
/**
* Consumption tab "Refresh" button — sends cached chunks + fresh delta, invalidates the
* history cache so the next poll fetches fresh data from the cloud, then refreshes the UI.
*/
private void refreshFromCloud() {
if (consumptionCloudClient == null || consumptionTracker == null) return;
AbrpUploadService.log().record(new UploadLog.Entry(
System.currentTimeMillis(), 0, true, "Refreshing consumption…"));
new Thread(() -> {
consumptionCloudClient.flushNow(consumptionTracker, System.currentTimeMillis());
consumptionCloudClient.invalidateHistory();
runOnUiThread(() -> {
refreshConsumption();
Toast.makeText(this, R.string.consumption_refreshed, Toast.LENGTH_SHORT).show();
});
}, "cloud-refresh").start();
}
private void refreshCallLog() {
java.util.List<UploadLog.Entry> entries = AbrpUploadService.log().recent();
if (entries.isEmpty()) {
callLogText.setText(R.string.call_log_empty);
return;
}
java.text.SimpleDateFormat format =
new java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.US);
StringBuilder sb = new StringBuilder();
for (UploadLog.Entry entry : entries) {
if (sb.length() > 0) sb.append('\n');
sb.append(entry.success ? "OK " : "ERR ")
.append(format.format(new java.util.Date(entry.timestampMs)))
.append(" ")
// httpStatus 0 means the request never reached a server.
.append(entry.httpStatus > 0 ? String.valueOf(entry.httpStatus) : "---")
.append(" ")
.append(entry.detail);
// What the payload held, under the attempt it belongs to. This is the only view
// of it the driver has: the car runs no adb, so "the field is 0 on ABRP" and
// "the app never sent the field" look identical from the outside without it.
if (entry.summary != null && !entry.summary.isEmpty()) {
sb.append("\n ").append(entry.summary);
}
}
callLogText.setText(sb.toString());
}
// ---------- Service status ----------
private void refreshStatus() {
// The service is always running. Show the cloud sync state derived from the log.
UploadLog.State state = AbrpUploadService.state();
switch (state) {
case ERROR:
// Derived from the log: three consecutive cloud sync failures is a problem.
statusText.setText(getString(R.string.state_error,
AbrpUploadService.log().consecutiveFailures()));
statusText.setTextColor(COLOR_ERROR);
break;
case RUNNING:
case STARTING:
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);
} else {
statusText.setText(R.string.state_running);
statusText.setTextColor(COLOR_OK);
}
break;
case STOPPED:
default:
statusText.setText(R.string.state_stopped);
statusText.setTextColor(COLOR_PENDING);
break;
}
}
// ---------- Helpers ----------
private String textOf(TextInputEditText field) {
CharSequence text = field.getText();
return text != null ? text.toString().trim() : "";
}
}