A new Overview style: the Grid over the roads round the car from
OpenStreetMap, drawn in the floor's perspective, heading up, the bigger
roads glowing brighter, the car as a chevron.
Server: POST /v1/map/roads {z:14,x,y} fetches a tile's drivable roads
from Overpass (OVERPASS_URL; one request at a time, at least a second
apart), simplifies them to tile-local points in four classes, caches
them for 30 days and never logs the tile.
App: MapRoads keeps the 3x3 tiles round the car from its own cache or
the server; GPS only while the style is shown; bearing from the GPS or
from successive positions. Without GPS, server or roads the plain floor
says why. OSM attribution on screen and in LICENSE.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2408 lines
122 KiB
Java
2408 lines
122 KiB
Java
package com.evsuite.abrp;
|
||
|
||
import android.content.Intent;
|
||
import android.content.SharedPreferences;
|
||
import android.content.pm.PackageManager;
|
||
import android.graphics.Color;
|
||
import android.graphics.drawable.ColorDrawable;
|
||
import android.net.Uri;
|
||
import android.os.Bundle;
|
||
import android.view.View;
|
||
import android.widget.Button;
|
||
import android.widget.Toast;
|
||
import android.widget.ImageView;
|
||
import android.widget.TextView;
|
||
|
||
import java.time.LocalDate;
|
||
import java.util.List;
|
||
|
||
import androidx.activity.result.ActivityResultLauncher;
|
||
import androidx.activity.result.contract.ActivityResultContracts;
|
||
import androidx.annotation.NonNull;
|
||
import androidx.appcompat.app.AppCompatActivity;
|
||
import androidx.appcompat.app.AppCompatDelegate;
|
||
import androidx.recyclerview.widget.RecyclerView;
|
||
import androidx.viewpager2.widget.ViewPager2;
|
||
|
||
import com.google.android.material.switchmaterial.SwitchMaterial;
|
||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||
import com.google.android.material.button.MaterialButton;
|
||
import com.google.android.material.textfield.TextInputEditText;
|
||
import com.google.android.material.textfield.TextInputLayout;
|
||
import com.evsuite.hardware.telemetry.EnergySnapshot;
|
||
import com.evsuite.hardware.telemetry.EnergyTelemetryReader;
|
||
|
||
|
||
public class MainActivity extends AppCompatActivity {
|
||
|
||
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 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 GridView gridView;
|
||
private VisorView visorView;
|
||
/** "Grid with map": the same style over the roads round the car (MapRoads, fed by GPS here). */
|
||
private GridView gridMapView;
|
||
private MapRoads mapRoads;
|
||
private android.location.LocationListener mapListener;
|
||
private boolean inFront;
|
||
/** 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 — the
|
||
// accent colour, or the Overview style's own palette while it shows that (StylePalette).
|
||
appliedOverlay = appOverlay();
|
||
getTheme().applyStyle(appliedOverlay, 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);
|
||
octagonView = new OctagonView(this, null);
|
||
View sciFiPane = drawnPane(sciFiView), cartoonPane = drawnPane(cartoonView),
|
||
mgPane = drawnPane(octagonView);
|
||
terminalView = new TerminalView(this);
|
||
gridView = new GridView(this);
|
||
mapRoads = new MapRoads(this);
|
||
gridMapView = new GridView(this, mapRoads);
|
||
visorView = new VisorView(this);
|
||
java.util.Collections.addAll(canvasStyles, new MinimalView(this), terminalView,
|
||
new NixieView(this), new ChronographView(this), gridView, gridMapView, new TimeCircuitsView(this),
|
||
new QuestView(this), visorView, new EmpireView(this));
|
||
View[] canvasPanes = new View[canvasStyles.size()];
|
||
for (int i = 0; i < canvasPanes.length; i++) canvasPanes[i] = drawnPane(canvasStyles.get(i));
|
||
View[] fixedPanes = { vehiclePane, gaugesPane, cockpitPane, retroPane,
|
||
futuristicPane, sciFiPane, cartoonPane, mgPane };
|
||
// By OverviewStyle ordinal: the fixed styles, then the canvas styles in their order.
|
||
stylePanes = new View[fixedPanes.length + canvasPanes.length];
|
||
System.arraycopy(fixedPanes, 0, stylePanes, 0, fixedPanes.length);
|
||
System.arraycopy(canvasPanes, 0, stylePanes, fixedPanes.length, canvasPanes.length);
|
||
// 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(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);
|
||
inFront = true;
|
||
updateMapLocation();
|
||
}
|
||
|
||
@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);
|
||
inFront = false;
|
||
updateMapLocation();
|
||
}
|
||
|
||
@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();
|
||
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 the app takes them on too (StylePalette).
|
||
*/
|
||
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) {
|
||
prefs.edit().putString(AccentColor.PREF_KEY, colors[position - offset].name()).apply();
|
||
}
|
||
applyStyleOrRecreate();
|
||
});
|
||
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());
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
|
||
/** The theme overlay applied in onCreate; a choice that changes it recreates the activity. */
|
||
private int appliedOverlay;
|
||
|
||
private int appOverlay() {
|
||
OverviewStyle style = overviewStyle();
|
||
return StylePalette.appOverlay(style, originalColours(style),
|
||
AccentColor.fromPref(prefs.getString(AccentColor.PREF_KEY, null)));
|
||
}
|
||
|
||
/**
|
||
* After a style or colour choice: the app's colours follow at once (the activity is
|
||
* recreated when they change, as for the accent colour), otherwise the Overview is redrawn.
|
||
*/
|
||
private void applyStyleOrRecreate() {
|
||
if (appOverlay() != appliedOverlay) recreate();
|
||
else applyOverviewStyle();
|
||
}
|
||
|
||
/** 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, Terminal and Grid: their original palette or the accent colour, as chosen for each.
|
||
retroView.setAccentPalette(!originalColours(OverviewStyle.RETRO));
|
||
terminalView.setAccentPalette(!originalColours(OverviewStyle.TERMINAL));
|
||
gridView.setAccentPalette(!originalColours(OverviewStyle.GRID));
|
||
gridMapView.setAccentPalette(!originalColours(OverviewStyle.GRID_MAP));
|
||
visorView.setAccentPalette(!originalColours(OverviewStyle.VISOR));
|
||
// The accent choices depend on the style ("Original colours" for Retro and Terminal).
|
||
if (settingsPane != null) bindAccentColor();
|
||
updateMapLocation();
|
||
}
|
||
|
||
/**
|
||
* GPS for the Grid's map, only while "Grid with map" is the style and the app is in front;
|
||
* every fix goes to {@link MapRoads}. Without the location permission there is no map (the
|
||
* floor says it waits for GPS), never a crash.
|
||
*/
|
||
private void updateMapLocation() {
|
||
android.location.LocationManager lm =
|
||
(android.location.LocationManager) getSystemService(LOCATION_SERVICE);
|
||
if (lm == null || mapRoads == null) return;
|
||
boolean wanted = inFront && overviewStyle() == OverviewStyle.GRID_MAP;
|
||
if (!wanted) {
|
||
if (mapListener != null) {
|
||
try {
|
||
lm.removeUpdates(mapListener);
|
||
} catch (RuntimeException ignored) {
|
||
// already gone
|
||
}
|
||
mapListener = null;
|
||
}
|
||
return;
|
||
}
|
||
if (mapListener != null) return;
|
||
if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION)
|
||
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||
return;
|
||
}
|
||
android.location.LocationListener listener = new android.location.LocationListener() {
|
||
@Override public void onLocationChanged(android.location.Location location) { mapRoads.onFix(location); }
|
||
@Override public void onStatusChanged(String provider, int status, android.os.Bundle extras) { }
|
||
@Override public void onProviderEnabled(String provider) { }
|
||
@Override public void onProviderDisabled(String provider) { }
|
||
};
|
||
try {
|
||
lm.requestLocationUpdates(android.location.LocationManager.GPS_PROVIDER, 1000L, 0f, listener);
|
||
mapListener = listener;
|
||
android.location.Location last = lm.getLastKnownLocation(android.location.LocationManager.GPS_PROVIDER);
|
||
if (last != null && android.os.SystemClock.elapsedRealtimeNanos() - last.getElapsedRealtimeNanos()
|
||
< MapRoads.FIX_FRESH_MS * 1_000_000L) {
|
||
mapRoads.onFix(last);
|
||
}
|
||
} catch (RuntimeException e) {
|
||
android.util.Log.w("MainActivity", "No GPS for the map: " + e.getClass().getSimpleName());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 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_mg),
|
||
getString(R.string.overview_style_minimal), getString(R.string.overview_style_terminal),
|
||
getString(R.string.overview_style_nixie),
|
||
getString(R.string.overview_style_chronograph), getString(R.string.overview_style_grid),
|
||
getString(R.string.overview_style_grid_map),
|
||
getString(R.string.overview_style_time_circuits), getString(R.string.overview_style_quest),
|
||
getString(R.string.overview_style_visor), getString(R.string.overview_style_empire) };
|
||
}
|
||
|
||
/** 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();
|
||
applyStyleOrRecreate();
|
||
});
|
||
}
|
||
|
||
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());
|
||
v.powerTrail = cockpitTrace.snapshot();
|
||
retroView.setValues(v);
|
||
flowView.setValues(v);
|
||
sciFiView.setValues(v);
|
||
cartoonView.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() : "";
|
||
}
|
||
}
|