Dashboard: every label, empty state, chart legend and the login page are English; numbers and dates use en-GB (24-hour clock, day-month dates), money is EUR, percentages read 80% instead of %80, durations "2 h 05 min". html lang is en. Server version 1.1.2; manifests pin it. Car app: the charging tab's price suffix, validation message and total cost use € instead of ₺. Checked by rendering all three pages from the 1.1.2 image against InfluxDB 2.7.12 in podman. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
1146 lines
56 KiB
Java
1146 lines
56 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 {
|
|
|
|
/** Shared with {@link AbrpUploadService}, which reads it on every tick. */
|
|
static final String WIFI_AUTO_KEY = "wifi_auto_connect";
|
|
private static final String REPOSITORY_URL = "https://github.com/fatihdonmezdev/FatihsMG4";
|
|
|
|
/** Navigation destinations in page order. Parallel to {@link #panes}. */
|
|
private static final int[] TAB_IDS =
|
|
{ R.id.tabVehicle, R.id.tabConsumption, R.id.tabCharging, R.id.tabService, R.id.tabServer,
|
|
R.id.tabWifi, R.id.tabLog };
|
|
|
|
private static final int[] PAGE_TITLES = {
|
|
R.string.design_overview_title, R.string.consumption_title, R.string.charging_title,
|
|
R.string.design_service_title, R.string.design_server_title, R.string.design_wifi_title,
|
|
R.string.design_log_title
|
|
};
|
|
private static final int[] PAGE_SUBTITLES = {
|
|
R.string.design_overview_subtitle, R.string.consumption_subtitle, R.string.charging_subtitle,
|
|
R.string.design_service_subtitle, R.string.design_server_subtitle, R.string.design_wifi_subtitle,
|
|
R.string.design_log_subtitle
|
|
};
|
|
|
|
// Status colours come from the palette, not from the Material swatches: #4CAF50 and
|
|
// #F44336 sit around 4:1 on this background, which disappears behind a sunlit
|
|
// reflection. The palette entries are the lightened variants (7:1 and above).
|
|
private static final int COLOR_OK = 0xFFA4DCC1;
|
|
private static final int COLOR_ERROR = 0xFFFFB4AB;
|
|
private static final int COLOR_PENDING = 0xFFBDC4C6;
|
|
|
|
private TextView statusText;
|
|
private TextView callLogText;
|
|
private View servicePane;
|
|
private View serverPane;
|
|
private View logPane;
|
|
private View vehiclePane;
|
|
private View consumptionPane;
|
|
private View chargingPane;
|
|
private View wifiPane;
|
|
|
|
/** The pages in navigation order. Parallel to {@link #TAB_IDS}. */
|
|
private View[] panes;
|
|
|
|
/** Wi-Fi page. The helper is the same one the upload service uses on its own tick. */
|
|
private WifiAutoConnect wifi;
|
|
private TextView wifiSsid, wifiState, wifiSavedList;
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
private EnergyTelemetryReader vehicleReader;
|
|
private TextView vehSoc, vehRange, vehPower, vehSpeed, vehOdometer,
|
|
vehCharging, vehHvac,
|
|
vehTireFl, vehTireFr, vehTireRl, vehTireRr;
|
|
private ConsumptionTracker consumptionTracker;
|
|
private ConsumptionCloudClient consumptionCloudClient;
|
|
private ConsumptionTracker.Period consumptionPeriod = ConsumptionTracker.Period.LIFETIME;
|
|
private TextView consumptionDistance, consumptionEnergy, consumptionAverage,
|
|
consumptionSpeed, consumptionTime, consumptionSoc, consumptionDailyHistory;
|
|
private Button consumptionReset;
|
|
private MaterialButton consumptionDateButton;
|
|
private TextInputLayout consumptionSohLayout;
|
|
private TextInputEditText consumptionSohInput;
|
|
private java.time.LocalDate selectedConsumptionDate = java.time.LocalDate.now();
|
|
private TextView chargeStatus, chargeSoc, chargeStartSoc, chargeVoltage, chargeCurrent, chargePower,
|
|
chargeEnergy, chargeGridEnergy, chargeDuration, chargeTotalCost, chargeCurveTable;
|
|
private TextInputLayout chargePriceLayout;
|
|
private TextInputEditText chargePriceInput;
|
|
private ChargeSessionTracker chargeSessionTracker;
|
|
private ChargingGraphView chargeGraph;
|
|
|
|
/** Server page. */
|
|
private TextInputLayout serverUrlLayout, serverTokenLayout, installationIdLayout;
|
|
private TextInputEditText serverUrlInput, serverTokenInput, installationIdInput;
|
|
private View connectionStatusRow, connectionIndicator;
|
|
private TextView connectionStatusText;
|
|
|
|
/** Upload page: live telemetry switch and cadence. */
|
|
private SwitchMaterial liveTelemetrySwitch, boostSwitch;
|
|
private android.widget.Spinner intervalSpinner;
|
|
private TextInputLayout lowSocLayout;
|
|
private TextInputEditText lowSocInput;
|
|
|
|
/**
|
|
* Picks a config text file. Only reachable on devices that ship a document picker — the
|
|
* MG4 head unit does not, which is why {@link #startConfigImport()} scans the app's own
|
|
* folders first and only falls back to this.
|
|
*/
|
|
private final ActivityResultLauncher<String[]> configPicker =
|
|
registerForActivityResult(new ActivityResultContracts.OpenDocument(), uri -> {
|
|
if (uri != null) importConfig(uri);
|
|
});
|
|
|
|
/**
|
|
* Inflates a page with no parent, then gives it the MATCH_PARENT/MATCH_PARENT layout
|
|
* params ViewPager2 requires of its items — inflating detached leaves them null, and
|
|
* the pager throws rather than guessing.
|
|
*/
|
|
private View inflatePane(int layoutRes) {
|
|
View pane = getLayoutInflater().inflate(layoutRes, null, false);
|
|
pane.setLayoutParams(new android.view.ViewGroup.LayoutParams(
|
|
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
|
|
android.view.ViewGroup.LayoutParams.MATCH_PARENT));
|
|
return pane;
|
|
}
|
|
|
|
/**
|
|
* Wires the navigation to the pager: every tab is a page, and every page is a tab.
|
|
*
|
|
* Each page has its own view type, so the pager asks for it once and keeps it: these
|
|
* views are the activity's own, held in {@link #panes}, not rows to be recycled.
|
|
*/
|
|
private void setUpPager() {
|
|
ViewPager2 pager = findViewById(R.id.content);
|
|
pager.setAdapter(new RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
|
@Override public int getItemCount() { return panes.length; }
|
|
@Override public int getItemViewType(int position) { return position; }
|
|
|
|
@NonNull @Override
|
|
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull android.view.ViewGroup parent,
|
|
int viewType) {
|
|
return new RecyclerView.ViewHolder(panes[viewType]) {};
|
|
}
|
|
|
|
@Override
|
|
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
|
|
// Nothing to bind: the pages are built once in onCreate and keep their own
|
|
// state, exactly as they did when they were siblings in the layout.
|
|
}
|
|
});
|
|
// Keep all pages alive: the log and vehicle readings are refreshed
|
|
// by the tick even when their page is off screen.
|
|
pager.setOffscreenPageLimit(panes.length - 1);
|
|
pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
|
|
@Override public void onPageSelected(int position) { markCurrentPage(position); }
|
|
});
|
|
|
|
for (int i = 0; i < TAB_IDS.length; i++) {
|
|
final int index = i;
|
|
// Animated, so the button does the same thing the swipe does: the direction of
|
|
// travel is what tells the driver where they are in the row.
|
|
findViewById(TAB_IDS[i]).setOnClickListener(v -> pager.setCurrentItem(index, false));
|
|
}
|
|
markCurrentPage(pager.getCurrentItem());
|
|
}
|
|
|
|
/**
|
|
* Marks the destination and labels the page on screen.
|
|
*
|
|
* Only isSelected is set: fill, text, icon and stroke come from the
|
|
* res/color/nav_tab_*.xml selectors applied by the Widget.EV.NavTab style. isSelected
|
|
* also serves TalkBack, which announces the current destination.
|
|
*/
|
|
private void markCurrentPage(int position) {
|
|
for (int i = 0; i < TAB_IDS.length; i++) {
|
|
findViewById(TAB_IDS[i]).setSelected(i == position);
|
|
}
|
|
((TextView) findViewById(R.id.header_title)).setText(PAGE_TITLES[position]);
|
|
((TextView) findViewById(R.id.header_subtitle)).setText(PAGE_SUBTITLES[position]);
|
|
}
|
|
|
|
/** Refreshes state + call log while the screen is visible. */
|
|
private final android.os.Handler uiHandler = new android.os.Handler(android.os.Looper.getMainLooper());
|
|
private final Runnable uiRefresh = new Runnable() {
|
|
@Override public void run() {
|
|
refreshStatus();
|
|
refreshCallLog();
|
|
refreshVehicle();
|
|
refreshConsumption();
|
|
refreshWifi();
|
|
uiHandler.postDelayed(this, 2_000L);
|
|
}
|
|
};
|
|
|
|
private SharedPreferences prefs;
|
|
|
|
@Override
|
|
protected void onCreate(Bundle savedInstanceState) {
|
|
getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES);
|
|
super.onCreate(savedInstanceState);
|
|
setContentView(R.layout.activity_main);
|
|
|
|
((TextView) findViewById(R.id.version_badge)).setText("v" + BuildConfig.VERSION_NAME);
|
|
|
|
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
|
|
|
|
// The pages are inflated here, once, and handed to the pager as fixed,
|
|
// non-recycled items. That is what lets every widget below be looked up now and
|
|
// held for the life of the activity, the way it was when the panes were siblings
|
|
// in activity_main.xml — a recycled page would invalidate these references as
|
|
// soon as the user swiped away from it.
|
|
servicePane = inflatePane(R.layout.pane_service);
|
|
serverPane = inflatePane(R.layout.pane_server);
|
|
vehiclePane = inflatePane(R.layout.pane_vehicle);
|
|
consumptionPane = inflatePane(R.layout.pane_consumption);
|
|
chargingPane = inflatePane(R.layout.pane_charging);
|
|
wifiPane = inflatePane(R.layout.pane_wifi);
|
|
logPane = inflatePane(R.layout.pane_log);
|
|
panes = new View[] { vehiclePane, consumptionPane, chargingPane, servicePane, serverPane,
|
|
wifiPane, logPane };
|
|
|
|
bindVehiclePane();
|
|
bindConsumptionPane();
|
|
bindChargingPane();
|
|
bindWifiPane();
|
|
bindServerPane();
|
|
bindServicePane();
|
|
requestLocationPermissionIfNeeded();
|
|
|
|
statusText = servicePane.findViewById(R.id.status_text);
|
|
callLogText = logPane.findViewById(R.id.call_log_text);
|
|
logPane.findViewById(R.id.flush_now_button).setOnClickListener(v -> manualFlush());
|
|
|
|
setUpPager();
|
|
findViewById(R.id.about_button).setOnClickListener(v -> showAbout());
|
|
findViewById(R.id.update_button).setOnClickListener(v -> checkForUpdate(v));
|
|
|
|
// Unstable builds check for a newer pre-release; the stable flavor's UpdateHook is
|
|
// a no-op and does not even contain the updater.
|
|
UpdateHook.checkInBackground(this);
|
|
|
|
// The service is always-on: it tracks consumption and sends everything to the server.
|
|
// startForegroundService is idempotent — if the service is already up this
|
|
// is a no-op aside from delivering a new intent.
|
|
startForegroundService(new Intent(this, AbrpUploadService.class));
|
|
}
|
|
|
|
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());
|
|
dialog.show();
|
|
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);
|
|
}
|
|
|
|
@Override
|
|
protected void onPause() {
|
|
super.onPause();
|
|
// Stop polling off-screen: this activity has no reason to spend cycles then.
|
|
uiHandler.removeCallbacks(uiRefresh);
|
|
}
|
|
|
|
// ---------- Update check ----------
|
|
|
|
/**
|
|
* Checks for a newer build, and installs one if it is there.
|
|
*
|
|
* The button is disabled for the duration rather than guarded by a flag: the whole
|
|
* sequence — API call, download, installer staging — runs on a background thread, and
|
|
* a second press would start a second download of the same APK. It comes back on when
|
|
* the result lands, whatever that result is.
|
|
*
|
|
* A modal progress surface keeps the driver informed while the APK is downloaded and
|
|
* SHA-256 verified; the final result remains visible until dismissed.
|
|
*/
|
|
private void checkForUpdate(View button) {
|
|
button.setEnabled(false);
|
|
android.widget.LinearLayout panel = new android.widget.LinearLayout(this);
|
|
panel.setOrientation(android.widget.LinearLayout.VERTICAL);
|
|
int padding = (int) (24 * getResources().getDisplayMetrics().density);
|
|
panel.setPadding(padding, padding, padding, padding);
|
|
TextView status = new TextView(this);
|
|
status.setText(R.string.update_checking);
|
|
status.setTextAppearance(R.style.Text_EV_Body);
|
|
android.widget.ProgressBar progress = new android.widget.ProgressBar(this, null,
|
|
android.R.attr.progressBarStyleHorizontal);
|
|
progress.setIndeterminate(true);
|
|
android.widget.LinearLayout.LayoutParams progressParams =
|
|
new android.widget.LinearLayout.LayoutParams(
|
|
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
|
|
android.view.ViewGroup.LayoutParams.WRAP_CONTENT);
|
|
progressParams.topMargin = padding;
|
|
panel.addView(status);
|
|
panel.addView(progress, progressParams);
|
|
androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(this)
|
|
.setTitle(R.string.update_dialog_title)
|
|
.setView(panel)
|
|
.setCancelable(false)
|
|
.create();
|
|
dialog.show();
|
|
UpdateHook.checkInBackground(this, new UpdateHook.Listener() {
|
|
@Override public void onProgress(int percent) {
|
|
if (percent < 0) {
|
|
progress.setIndeterminate(true);
|
|
status.setText(R.string.update_downloading);
|
|
} else {
|
|
progress.setIndeterminate(false);
|
|
progress.setMax(100);
|
|
progress.setProgress(percent);
|
|
status.setText(getString(R.string.update_downloading_percent, percent));
|
|
}
|
|
}
|
|
|
|
@Override public void onResult(String message) {
|
|
button.setEnabled(true);
|
|
dialog.dismiss();
|
|
new MaterialAlertDialogBuilder(MainActivity.this)
|
|
.setTitle(R.string.update_dialog_title)
|
|
.setMessage(message)
|
|
.setPositiveButton(android.R.string.ok, null)
|
|
.show();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------- Server page ----------
|
|
|
|
private void bindServerPane() {
|
|
serverUrlLayout = serverPane.findViewById(R.id.server_url_layout);
|
|
serverTokenLayout = serverPane.findViewById(R.id.server_token_layout);
|
|
installationIdLayout = serverPane.findViewById(R.id.installation_id_layout);
|
|
serverUrlInput = serverPane.findViewById(R.id.server_url_input);
|
|
serverTokenInput = serverPane.findViewById(R.id.server_token_input);
|
|
installationIdInput = serverPane.findViewById(R.id.installation_id_input);
|
|
connectionStatusRow = serverPane.findViewById(R.id.connection_status_row);
|
|
connectionIndicator = serverPane.findViewById(R.id.connection_indicator);
|
|
connectionStatusText = serverPane.findViewById(R.id.connection_status_text);
|
|
showServerConfig();
|
|
((TextView) serverPane.findViewById(R.id.import_hint))
|
|
.setText(getString(R.string.import_hint, configDirHint()));
|
|
|
|
serverPane.findViewById(R.id.server_save_button).setOnClickListener(v -> {
|
|
if (saveServerConfig(textOf(serverUrlInput), textOf(serverTokenInput), textOf(installationIdInput))) {
|
|
setConnectionStatus(COLOR_OK, getString(R.string.server_saved));
|
|
}
|
|
});
|
|
serverPane.findViewById(R.id.server_test_button).setOnClickListener(v -> testConnection());
|
|
serverPane.findViewById(R.id.import_button).setOnClickListener(v -> startConfigImport());
|
|
}
|
|
|
|
private void showServerConfig() {
|
|
ServerConfig config = ServerConfig.get(this);
|
|
serverUrlInput.setText(config.url);
|
|
serverTokenInput.setText(config.token);
|
|
installationIdInput.setText(config.installationId);
|
|
}
|
|
|
|
/** Validates, marks the bad fields, and saves only when all three are good. */
|
|
private boolean saveServerConfig(String rawUrl, String rawToken, String rawId) {
|
|
String url = ServerConfig.normalizeUrl(rawUrl);
|
|
String token = rawToken == null ? "" : rawToken.trim();
|
|
String id = rawId == null ? "" : rawId.trim().toLowerCase(java.util.Locale.US);
|
|
serverUrlLayout.setError(url == null ? getString(R.string.server_err_url) : null);
|
|
serverTokenLayout.setError(ServerConfig.isValidToken(token) ? null : getString(R.string.server_err_token));
|
|
installationIdLayout.setError(ServerConfig.isValidInstallationId(id)
|
|
? null : getString(R.string.server_err_installation_id));
|
|
if (url == null || !ServerConfig.isValidToken(token) || !ServerConfig.isValidInstallationId(id)) return false;
|
|
ServerConfig.save(this, url, token, id);
|
|
if (consumptionCloudClient != null) consumptionCloudClient.invalidateHistory();
|
|
showServerConfig();
|
|
refreshStatus();
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Checks reachability, then the token, without writing anything: the health endpoint
|
|
* first, then the car's own history read, which needs the ingest token. Uses what is in
|
|
* the fields, saved or not, so a typo can be caught before it is stored.
|
|
*/
|
|
private void testConnection() {
|
|
String url = ServerConfig.normalizeUrl(textOf(serverUrlInput));
|
|
String token = textOf(serverTokenInput);
|
|
String id = textOf(installationIdInput).toLowerCase(java.util.Locale.US);
|
|
if (url == null) {
|
|
serverUrlLayout.setError(getString(R.string.server_err_url));
|
|
return;
|
|
}
|
|
if (!ServerConfig.isValidInstallationId(id)) {
|
|
installationIdLayout.setError(getString(R.string.server_err_installation_id));
|
|
return;
|
|
}
|
|
setConnectionStatus(COLOR_PENDING, getString(R.string.conn_testing));
|
|
new Thread(() -> {
|
|
String message;
|
|
int color;
|
|
try {
|
|
int health = httpGet(url + "/v1/health", null);
|
|
if (health != 200) {
|
|
message = getString(R.string.conn_err_http, health);
|
|
color = COLOR_ERROR;
|
|
} else {
|
|
int auth = httpGet(url + "/v1/consumption/history/" + id, token);
|
|
if (auth == 200) { message = getString(R.string.conn_ok); color = COLOR_OK; }
|
|
else if (auth == 401) { message = getString(R.string.conn_err_auth); color = COLOR_ERROR; }
|
|
else { message = getString(R.string.conn_err_http, auth); color = COLOR_ERROR; }
|
|
}
|
|
} catch (java.net.SocketTimeoutException e) {
|
|
message = getString(R.string.conn_err_timeout);
|
|
color = COLOR_ERROR;
|
|
} catch (Exception e) {
|
|
message = getString(R.string.conn_err_no_internet);
|
|
color = COLOR_ERROR;
|
|
}
|
|
final String m = message;
|
|
final int c = color;
|
|
runOnUiThread(() -> setConnectionStatus(c, m));
|
|
}, "server-test").start();
|
|
}
|
|
|
|
private static int httpGet(String url, String token) throws java.io.IOException {
|
|
java.net.HttpURLConnection connection = (java.net.HttpURLConnection) new java.net.URL(url).openConnection();
|
|
try {
|
|
connection.setConnectTimeout(8_000);
|
|
connection.setReadTimeout(8_000);
|
|
if (token != null) connection.setRequestProperty("Authorization", "Bearer " + token);
|
|
return connection.getResponseCode();
|
|
} finally {
|
|
connection.disconnect();
|
|
}
|
|
}
|
|
|
|
private void setConnectionStatus(int color, String message) {
|
|
connectionStatusRow.setVisibility(View.VISIBLE);
|
|
connectionIndicator.getBackground().mutate().setTint(color);
|
|
connectionStatusText.setText(message);
|
|
connectionStatusText.setTextColor(color);
|
|
}
|
|
|
|
// ---------- Config import ----------
|
|
|
|
/**
|
|
* The MG4 head unit ships no document picker — SAF answers "FileManagement is no supported
|
|
* on this device" — so the file is found by scanning instead. getExternalFilesDirs() returns
|
|
* this app's own folder on internal storage and on every mounted volume including a USB
|
|
* stick, and those need no storage permission at any API level. The picker stays as the
|
|
* fallback for phones and tablets, where the user may keep the file anywhere.
|
|
*/
|
|
private void startConfigImport() {
|
|
java.io.File found = findConfigFile();
|
|
if (found != null) {
|
|
importConfig(found);
|
|
return;
|
|
}
|
|
try {
|
|
// Any MIME: the MG4 Files app tags .txt inconsistently, so filtering by type hides
|
|
// the very file the user is trying to pick. They select it by name instead.
|
|
configPicker.launch(new String[]{"*/*"});
|
|
} catch (android.content.ActivityNotFoundException e) {
|
|
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_not_found, configDirHint()));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* First readable file in an app-specific folder that parses as a config. Names are not
|
|
* filtered: the user copies one file to the folder, whatever they called it.
|
|
*/
|
|
private java.io.File findConfigFile() {
|
|
for (java.io.File dir : getExternalFilesDirs(null)) {
|
|
if (dir == null) continue;
|
|
java.io.File[] files = dir.listFiles();
|
|
if (files == null) continue;
|
|
for (java.io.File f : files) {
|
|
if (f.isFile() && f.canRead() && f.length() > 0 && f.length() <= 64 * 1024
|
|
&& !ConfigImport.parse(readText(f)).isEmpty()) {
|
|
return f;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** File text, or "" when unreadable — callers treat that as "not a config". */
|
|
private String readText(java.io.File file) {
|
|
try (java.io.InputStream in = new java.io.FileInputStream(file)) {
|
|
return readCapped(in);
|
|
} catch (Exception e) {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
/** Capped so a wrong file picked by mistake is not slurped whole into memory. */
|
|
private String readCapped(java.io.InputStream in) throws java.io.IOException {
|
|
java.io.ByteArrayOutputStream buf = new java.io.ByteArrayOutputStream();
|
|
byte[] chunk = new byte[4096];
|
|
int n;
|
|
while ((n = in.read(chunk)) != -1 && buf.size() < 64 * 1024) buf.write(chunk, 0, n);
|
|
return buf.toString(java.nio.charset.StandardCharsets.UTF_8.name());
|
|
}
|
|
|
|
/** Where the user should drop the file. */
|
|
private String configDirHint() {
|
|
java.io.File[] dirs = getExternalFilesDirs(null);
|
|
return dirs.length > 0 && dirs[0] != null ? dirs[0].getAbsolutePath() : "";
|
|
}
|
|
|
|
private void importConfig(java.io.File file) {
|
|
String text = readText(file);
|
|
if (text.isEmpty()) {
|
|
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_read));
|
|
return;
|
|
}
|
|
applyConfig(text);
|
|
}
|
|
|
|
private void importConfig(Uri uri) {
|
|
String text;
|
|
try (java.io.InputStream in = getContentResolver().openInputStream(uri)) {
|
|
if (in == null) throw new java.io.IOException("no stream");
|
|
text = readCapped(in);
|
|
} catch (Exception e) {
|
|
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_read));
|
|
return;
|
|
}
|
|
applyConfig(text);
|
|
}
|
|
|
|
/**
|
|
* Applies whatever the file sets; absent keys keep their current value, so a file with
|
|
* only the token does not wipe the URL. Server settings are validated and saved together;
|
|
* cadence keys go to the plain prefs the same way the Upload page writes them.
|
|
*/
|
|
private void applyConfig(String text) {
|
|
ConfigImport config = ConfigImport.parse(text);
|
|
if (config.isEmpty()) {
|
|
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_empty));
|
|
return;
|
|
}
|
|
ServerConfig current = ServerConfig.get(this);
|
|
if (config.serverUrl != null || config.token != null || config.installationId != null) {
|
|
boolean saved = saveServerConfig(
|
|
config.serverUrl != null ? config.serverUrl : current.url,
|
|
config.token != null ? config.token : current.token,
|
|
config.installationId != null ? config.installationId : current.installationId);
|
|
if (!saved) {
|
|
// Show what the file said, so the marked field is the one to fix in the file.
|
|
if (config.serverUrl != null) serverUrlInput.setText(config.serverUrl);
|
|
if (config.token != null) serverTokenInput.setText(config.token);
|
|
if (config.installationId != null) installationIdInput.setText(config.installationId);
|
|
return;
|
|
}
|
|
}
|
|
SharedPreferences.Editor edit = prefs.edit();
|
|
if (config.intervalSec != null) edit.putInt(UploadSettings.KEY_INTERVAL_SEC, config.intervalSec);
|
|
if (config.boostLowSoc != null) edit.putBoolean(UploadSettings.KEY_BOOST_LOW_SOC, config.boostLowSoc);
|
|
if (config.lowSocPercent != null) edit.putInt(UploadSettings.KEY_LOW_SOC_PERCENT, config.lowSocPercent);
|
|
edit.apply();
|
|
bindCadenceControls();
|
|
setConnectionStatus(COLOR_OK, getString(R.string.import_ok));
|
|
refreshStatus();
|
|
}
|
|
|
|
// ---------- Upload page ----------
|
|
|
|
private void bindServicePane() {
|
|
liveTelemetrySwitch = servicePane.findViewById(R.id.abrp_upload_switch);
|
|
intervalSpinner = servicePane.findViewById(R.id.interval_spinner);
|
|
boostSwitch = servicePane.findViewById(R.id.boost_switch);
|
|
lowSocInput = servicePane.findViewById(R.id.low_soc_input);
|
|
lowSocLayout = servicePane.findViewById(R.id.low_soc_layout);
|
|
liveTelemetrySwitch.setChecked(UploadSettings.from(prefs).abrpUploadEnabled);
|
|
liveTelemetrySwitch.setOnCheckedChangeListener((btn, checked) -> {
|
|
prefs.edit().putBoolean(UploadSettings.KEY_ABRP_UPLOAD_ENABLED, checked).apply();
|
|
refreshStatus();
|
|
});
|
|
bindCadenceControls();
|
|
}
|
|
|
|
/** The service reads these settings on every tick, so a change applies without a restart. */
|
|
private void bindCadenceControls() {
|
|
UploadSettings current = UploadSettings.from(prefs);
|
|
|
|
String[] labels = new String[UploadSettings.INTERVAL_CHOICES_SEC.length];
|
|
int selected = 0;
|
|
for (int i = 0; i < UploadSettings.INTERVAL_CHOICES_SEC.length; i++) {
|
|
int sec = UploadSettings.INTERVAL_CHOICES_SEC[i];
|
|
labels[i] = sec >= 60
|
|
? getResources().getQuantityString(R.plurals.interval_minutes, sec / 60, sec / 60)
|
|
: getResources().getQuantityString(R.plurals.interval_seconds, sec, sec);
|
|
if (sec == current.intervalSec) selected = i;
|
|
}
|
|
android.widget.ArrayAdapter<String> adapter = new android.widget.ArrayAdapter<>(
|
|
this, R.layout.spinner_value, labels);
|
|
adapter.setDropDownViewResource(R.layout.spinner_dropdown);
|
|
intervalSpinner.setAdapter(adapter);
|
|
intervalSpinner.setSelection(selected);
|
|
intervalSpinner.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() {
|
|
@Override
|
|
public void onItemSelected(android.widget.AdapterView<?> parent, View view, int position, long id) {
|
|
prefs.edit().putInt(UploadSettings.KEY_INTERVAL_SEC,
|
|
UploadSettings.INTERVAL_CHOICES_SEC[position]).apply();
|
|
}
|
|
@Override public void onNothingSelected(android.widget.AdapterView<?> parent) { }
|
|
});
|
|
|
|
boostSwitch.setOnCheckedChangeListener(null);
|
|
boostSwitch.setChecked(current.boostLowSoc);
|
|
lowSocLayout.setEnabled(current.boostLowSoc);
|
|
lowSocInput.setEnabled(current.boostLowSoc);
|
|
boostSwitch.setOnCheckedChangeListener((button, checked) -> {
|
|
prefs.edit().putBoolean(UploadSettings.KEY_BOOST_LOW_SOC, checked).apply();
|
|
lowSocLayout.setEnabled(checked);
|
|
lowSocInput.setEnabled(checked);
|
|
});
|
|
|
|
lowSocInput.setText(String.valueOf(current.lowSocPercent));
|
|
lowSocInput.setOnFocusChangeListener((view, hasFocus) -> {
|
|
if (!hasFocus) saveLowSocThreshold();
|
|
});
|
|
}
|
|
|
|
/** Clamped to 1-99: 0 would never trigger and 100 would boost permanently. */
|
|
private void saveLowSocThreshold() {
|
|
CharSequence raw = lowSocInput.getText();
|
|
int value;
|
|
try {
|
|
value = Integer.parseInt(raw == null ? "" : raw.toString().trim());
|
|
} catch (NumberFormatException e) {
|
|
value = UploadSettings.DEFAULT_LOW_SOC_PERCENT;
|
|
}
|
|
value = Math.max(1, Math.min(99, value));
|
|
lowSocInput.setText(String.valueOf(value));
|
|
prefs.edit().putInt(UploadSettings.KEY_LOW_SOC_PERCENT, value).apply();
|
|
}
|
|
|
|
/**
|
|
* GPS for the telemetry position. On the car the app runs as the system user and holds
|
|
* it already; this only asks where it does not, such as a phone running the stable build.
|
|
*/
|
|
private void requestLocationPermissionIfNeeded() {
|
|
if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION)
|
|
!= PackageManager.PERMISSION_GRANTED) {
|
|
requestPermissions(new String[]{android.Manifest.permission.ACCESS_FINE_LOCATION,
|
|
android.Manifest.permission.ACCESS_COARSE_LOCATION}, 1);
|
|
}
|
|
}
|
|
|
|
// ---------- Wi-Fi page ----------
|
|
|
|
private void bindWifiPane() {
|
|
wifi = new WifiAutoConnect(getApplicationContext());
|
|
wifiSsid = wifiPane.findViewById(R.id.wifi_ssid);
|
|
wifiState = wifiPane.findViewById(R.id.wifi_state);
|
|
wifiSavedList = wifiPane.findViewById(R.id.wifi_saved_list);
|
|
|
|
SwitchMaterial auto = wifiPane.findViewById(R.id.wifi_auto_switch);
|
|
auto.setChecked(prefs.getBoolean(WIFI_AUTO_KEY, true));
|
|
auto.setOnCheckedChangeListener((btn, checked) ->
|
|
prefs.edit().putBoolean(WIFI_AUTO_KEY, checked).apply());
|
|
|
|
wifiPane.findViewById(R.id.wifi_connect_button).setOnClickListener(v -> {
|
|
// force: the driver is asking now, so the rate limit does not apply.
|
|
WifiAutoConnect.Result r = wifi.ensureConnected(true);
|
|
wifiState.setText(r.message);
|
|
refreshWifi();
|
|
});
|
|
}
|
|
|
|
private void refreshWifi() {
|
|
if (wifi == null) return;
|
|
String ssid = wifi.currentSsid();
|
|
wifiSsid.setText(ssid == null ? getString(R.string.wifi_not_connected) : ssid);
|
|
if (ssid != null) {
|
|
wifiState.setText("Connected ♥");
|
|
} else if (!wifi.isWifiEnabled()) {
|
|
wifiState.setText("Wi-Fi off");
|
|
}
|
|
// Left alone otherwise: a message the button just set says more about what is
|
|
// happening than "not connected" would.
|
|
|
|
java.util.List<String> saved = wifi.savedNetworkNames();
|
|
wifiSavedList.setText(saved.isEmpty()
|
|
? getString(R.string.wifi_saved_empty)
|
|
: android.text.TextUtils.join("\n", saved));
|
|
}
|
|
|
|
// ---------- Vehicle page ----------
|
|
|
|
/** Looks up the value cells once; the labels are static and never touched from code. */
|
|
private void bindVehiclePane() {
|
|
vehSoc = vehiclePane.findViewById(R.id.vehicle_soc);
|
|
vehRange = vehiclePane.findViewById(R.id.vehicle_range);
|
|
vehPower = vehiclePane.findViewById(R.id.vehicle_power);
|
|
vehSpeed = vehiclePane.findViewById(R.id.vehicle_speed);
|
|
vehOdometer = vehiclePane.findViewById(R.id.vehicle_odometer);
|
|
vehCharging = vehiclePane.findViewById(R.id.vehicle_charging);
|
|
vehHvac = vehiclePane.findViewById(R.id.vehicle_hvac);
|
|
vehTireFl = vehiclePane.findViewById(R.id.vehicle_tire_fl);
|
|
vehTireFr = vehiclePane.findViewById(R.id.vehicle_tire_fr);
|
|
vehTireRl = vehiclePane.findViewById(R.id.vehicle_tire_rl);
|
|
vehTireRr = vehiclePane.findViewById(R.id.vehicle_tire_rr);
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
private void refreshVehicle() {
|
|
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());
|
|
ConsumptionTracker.get(this).sample(s);
|
|
refreshCharging(s);
|
|
|
|
vehSoc.setText(fmt(s.getSocPercent(), "%.0f %%"));
|
|
vehRange.setText(fmt(s.getRangeKm(), "%.0f km"));
|
|
vehPower.setText(fmt(s.getBatteryPowerKw(), "%.1f kW"));
|
|
vehSpeed.setText(fmt(s.getSpeedKmh(), "%.0f km/h"));
|
|
vehOdometer.setText(fmt(s.getOdometerKm(), "%.0f km"));
|
|
vehHvac.setText(fmt(s.getClimate().getDriverTargetCelsius(), "%.0f °C"));
|
|
|
|
vehCharging.setText(chargingLabel(s));
|
|
|
|
vehTireFl.setText(fmt(s.getTirePressures().getFrontLeftKpa(), "%.0f kPa"));
|
|
vehTireFr.setText(fmt(s.getTirePressures().getFrontRightKpa(), "%.0f kPa"));
|
|
vehTireRl.setText(fmt(s.getTirePressures().getRearLeftKpa(), "%.0f kPa"));
|
|
vehTireRr.setText(fmt(s.getTirePressures().getRearRightKpa(), "%.0f kPa"));
|
|
} 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 = consumptionPane.findViewById(R.id.consumption_soh_layout);
|
|
consumptionSohInput = consumptionPane.findViewById(R.id.consumption_soh_input);
|
|
Float savedSoh = consumptionTracker.sohPercent();
|
|
if (savedSoh != null) consumptionSohInput.setText(String.format(
|
|
java.util.Locale.US, "%.1f", savedSoh));
|
|
consumptionPane.findViewById(R.id.consumption_soh_save).setOnClickListener(v -> saveSoh());
|
|
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 = consumptionPeriod == ConsumptionTracker.Period.TRIP_A ? "Trip A" : "Trip B";
|
|
new MaterialAlertDialogBuilder(this).setTitle("Reset " + name + "?")
|
|
.setMessage("This counter's recorded consumption history will be permanently deleted.")
|
|
.setNegativeButton("Cancel", null)
|
|
.setPositiveButton("Reset", (d, w) -> {
|
|
consumptionTracker.reset(consumptionPeriod);
|
|
refreshConsumption();
|
|
}).show();
|
|
});
|
|
}
|
|
|
|
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, "SoH saved", Toast.LENGTH_SHORT).show();
|
|
} catch (NumberFormatException e) {
|
|
consumptionSohLayout.setError("Enter a value between 1 and 100");
|
|
}
|
|
}
|
|
|
|
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);
|
|
button.setTextColor(selected ? 0xFFE0424C : COLOR_PENDING);
|
|
button.setStrokeColor(android.content.res.ColorStateList.valueOf(
|
|
selected ? 0xFFE0424C : 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 = chargingPane.findViewById(R.id.charge_price_layout);
|
|
chargePriceInput = chargingPane.findViewById(R.id.charge_price_input);
|
|
chargePriceInput.setText(String.format(java.util.Locale.US, "%.2f",
|
|
chargeSessionTracker.pricePerKwh()));
|
|
chargingPane.findViewById(R.id.charge_price_save).setOnClickListener(v -> saveChargePrice());
|
|
}
|
|
|
|
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, "Charging price saved", Toast.LENGTH_SHORT).show();
|
|
} catch (NumberFormatException e) {
|
|
chargePriceLayout.setError("Enter a valid €/kWh value");
|
|
}
|
|
}
|
|
|
|
private void refreshCharging(EnergySnapshot s) {
|
|
if (chargeStatus == null) return;
|
|
chargeSessionTracker.sample(s);
|
|
Integer code = s.getChargingStatus();
|
|
boolean charging = ChargingState.isCharging(
|
|
code, s.getChargePortConnected(), s.getBatteryPowerKw(), s.getSpeedKmh());
|
|
ChargeSessionTracker.Snapshot session = chargeSessionTracker.snapshot();
|
|
// Only DC is metered, so an AC charge must say so rather than let the readouts
|
|
// below — which then describe the previous DC session — be read as this charge.
|
|
boolean dc = ChargingState.isDcCharging(
|
|
code, s.getChargePortConnected(), s.getBatteryPowerKw(), s.getSpeedKmh());
|
|
chargeStatus.setText(dc ? "DC fast charging"
|
|
: charging ? "AC charging (not metered)"
|
|
: (session.startedAtMs > 0 ? "Last charge" : "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("No records for this date");
|
|
} 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(String.format(java.util.Locale.getDefault(),
|
|
"Distance %.1f km\nEnergy %.2f kWh\nAverage %s\nDuration %d h %02d min\nSOC change %.1f %%",
|
|
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());
|
|
dialog.show();
|
|
}
|
|
|
|
/**
|
|
* 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 static String chargingLabel(EnergySnapshot s) {
|
|
Integer status = s.getChargingStatus();
|
|
if (status != null) {
|
|
switch (status) {
|
|
case 1: return "AC charging ♥";
|
|
case 10: return "DC fast charging ♥";
|
|
case 5: return "Connecting…";
|
|
case 7: return "Plugged in, not charging";
|
|
case 8: return "Stopped";
|
|
case 0: return "Not charging";
|
|
default: return "Status " + status;
|
|
}
|
|
}
|
|
Boolean plugged = s.getChargePortConnected();
|
|
if (plugged != null) return plugged ? "Connected ♥" : "Not connected";
|
|
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, "Consumption refreshed", Toast.LENGTH_SHORT).show();
|
|
});
|
|
}, "cloud-refresh").start();
|
|
}
|
|
|
|
private void refreshCallLog() {
|
|
java.util.List<UploadLog.Entry> entries = AbrpUploadService.log().recent();
|
|
if (entries.isEmpty()) {
|
|
callLogText.setText(R.string.call_log_empty);
|
|
return;
|
|
}
|
|
java.text.SimpleDateFormat format =
|
|
new java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.US);
|
|
StringBuilder sb = new StringBuilder();
|
|
for (UploadLog.Entry entry : entries) {
|
|
if (sb.length() > 0) sb.append('\n');
|
|
sb.append(entry.success ? "OK " : "ERR ")
|
|
.append(format.format(new java.util.Date(entry.timestampMs)))
|
|
.append(" ")
|
|
// httpStatus 0 means the request never reached a server.
|
|
.append(entry.httpStatus > 0 ? String.valueOf(entry.httpStatus) : "---")
|
|
.append(" ")
|
|
.append(entry.detail);
|
|
// What the payload held, under the attempt it belongs to. This is the only view
|
|
// of it the driver has: the car runs no adb, so "the field is 0 on ABRP" and
|
|
// "the app never sent the field" look identical from the outside without it.
|
|
if (entry.summary != null && !entry.summary.isEmpty()) {
|
|
sb.append("\n ").append(entry.summary);
|
|
}
|
|
}
|
|
callLogText.setText(sb.toString());
|
|
}
|
|
|
|
// ---------- Service status ----------
|
|
|
|
private void refreshStatus() {
|
|
// The service is always running. Show the cloud sync state derived from the log.
|
|
UploadLog.State state = AbrpUploadService.state();
|
|
|
|
switch (state) {
|
|
case ERROR:
|
|
// Derived from the log: three consecutive cloud sync failures is a problem.
|
|
statusText.setText(getString(R.string.state_error,
|
|
AbrpUploadService.log().consecutiveFailures()));
|
|
statusText.setTextColor(COLOR_ERROR);
|
|
break;
|
|
case RUNNING:
|
|
case STARTING:
|
|
if (!ServerConfig.get(this).isConfigured()) {
|
|
statusText.setText(R.string.server_not_configured);
|
|
statusText.setTextColor(COLOR_ERROR);
|
|
} else if (!UploadSettings.from(prefs).abrpUploadEnabled) {
|
|
statusText.setText(R.string.state_tracking_locally);
|
|
statusText.setTextColor(COLOR_PENDING);
|
|
} else {
|
|
statusText.setText(R.string.state_running);
|
|
statusText.setTextColor(COLOR_OK);
|
|
}
|
|
break;
|
|
case STOPPED:
|
|
default:
|
|
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() : "";
|
|
}
|
|
}
|