Settings -> App -> "Install from USB stick" (car build only). It looks in the top folder of every mounted volume (found from the app's own folder on it, since the head unit has no file picker) for NilsMG4-unstable-<version>.apk, takes the newest one newer than the installed version, checks it against its .sha256 sidecar and the package name, and installs it with the same PackageInstaller session as the over-the-air update. The stable build gets a stub and no button. Tested on the emulator: 2.18.0 installed a 2.18.1 from the storage's top folder without a confirmation, as the system user, so the OTA install step should not need one either. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
1767 lines
88 KiB
Java
1767 lines
88 KiB
Java
package com.evsuite.abrp;
|
|
|
|
import android.content.Intent;
|
|
import android.content.SharedPreferences;
|
|
import android.content.pm.PackageManager;
|
|
import android.graphics.Color;
|
|
import android.graphics.drawable.ColorDrawable;
|
|
import android.net.Uri;
|
|
import android.os.Bundle;
|
|
import android.view.View;
|
|
import android.widget.Button;
|
|
import android.widget.Toast;
|
|
import android.widget.ImageView;
|
|
import android.widget.TextView;
|
|
|
|
import java.time.LocalDate;
|
|
import java.util.List;
|
|
|
|
import androidx.activity.result.ActivityResultLauncher;
|
|
import androidx.activity.result.contract.ActivityResultContracts;
|
|
import androidx.annotation.NonNull;
|
|
import androidx.appcompat.app.AppCompatActivity;
|
|
import androidx.appcompat.app.AppCompatDelegate;
|
|
import androidx.recyclerview.widget.RecyclerView;
|
|
import androidx.viewpager2.widget.ViewPager2;
|
|
|
|
import com.google.android.material.switchmaterial.SwitchMaterial;
|
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
|
import com.google.android.material.button.MaterialButton;
|
|
import com.google.android.material.textfield.TextInputEditText;
|
|
import com.google.android.material.textfield.TextInputLayout;
|
|
import com.evsuite.hardware.telemetry.EnergySnapshot;
|
|
import com.evsuite.hardware.telemetry.EnergyTelemetryReader;
|
|
|
|
|
|
public class MainActivity extends AppCompatActivity {
|
|
|
|
private static final String REPOSITORY_URL = "https://git.stinnesbeck.com/nils/mg4";
|
|
|
|
/** Navigation destinations in page order. Parallel to {@link #panes}. */
|
|
private static final int[] TAB_IDS =
|
|
{ R.id.tabVehicle, R.id.tabConsumption, R.id.tabCharging, R.id.tabService, R.id.tabServer,
|
|
R.id.tabSettings, R.id.tabLog };
|
|
|
|
private static final int[] PAGE_TITLES = {
|
|
R.string.design_overview_title, R.string.consumption_title, R.string.charging_title,
|
|
R.string.design_service_title, R.string.design_server_title, R.string.design_settings_title,
|
|
R.string.design_log_title
|
|
};
|
|
private static final int[] PAGE_SUBTITLES = {
|
|
R.string.design_overview_subtitle, R.string.consumption_subtitle, R.string.charging_subtitle,
|
|
R.string.design_service_subtitle, R.string.design_server_subtitle, R.string.design_settings_subtitle,
|
|
R.string.design_log_subtitle
|
|
};
|
|
|
|
// Status colours come from the palette, not from the Material swatches: #4CAF50 and
|
|
// #F44336 sit around 4:1 on this background, which disappears behind a sunlit
|
|
// reflection. The palette entries are the lightened variants (7:1 and above).
|
|
private static final int COLOR_OK = 0xFFA4DCC1;
|
|
private static final int COLOR_ERROR = 0xFFFFB4AB;
|
|
private static final int COLOR_PENDING = 0xFFBDC4C6;
|
|
|
|
private TextView statusText;
|
|
private TextView callLogText;
|
|
private View servicePane;
|
|
private View serverPane;
|
|
private View logPane;
|
|
private View vehiclePane;
|
|
private View consumptionPane;
|
|
private View chargingPane;
|
|
/** Display, battery, charging price and Wi-Fi. */
|
|
private View settingsPane;
|
|
|
|
/** The pages in navigation order. Parallel to {@link #TAB_IDS}. */
|
|
private View[] panes;
|
|
|
|
|
|
/**
|
|
* Value cells of the Vehicle page, looked up once and refreshed on the UI tick.
|
|
*
|
|
* Read straight from {@link EnergyTelemetryReader} rather than from the uploader: the
|
|
* page shows what the car reports, whether or not the upload service is running, and a
|
|
* signal the vehicle does not publish must show a dash rather than a zero.
|
|
*/
|
|
/** Used only on the overview-read thread. */
|
|
private EnergyTelemetryReader vehicleReader;
|
|
private TextView vehSoc, vehRange, vehPower, vehSpeed, vehOdometer,
|
|
vehCharging, vehHvac,
|
|
vehTireFl, vehTireFr, vehTireRl, vehTireRr,
|
|
vehRangeTrip, vehRangeTripNote, vehRangeAverage, vehRangeAverageNote,
|
|
vehUsageTrip, vehUsageTripNote, vehUsageAverage, vehUsageAverageNote,
|
|
vehEnergyTrip, vehEnergyTripNote, vehPowerAverage, vehPowerAverageNote,
|
|
vehSpeedAverage, vehSpeedAverageNote;
|
|
|
|
/** Usable pack capacity for the range estimates; the car does not publish it. */
|
|
static final String USABLE_KWH_KEY = "usable_kwh";
|
|
private TextInputLayout usableCapacityLayout;
|
|
private TextInputEditText usableCapacityInput;
|
|
private ConsumptionTracker consumptionTracker;
|
|
private ConsumptionCloudClient consumptionCloudClient;
|
|
private ConsumptionTracker.Period consumptionPeriod = ConsumptionTracker.Period.LIFETIME;
|
|
private TextView consumptionDistance, consumptionEnergy, consumptionAverage,
|
|
consumptionSpeed, consumptionTime, consumptionSoc, consumptionDailyHistory;
|
|
private Button consumptionReset;
|
|
private MaterialButton consumptionDateButton;
|
|
private TextInputLayout consumptionSohLayout;
|
|
private TextInputEditText consumptionSohInput;
|
|
private java.time.LocalDate selectedConsumptionDate = java.time.LocalDate.now();
|
|
private TextView chargeStatus, chargeSoc, chargeStartSoc, chargeVoltage, chargeCurrent, chargePower,
|
|
chargeEnergy, chargeGridEnergy, chargeDuration, chargeTotalCost, chargeCurveTable;
|
|
private TextInputLayout chargePriceLayout;
|
|
private TextInputEditText chargePriceInput;
|
|
private ChargeSessionTracker chargeSessionTracker;
|
|
private ChargingGraphView chargeGraph;
|
|
|
|
/** Server page. */
|
|
private TextInputLayout serverUrlLayout, serverTokenLayout;
|
|
private TextInputEditText serverUrlInput, serverTokenInput;
|
|
/** Shown, not edited: derived from the head unit (ServerConfig.installationIdFor). */
|
|
private TextView installationIdText;
|
|
private View connectionStatusRow, connectionIndicator;
|
|
private TextView connectionStatusText;
|
|
|
|
/** Upload page: live telemetry switch and cadence. */
|
|
private SwitchMaterial liveTelemetrySwitch, boostSwitch, voiceKeySwitch, fullScreenSwitch;
|
|
private com.google.android.material.textfield.MaterialAutoCompleteTextView intervalChoices;
|
|
private TextInputLayout lowSocLayout;
|
|
private TextInputEditText lowSocInput;
|
|
|
|
/**
|
|
* Picks a config text file. Only reachable on devices that ship a document picker — the
|
|
* MG4 head unit does not, which is why {@link #startConfigImport()} scans the app's own
|
|
* folders first and only falls back to this.
|
|
*/
|
|
private final ActivityResultLauncher<String[]> configPicker =
|
|
registerForActivityResult(new ActivityResultContracts.OpenDocument(), uri -> {
|
|
if (uri != null) importConfig(uri);
|
|
});
|
|
|
|
/**
|
|
* Inflates a page with no parent, then gives it the MATCH_PARENT/MATCH_PARENT layout
|
|
* params ViewPager2 requires of its items — inflating detached leaves them null, and
|
|
* the pager throws rather than guessing.
|
|
*/
|
|
private View inflatePane(int layoutRes) {
|
|
View pane = getLayoutInflater().inflate(layoutRes, null, false);
|
|
pane.setLayoutParams(new android.view.ViewGroup.LayoutParams(
|
|
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
|
|
android.view.ViewGroup.LayoutParams.MATCH_PARENT));
|
|
return pane;
|
|
}
|
|
|
|
/**
|
|
* Wires the navigation to the pager: every tab is a page, and every page is a tab.
|
|
*
|
|
* Each page has its own view type, so the pager asks for it once and keeps it: these
|
|
* views are the activity's own, held in {@link #panes}, not rows to be recycled.
|
|
*/
|
|
private void setUpPager() {
|
|
ViewPager2 pager = findViewById(R.id.content);
|
|
pager.setAdapter(new RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
|
@Override public int getItemCount() { return panes.length; }
|
|
@Override public int getItemViewType(int position) { return position; }
|
|
|
|
@NonNull @Override
|
|
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull android.view.ViewGroup parent,
|
|
int viewType) {
|
|
return new RecyclerView.ViewHolder(panes[viewType]) {};
|
|
}
|
|
|
|
@Override
|
|
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
|
|
// Nothing to bind: the pages are built once in onCreate and keep their own
|
|
// state, exactly as they did when they were siblings in the layout.
|
|
}
|
|
});
|
|
// Keep all pages alive: the log and vehicle readings are refreshed
|
|
// by the tick even when their page is off screen.
|
|
pager.setOffscreenPageLimit(panes.length - 1);
|
|
pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
|
|
@Override public void onPageSelected(int position) { markCurrentPage(position); }
|
|
});
|
|
|
|
for (int i = 0; i < TAB_IDS.length; i++) {
|
|
final int index = i;
|
|
// Animated, so the button does the same thing the swipe does: the direction of
|
|
// travel is what tells the driver where they are in the row.
|
|
findViewById(TAB_IDS[i]).setOnClickListener(v -> pager.setCurrentItem(index, false));
|
|
}
|
|
markCurrentPage(pager.getCurrentItem());
|
|
}
|
|
|
|
/**
|
|
* Marks the destination and labels the page on screen.
|
|
*
|
|
* Only isSelected is set: fill, text, icon and stroke come from the
|
|
* res/color/nav_tab_*.xml selectors applied by the Widget.EV.NavTab style. isSelected
|
|
* also serves TalkBack, which announces the current destination.
|
|
*/
|
|
private void markCurrentPage(int position) {
|
|
for (int i = 0; i < TAB_IDS.length; i++) {
|
|
findViewById(TAB_IDS[i]).setSelected(i == position);
|
|
}
|
|
((TextView) findViewById(R.id.header_title)).setText(PAGE_TITLES[position]);
|
|
((TextView) findViewById(R.id.header_subtitle)).setText(PAGE_SUBTITLES[position]);
|
|
}
|
|
|
|
/** Refreshes state + call log while the screen is visible. */
|
|
private final android.os.Handler uiHandler = new android.os.Handler(android.os.Looper.getMainLooper());
|
|
private final Runnable uiRefresh = new Runnable() {
|
|
@Override public void run() {
|
|
refreshStatus();
|
|
refreshCallLog();
|
|
sampleTrackers();
|
|
refreshConsumption();
|
|
uiHandler.postDelayed(this, 2_000L);
|
|
}
|
|
};
|
|
|
|
private SharedPreferences prefs;
|
|
|
|
@Override
|
|
protected void onCreate(Bundle savedInstanceState) {
|
|
getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES);
|
|
super.onCreate(savedInstanceState);
|
|
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
|
|
// Before anything is inflated: every ?attr/evAccent… resolves against this overlay.
|
|
getTheme().applyStyle(AccentColor.fromPref(prefs.getString(AccentColor.PREF_KEY, null)).overlay, true);
|
|
setContentView(R.layout.activity_main);
|
|
|
|
|
|
|
|
// The pages are inflated here, once, and handed to the pager as fixed,
|
|
// non-recycled items. That is what lets every widget below be looked up now and
|
|
// held for the life of the activity, the way it was when the panes were siblings
|
|
// in activity_main.xml — a recycled page would invalidate these references as
|
|
// soon as the user swiped away from it.
|
|
servicePane = inflatePane(R.layout.pane_service);
|
|
serverPane = inflatePane(R.layout.pane_server);
|
|
vehiclePane = inflatePane(R.layout.pane_vehicle);
|
|
consumptionPane = inflatePane(R.layout.pane_consumption);
|
|
chargingPane = inflatePane(R.layout.pane_charging);
|
|
settingsPane = inflatePane(R.layout.pane_settings);
|
|
logPane = inflatePane(R.layout.pane_log);
|
|
panes = new View[] { vehiclePane, consumptionPane, chargingPane, servicePane, serverPane,
|
|
settingsPane, logPane };
|
|
|
|
bindVehiclePane();
|
|
bindConsumptionPane();
|
|
bindChargingPane();
|
|
bindServerPane();
|
|
bindServicePane();
|
|
requestLocationPermissionIfNeeded();
|
|
|
|
statusText = servicePane.findViewById(R.id.status_text);
|
|
callLogText = logPane.findViewById(R.id.call_log_text);
|
|
logPane.findViewById(R.id.flush_now_button).setOnClickListener(v -> manualFlush());
|
|
|
|
setUpPager();
|
|
// Back on the page that was open when the activity was recreated (accent colour change).
|
|
if (savedInstanceState != null) {
|
|
((ViewPager2) findViewById(R.id.content)).setCurrentItem(savedInstanceState.getInt(STATE_PAGE, 0), false);
|
|
}
|
|
((TextView) settingsPane.findViewById(R.id.settings_version))
|
|
.setText(getString(R.string.settings_version, BuildConfig.VERSION_NAME));
|
|
settingsPane.findViewById(R.id.settings_about_button).setOnClickListener(v -> showAbout());
|
|
settingsPane.findViewById(R.id.settings_update_button).setOnClickListener(v -> checkForUpdate(v));
|
|
View usbButton = settingsPane.findViewById(R.id.settings_usb_button);
|
|
usbButton.setOnClickListener(v -> installFromUsb(v));
|
|
// The stable build installs nothing itself, so it has no use for the button.
|
|
if (!UpdateHook.isSupported()) usbButton.setVisibility(View.GONE);
|
|
|
|
// Unstable builds check for a newer pre-release; the stable flavor's UpdateHook is
|
|
// a no-op and does not even contain the updater.
|
|
UpdateHook.checkInBackground(this);
|
|
|
|
// The service is always-on: it tracks consumption and sends everything to the server.
|
|
// startForegroundService is idempotent — if the service is already up this
|
|
// is a no-op aside from delivering a new intent.
|
|
startForegroundService(new Intent(this, AbrpUploadService.class));
|
|
}
|
|
|
|
/**
|
|
* Shows a dialog without bringing the car's bars back. A dialog is a window of its own, and
|
|
* the system shows the bars when a window without the full-screen flags takes focus. So it
|
|
* is shown unfocusable, given the activity's flags, and only then allowed focus. None of the
|
|
* dialogs here take typing, which an unfocusable window would block.
|
|
*/
|
|
@SuppressWarnings("deprecation") // the WindowInsetsController API starts at 30; this is 28
|
|
private void showKeepingFullScreen(android.app.Dialog dialog) {
|
|
android.view.Window window = dialog.getWindow();
|
|
if (window == null || !UploadSettings.fullScreen(prefs)) {
|
|
dialog.show();
|
|
return;
|
|
}
|
|
window.setFlags(android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
|
|
android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE);
|
|
dialog.show();
|
|
window.getDecorView().setSystemUiVisibility(getWindow().getDecorView().getSystemUiVisibility());
|
|
window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE);
|
|
}
|
|
|
|
private static final String STATE_PAGE = "page";
|
|
|
|
@Override
|
|
protected void onSaveInstanceState(Bundle outState) {
|
|
super.onSaveInstanceState(outState);
|
|
outState.putInt(STATE_PAGE, ((ViewPager2) findViewById(R.id.content)).getCurrentItem());
|
|
}
|
|
|
|
private void showAbout() {
|
|
String version;
|
|
try {
|
|
version = getPackageManager().getPackageInfo(getPackageName(), 0).versionName;
|
|
} catch (PackageManager.NameNotFoundException e) {
|
|
version = getString(R.string.about_version_unknown);
|
|
}
|
|
View content = getLayoutInflater().inflate(R.layout.dialog_about, null);
|
|
content.<TextView>findViewById(R.id.about_version)
|
|
.setText(getString(R.string.about_version, version));
|
|
ImageView qr = content.findViewById(R.id.about_qr_code);
|
|
android.graphics.Bitmap bitmap = QrCode.generate(REPOSITORY_URL, 416);
|
|
if (bitmap != null) qr.setImageBitmap(bitmap);
|
|
content.findViewById(R.id.about_repository).setOnClickListener(v ->
|
|
startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(REPOSITORY_URL))));
|
|
androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(this)
|
|
.setView(content)
|
|
.create();
|
|
content.<MaterialButton>findViewById(R.id.about_close).setOnClickListener(v -> dialog.dismiss());
|
|
showKeepingFullScreen(dialog);
|
|
if (dialog.getWindow() != null) {
|
|
dialog.getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
|
|
android.util.DisplayMetrics metrics = getResources().getDisplayMetrics();
|
|
int width = Math.min((int) (760 * metrics.density),
|
|
metrics.widthPixels - (int) (32 * metrics.density));
|
|
dialog.getWindow().setLayout(width, android.view.ViewGroup.LayoutParams.WRAP_CONTENT);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
protected void onResume() {
|
|
super.onResume();
|
|
// Poll state and log only while the screen is up; onPause cancels it.
|
|
uiHandler.post(uiRefresh);
|
|
uiHandler.post(overviewRefresh);
|
|
}
|
|
|
|
@Override
|
|
public void onWindowFocusChanged(boolean hasFocus) {
|
|
super.onWindowFocusChanged(hasFocus);
|
|
// The system shows its bars again when focus moves (a dialog, the bars swiped in);
|
|
// re-applying on regaining focus keeps the app full screen.
|
|
if (hasFocus) applyFullScreen();
|
|
}
|
|
|
|
/**
|
|
* Immersive full screen: the head unit's top and bottom bars are hidden while the app is
|
|
* in front, and a swipe from the edge shows them briefly. On the MG4 those bars are SAIC's
|
|
* own (climate strip, hazard and home shortcuts); if the platform keeps them, these flags
|
|
* simply have no effect.
|
|
*
|
|
* The content takes the space the bars leave: no LAYOUT_STABLE (it reports the bars' insets
|
|
* even while they are hidden), and the root stops padding itself for them. On the MG4 that
|
|
* padding stayed as a black band where the bars had been (2.8.1).
|
|
*/
|
|
@SuppressWarnings("deprecation") // the WindowInsetsController API starts at 30; this is 28
|
|
private void applyFullScreen() {
|
|
View decor = getWindow().getDecorView();
|
|
boolean full = UploadSettings.fullScreen(prefs);
|
|
View root = findViewById(R.id.app_root);
|
|
if (root != null) {
|
|
root.setFitsSystemWindows(!full);
|
|
if (full) root.setPadding(0, 0, 0, 0);
|
|
root.requestApplyInsets();
|
|
}
|
|
if (full) {
|
|
decor.setSystemUiVisibility(View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
|
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
|
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
|
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
|
| View.SYSTEM_UI_FLAG_FULLSCREEN);
|
|
} else {
|
|
decor.setSystemUiVisibility(View.SYSTEM_UI_FLAG_VISIBLE);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
protected void onPause() {
|
|
super.onPause();
|
|
// Stop polling off-screen: this activity has no reason to spend cycles then.
|
|
uiHandler.removeCallbacks(uiRefresh);
|
|
uiHandler.removeCallbacks(overviewRefresh);
|
|
}
|
|
|
|
@Override
|
|
protected void onDestroy() {
|
|
carReader.shutdownNow();
|
|
super.onDestroy();
|
|
}
|
|
|
|
// ---------- Update check ----------
|
|
|
|
/**
|
|
* Checks for a newer build, and installs one if it is there.
|
|
*
|
|
* The button is disabled for the duration rather than guarded by a flag: the whole
|
|
* sequence — API call, download, installer staging — runs on a background thread, and
|
|
* a second press would start a second download of the same APK. It comes back on when
|
|
* the result lands, whatever that result is.
|
|
*
|
|
* A modal progress surface keeps the driver informed while the APK is downloaded and
|
|
* SHA-256 verified; the final result remains visible until dismissed.
|
|
*/
|
|
private void checkForUpdate(View button) {
|
|
runUpdate(button, R.string.update_checking, listener -> UpdateHook.checkInBackground(this, listener));
|
|
}
|
|
|
|
/** Installs the newest car build from the top folder of a USB stick (unstable builds). */
|
|
private void installFromUsb(View button) {
|
|
runUpdate(button, R.string.update_usb_looking, listener -> UpdateHook.installFromUsbInBackground(this, listener));
|
|
}
|
|
|
|
/** The update dialog: progress while [start] runs, then its result. */
|
|
private void runUpdate(View button, int startText,
|
|
java.util.function.Consumer<UpdateHook.Listener> start) {
|
|
button.setEnabled(false);
|
|
android.widget.LinearLayout panel = new android.widget.LinearLayout(this);
|
|
panel.setOrientation(android.widget.LinearLayout.VERTICAL);
|
|
int padding = (int) (24 * getResources().getDisplayMetrics().density);
|
|
panel.setPadding(padding, padding, padding, padding);
|
|
TextView status = new TextView(this);
|
|
status.setText(startText);
|
|
status.setTextAppearance(R.style.Text_EV_Body);
|
|
android.widget.ProgressBar progress = new android.widget.ProgressBar(this, null,
|
|
android.R.attr.progressBarStyleHorizontal);
|
|
progress.setIndeterminate(true);
|
|
android.widget.LinearLayout.LayoutParams progressParams =
|
|
new android.widget.LinearLayout.LayoutParams(
|
|
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
|
|
android.view.ViewGroup.LayoutParams.WRAP_CONTENT);
|
|
progressParams.topMargin = padding;
|
|
panel.addView(status);
|
|
panel.addView(progress, progressParams);
|
|
androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(this)
|
|
.setTitle(R.string.update_dialog_title)
|
|
.setView(panel)
|
|
.setCancelable(false)
|
|
.create();
|
|
showKeepingFullScreen(dialog);
|
|
start.accept(new UpdateHook.Listener() {
|
|
@Override public void onProgress(int percent) {
|
|
if (percent < 0) {
|
|
progress.setIndeterminate(true);
|
|
status.setText(R.string.update_downloading);
|
|
} else {
|
|
progress.setIndeterminate(false);
|
|
progress.setMax(100);
|
|
progress.setProgress(percent);
|
|
status.setText(getString(R.string.update_downloading_percent, percent));
|
|
}
|
|
}
|
|
|
|
@Override public void onResult(String message) {
|
|
button.setEnabled(true);
|
|
dialog.dismiss();
|
|
showKeepingFullScreen(new MaterialAlertDialogBuilder(MainActivity.this)
|
|
.setTitle(R.string.update_dialog_title)
|
|
.setMessage(message)
|
|
.setPositiveButton(android.R.string.ok, null)
|
|
.create());
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------- Server page ----------
|
|
|
|
private void bindServerPane() {
|
|
serverUrlLayout = serverPane.findViewById(R.id.server_url_layout);
|
|
serverTokenLayout = serverPane.findViewById(R.id.server_token_layout);
|
|
serverUrlInput = serverPane.findViewById(R.id.server_url_input);
|
|
serverTokenInput = serverPane.findViewById(R.id.server_token_input);
|
|
installationIdText = serverPane.findViewById(R.id.installation_id_text);
|
|
connectionStatusRow = serverPane.findViewById(R.id.connection_status_row);
|
|
connectionIndicator = serverPane.findViewById(R.id.connection_indicator);
|
|
connectionStatusText = serverPane.findViewById(R.id.connection_status_text);
|
|
showServerConfig();
|
|
|
|
serverPane.findViewById(R.id.server_save_button).setOnClickListener(v -> {
|
|
if (saveServerConfig(textOf(serverUrlInput), textOf(serverTokenInput),
|
|
ServerConfig.get(this).installationId)) {
|
|
setConnectionStatus(COLOR_OK, getString(R.string.server_saved));
|
|
}
|
|
});
|
|
serverPane.findViewById(R.id.server_test_button).setOnClickListener(v -> testConnection());
|
|
serverPane.findViewById(R.id.server_pair_button).setOnClickListener(v -> startPairing());
|
|
serverPane.findViewById(R.id.import_button).setOnClickListener(v -> startConfigImport());
|
|
}
|
|
|
|
private void showServerConfig() {
|
|
ServerConfig config = ServerConfig.get(this);
|
|
serverUrlInput.setText(config.url);
|
|
serverTokenInput.setText(config.token);
|
|
installationIdText.setText(getString(R.string.installation_id_info, config.installationId));
|
|
}
|
|
|
|
/** Validates, marks the bad fields, and saves only when all three are good. */
|
|
private boolean saveServerConfig(String rawUrl, String rawToken, String rawId) {
|
|
String url = ServerConfig.normalizeUrl(rawUrl);
|
|
String token = rawToken == null ? "" : rawToken.trim();
|
|
String id = rawId == null ? "" : rawId.trim().toLowerCase(java.util.Locale.US);
|
|
serverUrlLayout.setError(url == null ? getString(R.string.server_err_url) : null);
|
|
serverTokenLayout.setError(ServerConfig.isValidToken(token) ? null : getString(R.string.server_err_token));
|
|
// Only an imported config file can bring a different id; the field is not editable.
|
|
if (!ServerConfig.isValidInstallationId(id)) {
|
|
setConnectionStatus(COLOR_ERROR, getString(R.string.server_err_installation_id));
|
|
return false;
|
|
}
|
|
if (url == null || !ServerConfig.isValidToken(token)) return false;
|
|
ServerConfig.save(this, url, token, id);
|
|
if (consumptionCloudClient != null) consumptionCloudClient.invalidateHistory();
|
|
showServerConfig();
|
|
refreshStatus();
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Checks reachability, then the token, without writing anything: the health endpoint
|
|
* first, then the car's own history read, which needs the ingest token. Uses what is in
|
|
* the fields, saved or not, so a typo can be caught before it is stored.
|
|
*/
|
|
private void testConnection() {
|
|
String url = ServerConfig.normalizeUrl(textOf(serverUrlInput));
|
|
String token = textOf(serverTokenInput);
|
|
String id = ServerConfig.get(this).installationId;
|
|
if (url == null) {
|
|
serverUrlLayout.setError(getString(R.string.server_err_url));
|
|
return;
|
|
}
|
|
setConnectionStatus(COLOR_PENDING, getString(R.string.conn_testing));
|
|
new Thread(() -> {
|
|
String message;
|
|
int color;
|
|
try {
|
|
int health = httpGet(url + "/v1/health", null);
|
|
if (health != 200) {
|
|
message = getString(R.string.conn_err_http, health);
|
|
color = COLOR_ERROR;
|
|
} else {
|
|
int auth = httpGet(url + "/v1/consumption/history/" + id, token);
|
|
if (auth == 200) { message = getString(R.string.conn_ok); color = COLOR_OK; }
|
|
else if (auth == 401) { message = getString(R.string.conn_err_auth); color = COLOR_ERROR; }
|
|
else { message = getString(R.string.conn_err_http, auth); color = COLOR_ERROR; }
|
|
}
|
|
} catch (java.net.SocketTimeoutException e) {
|
|
message = getString(R.string.conn_err_timeout);
|
|
color = COLOR_ERROR;
|
|
} catch (Exception e) {
|
|
message = describeNetworkError(e, url);
|
|
color = COLOR_ERROR;
|
|
}
|
|
final String m = message;
|
|
final int c = color;
|
|
runOnUiThread(() -> setConnectionStatus(c, m));
|
|
}, "server-test").start();
|
|
}
|
|
|
|
/**
|
|
* Pairs this car with the server through a phone: the server opens a pairing request, the
|
|
* dialog shows its code as a QR code, and a background thread polls until the request is
|
|
* approved, denied or expired. On approval the car's own token is saved with the URL from
|
|
* the field and the installation id, exactly as Save would.
|
|
*/
|
|
private void startPairing() {
|
|
String url = ServerConfig.normalizeUrl(textOf(serverUrlInput));
|
|
if (url == null) {
|
|
serverUrlLayout.setError(getString(R.string.server_err_url));
|
|
return;
|
|
}
|
|
serverUrlLayout.setError(null);
|
|
String installationId = ServerConfig.get(this).installationId;
|
|
|
|
PhoneQrDialog ui = new PhoneQrDialog(R.string.pair_title, R.string.pair_starting);
|
|
final boolean[] cancelled = ui.cancelled;
|
|
|
|
new Thread(() -> {
|
|
String outcome;
|
|
int color = COLOR_ERROR;
|
|
try {
|
|
PairingClient.Request request = PairingClient.start(url, installationId);
|
|
android.graphics.Bitmap bitmap = QrCode.generate(request.verificationUriComplete, 416);
|
|
runOnUiThread(() -> ui.showRequest(request, bitmap, R.string.pair_scan, R.string.pair_waiting));
|
|
PairingClient.PollResult result = null;
|
|
while (!cancelled[0] && System.currentTimeMillis() < request.expiresAtMs) {
|
|
Thread.sleep(request.intervalMs);
|
|
if (cancelled[0]) break;
|
|
try {
|
|
result = PairingClient.poll(url, request.deviceCode);
|
|
} catch (java.io.IOException e) {
|
|
continue; // a dropped connection mid-wait is not a failed pairing
|
|
}
|
|
if (result.outcome != PairingClient.Outcome.PENDING) break;
|
|
}
|
|
if (cancelled[0]) return;
|
|
if (result != null && result.outcome == PairingClient.Outcome.APPROVED) {
|
|
ServerConfig.save(this, url, result.token, installationId);
|
|
outcome = getString(R.string.pair_ok);
|
|
color = COLOR_OK;
|
|
} else if (result != null && result.outcome == PairingClient.Outcome.DENIED) {
|
|
outcome = getString(R.string.pair_denied);
|
|
} else {
|
|
outcome = getString(R.string.pair_expired);
|
|
}
|
|
} catch (PairingClient.NotEnabledException e) {
|
|
outcome = getString(R.string.pair_not_enabled);
|
|
} catch (PairingClient.HttpStatusException e) {
|
|
outcome = e.status == 404 ? getString(R.string.pair_err_old_server)
|
|
: getString(R.string.err_http, e.status);
|
|
} catch (InterruptedException e) {
|
|
return;
|
|
} catch (Exception e) {
|
|
outcome = describeNetworkError(e, url);
|
|
}
|
|
final String message = outcome;
|
|
final int c = color;
|
|
runOnUiThread(() -> {
|
|
setConnectionStatus(c, message);
|
|
if (c == COLOR_OK) {
|
|
if (consumptionCloudClient != null) consumptionCloudClient.invalidateHistory();
|
|
showServerConfig();
|
|
serverTokenLayout.setError(null);
|
|
refreshStatus();
|
|
if (!cancelled[0]) ui.dialog.dismiss();
|
|
return;
|
|
}
|
|
// Keep the dialog up with the reason, instead of closing it on a failure the
|
|
// driver would otherwise only see as a flicker.
|
|
if (!cancelled[0]) ui.finish(message, c);
|
|
});
|
|
}, "server-pairing").start();
|
|
}
|
|
|
|
/**
|
|
* Settings typed on a phone instead of the car's keyboard: the server opens a request for
|
|
* this car, the dialog shows it as a QR code, and the values the phone sends (ABRP
|
|
* credentials, charging price, usable capacity) are applied through the same checks as the
|
|
* fields here. Needs the server set up, since the request is made with this car's token.
|
|
*/
|
|
private void startPhoneSettings() {
|
|
ServerConfig config = ServerConfig.get(this);
|
|
PhoneQrDialog ui = new PhoneQrDialog(R.string.phone_settings_title, R.string.pair_starting);
|
|
if (!config.isConfigured()) {
|
|
ui.finish(getString(R.string.phone_settings_needs_server), COLOR_ERROR);
|
|
return;
|
|
}
|
|
new Thread(() -> {
|
|
String outcome;
|
|
int color = COLOR_ERROR;
|
|
PhoneSettingsClient.Settings received = null;
|
|
try {
|
|
PairingClient.Request request = PhoneSettingsClient.start(config);
|
|
android.graphics.Bitmap bitmap = QrCode.generate(request.verificationUriComplete, 416);
|
|
runOnUiThread(() -> ui.showRequest(request, bitmap, R.string.phone_settings_scan,
|
|
R.string.phone_settings_waiting));
|
|
while (!ui.cancelled[0] && System.currentTimeMillis() < request.expiresAtMs) {
|
|
Thread.sleep(request.intervalMs);
|
|
if (ui.cancelled[0]) return;
|
|
try {
|
|
received = PhoneSettingsClient.poll(config, request.deviceCode);
|
|
} catch (java.io.IOException e) {
|
|
continue; // a dropped connection mid-wait is not a failure
|
|
}
|
|
if (received != null) break;
|
|
}
|
|
if (ui.cancelled[0]) return;
|
|
outcome = received == null ? getString(R.string.pair_expired_settings) : null;
|
|
} catch (PairingClient.NotEnabledException e) {
|
|
outcome = getString(R.string.phone_settings_not_enabled);
|
|
} catch (PairingClient.HttpStatusException e) {
|
|
outcome = e.status == 410 ? getString(R.string.pair_expired_settings)
|
|
: getString(R.string.err_http, e.status);
|
|
} catch (InterruptedException e) {
|
|
return;
|
|
} catch (Exception e) {
|
|
outcome = describeNetworkError(e, config.url);
|
|
}
|
|
final PhoneSettingsClient.Settings settings = received;
|
|
final String failure = outcome;
|
|
runOnUiThread(() -> {
|
|
if (ui.cancelled[0]) return;
|
|
if (settings == null) {
|
|
ui.finish(failure, COLOR_ERROR);
|
|
return;
|
|
}
|
|
String applied = applyPhoneSettings(settings);
|
|
ui.finish(applied, applied.equals(getString(R.string.phone_settings_applied)) ? COLOR_OK : COLOR_ERROR);
|
|
});
|
|
}, "phone-settings").start();
|
|
}
|
|
|
|
/**
|
|
* Puts what the phone sent into the fields and saves each through its own Save, so the
|
|
* same limits apply as when typed here. A credential sent alone is paired with the one
|
|
* already stored. Returns the message for the dialog. UI thread.
|
|
*/
|
|
private String applyPhoneSettings(PhoneSettingsClient.Settings s) {
|
|
boolean ok = true;
|
|
if (s.abrpApiKey != null || s.abrpToken != null) {
|
|
AbrpConfig current = AbrpConfig.get(this);
|
|
ok &= saveAbrpConfig(s.abrpApiKey != null ? s.abrpApiKey : current.apiKey,
|
|
s.abrpToken != null ? s.abrpToken : current.userToken);
|
|
}
|
|
if (s.pricePerKwh != null) {
|
|
chargePriceInput.setText(String.format(java.util.Locale.US, "%.2f", s.pricePerKwh));
|
|
saveChargePrice();
|
|
ok &= chargePriceLayout.getError() == null;
|
|
}
|
|
if (s.usableKwh != null) {
|
|
usableCapacityInput.setText(String.format(java.util.Locale.US, "%.1f", s.usableKwh));
|
|
saveUsableCapacity();
|
|
ok &= usableCapacityLayout.getError() == null;
|
|
}
|
|
return getString(ok ? R.string.phone_settings_applied : R.string.phone_settings_partly);
|
|
}
|
|
|
|
/**
|
|
* The dialog of both phone flows (pairing, settings): a QR code beside the code to type
|
|
* and a status line. The QR code and code stay hidden until the server has answered.
|
|
*/
|
|
private final class PhoneQrDialog {
|
|
final TextView instructions, code, status;
|
|
final ImageView qr;
|
|
final androidx.appcompat.app.AlertDialog dialog;
|
|
/** Set when the driver closes the dialog; the polling thread then stops. */
|
|
final boolean[] cancelled = {false};
|
|
|
|
PhoneQrDialog(int title, int startingText) {
|
|
float density = getResources().getDisplayMetrics().density;
|
|
int pad = (int) (24 * density);
|
|
// On the head unit's wide, low screen the QR code sits left of the text: stacked, the
|
|
// code and status below a 240 dp QR code were cut off. Scrollable either way, so
|
|
// nothing can be clipped on any screen.
|
|
android.util.DisplayMetrics metrics = getResources().getDisplayMetrics();
|
|
boolean wide = metrics.widthPixels > metrics.heightPixels;
|
|
android.widget.LinearLayout panel = new android.widget.LinearLayout(MainActivity.this);
|
|
panel.setOrientation(wide ? android.widget.LinearLayout.HORIZONTAL : android.widget.LinearLayout.VERTICAL);
|
|
panel.setGravity(wide ? android.view.Gravity.CENTER_VERTICAL : android.view.Gravity.CENTER_HORIZONTAL);
|
|
panel.setPadding(pad, pad, pad, pad);
|
|
android.widget.LinearLayout column = new android.widget.LinearLayout(MainActivity.this);
|
|
column.setOrientation(android.widget.LinearLayout.VERTICAL);
|
|
instructions = new TextView(MainActivity.this);
|
|
instructions.setTextAppearance(R.style.Text_EV_Secondary);
|
|
qr = new ImageView(MainActivity.this);
|
|
qr.setContentDescription(getString(R.string.pair_qr_description));
|
|
int qrSize = (int) (240 * density);
|
|
code = new TextView(MainActivity.this);
|
|
code.setTextAppearance(R.style.Text_EV_Title);
|
|
code.setTypeface(android.graphics.Typeface.MONOSPACE, android.graphics.Typeface.BOLD);
|
|
code.setTextSize(36);
|
|
code.setLetterSpacing(0.15f);
|
|
code.setTextColor(AccentColor.resolve(MainActivity.this, R.attr.evAccentLight));
|
|
status = new TextView(MainActivity.this);
|
|
status.setTextAppearance(R.style.Text_EV_Body);
|
|
status.setText(startingText);
|
|
android.widget.LinearLayout.LayoutParams qrParams = new android.widget.LinearLayout.LayoutParams(qrSize, qrSize);
|
|
android.widget.LinearLayout.LayoutParams columnParams;
|
|
if (wide) {
|
|
qrParams.rightMargin = pad;
|
|
columnParams = new android.widget.LinearLayout.LayoutParams(
|
|
0, android.view.ViewGroup.LayoutParams.WRAP_CONTENT, 1f);
|
|
panel.addView(qr, qrParams);
|
|
panel.addView(column, columnParams);
|
|
column.addView(instructions);
|
|
} else {
|
|
qrParams.topMargin = pad / 2;
|
|
qrParams.bottomMargin = pad / 2;
|
|
columnParams = new android.widget.LinearLayout.LayoutParams(
|
|
android.view.ViewGroup.LayoutParams.MATCH_PARENT, android.view.ViewGroup.LayoutParams.WRAP_CONTENT);
|
|
panel.addView(instructions);
|
|
panel.addView(qr, qrParams);
|
|
panel.addView(column, columnParams);
|
|
}
|
|
android.widget.LinearLayout.LayoutParams codeParams = new android.widget.LinearLayout.LayoutParams(
|
|
android.view.ViewGroup.LayoutParams.WRAP_CONTENT, android.view.ViewGroup.LayoutParams.WRAP_CONTENT);
|
|
codeParams.topMargin = pad / 2;
|
|
codeParams.bottomMargin = pad / 2;
|
|
column.addView(code, codeParams);
|
|
column.addView(status);
|
|
android.widget.ScrollView scroll = new android.widget.ScrollView(MainActivity.this);
|
|
scroll.addView(panel);
|
|
|
|
dialog = new MaterialAlertDialogBuilder(MainActivity.this)
|
|
.setTitle(title)
|
|
.setView(scroll)
|
|
.setNegativeButton(R.string.pair_cancel, null)
|
|
.create();
|
|
// Shown only once the server has answered, so a failure leaves no empty QR frame.
|
|
qr.setVisibility(View.GONE);
|
|
code.setVisibility(View.GONE);
|
|
dialog.setOnDismissListener(d -> cancelled[0] = true);
|
|
showKeepingFullScreen(dialog);
|
|
}
|
|
|
|
/** Shows the request: QR code, the code in the sentence and below it. UI thread. */
|
|
void showRequest(PairingClient.Request request, android.graphics.Bitmap bitmap, int scanText, int waitingText) {
|
|
instructions.setText(getString(scanText, request.verificationUri, request.userCode));
|
|
if (bitmap != null) {
|
|
qr.setImageBitmap(bitmap);
|
|
qr.setVisibility(View.VISIBLE);
|
|
}
|
|
code.setText(request.userCode);
|
|
code.setVisibility(View.VISIBLE);
|
|
status.setText(waitingText);
|
|
}
|
|
|
|
/** Keeps the dialog up with the outcome instead of closing it. UI thread. */
|
|
void finish(String message, int color) {
|
|
qr.setVisibility(View.GONE);
|
|
code.setVisibility(View.GONE);
|
|
instructions.setVisibility(View.GONE);
|
|
status.setText(message);
|
|
status.setTextColor(color);
|
|
android.widget.Button close = dialog.getButton(android.content.DialogInterface.BUTTON_NEGATIVE);
|
|
if (close != null) close.setText(R.string.pair_close);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A network failure in words the driver can act on: a name that does not resolve, a
|
|
* server that does not answer, or a status. Shared by Test connection and pairing.
|
|
*/
|
|
private String describeNetworkError(Exception e, String url) {
|
|
String host = Uri.parse(url).getHost();
|
|
if (host == null) host = url;
|
|
if (e instanceof java.net.UnknownHostException) return getString(R.string.err_dns, host);
|
|
if (e instanceof java.net.SocketTimeoutException || e instanceof java.net.ConnectException
|
|
|| e instanceof java.net.NoRouteToHostException) {
|
|
return getString(R.string.err_unreachable, host);
|
|
}
|
|
if (e instanceof PairingClient.HttpStatusException) {
|
|
return getString(R.string.err_http, ((PairingClient.HttpStatusException) e).status);
|
|
}
|
|
return getString(R.string.pair_error, e.getClass().getSimpleName());
|
|
}
|
|
|
|
private static int httpGet(String url, String token) throws java.io.IOException {
|
|
java.net.HttpURLConnection connection = (java.net.HttpURLConnection) new java.net.URL(url).openConnection();
|
|
try {
|
|
connection.setConnectTimeout(8_000);
|
|
connection.setReadTimeout(8_000);
|
|
if (token != null) connection.setRequestProperty("Authorization", "Bearer " + token);
|
|
return connection.getResponseCode();
|
|
} finally {
|
|
connection.disconnect();
|
|
}
|
|
}
|
|
|
|
private void setConnectionStatus(int color, String message) {
|
|
connectionStatusRow.setVisibility(View.VISIBLE);
|
|
connectionIndicator.getBackground().mutate().setTint(color);
|
|
connectionStatusText.setText(message);
|
|
connectionStatusText.setTextColor(color);
|
|
}
|
|
|
|
// ---------- Config import ----------
|
|
|
|
/**
|
|
* The MG4 head unit ships no document picker — SAF answers "FileManagement is no supported
|
|
* on this device" — so the file is found by scanning instead. getExternalFilesDirs() returns
|
|
* this app's own folder on internal storage and on every mounted volume including a USB
|
|
* stick, and those need no storage permission at any API level. The picker stays as the
|
|
* fallback for phones and tablets, where the user may keep the file anywhere.
|
|
*/
|
|
private void startConfigImport() {
|
|
java.io.File found = findConfigFile();
|
|
if (found != null) {
|
|
importConfig(found);
|
|
return;
|
|
}
|
|
try {
|
|
// Any MIME: the MG4 Files app tags .txt inconsistently, so filtering by type hides
|
|
// the very file the user is trying to pick. They select it by name instead.
|
|
configPicker.launch(new String[]{"*/*"});
|
|
} catch (android.content.ActivityNotFoundException e) {
|
|
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_not_found, configDirHint()));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* First readable file in an app-specific folder that parses as a config. Names are not
|
|
* filtered: the user copies one file to the folder, whatever they called it.
|
|
*/
|
|
private java.io.File findConfigFile() {
|
|
for (java.io.File dir : getExternalFilesDirs(null)) {
|
|
if (dir == null) continue;
|
|
java.io.File[] files = dir.listFiles();
|
|
if (files == null) continue;
|
|
for (java.io.File f : files) {
|
|
if (f.isFile() && f.canRead() && f.length() > 0 && f.length() <= 64 * 1024
|
|
&& !ConfigImport.parse(readText(f)).isEmpty()) {
|
|
return f;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** File text, or "" when unreadable — callers treat that as "not a config". */
|
|
private String readText(java.io.File file) {
|
|
try (java.io.InputStream in = new java.io.FileInputStream(file)) {
|
|
return readCapped(in);
|
|
} catch (Exception e) {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
/** Capped so a wrong file picked by mistake is not slurped whole into memory. */
|
|
private String readCapped(java.io.InputStream in) throws java.io.IOException {
|
|
java.io.ByteArrayOutputStream buf = new java.io.ByteArrayOutputStream();
|
|
byte[] chunk = new byte[4096];
|
|
int n;
|
|
while ((n = in.read(chunk)) != -1 && buf.size() < 64 * 1024) buf.write(chunk, 0, n);
|
|
return buf.toString(java.nio.charset.StandardCharsets.UTF_8.name());
|
|
}
|
|
|
|
/** Where the user should drop the file. */
|
|
private String configDirHint() {
|
|
java.io.File[] dirs = getExternalFilesDirs(null);
|
|
return dirs.length > 0 && dirs[0] != null ? dirs[0].getAbsolutePath() : "";
|
|
}
|
|
|
|
/**
|
|
* Imports a scanned file and then deletes it: it holds the ingest token in plaintext, maybe
|
|
* on a USB stick, and the settings live in encrypted storage from now on.
|
|
*/
|
|
private void importConfig(java.io.File file) {
|
|
String text = readText(file);
|
|
if (text.isEmpty()) {
|
|
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_read));
|
|
return;
|
|
}
|
|
if (!applyConfig(text)) return;
|
|
boolean deleted;
|
|
try {
|
|
deleted = file.delete();
|
|
} catch (SecurityException e) {
|
|
deleted = false;
|
|
}
|
|
if (deleted) {
|
|
setConnectionStatus(COLOR_OK, getString(R.string.import_ok_deleted));
|
|
} else {
|
|
setConnectionStatus(COLOR_ERROR, getString(R.string.import_ok_not_deleted, file.getAbsolutePath()));
|
|
}
|
|
}
|
|
|
|
private void importConfig(Uri uri) {
|
|
String text;
|
|
try (java.io.InputStream in = getContentResolver().openInputStream(uri)) {
|
|
if (in == null) throw new java.io.IOException("no stream");
|
|
text = readCapped(in);
|
|
} catch (Exception e) {
|
|
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_read));
|
|
return;
|
|
}
|
|
applyConfig(text);
|
|
}
|
|
|
|
/**
|
|
* Applies whatever the file sets; absent keys keep their current value, so a file with
|
|
* only the token does not wipe the URL. Server settings are validated and saved together;
|
|
* cadence keys go to the plain prefs the same way the Upload page writes them.
|
|
*/
|
|
private boolean applyConfig(String text) {
|
|
ConfigImport config = ConfigImport.parse(text);
|
|
if (config.isEmpty()) {
|
|
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_empty));
|
|
return false;
|
|
}
|
|
ServerConfig current = ServerConfig.get(this);
|
|
if (config.serverUrl != null || config.token != null || config.installationId != null) {
|
|
boolean saved = saveServerConfig(
|
|
config.serverUrl != null ? config.serverUrl : current.url,
|
|
config.token != null ? config.token : current.token,
|
|
config.installationId != null ? config.installationId : current.installationId);
|
|
if (!saved) {
|
|
// Show what the file said, so the marked field is the one to fix in the file.
|
|
if (config.serverUrl != null) serverUrlInput.setText(config.serverUrl);
|
|
if (config.token != null) serverTokenInput.setText(config.token);
|
|
return false;
|
|
}
|
|
}
|
|
if (config.abrpApiKey != null || config.abrpToken != null) {
|
|
AbrpConfig abrp = AbrpConfig.get(this);
|
|
if (!saveAbrpConfig(config.abrpApiKey != null ? config.abrpApiKey : abrp.apiKey,
|
|
config.abrpToken != null ? config.abrpToken : abrp.userToken)) {
|
|
setConnectionStatus(COLOR_ERROR, getString(R.string.abrp_invalid));
|
|
return false;
|
|
}
|
|
}
|
|
if (config.chargingPrice != null || config.usableKwh != null) {
|
|
// Through the same Save as the fields (and the phone form), with the same limits.
|
|
String applied = applyPhoneSettings(new PhoneSettingsClient.Settings(
|
|
null, null, config.chargingPrice, config.usableKwh));
|
|
if (!applied.equals(getString(R.string.phone_settings_applied))) {
|
|
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_values));
|
|
return false;
|
|
}
|
|
}
|
|
SharedPreferences.Editor edit = prefs.edit();
|
|
if (config.intervalSec != null) edit.putInt(UploadSettings.KEY_INTERVAL_SEC, config.intervalSec);
|
|
if (config.boostLowSoc != null) edit.putBoolean(UploadSettings.KEY_BOOST_LOW_SOC, config.boostLowSoc);
|
|
if (config.lowSocPercent != null) edit.putInt(UploadSettings.KEY_LOW_SOC_PERCENT, config.lowSocPercent);
|
|
edit.apply();
|
|
bindCadenceControls();
|
|
setConnectionStatus(COLOR_OK, getString(R.string.import_ok));
|
|
refreshStatus();
|
|
return true;
|
|
}
|
|
|
|
// ---------- Upload page ----------
|
|
|
|
private void bindServicePane() {
|
|
voiceKeySwitch = settingsPane.findViewById(R.id.voice_key_switch);
|
|
voiceKeySwitch.setChecked(UploadSettings.voiceKeyOpensApp(prefs));
|
|
voiceKeySwitch.setOnCheckedChangeListener((btn, checked) ->
|
|
prefs.edit().putBoolean(UploadSettings.KEY_VOICE_KEY_OPENS, checked).apply());
|
|
bindTireUnit();
|
|
bindAccentColor();
|
|
fullScreenSwitch = settingsPane.findViewById(R.id.full_screen_switch);
|
|
fullScreenSwitch.setChecked(UploadSettings.fullScreen(prefs));
|
|
fullScreenSwitch.setOnCheckedChangeListener((btn, checked) -> {
|
|
prefs.edit().putBoolean(UploadSettings.KEY_FULL_SCREEN, checked).apply();
|
|
applyFullScreen();
|
|
});
|
|
liveTelemetrySwitch = servicePane.findViewById(R.id.abrp_upload_switch);
|
|
intervalChoices = servicePane.findViewById(R.id.interval_choices);
|
|
boostSwitch = servicePane.findViewById(R.id.boost_switch);
|
|
lowSocInput = servicePane.findViewById(R.id.low_soc_input);
|
|
lowSocLayout = servicePane.findViewById(R.id.low_soc_layout);
|
|
liveTelemetrySwitch.setChecked(UploadSettings.from(prefs).abrpUploadEnabled);
|
|
liveTelemetrySwitch.setOnCheckedChangeListener((btn, checked) -> {
|
|
prefs.edit().putBoolean(UploadSettings.KEY_ABRP_UPLOAD_ENABLED, checked).apply();
|
|
refreshStatus();
|
|
});
|
|
bindCadenceControls();
|
|
bindAbrpControls();
|
|
}
|
|
|
|
// ---------- ABRP, directly from the car ----------
|
|
|
|
private TextInputEditText abrpApiKeyInput, abrpTokenInput;
|
|
private TextView abrpStatusText;
|
|
|
|
private void bindAbrpControls() {
|
|
abrpApiKeyInput = servicePane.findViewById(R.id.abrp_api_key_input);
|
|
abrpTokenInput = servicePane.findViewById(R.id.abrp_token_input);
|
|
abrpStatusText = servicePane.findViewById(R.id.abrp_status_text);
|
|
AbrpConfig current = AbrpConfig.get(this);
|
|
abrpApiKeyInput.setText(current.apiKey);
|
|
abrpTokenInput.setText(current.userToken);
|
|
servicePane.findViewById(R.id.abrp_save_button).setOnClickListener(v ->
|
|
saveAbrpConfig(text(abrpApiKeyInput), text(abrpTokenInput)));
|
|
servicePane.findViewById(R.id.abrp_test_button).setOnClickListener(v -> testAbrpConfig());
|
|
servicePane.findViewById(R.id.abrp_phone_button).setOnClickListener(v -> startPhoneSettings());
|
|
}
|
|
|
|
private static String text(TextInputEditText input) {
|
|
CharSequence raw = input.getText();
|
|
return raw == null ? "" : raw.toString().trim();
|
|
}
|
|
|
|
/** Both set and plausible, or both empty (direct sending off). Returns whether it saved. */
|
|
private boolean saveAbrpConfig(String apiKey, String token) {
|
|
boolean clear = apiKey.isEmpty() && token.isEmpty();
|
|
if (!clear && !(AbrpConfig.isPlausible(apiKey) && AbrpConfig.isPlausible(token))) {
|
|
abrpStatusText.setText(R.string.abrp_invalid);
|
|
return false;
|
|
}
|
|
AbrpConfig.save(this, apiKey, token);
|
|
abrpApiKeyInput.setText(apiKey);
|
|
abrpTokenInput.setText(token);
|
|
abrpStatusText.setText(clear ? R.string.abrp_cleared : R.string.abrp_saved);
|
|
refreshStatus();
|
|
return true;
|
|
}
|
|
|
|
/** Read-only check against ABRP; never sends telemetry. Off the main thread. */
|
|
private void testAbrpConfig() {
|
|
AbrpConfig config = new AbrpConfig(text(abrpApiKeyInput), text(abrpTokenInput));
|
|
if (!config.isConfigured()) {
|
|
abrpStatusText.setText(R.string.abrp_not_set);
|
|
return;
|
|
}
|
|
abrpStatusText.setText(R.string.abrp_testing);
|
|
new Thread(() -> {
|
|
String message;
|
|
try {
|
|
ServerHttp.Response response = AbrpApi.verify(config);
|
|
message = response.ok() ? getString(R.string.abrp_test_ok)
|
|
: getString(R.string.abrp_test_failed, response.status);
|
|
} catch (Exception e) {
|
|
message = getString(R.string.abrp_test_offline, e.getClass().getSimpleName());
|
|
}
|
|
String shown = message;
|
|
runOnUiThread(() -> abrpStatusText.setText(shown));
|
|
}, "abrp-test").start();
|
|
}
|
|
|
|
/**
|
|
* A single choice as a drop-down (Widget.EV.Dropdown). An exposed dropdown menu rather than
|
|
* a Spinner: a Spinner's list is a focusable window of its own without the full-screen
|
|
* flags, and the car showed its bars while it was open; this list leaves the focus with
|
|
* the activity. setSimpleItems keeps every option listed (a plain adapter would filter the
|
|
* list down to the current text). Rebinding is safe.
|
|
*/
|
|
private void bindChoices(com.google.android.material.textfield.MaterialAutoCompleteTextView view,
|
|
String[] labels, int selected, java.util.function.IntConsumer onPick) {
|
|
view.setOnItemClickListener(null);
|
|
view.setSimpleItems(labels);
|
|
view.setText(labels[selected], false);
|
|
view.setOnItemClickListener((parent, item, position, id) -> onPick.accept(position));
|
|
}
|
|
|
|
/** The accent colour; a change recreates the activity so every view takes it. */
|
|
private void bindAccentColor() {
|
|
AccentColor[] colors = AccentColor.values();
|
|
String[] labels = new String[colors.length];
|
|
for (int i = 0; i < colors.length; i++) labels[i] = colors[i].label;
|
|
AccentColor current = AccentColor.fromPref(prefs.getString(AccentColor.PREF_KEY, null));
|
|
bindChoices(settingsPane.findViewById(R.id.accent_choices), labels, current.ordinal(), position -> {
|
|
if (colors[position] == current) return;
|
|
prefs.edit().putString(AccentColor.PREF_KEY, colors[position].name()).apply();
|
|
recreate();
|
|
});
|
|
}
|
|
|
|
/** Tyre pressure unit: display only, read on every refresh of the Overview. */
|
|
private void bindTireUnit() {
|
|
PressureUnit[] units = PressureUnit.values();
|
|
String[] labels = new String[units.length];
|
|
for (int i = 0; i < units.length; i++) labels[i] = units[i].label;
|
|
int current = PressureUnit.fromPref(prefs.getString(PressureUnit.PREF_KEY, null)).ordinal();
|
|
bindChoices(settingsPane.findViewById(R.id.tire_unit_choices), labels, current, position ->
|
|
prefs.edit().putString(PressureUnit.PREF_KEY, units[position].name()).apply());
|
|
}
|
|
|
|
/** The service reads these settings on every tick, so a change applies without a restart. */
|
|
private void bindCadenceControls() {
|
|
UploadSettings current = UploadSettings.from(prefs);
|
|
|
|
String[] labels = new String[UploadSettings.INTERVAL_CHOICES_SEC.length];
|
|
int selected = 0;
|
|
for (int i = 0; i < UploadSettings.INTERVAL_CHOICES_SEC.length; i++) {
|
|
int sec = UploadSettings.INTERVAL_CHOICES_SEC[i];
|
|
labels[i] = sec >= 60
|
|
? getResources().getQuantityString(R.plurals.interval_minutes, sec / 60, sec / 60)
|
|
: getResources().getQuantityString(R.plurals.interval_seconds, sec, sec);
|
|
if (sec == current.intervalSec) selected = i;
|
|
}
|
|
bindChoices(intervalChoices, labels, selected, position ->
|
|
prefs.edit().putInt(UploadSettings.KEY_INTERVAL_SEC,
|
|
UploadSettings.INTERVAL_CHOICES_SEC[position]).apply());
|
|
|
|
boostSwitch.setOnCheckedChangeListener(null);
|
|
boostSwitch.setChecked(current.boostLowSoc);
|
|
lowSocLayout.setEnabled(current.boostLowSoc);
|
|
lowSocInput.setEnabled(current.boostLowSoc);
|
|
boostSwitch.setOnCheckedChangeListener((button, checked) -> {
|
|
prefs.edit().putBoolean(UploadSettings.KEY_BOOST_LOW_SOC, checked).apply();
|
|
lowSocLayout.setEnabled(checked);
|
|
lowSocInput.setEnabled(checked);
|
|
});
|
|
|
|
lowSocInput.setText(String.valueOf(current.lowSocPercent));
|
|
lowSocInput.setOnFocusChangeListener((view, hasFocus) -> {
|
|
if (!hasFocus) saveLowSocThreshold();
|
|
});
|
|
}
|
|
|
|
/** Clamped to 1-99: 0 would never trigger and 100 would boost permanently. */
|
|
private void saveLowSocThreshold() {
|
|
CharSequence raw = lowSocInput.getText();
|
|
int value;
|
|
try {
|
|
value = Integer.parseInt(raw == null ? "" : raw.toString().trim());
|
|
} catch (NumberFormatException e) {
|
|
value = UploadSettings.DEFAULT_LOW_SOC_PERCENT;
|
|
}
|
|
value = Math.max(1, Math.min(99, value));
|
|
lowSocInput.setText(String.valueOf(value));
|
|
prefs.edit().putInt(UploadSettings.KEY_LOW_SOC_PERCENT, value).apply();
|
|
}
|
|
|
|
/**
|
|
* GPS for the telemetry position. On the car the app runs as the system user and holds
|
|
* it already; this only asks where it does not, such as a phone running the stable build.
|
|
*/
|
|
private void requestLocationPermissionIfNeeded() {
|
|
if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION)
|
|
!= PackageManager.PERMISSION_GRANTED) {
|
|
requestPermissions(new String[]{android.Manifest.permission.ACCESS_FINE_LOCATION,
|
|
android.Manifest.permission.ACCESS_COARSE_LOCATION}, 1);
|
|
}
|
|
}
|
|
|
|
// ---------- Vehicle page ----------
|
|
|
|
/** Looks up the value cells once; the labels are static and never touched from code. */
|
|
private void bindVehiclePane() {
|
|
vehSoc = vehiclePane.findViewById(R.id.vehicle_soc);
|
|
vehRange = vehiclePane.findViewById(R.id.vehicle_range);
|
|
vehPower = vehiclePane.findViewById(R.id.vehicle_power);
|
|
vehSpeed = vehiclePane.findViewById(R.id.vehicle_speed);
|
|
vehOdometer = vehiclePane.findViewById(R.id.vehicle_odometer);
|
|
vehCharging = vehiclePane.findViewById(R.id.vehicle_charging);
|
|
vehHvac = vehiclePane.findViewById(R.id.vehicle_hvac);
|
|
vehTireFl = vehiclePane.findViewById(R.id.vehicle_tire_fl);
|
|
vehTireFr = vehiclePane.findViewById(R.id.vehicle_tire_fr);
|
|
vehTireRl = vehiclePane.findViewById(R.id.vehicle_tire_rl);
|
|
vehTireRr = vehiclePane.findViewById(R.id.vehicle_tire_rr);
|
|
vehRangeTrip = vehiclePane.findViewById(R.id.vehicle_range_trip);
|
|
vehRangeTripNote = vehiclePane.findViewById(R.id.vehicle_range_trip_note);
|
|
vehRangeAverage = vehiclePane.findViewById(R.id.vehicle_range_average);
|
|
vehRangeAverageNote = vehiclePane.findViewById(R.id.vehicle_range_average_note);
|
|
vehUsageTrip = vehiclePane.findViewById(R.id.vehicle_usage_trip);
|
|
vehUsageTripNote = vehiclePane.findViewById(R.id.vehicle_usage_trip_note);
|
|
vehUsageAverage = vehiclePane.findViewById(R.id.vehicle_usage_average);
|
|
vehUsageAverageNote = vehiclePane.findViewById(R.id.vehicle_usage_average_note);
|
|
vehEnergyTrip = vehiclePane.findViewById(R.id.vehicle_energy_trip);
|
|
vehEnergyTripNote = vehiclePane.findViewById(R.id.vehicle_energy_trip_note);
|
|
vehPowerAverage = vehiclePane.findViewById(R.id.vehicle_power_average);
|
|
vehPowerAverageNote = vehiclePane.findViewById(R.id.vehicle_power_average_note);
|
|
vehSpeedAverage = vehiclePane.findViewById(R.id.vehicle_speed_average);
|
|
vehSpeedAverageNote = vehiclePane.findViewById(R.id.vehicle_speed_average_note);
|
|
}
|
|
|
|
private double usableKwh() {
|
|
return prefs.getFloat(USABLE_KWH_KEY, (float) RangeEstimator.DEFAULT_USABLE_KWH);
|
|
}
|
|
|
|
/**
|
|
* The range estimates since start and at the 5-minute average, each with its usage in a
|
|
* card below it. Since start needs the car to have moved; the average needs a minute or more
|
|
* of driving, which the service has usually collected already.
|
|
*/
|
|
private void refreshRangeEstimates(EnergySnapshot s) {
|
|
RangeEstimator estimator = RangeEstimator.shared();
|
|
long monotonicMs = android.os.SystemClock.elapsedRealtime();
|
|
estimator.add(monotonicMs, s.getBatteryPowerKw(), s.getSpeedKmh());
|
|
Double remaining = RangeEstimator.remainingKwh(s.getSocPercent(), usableKwh());
|
|
RangeEstimator.Estimate trip = estimator.sinceStart(remaining);
|
|
Double power = estimator.averagePowerKw(monotonicMs);
|
|
vehPowerAverage.setText(power == null ? "—" : getString(R.string.power_kw, power));
|
|
vehPowerAverageNote.setText(power == null ? R.string.power_waiting : R.string.usage_last_5_min);
|
|
Double speed = estimator.averageSpeedKmh(monotonicMs);
|
|
vehSpeedAverage.setText(speed == null ? "—" : getString(R.string.speed_kmh, speed));
|
|
vehSpeedAverageNote.setText(speed == null ? R.string.power_waiting : R.string.usage_last_5_min);
|
|
Double energy = estimator.energySinceStart();
|
|
vehEnergyTrip.setText(energy == null ? "—" : getString(R.string.energy_kwh, energy));
|
|
vehEnergyTripNote.setText(energy == null ? R.string.energy_trip_waiting : R.string.energy_trip_note);
|
|
showEstimate(vehRangeTrip, vehRangeTripNote, vehUsageTrip, vehUsageTripNote, trip,
|
|
trip == null ? "" : getString(R.string.usage_over_distance, trip.distanceKm),
|
|
R.string.range_trip_waiting);
|
|
showEstimate(vehRangeAverage, vehRangeAverageNote, vehUsageAverage, vehUsageAverageNote,
|
|
estimator.average(remaining, monotonicMs), getString(R.string.usage_last_5_min),
|
|
R.string.range_waiting);
|
|
}
|
|
|
|
private void showEstimate(TextView range, TextView rangeNote, TextView usage, TextView usageNote,
|
|
RangeEstimator.Estimate e, String basis, int whyNot) {
|
|
if (e == null) {
|
|
range.setText("—");
|
|
rangeNote.setText(whyNot);
|
|
usage.setText("—");
|
|
usageNote.setText(whyNot);
|
|
return;
|
|
}
|
|
// Recuperating, the pack gains energy: the range is unbounded. Coasting at speed costs
|
|
// almost nothing, which would read as thousands of km.
|
|
range.setText(e.kwhPer100Km <= 0 ? "∞"
|
|
: e.rangeKm > 999 ? getString(R.string.range_over, 999)
|
|
: String.format(java.util.Locale.US, "%.0f km", e.rangeKm));
|
|
rangeNote.setText(e.kwhPer100Km <= 0 ? getString(R.string.range_recuperating) : "");
|
|
usage.setText(getString(R.string.range_consumption, e.kwhPer100Km));
|
|
usageNote.setText(basis);
|
|
}
|
|
|
|
/**
|
|
* Reads one snapshot and writes it to the page.
|
|
*
|
|
* The reader is built lazily and kept: constructing it binds the vendor services, which
|
|
* is not work to repeat every two seconds. A read that throws leaves the cells as they
|
|
* were rather than blanking the page — a transient miss is not news worth showing.
|
|
*/
|
|
// ---------- Overview: read every half second, off the main thread ----------
|
|
|
|
/** How often the Overview reads the car while it is on screen. */
|
|
private static final long OVERVIEW_REFRESH_MS = 500L;
|
|
|
|
/** One reader thread; a read that is still running makes the next tick skip, not queue. */
|
|
private final java.util.concurrent.ExecutorService carReader =
|
|
java.util.concurrent.Executors.newSingleThreadExecutor(r -> new Thread(r, "overview-read"));
|
|
private final java.util.concurrent.atomic.AtomicBoolean reading =
|
|
new java.util.concurrent.atomic.AtomicBoolean();
|
|
/** The newest reading, for the slower work on the 2-second tick. */
|
|
private volatile EnergySnapshot latestSnapshot;
|
|
|
|
private final Runnable overviewRefresh = new Runnable() {
|
|
@Override public void run() {
|
|
readVehicle();
|
|
uiHandler.postDelayed(this, OVERVIEW_REFRESH_MS);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* About twenty calls into the car's services: on a background thread, so the screen never
|
|
* waits for them; the values are shown on the main thread once they are in.
|
|
*/
|
|
private void readVehicle() {
|
|
if (!reading.compareAndSet(false, true)) return;
|
|
try {
|
|
carReader.execute(() -> {
|
|
try {
|
|
if (vehicleReader == null) {
|
|
vehicleReader = new EnergyTelemetryReader(getApplicationContext());
|
|
}
|
|
// The timestamp is passed explicitly: its Kotlin default is not visible from Java.
|
|
EnergySnapshot s = vehicleReader.read(System.currentTimeMillis());
|
|
latestSnapshot = s;
|
|
runOnUiThread(() -> {
|
|
if (!isDestroyed()) showVehicle(s);
|
|
});
|
|
} catch (Throwable ignored) {
|
|
// Leave the last good reading on screen.
|
|
} finally {
|
|
reading.set(false);
|
|
}
|
|
});
|
|
} catch (java.util.concurrent.RejectedExecutionException e) {
|
|
reading.set(false); // the activity is going away
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The consumption and charging trackers stay on the 2-second tick: they integrate over
|
|
* time, so a faster rate adds nothing, and the charging tracker writes its session to
|
|
* storage on every sample while charging.
|
|
*/
|
|
private void sampleTrackers() {
|
|
EnergySnapshot s = latestSnapshot;
|
|
if (s == null) return;
|
|
try {
|
|
ConsumptionTracker.get(this).sample(s);
|
|
refreshCharging(s);
|
|
} catch (Throwable ignored) {
|
|
// A tracker failure must not stop the page.
|
|
}
|
|
}
|
|
|
|
private void showVehicle(EnergySnapshot s) {
|
|
try {
|
|
vehSoc.setText(fmt(s.getSocPercent(), "%.0f %%"));
|
|
vehRange.setText(fmt(s.getRangeKm(), "%.0f km"));
|
|
vehPower.setText(fmt(s.getBatteryPowerKw(), "%.1f kW"));
|
|
vehSpeed.setText(fmt(s.getSpeedKmh(), "%.0f km/h"));
|
|
vehOdometer.setText(fmt(s.getOdometerKm(), "%.0f km"));
|
|
vehHvac.setText(fmt(s.getClimate().getDriverTargetCelsius(), "%.0f °C"));
|
|
|
|
vehCharging.setText(chargingLabel(s));
|
|
refreshRangeEstimates(s);
|
|
|
|
PressureUnit unit = PressureUnit.fromPref(prefs.getString(PressureUnit.PREF_KEY, null));
|
|
vehTireFl.setText(unit.format(s.getTirePressures().getFrontLeftKpa()));
|
|
vehTireFr.setText(unit.format(s.getTirePressures().getFrontRightKpa()));
|
|
vehTireRl.setText(unit.format(s.getTirePressures().getRearLeftKpa()));
|
|
vehTireRr.setText(unit.format(s.getTirePressures().getRearRightKpa()));
|
|
} catch (Throwable ignored) {
|
|
// Leave the last good reading on screen.
|
|
}
|
|
}
|
|
|
|
private void bindConsumptionPane() {
|
|
consumptionTracker = ConsumptionTracker.get(this);
|
|
consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext());
|
|
consumptionDistance = consumptionPane.findViewById(R.id.consumption_distance);
|
|
consumptionEnergy = consumptionPane.findViewById(R.id.consumption_energy);
|
|
consumptionAverage = consumptionPane.findViewById(R.id.consumption_average);
|
|
consumptionSpeed = consumptionPane.findViewById(R.id.consumption_speed);
|
|
consumptionTime = consumptionPane.findViewById(R.id.consumption_time);
|
|
consumptionSoc = consumptionPane.findViewById(R.id.consumption_soc);
|
|
consumptionDailyHistory = consumptionPane.findViewById(R.id.consumption_daily_history);
|
|
consumptionDateButton = consumptionPane.findViewById(R.id.consumption_date_button);
|
|
consumptionDateButton.setText(selectedConsumptionDate.toString());
|
|
consumptionDateButton.setOnClickListener(v -> showConsumptionDatePicker());
|
|
consumptionPane.findViewById(R.id.consumption_refresh).setOnClickListener(v -> refreshFromCloud());
|
|
consumptionReset = consumptionPane.findViewById(R.id.consumption_reset);
|
|
consumptionSohLayout = settingsPane.findViewById(R.id.consumption_soh_layout);
|
|
consumptionSohInput = settingsPane.findViewById(R.id.consumption_soh_input);
|
|
Float savedSoh = consumptionTracker.sohPercent();
|
|
if (savedSoh != null) consumptionSohInput.setText(String.format(
|
|
java.util.Locale.US, "%.1f", savedSoh));
|
|
settingsPane.findViewById(R.id.consumption_soh_save).setOnClickListener(v -> saveSoh());
|
|
usableCapacityLayout = settingsPane.findViewById(R.id.usable_capacity_layout);
|
|
usableCapacityInput = settingsPane.findViewById(R.id.usable_capacity_input);
|
|
usableCapacityInput.setText(String.format(java.util.Locale.US, "%.1f", usableKwh()));
|
|
settingsPane.findViewById(R.id.usable_capacity_save).setOnClickListener(v -> saveUsableCapacity());
|
|
bindPeriodButton(R.id.consumption_lifetime, ConsumptionTracker.Period.LIFETIME);
|
|
bindPeriodButton(R.id.consumption_week, ConsumptionTracker.Period.WEEK);
|
|
bindPeriodButton(R.id.consumption_month, ConsumptionTracker.Period.MONTH);
|
|
bindPeriodButton(R.id.consumption_trip_a, ConsumptionTracker.Period.TRIP_A);
|
|
bindPeriodButton(R.id.consumption_trip_b, ConsumptionTracker.Period.TRIP_B);
|
|
selectPeriodButton(R.id.consumption_lifetime);
|
|
consumptionReset.setOnClickListener(v -> {
|
|
if (consumptionPeriod != ConsumptionTracker.Period.TRIP_A && consumptionPeriod != ConsumptionTracker.Period.TRIP_B) return;
|
|
String name = getString(consumptionPeriod == ConsumptionTracker.Period.TRIP_A
|
|
? R.string.consumption_trip_a : R.string.consumption_trip_b);
|
|
showKeepingFullScreen(new MaterialAlertDialogBuilder(this)
|
|
.setTitle(getString(R.string.trip_reset_title, name))
|
|
.setMessage(R.string.trip_reset_message)
|
|
.setNegativeButton(R.string.pair_cancel, null)
|
|
.setPositiveButton(R.string.trip_reset_confirm, (d, w) -> {
|
|
consumptionTracker.reset(consumptionPeriod);
|
|
refreshConsumption();
|
|
}).create());
|
|
});
|
|
}
|
|
|
|
private void saveUsableCapacity() {
|
|
try {
|
|
float value = Float.parseFloat(textOf(usableCapacityInput).replace(',', '.'));
|
|
if (!(value >= 10f && value <= 150f)) throw new NumberFormatException();
|
|
prefs.edit().putFloat(USABLE_KWH_KEY, value).apply();
|
|
usableCapacityLayout.setError(null);
|
|
usableCapacityInput.setText(String.format(java.util.Locale.US, "%.1f", value));
|
|
Toast.makeText(this, R.string.usable_capacity_saved, Toast.LENGTH_SHORT).show();
|
|
} catch (NumberFormatException e) {
|
|
usableCapacityLayout.setError(getString(R.string.usable_capacity_invalid));
|
|
}
|
|
}
|
|
|
|
private void saveSoh() {
|
|
String raw = textOf(consumptionSohInput).replace(',', '.');
|
|
try {
|
|
float value = Float.parseFloat(raw);
|
|
if (!consumptionTracker.setSohPercent(value)) throw new NumberFormatException();
|
|
consumptionSohLayout.setError(null);
|
|
consumptionSohInput.setText(String.format(java.util.Locale.US, "%.1f", value));
|
|
Toast.makeText(this, R.string.soh_saved, Toast.LENGTH_SHORT).show();
|
|
} catch (NumberFormatException e) {
|
|
consumptionSohLayout.setError(getString(R.string.soh_invalid));
|
|
}
|
|
}
|
|
|
|
private void bindPeriodButton(int id, ConsumptionTracker.Period period) {
|
|
View button = consumptionPane.findViewById(id);
|
|
button.setOnClickListener(v -> {
|
|
selectPeriodButton(id);
|
|
consumptionPeriod = period;
|
|
consumptionReset.setVisibility(period == ConsumptionTracker.Period.TRIP_A ||
|
|
period == ConsumptionTracker.Period.TRIP_B ? View.VISIBLE : View.GONE);
|
|
refreshConsumption();
|
|
});
|
|
}
|
|
|
|
private void selectPeriodButton(int selectedId) {
|
|
int[] ids = {R.id.consumption_lifetime, R.id.consumption_week,
|
|
R.id.consumption_month, R.id.consumption_trip_a, R.id.consumption_trip_b};
|
|
for (int id : ids) {
|
|
MaterialButton button = consumptionPane.findViewById(id);
|
|
boolean selected = id == selectedId;
|
|
button.setSelected(selected);
|
|
int accent = AccentColor.resolve(this, R.attr.evAccent);
|
|
button.setTextColor(selected ? accent : COLOR_PENDING);
|
|
button.setStrokeColor(android.content.res.ColorStateList.valueOf(
|
|
selected ? accent : 0xFF3A4143));
|
|
}
|
|
}
|
|
|
|
private void bindChargingPane() {
|
|
chargeSessionTracker = ChargeSessionTracker.get(this);
|
|
chargeStatus = chargingPane.findViewById(R.id.charge_status);
|
|
chargeSoc = chargingPane.findViewById(R.id.charge_soc);
|
|
chargeStartSoc = chargingPane.findViewById(R.id.charge_start_soc);
|
|
chargeVoltage = chargingPane.findViewById(R.id.charge_voltage);
|
|
chargeCurrent = chargingPane.findViewById(R.id.charge_current);
|
|
chargePower = chargingPane.findViewById(R.id.charge_power);
|
|
chargeEnergy = chargingPane.findViewById(R.id.charge_energy);
|
|
chargeGridEnergy = chargingPane.findViewById(R.id.charge_grid_energy);
|
|
chargeDuration = chargingPane.findViewById(R.id.charge_duration);
|
|
chargeTotalCost = chargingPane.findViewById(R.id.charge_total_cost);
|
|
chargeGraph = chargingPane.findViewById(R.id.charge_graph);
|
|
chargeCurveTable = chargingPane.findViewById(R.id.charge_curve_table);
|
|
chargePriceLayout = settingsPane.findViewById(R.id.charge_price_layout);
|
|
chargePriceInput = settingsPane.findViewById(R.id.charge_price_input);
|
|
chargePriceInput.setText(String.format(java.util.Locale.US, "%.2f",
|
|
chargeSessionTracker.pricePerKwh()));
|
|
settingsPane.findViewById(R.id.charge_price_save).setOnClickListener(v -> saveChargePrice());
|
|
settingsPane.findViewById(R.id.settings_phone_button).setOnClickListener(v -> startPhoneSettings());
|
|
}
|
|
|
|
private void saveChargePrice() {
|
|
String raw = textOf(chargePriceInput).replace(',', '.');
|
|
try {
|
|
double value = Double.parseDouble(raw);
|
|
if (!chargeSessionTracker.setPricePerKwh(value)) throw new NumberFormatException();
|
|
chargePriceLayout.setError(null);
|
|
chargePriceInput.setText(String.format(java.util.Locale.US, "%.2f", value));
|
|
Toast.makeText(this, R.string.price_saved, Toast.LENGTH_SHORT).show();
|
|
} catch (NumberFormatException e) {
|
|
chargePriceLayout.setError(getString(R.string.price_invalid));
|
|
}
|
|
}
|
|
|
|
private void refreshCharging(EnergySnapshot s) {
|
|
if (chargeStatus == null) return;
|
|
chargeSessionTracker.sample(s);
|
|
Integer code = s.getChargingStatus();
|
|
boolean charging = ChargingState.isCharging(
|
|
code, s.getChargePortConnected(), s.getBatteryPowerKw(), s.getSpeedKmh());
|
|
ChargeSessionTracker.Snapshot session = chargeSessionTracker.snapshot();
|
|
// Only DC is metered, so an AC charge must say so rather than let the readouts
|
|
// below — which then describe the previous DC session — be read as this charge.
|
|
boolean dc = ChargingState.isDcCharging(
|
|
code, s.getChargePortConnected(), s.getBatteryPowerKw(), s.getSpeedKmh());
|
|
chargeStatus.setText(dc ? R.string.charge_dc
|
|
: charging ? R.string.charge_ac_not_metered
|
|
: (session.startedAtMs > 0 ? R.string.charge_last : R.string.charging_not_charging));
|
|
chargeSoc.setText(fmt(s.getSocPercent(), "%.1f %%"));
|
|
chargeStartSoc.setText(fmt(session.startSocPercent, "%.1f %%"));
|
|
chargeVoltage.setText(fmt(s.getBatteryVoltageV(), "%.1f V"));
|
|
chargeCurrent.setText(fmt(s.getBatteryCurrentA(), "%.1f A"));
|
|
chargePower.setText(s.getBatteryPowerKw() == null ? "—" : String.format(java.util.Locale.getDefault(), "%.1f kW", Math.abs(s.getBatteryPowerKw())));
|
|
chargeEnergy.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "%.2f kWh", session.energyKwh) : "—");
|
|
chargeGridEnergy.setText(session.startedAtMs > 0 ? String.format(
|
|
java.util.Locale.getDefault(), "%.2f kWh", session.gridEnergyKwh) : "—");
|
|
long sec = session.durationSeconds;
|
|
chargeDuration.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "%02d:%02d:%02d", sec/3600,(sec%3600)/60,sec%60) : "—");
|
|
chargeTotalCost.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "€%.2f", session.totalCost) : "—");
|
|
chargeGraph.setPoints(session.points);
|
|
chargeCurveTable.setText(chargeGraph.tableText());
|
|
}
|
|
|
|
private void refreshConsumption() {
|
|
if (consumptionTracker == null) return;
|
|
// Trip counters are local-only (cloud has no trip concept); everything else reads
|
|
// back from the cloud so an APK wipe does not blank the display.
|
|
ConsumptionTracker.Totals t;
|
|
if (consumptionPeriod == ConsumptionTracker.Period.TRIP_A
|
|
|| consumptionPeriod == ConsumptionTracker.Period.TRIP_B) {
|
|
t = consumptionTracker.totals(consumptionPeriod);
|
|
} else {
|
|
t = cloudTotals(consumptionPeriod);
|
|
}
|
|
consumptionDistance.setText(String.format(java.util.Locale.getDefault(), "%.1f km", t.km));
|
|
consumptionEnergy.setText(String.format(java.util.Locale.getDefault(), "%.2f kWh", t.kwh));
|
|
consumptionAverage.setText(t.km < 0.1 ? "—" : String.format(java.util.Locale.getDefault(),
|
|
"%.1f kWh/100 km", t.kwh * 100d / t.km));
|
|
consumptionSpeed.setText(t.hours <= 0 ? "—" : String.format(java.util.Locale.getDefault(),
|
|
"%.1f km/h", t.km / t.hours));
|
|
long minutes = Math.round(t.hours * 60d);
|
|
consumptionTime.setText(String.format(java.util.Locale.getDefault(), "%d h %02d min",
|
|
minutes / 60, minutes % 60));
|
|
consumptionSoc.setText(String.format(java.util.Locale.getDefault(), "%.1f %%", t.soc));
|
|
// Daily history: cloud first, local fallback for today (not yet uploaded).
|
|
ConsumptionTracker.Totals day = cloudDay(selectedConsumptionDate);
|
|
if (day == null) day = consumptionTracker.totalsForDay(selectedConsumptionDate);
|
|
if (day.km == 0 && day.kwh == 0 && day.hours == 0 && day.soc == 0) {
|
|
consumptionDailyHistory.setText(R.string.consumption_no_records);
|
|
} else {
|
|
String average = day.km < 0.1 ? "—" : String.format(java.util.Locale.getDefault(),
|
|
"%.1f kWh/100 km", day.kwh * 100d / day.km);
|
|
long dayMinutes = Math.round(day.hours * 60d);
|
|
consumptionDailyHistory.setText(getString(R.string.consumption_day_summary,
|
|
day.km, day.kwh, average, dayMinutes / 60, dayMinutes % 60, day.soc));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Period totals from the cloud history. LIFETIME = the latest record's lifetime field;
|
|
* WEEK/MONTH = sum of the last 7/30 days' day-totals. Falls back to the local tracker
|
|
* when the cloud is empty or not yet fetched, so the UI is never blank on a cold start.
|
|
*/
|
|
private ConsumptionTracker.Totals cloudTotals(ConsumptionTracker.Period period) {
|
|
List<ConsumptionCloudClient.DayRecord> history = consumptionCloudClient != null
|
|
? consumptionCloudClient.fetchHistoryIfDue() : java.util.Collections.emptyList();
|
|
if (history.isEmpty()) return consumptionTracker.totals(period);
|
|
if (period == ConsumptionTracker.Period.LIFETIME) {
|
|
// history is sorted date-desc, so index 0 is the latest day carrying lifetime.
|
|
return history.get(0).lifetime;
|
|
}
|
|
int days = period == ConsumptionTracker.Period.WEEK ? 7 : 30;
|
|
double km = 0, kwh = 0, hours = 0, soc = 0;
|
|
LocalDate today = LocalDate.now();
|
|
for (int i = 0; i < days; i++) {
|
|
ConsumptionTracker.Totals d = cloudDay(today.minusDays(i), history);
|
|
if (d != null) { km += d.km; kwh += d.kwh; hours += d.hours; soc += d.soc; }
|
|
}
|
|
return new ConsumptionTracker.Totals(km, kwh, hours, soc);
|
|
}
|
|
|
|
/** Looks up one day in the cloud cache; null when that date is not present. */
|
|
private ConsumptionTracker.Totals cloudDay(LocalDate date) {
|
|
return cloudDay(date, consumptionCloudClient != null
|
|
? consumptionCloudClient.fetchHistoryIfDue() : java.util.Collections.emptyList());
|
|
}
|
|
|
|
private static ConsumptionTracker.Totals cloudDay(LocalDate date,
|
|
List<ConsumptionCloudClient.DayRecord> history) {
|
|
for (ConsumptionCloudClient.DayRecord r : history) {
|
|
if (r.date.equals(date)) return r.day;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private void showConsumptionDatePicker() {
|
|
java.time.LocalDate selected = selectedConsumptionDate;
|
|
android.app.DatePickerDialog dialog = new android.app.DatePickerDialog(this,
|
|
(view, year, month, day) -> {
|
|
selectedConsumptionDate = java.time.LocalDate.of(year, month + 1, day);
|
|
consumptionDateButton.setText(selectedConsumptionDate.toString());
|
|
refreshConsumption();
|
|
}, selected.getYear(), selected.getMonthValue() - 1, selected.getDayOfMonth());
|
|
java.time.ZoneId zone = java.time.ZoneId.systemDefault();
|
|
dialog.getDatePicker().setMaxDate(System.currentTimeMillis());
|
|
dialog.getDatePicker().setMinDate(java.time.LocalDate.now().minusMonths(4)
|
|
.atStartOfDay(zone).toInstant().toEpochMilli());
|
|
showKeepingFullScreen(dialog);
|
|
}
|
|
|
|
/**
|
|
* The charging row: the session state when the car reports one, the port otherwise.
|
|
*
|
|
* The codes are the vehicle's own — see EVHardware.PROP_VENDOR_CHARGE_STATUS. An
|
|
* unrecognised code is shown as itself rather than guessed at.
|
|
*/
|
|
private String chargingLabel(EnergySnapshot s) {
|
|
Integer status = s.getChargingStatus();
|
|
if (status != null) {
|
|
switch (status) {
|
|
case 1: return getString(R.string.charge_status_ac);
|
|
case 10: return getString(R.string.charge_status_dc);
|
|
case 5: return getString(R.string.charge_status_connecting);
|
|
case 7: return getString(R.string.charge_status_plugged);
|
|
case 8: return getString(R.string.charge_status_stopped);
|
|
case 0: return getString(R.string.charging_not_charging);
|
|
default: return getString(R.string.charge_status_code, status);
|
|
}
|
|
}
|
|
Boolean plugged = s.getChargePortConnected();
|
|
if (plugged != null) return getString(plugged ? R.string.charge_status_connected : R.string.charge_status_unplugged);
|
|
return "—";
|
|
}
|
|
|
|
/** A dash for a signal the car does not publish — never a zero. See pane_vehicle.xml. */
|
|
private static String fmt(Float value, String pattern) {
|
|
if (value == null || value.isNaN() || value.isInfinite()) return "—";
|
|
return String.format(java.util.Locale.US, pattern, value);
|
|
}
|
|
|
|
// ---------- Call log ----------
|
|
|
|
/**
|
|
* "Upload now" button — forces a cache drain + fresh chunk on a background thread,
|
|
* ignoring the 2-minute throttle. The result lands in the upload log (Logs tab).
|
|
*/
|
|
private void manualFlush() {
|
|
if (consumptionCloudClient == null || consumptionTracker == null) return;
|
|
AbrpUploadService.log().record(new UploadLog.Entry(
|
|
System.currentTimeMillis(), 0, true, "Manual sync started…"));
|
|
new Thread(() -> {
|
|
String result = consumptionCloudClient.flushNow(
|
|
consumptionTracker, System.currentTimeMillis());
|
|
runOnUiThread(() -> Toast.makeText(this, result, Toast.LENGTH_SHORT).show());
|
|
}, "manual-flush").start();
|
|
}
|
|
|
|
/**
|
|
* Consumption tab "Refresh" button — sends cached chunks + fresh delta, invalidates the
|
|
* history cache so the next poll fetches fresh data from the cloud, then refreshes the UI.
|
|
*/
|
|
private void refreshFromCloud() {
|
|
if (consumptionCloudClient == null || consumptionTracker == null) return;
|
|
AbrpUploadService.log().record(new UploadLog.Entry(
|
|
System.currentTimeMillis(), 0, true, "Refreshing consumption…"));
|
|
new Thread(() -> {
|
|
consumptionCloudClient.flushNow(consumptionTracker, System.currentTimeMillis());
|
|
consumptionCloudClient.invalidateHistory();
|
|
runOnUiThread(() -> {
|
|
refreshConsumption();
|
|
Toast.makeText(this, R.string.consumption_refreshed, Toast.LENGTH_SHORT).show();
|
|
});
|
|
}, "cloud-refresh").start();
|
|
}
|
|
|
|
private void refreshCallLog() {
|
|
java.util.List<UploadLog.Entry> entries = AbrpUploadService.log().recent();
|
|
if (entries.isEmpty()) {
|
|
callLogText.setText(R.string.call_log_empty);
|
|
return;
|
|
}
|
|
java.text.SimpleDateFormat format =
|
|
new java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.US);
|
|
StringBuilder sb = new StringBuilder();
|
|
for (UploadLog.Entry entry : entries) {
|
|
if (sb.length() > 0) sb.append('\n');
|
|
sb.append(entry.success ? "OK " : "ERR ")
|
|
.append(format.format(new java.util.Date(entry.timestampMs)))
|
|
.append(" ")
|
|
// httpStatus 0 means the request never reached a server.
|
|
.append(entry.httpStatus > 0 ? String.valueOf(entry.httpStatus) : "---")
|
|
.append(" ")
|
|
.append(entry.detail);
|
|
// What the payload held, under the attempt it belongs to. This is the only view
|
|
// of it the driver has: the car runs no adb, so "the field is 0 on ABRP" and
|
|
// "the app never sent the field" look identical from the outside without it.
|
|
if (entry.summary != null && !entry.summary.isEmpty()) {
|
|
sb.append("\n ").append(entry.summary);
|
|
}
|
|
}
|
|
callLogText.setText(sb.toString());
|
|
}
|
|
|
|
// ---------- Service status ----------
|
|
|
|
private void refreshStatus() {
|
|
// The service is always running. Show the cloud sync state derived from the log.
|
|
UploadLog.State state = AbrpUploadService.state();
|
|
|
|
switch (state) {
|
|
case ERROR:
|
|
// Derived from the log: three consecutive cloud sync failures is a problem.
|
|
statusText.setText(getString(R.string.state_error,
|
|
AbrpUploadService.log().consecutiveFailures()));
|
|
statusText.setTextColor(COLOR_ERROR);
|
|
break;
|
|
case RUNNING:
|
|
case STARTING:
|
|
boolean server = ServerConfig.get(this).isConfigured();
|
|
if (!server && !AbrpConfig.get(this).isConfigured()) {
|
|
statusText.setText(R.string.server_not_configured);
|
|
statusText.setTextColor(COLOR_ERROR);
|
|
} else if (!server && UploadSettings.from(prefs).abrpUploadEnabled) {
|
|
statusText.setText(R.string.state_abrp_only);
|
|
statusText.setTextColor(COLOR_PENDING);
|
|
} else if (!UploadSettings.from(prefs).abrpUploadEnabled) {
|
|
statusText.setText(R.string.state_tracking_locally);
|
|
statusText.setTextColor(COLOR_PENDING);
|
|
} else {
|
|
statusText.setText(R.string.state_running);
|
|
statusText.setTextColor(COLOR_OK);
|
|
}
|
|
break;
|
|
case STOPPED:
|
|
default:
|
|
statusText.setText(R.string.state_stopped);
|
|
statusText.setTextColor(COLOR_PENDING);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ---------- Helpers ----------
|
|
|
|
private String textOf(TextInputEditText field) {
|
|
CharSequence text = field.getText();
|
|
return text != null ? text.toString().trim() : "";
|
|
}
|
|
}
|