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 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(); 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.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()); 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 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 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()); 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 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() : ""; } }