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, vehRangePower, vehRangePowerNote, vehRangeAverage, vehRangeAverageNote; /** Usable pack capacity for the range estimates; the car does not publish it. */ static final String USABLE_KWH_KEY = "usable_kwh"; private TextInputLayout usableCapacityLayout; private TextInputEditText usableCapacityInput; private ConsumptionTracker consumptionTracker; private ConsumptionCloudClient consumptionCloudClient; private ConsumptionTracker.Period consumptionPeriod = ConsumptionTracker.Period.LIFETIME; private TextView consumptionDistance, consumptionEnergy, consumptionAverage, consumptionSpeed, consumptionTime, consumptionSoc, consumptionDailyHistory; private Button consumptionReset; private MaterialButton consumptionDateButton; private TextInputLayout consumptionSohLayout; private TextInputEditText consumptionSohInput; private java.time.LocalDate selectedConsumptionDate = java.time.LocalDate.now(); private TextView chargeStatus, chargeSoc, chargeStartSoc, chargeVoltage, chargeCurrent, chargePower, chargeEnergy, chargeGridEnergy, chargeDuration, chargeTotalCost, chargeCurveTable; private TextInputLayout chargePriceLayout; private TextInputEditText chargePriceInput; private ChargeSessionTracker chargeSessionTracker; private ChargingGraphView chargeGraph; /** Server page. */ private TextInputLayout serverUrlLayout, serverTokenLayout, installationIdLayout; private TextInputEditText serverUrlInput, serverTokenInput, installationIdInput; private View connectionStatusRow, connectionIndicator; private TextView connectionStatusText; /** Upload page: live telemetry switch and cadence. */ private SwitchMaterial liveTelemetrySwitch, boostSwitch; 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 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() { @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.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.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.server_pair_button).setOnClickListener(v -> startPairing()); serverPane.findViewById(R.id.import_button).setOnClickListener(v -> startConfigImport()); } private void showServerConfig() { ServerConfig config = ServerConfig.get(this); serverUrlInput.setText(config.url); serverTokenInput.setText(config.token); installationIdInput.setText(config.installationId); } /** Validates, marks the bad fields, and saves only when all three are good. */ private boolean saveServerConfig(String rawUrl, String rawToken, String rawId) { String url = ServerConfig.normalizeUrl(rawUrl); String token = rawToken == null ? "" : rawToken.trim(); String id = rawId == null ? "" : rawId.trim().toLowerCase(java.util.Locale.US); serverUrlLayout.setError(url == null ? getString(R.string.server_err_url) : null); serverTokenLayout.setError(ServerConfig.isValidToken(token) ? null : getString(R.string.server_err_token)); installationIdLayout.setError(ServerConfig.isValidInstallationId(id) ? null : getString(R.string.server_err_installation_id)); if (url == null || !ServerConfig.isValidToken(token) || !ServerConfig.isValidInstallationId(id)) return false; ServerConfig.save(this, url, token, id); if (consumptionCloudClient != null) consumptionCloudClient.invalidateHistory(); showServerConfig(); refreshStatus(); return true; } /** * Checks reachability, then the token, without writing anything: the health endpoint * first, then the car's own history read, which needs the ingest token. Uses what is in * the fields, saved or not, so a typo can be caught before it is stored. */ private void testConnection() { String url = ServerConfig.normalizeUrl(textOf(serverUrlInput)); String token = textOf(serverTokenInput); String id = textOf(installationIdInput).toLowerCase(java.util.Locale.US); if (url == null) { serverUrlLayout.setError(getString(R.string.server_err_url)); return; } if (!ServerConfig.isValidInstallationId(id)) { installationIdLayout.setError(getString(R.string.server_err_installation_id)); return; } setConnectionStatus(COLOR_PENDING, getString(R.string.conn_testing)); new Thread(() -> { String message; int color; try { int health = httpGet(url + "/v1/health", null); if (health != 200) { message = getString(R.string.conn_err_http, health); color = COLOR_ERROR; } else { int auth = httpGet(url + "/v1/consumption/history/" + id, token); if (auth == 200) { message = getString(R.string.conn_ok); color = COLOR_OK; } else if (auth == 401) { message = getString(R.string.conn_err_auth); color = COLOR_ERROR; } else { message = getString(R.string.conn_err_http, auth); color = COLOR_ERROR; } } } catch (java.net.SocketTimeoutException e) { message = getString(R.string.conn_err_timeout); color = COLOR_ERROR; } catch (Exception e) { message = describeNetworkError(e, url); color = COLOR_ERROR; } final String m = message; final int c = color; runOnUiThread(() -> setConnectionStatus(c, m)); }, "server-test").start(); } /** * Pairs this car with the server through a phone: the server opens a pairing request, the * dialog shows its code as a QR code, and a background thread polls until the request is * approved, denied or expired. On approval the car's own token is saved with the URL from * the field and the installation id, exactly as Save would. */ private void startPairing() { String url = ServerConfig.normalizeUrl(textOf(serverUrlInput)); if (url == null) { serverUrlLayout.setError(getString(R.string.server_err_url)); return; } serverUrlLayout.setError(null); String typedId = textOf(installationIdInput).toLowerCase(java.util.Locale.US); String installationId = ServerConfig.isValidInstallationId(typedId) ? typedId : ServerConfig.get(this).installationId; 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(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(this); column.setOrientation(android.widget.LinearLayout.VERTICAL); TextView instructions = new TextView(this); instructions.setTextAppearance(R.style.Text_EV_Secondary); ImageView qr = new ImageView(this); qr.setContentDescription(getString(R.string.pair_qr_description)); int qrSize = (int) (240 * density); TextView code = new TextView(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(getColor(R.color.ev_cyan)); TextView status = new TextView(this); status.setTextAppearance(R.style.Text_EV_Body); status.setText(R.string.pair_starting); 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(this); scroll.addView(panel); final boolean[] cancelled = {false}; androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(this) .setTitle(R.string.pair_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); dialog.show(); 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(() -> { // The code is in the sentence as well as below it, so it is readable // wherever the text is. instructions.setText(getString(R.string.pair_scan, request.verificationUri, request.userCode)); if (bitmap != null) { qr.setImageBitmap(bitmap); qr.setVisibility(View.VISIBLE); } code.setText(request.userCode); code.setVisibility(View.VISIBLE); status.setText(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]) dialog.dismiss(); return; } if (cancelled[0]) return; // Keep the dialog up with the reason, instead of closing it on a failure the // driver would otherwise only see as a flicker. qr.setVisibility(View.GONE); code.setVisibility(View.GONE); instructions.setVisibility(View.GONE); status.setText(message); status.setTextColor(c); android.widget.Button close = dialog.getButton(android.content.DialogInterface.BUTTON_NEGATIVE); if (close != null) close.setText(R.string.pair_close); }); }, "server-pairing").start(); } /** * 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() : ""; } 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 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 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); vehRangePower = vehiclePane.findViewById(R.id.vehicle_range_power); vehRangePowerNote = vehiclePane.findViewById(R.id.vehicle_range_power_note); vehRangeAverage = vehiclePane.findViewById(R.id.vehicle_range_average); vehRangeAverageNote = vehiclePane.findViewById(R.id.vehicle_range_average_note); } private double usableKwh() { return prefs.getFloat(USABLE_KWH_KEY, (float) RangeEstimator.DEFAULT_USABLE_KWH); } /** * The two range estimates. Both need the car to be moving on battery power; the average * also needs a minute or more of driving, which the service has usually collected already. */ private void refreshRangeEstimates(EnergySnapshot s, long nowMs) { RangeEstimator estimator = RangeEstimator.shared(); estimator.add(nowMs, s.getBatteryPowerKw(), s.getSpeedKmh()); Double remaining = RangeEstimator.remainingKwh(s.getSocPercent(), usableKwh()); showEstimate(vehRangePower, vehRangePowerNote, RangeEstimator.instant(remaining, s.getBatteryPowerKw(), s.getSpeedKmh()), R.string.range_not_driving); showEstimate(vehRangeAverage, vehRangeAverageNote, estimator.average(remaining, nowMs), R.string.range_waiting); } private void showEstimate(TextView value, TextView note, RangeEstimator.Estimate e, int whyNot) { if (e == null) { value.setText("—"); note.setText(whyNot); return; } // Coasting at speed costs almost nothing, which would read as thousands of km. value.setText(e.rangeKm > 999 ? getString(R.string.range_over, 999) : String.format(java.util.Locale.US, "%.0f km", e.rangeKm)); note.setText(getString(R.string.range_consumption, e.kwhPer100Km)); } /** * 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)); refreshRangeEstimates(s, System.currentTimeMillis()); 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()); usableCapacityLayout = consumptionPane.findViewById(R.id.usable_capacity_layout); usableCapacityInput = consumptionPane.findViewById(R.id.usable_capacity_input); usableCapacityInput.setText(String.format(java.util.Locale.US, "%.1f", usableKwh())); consumptionPane.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 = 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 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, "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 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 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 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() : ""; } }