Files
mg4/app/src/main/java/com/evsuite/abrp/MainActivity.java
T
nilsandClaude Opus 5.5 5e79b91d63 App 2.34.1: a tab to leave the Overview's full screen beside the car's sidebar
Without the app's own full screen, the car's sidebar takes the left edge
and a swipe from there never reaches the app. A small tab at the app's
left edge now brings the navigation back, by a tap or a swipe from it.
In the app's full screen the handle and edge swipe stay as they were.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-25 20:15:46 +01:00

2348 lines
120 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
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 };
// 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 gaugesPane;
private View cockpitPane;
private View retroPane, futuristicPane;
/** The drawn styles' views, each fed the same {@link ClusterValues} per reading. */
private RetroView retroView;
private FlowView flowView;
private SciFiView sciFiView;
private CartoonView cartoonView;
private BlueprintView blueprintView;
private OctagonView octagonView;
/** The styles built on {@link ClusterCanvasView}, fed together. */
private final java.util.List<ClusterCanvasView> canvasStyles = new java.util.ArrayList<>();
private TerminalView terminalView;
private MascotView mascotView;
/** The pane of each Overview style, by its {@link OverviewStyle} ordinal. */
private View[] stylePanes;
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, vehLeaseAllowance, vehLeaseDifference,
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;
private TextInputEditText serverUrlInput, serverTokenInput;
/** Shown, not edited: derived from the head unit (ServerConfig.installationIdFor). */
private TextView installationIdText;
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);
// Pages change only by the navigation: a sideways swipe on a page (a slider, a chart,
// the swipe that brings the navigation back) must not turn it.
pager.setUserInputEnabled(false);
pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
@Override public void onPageSelected(int position) {
markCurrentPage(position);
applyOverviewFullScreen(position);
}
});
findViewById(R.id.edge_tab).setOnClickListener(v -> leaveOverviewFullScreen());
findViewById(R.id.overview_fullscreen).setOnClickListener(v -> {
prefs.edit().putBoolean(PREF_OVERVIEW_FULLSCREEN, !prefs.getBoolean(PREF_OVERVIEW_FULLSCREEN, false)).apply();
applyOverviewFullScreen(pager.getCurrentItem());
});
for (int i = 0; i < TAB_IDS.length; i++) {
final int index = i;
findViewById(TAB_IDS[i]).setOnClickListener(v -> pager.setCurrentItem(index, false));
}
markCurrentPage(pager.getCurrentItem());
applyOverviewFullScreen(pager.getCurrentItem());
}
/** Whether the Overview hides the navigation (the button in the navigation's header). */
static final String PREF_OVERVIEW_FULLSCREEN = "overview_fullscreen";
/** True while the Overview is full screen: the navigation hidden, the edge swipe armed. */
private boolean overviewFull;
/**
* The Overview full screen: the navigation hidden while the Overview is the page and the
* driver chose it, with a handle at the left edge where a swipe brings it back. Every
* other page always has its navigation. The button sits in the navigation and only shows
* on the Overview (invisible elsewhere, so the header keeps its layout).
*/
private void applyOverviewFullScreen(int position) {
boolean overview = position == 0;
overviewFull = overview && prefs.getBoolean(PREF_OVERVIEW_FULLSCREEN, false);
findViewById(R.id.nav_bar).setVisibility(overviewFull ? View.GONE : View.VISIBLE);
// The way back: the edge swipe with a faint handle in the app's full screen; with the
// car's bars shown its sidebar takes the left edge, so a tab to tap (or swipe from).
boolean appFull = UploadSettings.fullScreen(prefs);
findViewById(R.id.edge_handle).setVisibility(overviewFull && appFull ? View.VISIBLE : View.GONE);
findViewById(R.id.edge_tab).setVisibility(overviewFull && !appFull ? View.VISIBLE : View.GONE);
findViewById(R.id.overview_fullscreen).setVisibility(overview ? View.VISIBLE : View.INVISIBLE);
}
/** Back to the navigation: the edge swipe or the tab. */
private void leaveOverviewFullScreen() {
prefs.edit().putBoolean(PREF_OVERVIEW_FULLSCREEN, false).apply();
applyOverviewFullScreen(((ViewPager2) findViewById(R.id.content)).getCurrentItem());
}
/** The swipe in from the left edge, set up with this screen's density; null until first used. */
private EdgeSwipe edgeSwipe, tabSwipe;
/** The rest of a touch that became the edge swipe: kept from the page underneath. */
private boolean swallowTouch;
/**
* Watches every touch in the Overview's full screen for the swipe in from the left edge,
* which brings the navigation back. The page still gets the touch until it becomes the
* swipe; then it gets a cancel and nothing more of that touch.
*/
@Override
public boolean dispatchTouchEvent(android.view.MotionEvent ev) {
int action = ev.getActionMasked();
if (swallowTouch) {
if (action == android.view.MotionEvent.ACTION_UP
|| action == android.view.MotionEvent.ACTION_CANCEL) swallowTouch = false;
return true;
}
if (overviewFull) {
if (edgeSwipe == null) {
float density = getResources().getDisplayMetrics().density;
edgeSwipe = new EdgeSwipe(48 * density, 64 * density);
// With the car's sidebar beside the app, the swipe starts on the tab instead.
tabSwipe = new EdgeSwipe(56 * density, 64 * density);
}
EdgeSwipe swipe = UploadSettings.fullScreen(prefs) ? edgeSwipe : tabSwipe;
if (action == android.view.MotionEvent.ACTION_DOWN) {
swipe.down(ev.getX(), ev.getY());
} else if (action == android.view.MotionEvent.ACTION_MOVE && swipe.move(ev.getX(), ev.getY())) {
android.view.MotionEvent cancel = android.view.MotionEvent.obtain(ev);
cancel.setAction(android.view.MotionEvent.ACTION_CANCEL);
super.dispatchTouchEvent(cancel);
cancel.recycle();
swallowTouch = true;
leaveOverviewFullScreen();
return true;
} else if (action == android.view.MotionEvent.ACTION_UP
|| action == android.view.MotionEvent.ACTION_CANCEL) {
swipe.up();
}
}
return super.dispatchTouchEvent(ev);
}
/**
* Marks the destination in the navigation (the pages have no header line).
*
* 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);
}
}
/** Background thread for the one system-setting write (clearCarPlayFullScreen). */
private final java.util.concurrent.ExecutorService settingsWriter =
java.util.concurrent.Executors.newSingleThreadExecutor(r -> new Thread(r, "settings-write"));
/** 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);
gaugesPane = inflatePane(R.layout.pane_gauges);
cockpitPane = inflatePane(R.layout.pane_cockpit);
retroPane = inflatePane(R.layout.pane_retro);
futuristicPane = inflatePane(R.layout.pane_futuristic);
retroView = retroPane.findViewById(R.id.retro_view);
flowView = futuristicPane.findViewById(R.id.flow_view);
sciFiView = new SciFiView(this, null);
cartoonView = new CartoonView(this, null);
blueprintView = new BlueprintView(this, null);
octagonView = new OctagonView(this, null);
View sciFiPane = drawnPane(sciFiView), cartoonPane = drawnPane(cartoonView),
blueprintPane = drawnPane(blueprintView), mgPane = drawnPane(octagonView);
terminalView = new TerminalView(this);
mascotView = new MascotView(this);
mascotView.setCharacter(MascotCharacter.fromPref(prefs.getString(MascotCharacter.PREF_KEY, null)));
java.util.Collections.addAll(canvasStyles, new MinimalView(this), terminalView,
new AviationView(this), new NixieView(this), new ChronographView(this), mascotView);
View[] canvasPanes = new View[canvasStyles.size()];
for (int i = 0; i < canvasPanes.length; i++) canvasPanes[i] = drawnPane(canvasStyles.get(i));
stylePanes = new View[] { vehiclePane, gaugesPane, cockpitPane, retroPane,
futuristicPane, sciFiPane, cartoonPane, blueprintPane, mgPane,
canvasPanes[0], canvasPanes[1], canvasPanes[2], canvasPanes[3], canvasPanes[4],
canvasPanes[5] };
// The Overview page holds every style; Settings → Overview style picks the one shown.
android.widget.FrameLayout overviewPage = new android.widget.FrameLayout(this);
overviewPage.setLayoutParams(new android.view.ViewGroup.LayoutParams(
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
android.view.ViewGroup.LayoutParams.MATCH_PARENT));
overviewPage.addView(vehiclePane);
overviewPage.addView(gaugesPane);
overviewPage.addView(cockpitPane);
overviewPage.addView(retroPane);
overviewPage.addView(futuristicPane);
overviewPage.addView(sciFiPane);
overviewPage.addView(cartoonPane);
overviewPage.addView(blueprintPane);
overviewPage.addView(mgPane);
for (View pane : canvasPanes) overviewPage.addView(pane);
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[] { overviewPage, consumptionPane, chargingPane, servicePane, serverPane,
settingsPane, logPane };
UpdateLog.noteVersion(this, BuildConfig.VERSION_NAME);
// The cruise control logs of 2.31.1–2.32.0, gone with the feature (SPECS.md).
deleteFile("cruise_log.txt");
deleteFile("acc_recording.txt");
bindVehiclePane();
bindGauges();
bindCockpit();
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());
logPane.findViewById(R.id.signal_record_button).setOnClickListener(v -> {
if (SignalRecorder.running()) SignalRecorder.stop();
else SignalRecorder.start(this);
refreshSignalRecorder();
});
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));
View usbButton = settingsPane.findViewById(R.id.settings_usb_button);
usbButton.setOnClickListener(v -> installFromUsb(v));
// The stable build installs nothing itself, so it has no use for the button.
if (!UpdateHook.isSupported()) usbButton.setVisibility(View.GONE);
// 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();
settingsWriter.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) {
runUpdate(button, R.string.update_checking, listener -> UpdateHook.checkInBackground(this, listener));
}
/** Installs the newest car build from the top folder of a USB stick (unstable builds). */
private void installFromUsb(View button) {
runUpdate(button, R.string.update_usb_looking, listener -> UpdateHook.installFromUsbInBackground(this, listener));
}
/** The update dialog: progress while [start] runs, then its result. */
private void runUpdate(View button, int startText,
java.util.function.Consumer<UpdateHook.Listener> start) {
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(startText);
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);
start.accept(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);
serverUrlInput = serverPane.findViewById(R.id.server_url_input);
serverTokenInput = serverPane.findViewById(R.id.server_token_input);
installationIdText = serverPane.findViewById(R.id.installation_id_text);
connectionStatusRow = serverPane.findViewById(R.id.connection_status_row);
connectionIndicator = serverPane.findViewById(R.id.connection_indicator);
connectionStatusText = serverPane.findViewById(R.id.connection_status_text);
showServerConfig();
serverPane.findViewById(R.id.server_save_button).setOnClickListener(v -> {
if (saveServerConfig(textOf(serverUrlInput), textOf(serverTokenInput),
ServerConfig.get(this).installationId)) {
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);
installationIdText.setText(getString(R.string.installation_id_info, 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));
// Only an imported config file can bring a different id; the field is not editable.
if (!ServerConfig.isValidInstallationId(id)) {
setConnectionStatus(COLOR_ERROR, getString(R.string.server_err_installation_id));
return false;
}
if (url == null || !ServerConfig.isValidToken(token)) 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 = ServerConfig.get(this).installationId;
if (url == null) {
serverUrlLayout.setError(getString(R.string.server_err_url));
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 installationId = 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);
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 ----------
/**
* App 2.19.2–2.19.5 had a "Full screen for CarPlay" experiment that added CarPlay to Android 9's
* policy_control setting; CarPlay's 1920×640 picture turned out to be fixed in its own app, so
* the switch is gone. This takes our entry out again where the switch was left on, and leaves
* anything else in the setting alone. Only the car build (system user) may write it.
*/
private void clearCarPlayFullScreen() {
if (checkSelfPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS)
!= android.content.pm.PackageManager.PERMISSION_GRANTED) return;
settingsWriter.execute(() -> {
try {
android.content.ContentResolver cr = getContentResolver();
String before = android.provider.Settings.Global.getString(cr, PolicyControl.KEY);
if (!PolicyControl.hasFull(before, PolicyControl.CARPLAY_PACKAGE)) return;
android.provider.Settings.Global.putString(cr, PolicyControl.KEY,
PolicyControl.withoutFull(before, PolicyControl.CARPLAY_PACKAGE));
android.util.Log.i("EVABRP.Settings", "CarPlay entry removed from policy_control");
} catch (RuntimeException e) {
android.util.Log.w("EVABRP.Settings", "policy_control not cleared", e);
}
});
}
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();
bindMascotCharacter();
bindDriveMode();
bindOverviewStyle();
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();
applyOverviewFullScreen(((ViewPager2) findViewById(R.id.content)).getCurrentItem());
});
clearCarPlayFullScreen();
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. */
/**
* The accent colour; a change recreates the activity so every view takes it. For a style
* with an original palette, "Original colours" comes first: it keeps the style in its own
* colours and leaves the app's accent as it is.
*/
private void bindAccentColor() {
OverviewStyle style = overviewStyle();
AccentColor[] colors = AccentColor.values();
int offset = style.hasOriginal ? 1 : 0;
String[] labels = new String[colors.length + offset];
if (offset == 1) labels[0] = getString(R.string.accent_original);
for (int i = 0; i < colors.length; i++) labels[i + offset] = colors[i].label;
AccentColor current = AccentColor.fromPref(prefs.getString(AccentColor.PREF_KEY, null));
int selected = originalColours(style) ? 0 : current.ordinal() + offset;
bindChoices(settingsPane.findViewById(R.id.accent_choices), labels, selected, position -> {
if (style.hasOriginal) prefs.edit().putBoolean(style.originalKey(), position == 0).apply();
if (position < offset) {
applyOverviewStyle();
return;
}
AccentColor picked = colors[position - offset];
if (picked == current) {
applyOverviewStyle();
return;
}
prefs.edit().putString(AccentColor.PREF_KEY, picked.name()).apply();
recreate();
});
applyAccentAvailability();
}
/** 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());
}
/** Who sits in the Mascot style; the drop-down shows only while that style is chosen. */
private static final int[] DRIVE_MODE_IDS = { R.id.drive_mode_eco, R.id.drive_mode_normal,
R.id.drive_mode_sport, R.id.drive_mode_snow, R.id.drive_mode_custom };
private static final int[] DRIVE_MODE_LABELS = { R.string.drive_mode_eco, R.string.drive_mode_normal,
R.string.drive_mode_sport, R.string.drive_mode_snow, R.string.drive_mode_custom };
/** True from a tap on a drive mode until the car's answer is in: one change at a time. */
private final java.util.concurrent.atomic.AtomicBoolean driveModeBusy =
new java.util.concurrent.atomic.AtomicBoolean();
/** The drive mode buttons on Settings: a tap asks the car, the highlight shows what it reports. */
private void bindDriveMode() {
for (int i = 0; i < DRIVE_MODE_IDS.length; i++) {
final int code = DriveModeWrite.CODES[i];
settingsPane.findViewById(DRIVE_MODE_IDS[i]).setOnClickListener(v -> requestDriveMode(code));
}
SwitchMaterial atStart = settingsPane.findViewById(R.id.drive_mode_at_start_switch);
atStart.setChecked(prefs.getBoolean(DriveModeWrite.PREF_AT_START, true));
atStart.setOnCheckedChangeListener((b, checked) ->
prefs.edit().putBoolean(DriveModeWrite.PREF_AT_START, checked).apply());
String started = driveModeAtStartText();
if (started != null) ((TextView) settingsPane.findViewById(R.id.drive_mode_status)).setText(started);
}
/** The last start's result as a sentence, from "25.10. 20:31|4|APPLIED|4"; null when none. */
private String driveModeAtStartText() {
String stored = prefs.getString(DriveModeWrite.PREF_START_RESULT, null);
if (stored == null) return null;
String[] p = stored.split("\\|", -1);
if (p.length < 4) return null;
try {
String when = p[0];
String name = driveModeName(Integer.valueOf(p[1]));
Integer reported = p[3].isEmpty() ? null : Integer.valueOf(p[3]);
switch (p[2]) {
case "APPLIED": return getString(R.string.drive_mode_start_applied, when, name);
case "ALREADY": return getString(R.string.drive_mode_start_already, when, name);
case "NOT_APPLIED": return getString(R.string.drive_mode_start_not_applied, when, name, driveModeName(reported));
case "UNREAD": return getString(R.string.drive_mode_start_unread, when, name);
default: return getString(R.string.drive_mode_start_refused, when, name);
}
} catch (NumberFormatException e) {
return null;
}
}
/** The highlight follows the car's reported mode, from every reading. */
private void showDriveMode(Integer code) {
if (settingsPane == null) return;
int selected = DriveModeWrite.indexOf(code);
for (int i = 0; i < DRIVE_MODE_IDS.length; i++) {
settingsPane.findViewById(DRIVE_MODE_IDS[i]).setSelected(i == selected);
}
}
private String driveModeName(Integer code) {
int i = DriveModeWrite.indexOf(code);
return i >= 0 ? getString(DRIVE_MODE_LABELS[i]) : getString(R.string.drive_mode_unknown, String.valueOf(code));
}
/**
* Writes the drive mode, on the settings thread: gear and speed read fresh (P, 0 km/h, or
* nothing), the write itself behind EVHardware's standstill gate, then the car's mode read
* back for up to three seconds. The status line says what the car reports, not what was asked.
*/
private void requestDriveMode(int code) {
if (!driveModeBusy.compareAndSet(false, true)) return;
TextView status = settingsPane.findViewById(R.id.drive_mode_status);
String name = driveModeName(code);
status.setText(getString(R.string.drive_mode_setting, name));
try {
settingsWriter.execute(() -> {
String message;
try {
message = writeDriveMode(code, name);
} catch (Throwable t) {
message = getString(R.string.drive_mode_write_failed, name);
}
android.util.Log.i("NilsMG4.DriveMode", "Drive mode " + code + ": " + message);
final String result = message;
runOnUiThread(() -> {
driveModeBusy.set(false);
if (!isDestroyed()) status.setText(result);
});
});
} catch (java.util.concurrent.RejectedExecutionException e) {
driveModeBusy.set(false); // the activity is going away
}
}
private String writeDriveMode(int code, String name) throws InterruptedException {
switch (DriveModeWrite.check(com.evsuite.hardware.EVHardware.INSTANCE.getVehicleSpeedKmh(),
com.evsuite.hardware.EVHardware.INSTANCE.getGearRaw())) {
case NOT_IN_P: return getString(R.string.drive_mode_not_in_p);
case MOVING: return getString(R.string.drive_mode_moving);
case UNREADABLE: return getString(R.string.drive_mode_unreadable);
default: break;
}
DriveModeSetter.Result result = DriveModeSetter.set(code);
if (!result.written) return getString(R.string.drive_mode_write_failed, name);
final Integer now = result.reported;
runOnUiThread(() -> showDriveMode(now));
switch (DriveModeWrite.outcome(code, now)) {
case APPLIED:
// Confirmed by the car: this is the mode set again at start.
prefs.edit().putInt(DriveModeWrite.PREF_CHOSEN, code).apply();
return getString(R.string.drive_mode_applied, name);
case NOT_APPLIED: return getString(R.string.drive_mode_not_applied, name, driveModeName(now));
default: return getString(R.string.drive_mode_unread_back, name);
}
}
private void bindMascotCharacter() {
MascotCharacter[] characters = MascotCharacter.values();
String[] labels = { getString(R.string.mascot_character_classic), getString(R.string.mascot_character_chibi),
getString(R.string.mascot_character_fox) };
int current = MascotCharacter.fromPref(prefs.getString(MascotCharacter.PREF_KEY, null)).ordinal();
bindChoices(settingsPane.findViewById(R.id.mascot_choices), labels, current, position -> {
prefs.edit().putString(MascotCharacter.PREF_KEY, characters[position].name()).apply();
mascotView.setCharacter(characters[position]);
});
}
/** 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);
vehLeaseAllowance = vehiclePane.findViewById(R.id.vehicle_lease_allowance);
vehLeaseDifference = vehiclePane.findViewById(R.id.vehicle_lease_difference);
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);
RangeEstimator.Estimate average = estimator.average(remaining, monotonicMs);
showEstimate(vehRangeAverage, vehRangeAverageNote, vehUsageAverage, vehUsageAverageNote,
average, getString(R.string.usage_last_5_min), R.string.range_waiting);
dashTripKm = trip == null ? null : trip.distanceKm;
cockpitTrip = trip;
cockpitAverage = average;
if (dashUsage != null) dashUsage.setText(average == null ? "—"
: String.format(java.util.Locale.US, "%.1f kWh/100 km", average.kwhPer100Km));
}
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 five times a second, off the main thread ----------
/**
* How often the Overview reads the car while it is on screen: 0.2 s, so needles, rings and
* the speed follow the car closely. A read still running makes the next tick skip.
*/
private static final long OVERVIEW_REFRESH_MS = 200L;
/** 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.
}
}
/** The lease allowance by today and the odometer against it (LeaseAllowance). */
private void showLease(Float odometerKm) {
java.time.LocalDate today = java.time.LocalDate.now();
// Locale.US like fmt(), so the card's numbers share one format.
vehLeaseAllowance.setText(String.format(java.util.Locale.US, "%,.0f km",
LeaseAllowance.allowedKm(today)));
Double diff = LeaseAllowance.differenceKm(odometerKm, today);
if (diff == null) {
vehLeaseDifference.setText(R.string.no_value);
} else {
String km = String.format(java.util.Locale.US, "%,.0f", Math.abs(diff));
vehLeaseDifference.setText(Math.round(diff) == 0 ? getString(R.string.lease_on_target)
: getString(diff > 0 ? R.string.lease_over : R.string.lease_under, km));
}
}
// ---------- Gauges (Overview style) ----------
static final String PREF_OVERVIEW_STYLE = OverviewStyle.PREF_KEY;
private GaugeView dashPower, dashSpeed, dashBattery;
private BatteryBarView dashBatteryBar;
private TextView dashGear, dashClimate, dashUsage, dashBatteryText;
/** Kilometres since start, from the range estimator; null until it has a distance. */
private Double dashTripKm;
private OverviewStyle overviewStyle() {
String stored = prefs.getString(PREF_OVERVIEW_STYLE, null);
OverviewStyle style = OverviewStyle.fromPref(stored);
if (OverviewStyle.wasAccentEntry(stored)) {
// "Retro (accent colour)" before 2.27.2: now Retro, in the accent colour.
prefs.edit().putString(PREF_OVERVIEW_STYLE, style.name()).putBoolean(style.originalKey(), false).apply();
}
return style;
}
/** Whether a style with an original palette shows it (the default) or the accent colour. */
private boolean originalColours(OverviewStyle style) {
return style.hasOriginal && prefs.getBoolean(style.originalKey(), true);
}
private void applyOverviewStyle() {
OverviewStyle style = overviewStyle();
for (View pane : stylePanes) pane.setVisibility(pane == stylePanes[style.ordinal()] ? View.VISIBLE : View.GONE);
// Retro and Terminal: their original palette or the accent colour, as chosen for each.
retroView.setAccentPalette(!originalColours(OverviewStyle.RETRO));
terminalView.setAccentPalette(!originalColours(OverviewStyle.TERMINAL));
// The accent choices depend on the style ("Original colours" for Retro and Terminal).
if (settingsPane != null) bindAccentColor();
}
/**
* The accent colour can only be picked for a style drawn in it; for the others the drop-down
* is greyed out (keeping the colour chosen before) and a line says why.
*/
private void applyAccentAvailability() {
if (settingsPane == null) return;
OverviewStyle style = overviewStyle();
View field = settingsPane.findViewById(R.id.accent_field);
if (field == null) return;
field.setEnabled(style.usesAccent);
settingsPane.findViewById(R.id.accent_choices).setEnabled(style.usesAccent);
field.setAlpha(style.usesAccent ? 1f : 0.45f);
TextView note = settingsPane.findViewById(R.id.accent_note);
note.setVisibility(style.usesAccent ? View.GONE : View.VISIBLE);
note.setText(getString(R.string.accent_not_used, styleLabels()[style.ordinal()]));
// The wide layout keeps the Mascot field's place so the page does not jump.
View mascot = settingsPane.findViewById(R.id.mascot_field);
boolean wide = getResources().getConfiguration().screenWidthDp >= 1000;
mascot.setVisibility(style == OverviewStyle.MASCOT ? View.VISIBLE : wide ? View.INVISIBLE : View.GONE);
}
/** The drop-down's labels, by {@link OverviewStyle} ordinal. */
private String[] styleLabels() {
return new String[] { getString(R.string.overview_style_tiles), getString(R.string.overview_style_gauges),
getString(R.string.overview_style_cockpit), getString(R.string.overview_style_retro),
getString(R.string.overview_style_futuristic),
getString(R.string.overview_style_scifi), getString(R.string.overview_style_cartoon),
getString(R.string.overview_style_blueprint), getString(R.string.overview_style_mg),
getString(R.string.overview_style_minimal), getString(R.string.overview_style_terminal),
getString(R.string.overview_style_aviation), getString(R.string.overview_style_nixie),
getString(R.string.overview_style_chronograph), getString(R.string.overview_style_mascot) };
}
/** The style drop-down; every style is fed on every reading, only the chosen one is shown. */
private void bindOverviewStyle() {
OverviewStyle[] styles = OverviewStyle.values();
com.google.android.material.textfield.MaterialAutoCompleteTextView field =
settingsPane.findViewById(R.id.overview_style_choices);
// Wider than its field, which shares a row with the accent colour.
field.setDropDownWidth(Math.round(320 * getResources().getDisplayMetrics().density));
bindChoices(field, styleLabels(), overviewStyle().ordinal(),
position -> {
prefs.edit().putString(PREF_OVERVIEW_STYLE, styles[position].name()).apply();
applyOverviewStyle();
});
}
private void bindGauges() {
dashPower = gaugesPane.findViewById(R.id.dash_power);
dashSpeed = gaugesPane.findViewById(R.id.dash_speed);
dashBattery = gaugesPane.findViewById(R.id.dash_battery);
dashBatteryBar = gaugesPane.findViewById(R.id.dash_battery_bar);
dashGear = gaugesPane.findViewById(R.id.dash_gear);
dashClimate = gaugesPane.findViewById(R.id.dash_climate);
dashUsage = gaugesPane.findViewById(R.id.dash_usage);
dashBatteryText = gaugesPane.findViewById(R.id.dash_battery_text);
dashPower.configure(-50f, 150f, 50f, 5, getString(R.string.dash_unit_kw));
dashSpeed.configure(0f, 180f, 20f, 4, getString(R.string.dash_unit_kmh));
dashBattery.configure(0f, 100f, 25f, 5, getString(R.string.dash_unit_battery));
dashBattery.setEndLabels("E", "F");
dashBattery.setWarnBelow(20f);
}
// ---------- Retro and Futuristic (Overview styles, each one drawn view) ----------
private final java.text.SimpleDateFormat clockFormat =
new java.text.SimpleDateFormat("HH:mm", java.util.Locale.US);
/** A drawn style's view in a pane with the Overview's margins. */
private View drawnPane(View view) {
android.widget.FrameLayout pane = new android.widget.FrameLayout(this);
float dp = getResources().getDisplayMetrics().density;
pane.setPadding(Math.round(24 * dp), Math.round(12 * dp), Math.round(24 * dp), Math.round(12 * dp));
pane.addView(view, new android.widget.FrameLayout.LayoutParams(
android.view.ViewGroup.LayoutParams.MATCH_PARENT, android.view.ViewGroup.LayoutParams.MATCH_PARENT));
return pane;
}
private void showDrawnStyles(EnergySnapshot s) {
ClusterValues v = new ClusterValues();
v.socPercent = s.getSocPercent();
v.rangeKm = s.getRangeKm();
v.powerKw = s.getBatteryPowerKw();
v.speedKmh = s.getSpeedKmh();
v.gear = s.getGearRaw();
v.usage5Min = cockpitAverage == null ? null : cockpitAverage.kwhPer100Km;
v.usageTrip = cockpitTrip == null ? null : cockpitTrip.kwhPer100Km;
v.tripKm = cockpitTrip == null ? null : cockpitTrip.distanceKm;
v.climateCelsius = s.getClimate().getDriverTargetCelsius();
v.odometerKm = s.getOdometerKm();
v.clock = clockFormat.format(new java.util.Date());
PressureUnit unit = PressureUnit.fromPref(prefs.getString(PressureUnit.PREF_KEY, null));
v.tyreFl = unit.format(s.getTirePressures().getFrontLeftKpa());
v.tyreFr = unit.format(s.getTirePressures().getFrontRightKpa());
v.tyreRl = unit.format(s.getTirePressures().getRearLeftKpa());
v.tyreRr = unit.format(s.getTirePressures().getRearRightKpa());
retroView.setValues(v);
flowView.setValues(v);
sciFiView.setValues(v);
cartoonView.setValues(v);
blueprintView.setValues(v);
octagonView.setValues(v);
for (ClusterCanvasView view : canvasStyles) view.setValues(v);
}
// ---------- Cockpit (Overview style) ----------
/** Power over the last two minutes, a slot per second. */
private final PowerTrace cockpitTrace = new PowerTrace(120_000, 120);
private RingView cockpitBattery, cockpitPower, cockpitUsage;
private TraceView cockpitTraceView;
private TextView cockpitClimate, cockpitOdometer;
/** From the range estimator: since start and the last 5 minutes; null until known. */
private RangeEstimator.Estimate cockpitTrip, cockpitAverage;
private void bindCockpit() {
cockpitBattery = cockpitPane.findViewById(R.id.cockpit_battery);
cockpitPower = cockpitPane.findViewById(R.id.cockpit_power);
cockpitUsage = cockpitPane.findViewById(R.id.cockpit_usage);
cockpitTraceView = cockpitPane.findViewById(R.id.cockpit_trace);
cockpitClimate = cockpitPane.findViewById(R.id.cockpit_climate);
cockpitOdometer = cockpitPane.findViewById(R.id.cockpit_odometer);
cockpitBattery.configure(0f, 100f, 40);
cockpitBattery.setWarnBelow(20f);
cockpitBattery.setBigScale(0.36f);
cockpitBattery.setScaleLabels("0", null, "100");
cockpitPower.setCentred(-50f, 150f, 60);
cockpitPower.setTopPill(true);
cockpitPower.setBigScale(0.44f);
cockpitPower.setScaleLabels("−50", "0", "150 kW");
cockpitUsage.configure(0f, 30f, 40);
cockpitUsage.setBigScale(0.36f);
cockpitUsage.setScaleLabels("0", null, "30");
applyOverviewStyle();
}
/** The rings and the strip; every value null-safe ("—", nothing lit, gaps in the trace). */
private void showCockpit(EnergySnapshot s) {
if (cockpitPower == null) return;
java.util.Locale us = java.util.Locale.US;
Float soc = s.getSocPercent();
cockpitBattery.setValue(soc, getString(R.string.cockpit_battery),
soc == null ? null : String.format(us, "%.0f", soc), "%",
fmt(s.getRangeKm(), "%.0f km"), null);
Float power = s.getBatteryPowerKw();
Float speed = s.getSpeedKmh();
cockpitPower.setValue(power, Gear.label(s.getGearRaw()),
speed == null ? null : String.format(us, "%.0f", speed), getString(R.string.dash_unit_kmh),
power == null ? "—" : String.format(us, "%.1f kW", power), null);
cockpitTrace.add(android.os.SystemClock.elapsedRealtime(), power);
cockpitTraceView.setValues(cockpitTrace.snapshot());
RangeEstimator.Estimate now = cockpitAverage, trip = cockpitTrip;
cockpitUsage.setValue(now == null ? null : (float) now.kwhPer100Km, getString(R.string.cockpit_usage),
now == null ? null : String.format(us, "%.1f", now.kwhPer100Km), getString(R.string.cockpit_usage_unit),
trip == null ? getString(R.string.cockpit_usage_trip, "—")
: getString(R.string.cockpit_usage_trip, String.format(us, "%.1f", trip.kwhPer100Km)),
trip == null ? null : String.format(us, "%.1f km", trip.distanceKm));
cockpitUsage.setMarker(trip == null ? null : (float) trip.kwhPer100Km);
cockpitClimate.setText(fmt(s.getClimate().getDriverTargetCelsius(), "%.0f °C"));
cockpitOdometer.setText(fmt(s.getOdometerKm(), "%,.0f km"));
}
/** The gauges and the strip; every value null-safe ("—", no needle). */
private void showGauges(EnergySnapshot s) {
if (dashPower == null) return;
Float power = s.getBatteryPowerKw();
dashPower.setValue(power, power == null ? null : String.format(java.util.Locale.US, "%.1f", power), null);
Float speed = s.getSpeedKmh();
String total = getString(R.string.dash_total, fmt(s.getOdometerKm(), "%,.0f km"));
String trip = getString(R.string.dash_trip, dashTripKm == null ? "—"
: String.format(java.util.Locale.US, "%.1f km", dashTripKm));
dashSpeed.setValue(speed, speed == null ? null : String.format(java.util.Locale.US, "%.0f", speed),
total + "\n" + trip);
Float soc = s.getSocPercent();
dashBattery.setValue(soc, soc == null ? null : String.format(java.util.Locale.US, "%.0f %%", soc),
fmt(s.getRangeKm(), "%.0f km"));
dashBatteryBar.setSoc(soc);
dashBatteryText.setText(fmt(soc, "%.0f %%"));
dashGear.setText(Gear.label(s.getGearRaw()));
dashClimate.setText(fmt(s.getClimate().getDriverTargetCelsius(), "%.0f °C"));
}
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"));
showLease(s.getOdometerKm());
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()));
showGauges(s);
showCockpit(s);
showDrawnStyles(s);
showDriveMode(s.getDriveModeRaw());
} 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();
// AC and DC are both metered while the app runs; the label only says which.
boolean dc = ChargingState.isDcCharging(
code, s.getChargePortConnected(), s.getBatteryPowerKw(), s.getSpeedKmh());
chargeStatus.setText(dc ? R.string.charge_dc
: charging ? R.string.charge_ac
: (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();
}
/** The ACC recorder's button and what it has found so far. */
private void refreshSignalRecorder() {
MaterialButton button = logPane.findViewById(R.id.signal_record_button);
button.setText(SignalRecorder.running() ? R.string.btn_signal_stop : R.string.btn_signal_record);
TextView text = logPane.findViewById(R.id.signal_record_text);
java.util.List<String> status = SignalRecorder.status(this);
text.setVisibility(status.isEmpty() ? View.GONE : View.VISIBLE);
// The rarest changes are the leads; the rest is in the saved recording.
if (status.size() > 81) status = status.subList(0, 81);
if (!status.isEmpty()) text.setText(String.join("\n", status));
}
private void refreshCallLog() {
refreshSignalRecorder();
java.util.List<UploadLog.Entry> entries = AbrpUploadService.log().recent();
// Update steps first: after a failed install, they are what the driver came here for.
java.util.List<String> updates = UpdateLog.lines(this);
StringBuilder sb = new StringBuilder();
if (!updates.isEmpty()) {
sb.append(getString(R.string.update_log_title));
for (String line : updates) sb.append('\n').append(line);
sb.append('\n');
}
if (entries.isEmpty()) {
if (sb.length() == 0) callLogText.setText(R.string.call_log_empty);
else callLogText.setText(sb.append('\n').append(getString(R.string.call_log_empty)).toString());
return;
}
java.text.SimpleDateFormat format =
new java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.US);
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() : "";
}
}