The Vehicle tab gets two tiles next to the car's own range: - Range at current power: remaining energy / (power / speed) right now. - Range at 5-min average: remaining energy / (energy used / distance driven) over the last 5 minutes, integrated time-weighted from both the service tick (15 s) and the open screen (2 s), so it is ready as soon as the app opens. Each shows the consumption it is based on (kWh/100 km). Remaining energy is SOC x usable capacity, because the car publishes no pack energy; the capacity is a new setting in the Consumption tab (default 50.8 kWh, the 51 kWh battery). Estimates read "—" with the reason when they cannot mean anything: standing still, regenerating, too little driving, or across a gap in the samples; over 999 km shows "> 999 km". RangeEstimator is Android-free and has 7 tests. Also fixes the battery card's note, which still said the SoH goes to MongoDB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
1382 lines
68 KiB
Java
1382 lines
68 KiB
Java
package com.evsuite.abrp;
|
|
|
|
import android.content.Intent;
|
|
import android.content.SharedPreferences;
|
|
import android.content.pm.PackageManager;
|
|
import android.graphics.Color;
|
|
import android.graphics.drawable.ColorDrawable;
|
|
import android.net.Uri;
|
|
import android.os.Bundle;
|
|
import android.view.View;
|
|
import android.widget.Button;
|
|
import android.widget.Toast;
|
|
import android.widget.ImageView;
|
|
import android.widget.TextView;
|
|
|
|
import java.time.LocalDate;
|
|
import java.util.List;
|
|
|
|
import androidx.activity.result.ActivityResultLauncher;
|
|
import androidx.activity.result.contract.ActivityResultContracts;
|
|
import androidx.annotation.NonNull;
|
|
import androidx.appcompat.app.AppCompatActivity;
|
|
import androidx.appcompat.app.AppCompatDelegate;
|
|
import androidx.recyclerview.widget.RecyclerView;
|
|
import androidx.viewpager2.widget.ViewPager2;
|
|
|
|
import com.google.android.material.switchmaterial.SwitchMaterial;
|
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
|
import com.google.android.material.button.MaterialButton;
|
|
import com.google.android.material.textfield.TextInputEditText;
|
|
import com.google.android.material.textfield.TextInputLayout;
|
|
import com.evsuite.hardware.telemetry.EnergySnapshot;
|
|
import com.evsuite.hardware.telemetry.EnergyTelemetryReader;
|
|
|
|
|
|
public class MainActivity extends AppCompatActivity {
|
|
|
|
/** Shared with {@link AbrpUploadService}, which reads it on every tick. */
|
|
static final String WIFI_AUTO_KEY = "wifi_auto_connect";
|
|
private static final String REPOSITORY_URL = "https://github.com/fatihdonmezdev/FatihsMG4";
|
|
|
|
/** Navigation destinations in page order. Parallel to {@link #panes}. */
|
|
private static final int[] TAB_IDS =
|
|
{ R.id.tabVehicle, R.id.tabConsumption, R.id.tabCharging, R.id.tabService, R.id.tabServer,
|
|
R.id.tabWifi, R.id.tabLog };
|
|
|
|
private static final int[] PAGE_TITLES = {
|
|
R.string.design_overview_title, R.string.consumption_title, R.string.charging_title,
|
|
R.string.design_service_title, R.string.design_server_title, R.string.design_wifi_title,
|
|
R.string.design_log_title
|
|
};
|
|
private static final int[] PAGE_SUBTITLES = {
|
|
R.string.design_overview_subtitle, R.string.consumption_subtitle, R.string.charging_subtitle,
|
|
R.string.design_service_subtitle, R.string.design_server_subtitle, R.string.design_wifi_subtitle,
|
|
R.string.design_log_subtitle
|
|
};
|
|
|
|
// Status colours come from the palette, not from the Material swatches: #4CAF50 and
|
|
// #F44336 sit around 4:1 on this background, which disappears behind a sunlit
|
|
// reflection. The palette entries are the lightened variants (7:1 and above).
|
|
private static final int COLOR_OK = 0xFFA4DCC1;
|
|
private static final int COLOR_ERROR = 0xFFFFB4AB;
|
|
private static final int COLOR_PENDING = 0xFFBDC4C6;
|
|
|
|
private TextView statusText;
|
|
private TextView callLogText;
|
|
private View servicePane;
|
|
private View serverPane;
|
|
private View logPane;
|
|
private View vehiclePane;
|
|
private View consumptionPane;
|
|
private View chargingPane;
|
|
private View wifiPane;
|
|
|
|
/** The pages in navigation order. Parallel to {@link #TAB_IDS}. */
|
|
private View[] panes;
|
|
|
|
/** Wi-Fi page. The helper is the same one the upload service uses on its own tick. */
|
|
private WifiAutoConnect wifi;
|
|
private TextView wifiSsid, wifiState, wifiSavedList;
|
|
|
|
/**
|
|
* Value cells of the Vehicle page, looked up once and refreshed on the UI tick.
|
|
*
|
|
* Read straight from {@link EnergyTelemetryReader} rather than from the uploader: the
|
|
* page shows what the car reports, whether or not the upload service is running, and a
|
|
* signal the vehicle does not publish must show a dash rather than a zero.
|
|
*/
|
|
private EnergyTelemetryReader vehicleReader;
|
|
private TextView vehSoc, vehRange, vehPower, vehSpeed, vehOdometer,
|
|
vehCharging, vehHvac,
|
|
vehTireFl, vehTireFr, vehTireRl, vehTireRr,
|
|
vehRangePower, vehRangePowerNote, vehRangeAverage, vehRangeAverageNote;
|
|
|
|
/** Usable pack capacity for the range estimates; the car does not publish it. */
|
|
static final String USABLE_KWH_KEY = "usable_kwh";
|
|
private TextInputLayout usableCapacityLayout;
|
|
private TextInputEditText usableCapacityInput;
|
|
private ConsumptionTracker consumptionTracker;
|
|
private ConsumptionCloudClient consumptionCloudClient;
|
|
private ConsumptionTracker.Period consumptionPeriod = ConsumptionTracker.Period.LIFETIME;
|
|
private TextView consumptionDistance, consumptionEnergy, consumptionAverage,
|
|
consumptionSpeed, consumptionTime, consumptionSoc, consumptionDailyHistory;
|
|
private Button consumptionReset;
|
|
private MaterialButton consumptionDateButton;
|
|
private TextInputLayout consumptionSohLayout;
|
|
private TextInputEditText consumptionSohInput;
|
|
private java.time.LocalDate selectedConsumptionDate = java.time.LocalDate.now();
|
|
private TextView chargeStatus, chargeSoc, chargeStartSoc, chargeVoltage, chargeCurrent, chargePower,
|
|
chargeEnergy, chargeGridEnergy, chargeDuration, chargeTotalCost, chargeCurveTable;
|
|
private TextInputLayout chargePriceLayout;
|
|
private TextInputEditText chargePriceInput;
|
|
private ChargeSessionTracker chargeSessionTracker;
|
|
private ChargingGraphView chargeGraph;
|
|
|
|
/** Server page. */
|
|
private TextInputLayout serverUrlLayout, serverTokenLayout, installationIdLayout;
|
|
private TextInputEditText serverUrlInput, serverTokenInput, installationIdInput;
|
|
private View connectionStatusRow, connectionIndicator;
|
|
private TextView connectionStatusText;
|
|
|
|
/** Upload page: live telemetry switch and cadence. */
|
|
private SwitchMaterial liveTelemetrySwitch, boostSwitch;
|
|
private android.widget.Spinner intervalSpinner;
|
|
private TextInputLayout lowSocLayout;
|
|
private TextInputEditText lowSocInput;
|
|
|
|
/**
|
|
* Picks a config text file. Only reachable on devices that ship a document picker — the
|
|
* MG4 head unit does not, which is why {@link #startConfigImport()} scans the app's own
|
|
* folders first and only falls back to this.
|
|
*/
|
|
private final ActivityResultLauncher<String[]> configPicker =
|
|
registerForActivityResult(new ActivityResultContracts.OpenDocument(), uri -> {
|
|
if (uri != null) importConfig(uri);
|
|
});
|
|
|
|
/**
|
|
* Inflates a page with no parent, then gives it the MATCH_PARENT/MATCH_PARENT layout
|
|
* params ViewPager2 requires of its items — inflating detached leaves them null, and
|
|
* the pager throws rather than guessing.
|
|
*/
|
|
private View inflatePane(int layoutRes) {
|
|
View pane = getLayoutInflater().inflate(layoutRes, null, false);
|
|
pane.setLayoutParams(new android.view.ViewGroup.LayoutParams(
|
|
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
|
|
android.view.ViewGroup.LayoutParams.MATCH_PARENT));
|
|
return pane;
|
|
}
|
|
|
|
/**
|
|
* Wires the navigation to the pager: every tab is a page, and every page is a tab.
|
|
*
|
|
* Each page has its own view type, so the pager asks for it once and keeps it: these
|
|
* views are the activity's own, held in {@link #panes}, not rows to be recycled.
|
|
*/
|
|
private void setUpPager() {
|
|
ViewPager2 pager = findViewById(R.id.content);
|
|
pager.setAdapter(new RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
|
@Override public int getItemCount() { return panes.length; }
|
|
@Override public int getItemViewType(int position) { return position; }
|
|
|
|
@NonNull @Override
|
|
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull android.view.ViewGroup parent,
|
|
int viewType) {
|
|
return new RecyclerView.ViewHolder(panes[viewType]) {};
|
|
}
|
|
|
|
@Override
|
|
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
|
|
// Nothing to bind: the pages are built once in onCreate and keep their own
|
|
// state, exactly as they did when they were siblings in the layout.
|
|
}
|
|
});
|
|
// Keep all pages alive: the log and vehicle readings are refreshed
|
|
// by the tick even when their page is off screen.
|
|
pager.setOffscreenPageLimit(panes.length - 1);
|
|
pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
|
|
@Override public void onPageSelected(int position) { markCurrentPage(position); }
|
|
});
|
|
|
|
for (int i = 0; i < TAB_IDS.length; i++) {
|
|
final int index = i;
|
|
// Animated, so the button does the same thing the swipe does: the direction of
|
|
// travel is what tells the driver where they are in the row.
|
|
findViewById(TAB_IDS[i]).setOnClickListener(v -> pager.setCurrentItem(index, false));
|
|
}
|
|
markCurrentPage(pager.getCurrentItem());
|
|
}
|
|
|
|
/**
|
|
* Marks the destination and labels the page on screen.
|
|
*
|
|
* Only isSelected is set: fill, text, icon and stroke come from the
|
|
* res/color/nav_tab_*.xml selectors applied by the Widget.EV.NavTab style. isSelected
|
|
* also serves TalkBack, which announces the current destination.
|
|
*/
|
|
private void markCurrentPage(int position) {
|
|
for (int i = 0; i < TAB_IDS.length; i++) {
|
|
findViewById(TAB_IDS[i]).setSelected(i == position);
|
|
}
|
|
((TextView) findViewById(R.id.header_title)).setText(PAGE_TITLES[position]);
|
|
((TextView) findViewById(R.id.header_subtitle)).setText(PAGE_SUBTITLES[position]);
|
|
}
|
|
|
|
/** Refreshes state + call log while the screen is visible. */
|
|
private final android.os.Handler uiHandler = new android.os.Handler(android.os.Looper.getMainLooper());
|
|
private final Runnable uiRefresh = new Runnable() {
|
|
@Override public void run() {
|
|
refreshStatus();
|
|
refreshCallLog();
|
|
refreshVehicle();
|
|
refreshConsumption();
|
|
refreshWifi();
|
|
uiHandler.postDelayed(this, 2_000L);
|
|
}
|
|
};
|
|
|
|
private SharedPreferences prefs;
|
|
|
|
@Override
|
|
protected void onCreate(Bundle savedInstanceState) {
|
|
getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES);
|
|
super.onCreate(savedInstanceState);
|
|
setContentView(R.layout.activity_main);
|
|
|
|
((TextView) findViewById(R.id.version_badge)).setText("v" + BuildConfig.VERSION_NAME);
|
|
|
|
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
|
|
|
|
// The pages are inflated here, once, and handed to the pager as fixed,
|
|
// non-recycled items. That is what lets every widget below be looked up now and
|
|
// held for the life of the activity, the way it was when the panes were siblings
|
|
// in activity_main.xml — a recycled page would invalidate these references as
|
|
// soon as the user swiped away from it.
|
|
servicePane = inflatePane(R.layout.pane_service);
|
|
serverPane = inflatePane(R.layout.pane_server);
|
|
vehiclePane = inflatePane(R.layout.pane_vehicle);
|
|
consumptionPane = inflatePane(R.layout.pane_consumption);
|
|
chargingPane = inflatePane(R.layout.pane_charging);
|
|
wifiPane = inflatePane(R.layout.pane_wifi);
|
|
logPane = inflatePane(R.layout.pane_log);
|
|
panes = new View[] { vehiclePane, consumptionPane, chargingPane, servicePane, serverPane,
|
|
wifiPane, logPane };
|
|
|
|
bindVehiclePane();
|
|
bindConsumptionPane();
|
|
bindChargingPane();
|
|
bindWifiPane();
|
|
bindServerPane();
|
|
bindServicePane();
|
|
requestLocationPermissionIfNeeded();
|
|
|
|
statusText = servicePane.findViewById(R.id.status_text);
|
|
callLogText = logPane.findViewById(R.id.call_log_text);
|
|
logPane.findViewById(R.id.flush_now_button).setOnClickListener(v -> manualFlush());
|
|
|
|
setUpPager();
|
|
findViewById(R.id.about_button).setOnClickListener(v -> showAbout());
|
|
findViewById(R.id.update_button).setOnClickListener(v -> checkForUpdate(v));
|
|
|
|
// Unstable builds check for a newer pre-release; the stable flavor's UpdateHook is
|
|
// a no-op and does not even contain the updater.
|
|
UpdateHook.checkInBackground(this);
|
|
|
|
// The service is always-on: it tracks consumption and sends everything to the server.
|
|
// startForegroundService is idempotent — if the service is already up this
|
|
// is a no-op aside from delivering a new intent.
|
|
startForegroundService(new Intent(this, AbrpUploadService.class));
|
|
}
|
|
|
|
private void showAbout() {
|
|
String version;
|
|
try {
|
|
version = getPackageManager().getPackageInfo(getPackageName(), 0).versionName;
|
|
} catch (PackageManager.NameNotFoundException e) {
|
|
version = getString(R.string.about_version_unknown);
|
|
}
|
|
View content = getLayoutInflater().inflate(R.layout.dialog_about, null);
|
|
content.<TextView>findViewById(R.id.about_version)
|
|
.setText(getString(R.string.about_version, version));
|
|
ImageView qr = content.findViewById(R.id.about_qr_code);
|
|
android.graphics.Bitmap bitmap = QrCode.generate(REPOSITORY_URL, 416);
|
|
if (bitmap != null) qr.setImageBitmap(bitmap);
|
|
content.findViewById(R.id.about_repository).setOnClickListener(v ->
|
|
startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(REPOSITORY_URL))));
|
|
androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(this)
|
|
.setView(content)
|
|
.create();
|
|
content.<MaterialButton>findViewById(R.id.about_close).setOnClickListener(v -> dialog.dismiss());
|
|
dialog.show();
|
|
if (dialog.getWindow() != null) {
|
|
dialog.getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
|
|
android.util.DisplayMetrics metrics = getResources().getDisplayMetrics();
|
|
int width = Math.min((int) (760 * metrics.density),
|
|
metrics.widthPixels - (int) (32 * metrics.density));
|
|
dialog.getWindow().setLayout(width, android.view.ViewGroup.LayoutParams.WRAP_CONTENT);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
protected void onResume() {
|
|
super.onResume();
|
|
// Poll state and log only while the screen is up; onPause cancels it.
|
|
uiHandler.post(uiRefresh);
|
|
}
|
|
|
|
@Override
|
|
protected void onPause() {
|
|
super.onPause();
|
|
// Stop polling off-screen: this activity has no reason to spend cycles then.
|
|
uiHandler.removeCallbacks(uiRefresh);
|
|
}
|
|
|
|
// ---------- Update check ----------
|
|
|
|
/**
|
|
* Checks for a newer build, and installs one if it is there.
|
|
*
|
|
* The button is disabled for the duration rather than guarded by a flag: the whole
|
|
* sequence — API call, download, installer staging — runs on a background thread, and
|
|
* a second press would start a second download of the same APK. It comes back on when
|
|
* the result lands, whatever that result is.
|
|
*
|
|
* A modal progress surface keeps the driver informed while the APK is downloaded and
|
|
* SHA-256 verified; the final result remains visible until dismissed.
|
|
*/
|
|
private void checkForUpdate(View button) {
|
|
button.setEnabled(false);
|
|
android.widget.LinearLayout panel = new android.widget.LinearLayout(this);
|
|
panel.setOrientation(android.widget.LinearLayout.VERTICAL);
|
|
int padding = (int) (24 * getResources().getDisplayMetrics().density);
|
|
panel.setPadding(padding, padding, padding, padding);
|
|
TextView status = new TextView(this);
|
|
status.setText(R.string.update_checking);
|
|
status.setTextAppearance(R.style.Text_EV_Body);
|
|
android.widget.ProgressBar progress = new android.widget.ProgressBar(this, null,
|
|
android.R.attr.progressBarStyleHorizontal);
|
|
progress.setIndeterminate(true);
|
|
android.widget.LinearLayout.LayoutParams progressParams =
|
|
new android.widget.LinearLayout.LayoutParams(
|
|
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
|
|
android.view.ViewGroup.LayoutParams.WRAP_CONTENT);
|
|
progressParams.topMargin = padding;
|
|
panel.addView(status);
|
|
panel.addView(progress, progressParams);
|
|
androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(this)
|
|
.setTitle(R.string.update_dialog_title)
|
|
.setView(panel)
|
|
.setCancelable(false)
|
|
.create();
|
|
dialog.show();
|
|
UpdateHook.checkInBackground(this, new UpdateHook.Listener() {
|
|
@Override public void onProgress(int percent) {
|
|
if (percent < 0) {
|
|
progress.setIndeterminate(true);
|
|
status.setText(R.string.update_downloading);
|
|
} else {
|
|
progress.setIndeterminate(false);
|
|
progress.setMax(100);
|
|
progress.setProgress(percent);
|
|
status.setText(getString(R.string.update_downloading_percent, percent));
|
|
}
|
|
}
|
|
|
|
@Override public void onResult(String message) {
|
|
button.setEnabled(true);
|
|
dialog.dismiss();
|
|
new MaterialAlertDialogBuilder(MainActivity.this)
|
|
.setTitle(R.string.update_dialog_title)
|
|
.setMessage(message)
|
|
.setPositiveButton(android.R.string.ok, null)
|
|
.show();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------- Server page ----------
|
|
|
|
private void bindServerPane() {
|
|
serverUrlLayout = serverPane.findViewById(R.id.server_url_layout);
|
|
serverTokenLayout = serverPane.findViewById(R.id.server_token_layout);
|
|
installationIdLayout = serverPane.findViewById(R.id.installation_id_layout);
|
|
serverUrlInput = serverPane.findViewById(R.id.server_url_input);
|
|
serverTokenInput = serverPane.findViewById(R.id.server_token_input);
|
|
installationIdInput = serverPane.findViewById(R.id.installation_id_input);
|
|
connectionStatusRow = serverPane.findViewById(R.id.connection_status_row);
|
|
connectionIndicator = serverPane.findViewById(R.id.connection_indicator);
|
|
connectionStatusText = serverPane.findViewById(R.id.connection_status_text);
|
|
showServerConfig();
|
|
((TextView) serverPane.findViewById(R.id.import_hint))
|
|
.setText(getString(R.string.import_hint, configDirHint()));
|
|
|
|
serverPane.findViewById(R.id.server_save_button).setOnClickListener(v -> {
|
|
if (saveServerConfig(textOf(serverUrlInput), textOf(serverTokenInput), textOf(installationIdInput))) {
|
|
setConnectionStatus(COLOR_OK, getString(R.string.server_saved));
|
|
}
|
|
});
|
|
serverPane.findViewById(R.id.server_test_button).setOnClickListener(v -> testConnection());
|
|
serverPane.findViewById(R.id.server_pair_button).setOnClickListener(v -> startPairing());
|
|
serverPane.findViewById(R.id.import_button).setOnClickListener(v -> startConfigImport());
|
|
}
|
|
|
|
private void showServerConfig() {
|
|
ServerConfig config = ServerConfig.get(this);
|
|
serverUrlInput.setText(config.url);
|
|
serverTokenInput.setText(config.token);
|
|
installationIdInput.setText(config.installationId);
|
|
}
|
|
|
|
/** Validates, marks the bad fields, and saves only when all three are good. */
|
|
private boolean saveServerConfig(String rawUrl, String rawToken, String rawId) {
|
|
String url = ServerConfig.normalizeUrl(rawUrl);
|
|
String token = rawToken == null ? "" : rawToken.trim();
|
|
String id = rawId == null ? "" : rawId.trim().toLowerCase(java.util.Locale.US);
|
|
serverUrlLayout.setError(url == null ? getString(R.string.server_err_url) : null);
|
|
serverTokenLayout.setError(ServerConfig.isValidToken(token) ? null : getString(R.string.server_err_token));
|
|
installationIdLayout.setError(ServerConfig.isValidInstallationId(id)
|
|
? null : getString(R.string.server_err_installation_id));
|
|
if (url == null || !ServerConfig.isValidToken(token) || !ServerConfig.isValidInstallationId(id)) return false;
|
|
ServerConfig.save(this, url, token, id);
|
|
if (consumptionCloudClient != null) consumptionCloudClient.invalidateHistory();
|
|
showServerConfig();
|
|
refreshStatus();
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Checks reachability, then the token, without writing anything: the health endpoint
|
|
* first, then the car's own history read, which needs the ingest token. Uses what is in
|
|
* the fields, saved or not, so a typo can be caught before it is stored.
|
|
*/
|
|
private void testConnection() {
|
|
String url = ServerConfig.normalizeUrl(textOf(serverUrlInput));
|
|
String token = textOf(serverTokenInput);
|
|
String id = textOf(installationIdInput).toLowerCase(java.util.Locale.US);
|
|
if (url == null) {
|
|
serverUrlLayout.setError(getString(R.string.server_err_url));
|
|
return;
|
|
}
|
|
if (!ServerConfig.isValidInstallationId(id)) {
|
|
installationIdLayout.setError(getString(R.string.server_err_installation_id));
|
|
return;
|
|
}
|
|
setConnectionStatus(COLOR_PENDING, getString(R.string.conn_testing));
|
|
new Thread(() -> {
|
|
String message;
|
|
int color;
|
|
try {
|
|
int health = httpGet(url + "/v1/health", null);
|
|
if (health != 200) {
|
|
message = getString(R.string.conn_err_http, health);
|
|
color = COLOR_ERROR;
|
|
} else {
|
|
int auth = httpGet(url + "/v1/consumption/history/" + id, token);
|
|
if (auth == 200) { message = getString(R.string.conn_ok); color = COLOR_OK; }
|
|
else if (auth == 401) { message = getString(R.string.conn_err_auth); color = COLOR_ERROR; }
|
|
else { message = getString(R.string.conn_err_http, auth); color = COLOR_ERROR; }
|
|
}
|
|
} catch (java.net.SocketTimeoutException e) {
|
|
message = getString(R.string.conn_err_timeout);
|
|
color = COLOR_ERROR;
|
|
} catch (Exception e) {
|
|
message = describeNetworkError(e, url);
|
|
color = COLOR_ERROR;
|
|
}
|
|
final String m = message;
|
|
final int c = color;
|
|
runOnUiThread(() -> setConnectionStatus(c, m));
|
|
}, "server-test").start();
|
|
}
|
|
|
|
/**
|
|
* Pairs this car with the server through a phone: the server opens a pairing request, the
|
|
* dialog shows its code as a QR code, and a background thread polls until the request is
|
|
* approved, denied or expired. On approval the car's own token is saved with the URL from
|
|
* the field and the installation id, exactly as Save would.
|
|
*/
|
|
private void startPairing() {
|
|
String url = ServerConfig.normalizeUrl(textOf(serverUrlInput));
|
|
if (url == null) {
|
|
serverUrlLayout.setError(getString(R.string.server_err_url));
|
|
return;
|
|
}
|
|
serverUrlLayout.setError(null);
|
|
String typedId = textOf(installationIdInput).toLowerCase(java.util.Locale.US);
|
|
String installationId = ServerConfig.isValidInstallationId(typedId)
|
|
? typedId : ServerConfig.get(this).installationId;
|
|
|
|
float density = getResources().getDisplayMetrics().density;
|
|
int pad = (int) (24 * density);
|
|
// On the head unit's wide, low screen the QR code sits left of the text: stacked, the
|
|
// code and status below a 240 dp QR code were cut off. Scrollable either way, so
|
|
// nothing can be clipped on any screen.
|
|
android.util.DisplayMetrics metrics = getResources().getDisplayMetrics();
|
|
boolean wide = metrics.widthPixels > metrics.heightPixels;
|
|
android.widget.LinearLayout panel = new android.widget.LinearLayout(this);
|
|
panel.setOrientation(wide ? android.widget.LinearLayout.HORIZONTAL : android.widget.LinearLayout.VERTICAL);
|
|
panel.setGravity(wide ? android.view.Gravity.CENTER_VERTICAL : android.view.Gravity.CENTER_HORIZONTAL);
|
|
panel.setPadding(pad, pad, pad, pad);
|
|
android.widget.LinearLayout column = new android.widget.LinearLayout(this);
|
|
column.setOrientation(android.widget.LinearLayout.VERTICAL);
|
|
TextView instructions = new TextView(this);
|
|
instructions.setTextAppearance(R.style.Text_EV_Secondary);
|
|
ImageView qr = new ImageView(this);
|
|
qr.setContentDescription(getString(R.string.pair_qr_description));
|
|
int qrSize = (int) (240 * density);
|
|
TextView code = new TextView(this);
|
|
code.setTextAppearance(R.style.Text_EV_Title);
|
|
code.setTypeface(android.graphics.Typeface.MONOSPACE, android.graphics.Typeface.BOLD);
|
|
code.setTextSize(36);
|
|
code.setLetterSpacing(0.15f);
|
|
code.setTextColor(getColor(R.color.ev_cyan));
|
|
TextView status = new TextView(this);
|
|
status.setTextAppearance(R.style.Text_EV_Body);
|
|
status.setText(R.string.pair_starting);
|
|
android.widget.LinearLayout.LayoutParams qrParams = new android.widget.LinearLayout.LayoutParams(qrSize, qrSize);
|
|
android.widget.LinearLayout.LayoutParams columnParams;
|
|
if (wide) {
|
|
qrParams.rightMargin = pad;
|
|
columnParams = new android.widget.LinearLayout.LayoutParams(
|
|
0, android.view.ViewGroup.LayoutParams.WRAP_CONTENT, 1f);
|
|
panel.addView(qr, qrParams);
|
|
panel.addView(column, columnParams);
|
|
column.addView(instructions);
|
|
} else {
|
|
qrParams.topMargin = pad / 2;
|
|
qrParams.bottomMargin = pad / 2;
|
|
columnParams = new android.widget.LinearLayout.LayoutParams(
|
|
android.view.ViewGroup.LayoutParams.MATCH_PARENT, android.view.ViewGroup.LayoutParams.WRAP_CONTENT);
|
|
panel.addView(instructions);
|
|
panel.addView(qr, qrParams);
|
|
panel.addView(column, columnParams);
|
|
}
|
|
android.widget.LinearLayout.LayoutParams codeParams = new android.widget.LinearLayout.LayoutParams(
|
|
android.view.ViewGroup.LayoutParams.WRAP_CONTENT, android.view.ViewGroup.LayoutParams.WRAP_CONTENT);
|
|
codeParams.topMargin = pad / 2;
|
|
codeParams.bottomMargin = pad / 2;
|
|
column.addView(code, codeParams);
|
|
column.addView(status);
|
|
android.widget.ScrollView scroll = new android.widget.ScrollView(this);
|
|
scroll.addView(panel);
|
|
|
|
final boolean[] cancelled = {false};
|
|
androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(this)
|
|
.setTitle(R.string.pair_title)
|
|
.setView(scroll)
|
|
.setNegativeButton(R.string.pair_cancel, null)
|
|
.create();
|
|
// Shown only once the server has answered, so a failure leaves no empty QR frame.
|
|
qr.setVisibility(View.GONE);
|
|
code.setVisibility(View.GONE);
|
|
dialog.setOnDismissListener(d -> cancelled[0] = true);
|
|
dialog.show();
|
|
|
|
new Thread(() -> {
|
|
String outcome;
|
|
int color = COLOR_ERROR;
|
|
try {
|
|
PairingClient.Request request = PairingClient.start(url, installationId);
|
|
android.graphics.Bitmap bitmap = QrCode.generate(request.verificationUriComplete, 416);
|
|
runOnUiThread(() -> {
|
|
// The code is in the sentence as well as below it, so it is readable
|
|
// wherever the text is.
|
|
instructions.setText(getString(R.string.pair_scan, request.verificationUri, request.userCode));
|
|
if (bitmap != null) {
|
|
qr.setImageBitmap(bitmap);
|
|
qr.setVisibility(View.VISIBLE);
|
|
}
|
|
code.setText(request.userCode);
|
|
code.setVisibility(View.VISIBLE);
|
|
status.setText(R.string.pair_waiting);
|
|
});
|
|
PairingClient.PollResult result = null;
|
|
while (!cancelled[0] && System.currentTimeMillis() < request.expiresAtMs) {
|
|
Thread.sleep(request.intervalMs);
|
|
if (cancelled[0]) break;
|
|
try {
|
|
result = PairingClient.poll(url, request.deviceCode);
|
|
} catch (java.io.IOException e) {
|
|
continue; // a dropped connection mid-wait is not a failed pairing
|
|
}
|
|
if (result.outcome != PairingClient.Outcome.PENDING) break;
|
|
}
|
|
if (cancelled[0]) return;
|
|
if (result != null && result.outcome == PairingClient.Outcome.APPROVED) {
|
|
ServerConfig.save(this, url, result.token, installationId);
|
|
outcome = getString(R.string.pair_ok);
|
|
color = COLOR_OK;
|
|
} else if (result != null && result.outcome == PairingClient.Outcome.DENIED) {
|
|
outcome = getString(R.string.pair_denied);
|
|
} else {
|
|
outcome = getString(R.string.pair_expired);
|
|
}
|
|
} catch (PairingClient.NotEnabledException e) {
|
|
outcome = getString(R.string.pair_not_enabled);
|
|
} catch (PairingClient.HttpStatusException e) {
|
|
outcome = e.status == 404 ? getString(R.string.pair_err_old_server)
|
|
: getString(R.string.err_http, e.status);
|
|
} catch (InterruptedException e) {
|
|
return;
|
|
} catch (Exception e) {
|
|
outcome = describeNetworkError(e, url);
|
|
}
|
|
final String message = outcome;
|
|
final int c = color;
|
|
runOnUiThread(() -> {
|
|
setConnectionStatus(c, message);
|
|
if (c == COLOR_OK) {
|
|
if (consumptionCloudClient != null) consumptionCloudClient.invalidateHistory();
|
|
showServerConfig();
|
|
serverTokenLayout.setError(null);
|
|
refreshStatus();
|
|
if (!cancelled[0]) dialog.dismiss();
|
|
return;
|
|
}
|
|
if (cancelled[0]) return;
|
|
// Keep the dialog up with the reason, instead of closing it on a failure the
|
|
// driver would otherwise only see as a flicker.
|
|
qr.setVisibility(View.GONE);
|
|
code.setVisibility(View.GONE);
|
|
instructions.setVisibility(View.GONE);
|
|
status.setText(message);
|
|
status.setTextColor(c);
|
|
android.widget.Button close = dialog.getButton(android.content.DialogInterface.BUTTON_NEGATIVE);
|
|
if (close != null) close.setText(R.string.pair_close);
|
|
});
|
|
}, "server-pairing").start();
|
|
}
|
|
|
|
/**
|
|
* A network failure in words the driver can act on: a name that does not resolve, a
|
|
* server that does not answer, or a status. Shared by Test connection and pairing.
|
|
*/
|
|
private String describeNetworkError(Exception e, String url) {
|
|
String host = Uri.parse(url).getHost();
|
|
if (host == null) host = url;
|
|
if (e instanceof java.net.UnknownHostException) return getString(R.string.err_dns, host);
|
|
if (e instanceof java.net.SocketTimeoutException || e instanceof java.net.ConnectException
|
|
|| e instanceof java.net.NoRouteToHostException) {
|
|
return getString(R.string.err_unreachable, host);
|
|
}
|
|
if (e instanceof PairingClient.HttpStatusException) {
|
|
return getString(R.string.err_http, ((PairingClient.HttpStatusException) e).status);
|
|
}
|
|
return getString(R.string.pair_error, e.getClass().getSimpleName());
|
|
}
|
|
|
|
private static int httpGet(String url, String token) throws java.io.IOException {
|
|
java.net.HttpURLConnection connection = (java.net.HttpURLConnection) new java.net.URL(url).openConnection();
|
|
try {
|
|
connection.setConnectTimeout(8_000);
|
|
connection.setReadTimeout(8_000);
|
|
if (token != null) connection.setRequestProperty("Authorization", "Bearer " + token);
|
|
return connection.getResponseCode();
|
|
} finally {
|
|
connection.disconnect();
|
|
}
|
|
}
|
|
|
|
private void setConnectionStatus(int color, String message) {
|
|
connectionStatusRow.setVisibility(View.VISIBLE);
|
|
connectionIndicator.getBackground().mutate().setTint(color);
|
|
connectionStatusText.setText(message);
|
|
connectionStatusText.setTextColor(color);
|
|
}
|
|
|
|
// ---------- Config import ----------
|
|
|
|
/**
|
|
* The MG4 head unit ships no document picker — SAF answers "FileManagement is no supported
|
|
* on this device" — so the file is found by scanning instead. getExternalFilesDirs() returns
|
|
* this app's own folder on internal storage and on every mounted volume including a USB
|
|
* stick, and those need no storage permission at any API level. The picker stays as the
|
|
* fallback for phones and tablets, where the user may keep the file anywhere.
|
|
*/
|
|
private void startConfigImport() {
|
|
java.io.File found = findConfigFile();
|
|
if (found != null) {
|
|
importConfig(found);
|
|
return;
|
|
}
|
|
try {
|
|
// Any MIME: the MG4 Files app tags .txt inconsistently, so filtering by type hides
|
|
// the very file the user is trying to pick. They select it by name instead.
|
|
configPicker.launch(new String[]{"*/*"});
|
|
} catch (android.content.ActivityNotFoundException e) {
|
|
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_not_found, configDirHint()));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* First readable file in an app-specific folder that parses as a config. Names are not
|
|
* filtered: the user copies one file to the folder, whatever they called it.
|
|
*/
|
|
private java.io.File findConfigFile() {
|
|
for (java.io.File dir : getExternalFilesDirs(null)) {
|
|
if (dir == null) continue;
|
|
java.io.File[] files = dir.listFiles();
|
|
if (files == null) continue;
|
|
for (java.io.File f : files) {
|
|
if (f.isFile() && f.canRead() && f.length() > 0 && f.length() <= 64 * 1024
|
|
&& !ConfigImport.parse(readText(f)).isEmpty()) {
|
|
return f;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** File text, or "" when unreadable — callers treat that as "not a config". */
|
|
private String readText(java.io.File file) {
|
|
try (java.io.InputStream in = new java.io.FileInputStream(file)) {
|
|
return readCapped(in);
|
|
} catch (Exception e) {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
/** Capped so a wrong file picked by mistake is not slurped whole into memory. */
|
|
private String readCapped(java.io.InputStream in) throws java.io.IOException {
|
|
java.io.ByteArrayOutputStream buf = new java.io.ByteArrayOutputStream();
|
|
byte[] chunk = new byte[4096];
|
|
int n;
|
|
while ((n = in.read(chunk)) != -1 && buf.size() < 64 * 1024) buf.write(chunk, 0, n);
|
|
return buf.toString(java.nio.charset.StandardCharsets.UTF_8.name());
|
|
}
|
|
|
|
/** Where the user should drop the file. */
|
|
private String configDirHint() {
|
|
java.io.File[] dirs = getExternalFilesDirs(null);
|
|
return dirs.length > 0 && dirs[0] != null ? dirs[0].getAbsolutePath() : "";
|
|
}
|
|
|
|
private void importConfig(java.io.File file) {
|
|
String text = readText(file);
|
|
if (text.isEmpty()) {
|
|
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_read));
|
|
return;
|
|
}
|
|
applyConfig(text);
|
|
}
|
|
|
|
private void importConfig(Uri uri) {
|
|
String text;
|
|
try (java.io.InputStream in = getContentResolver().openInputStream(uri)) {
|
|
if (in == null) throw new java.io.IOException("no stream");
|
|
text = readCapped(in);
|
|
} catch (Exception e) {
|
|
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_read));
|
|
return;
|
|
}
|
|
applyConfig(text);
|
|
}
|
|
|
|
/**
|
|
* Applies whatever the file sets; absent keys keep their current value, so a file with
|
|
* only the token does not wipe the URL. Server settings are validated and saved together;
|
|
* cadence keys go to the plain prefs the same way the Upload page writes them.
|
|
*/
|
|
private void applyConfig(String text) {
|
|
ConfigImport config = ConfigImport.parse(text);
|
|
if (config.isEmpty()) {
|
|
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_empty));
|
|
return;
|
|
}
|
|
ServerConfig current = ServerConfig.get(this);
|
|
if (config.serverUrl != null || config.token != null || config.installationId != null) {
|
|
boolean saved = saveServerConfig(
|
|
config.serverUrl != null ? config.serverUrl : current.url,
|
|
config.token != null ? config.token : current.token,
|
|
config.installationId != null ? config.installationId : current.installationId);
|
|
if (!saved) {
|
|
// Show what the file said, so the marked field is the one to fix in the file.
|
|
if (config.serverUrl != null) serverUrlInput.setText(config.serverUrl);
|
|
if (config.token != null) serverTokenInput.setText(config.token);
|
|
if (config.installationId != null) installationIdInput.setText(config.installationId);
|
|
return;
|
|
}
|
|
}
|
|
SharedPreferences.Editor edit = prefs.edit();
|
|
if (config.intervalSec != null) edit.putInt(UploadSettings.KEY_INTERVAL_SEC, config.intervalSec);
|
|
if (config.boostLowSoc != null) edit.putBoolean(UploadSettings.KEY_BOOST_LOW_SOC, config.boostLowSoc);
|
|
if (config.lowSocPercent != null) edit.putInt(UploadSettings.KEY_LOW_SOC_PERCENT, config.lowSocPercent);
|
|
edit.apply();
|
|
bindCadenceControls();
|
|
setConnectionStatus(COLOR_OK, getString(R.string.import_ok));
|
|
refreshStatus();
|
|
}
|
|
|
|
// ---------- Upload page ----------
|
|
|
|
private void bindServicePane() {
|
|
liveTelemetrySwitch = servicePane.findViewById(R.id.abrp_upload_switch);
|
|
intervalSpinner = servicePane.findViewById(R.id.interval_spinner);
|
|
boostSwitch = servicePane.findViewById(R.id.boost_switch);
|
|
lowSocInput = servicePane.findViewById(R.id.low_soc_input);
|
|
lowSocLayout = servicePane.findViewById(R.id.low_soc_layout);
|
|
liveTelemetrySwitch.setChecked(UploadSettings.from(prefs).abrpUploadEnabled);
|
|
liveTelemetrySwitch.setOnCheckedChangeListener((btn, checked) -> {
|
|
prefs.edit().putBoolean(UploadSettings.KEY_ABRP_UPLOAD_ENABLED, checked).apply();
|
|
refreshStatus();
|
|
});
|
|
bindCadenceControls();
|
|
}
|
|
|
|
/** The service reads these settings on every tick, so a change applies without a restart. */
|
|
private void bindCadenceControls() {
|
|
UploadSettings current = UploadSettings.from(prefs);
|
|
|
|
String[] labels = new String[UploadSettings.INTERVAL_CHOICES_SEC.length];
|
|
int selected = 0;
|
|
for (int i = 0; i < UploadSettings.INTERVAL_CHOICES_SEC.length; i++) {
|
|
int sec = UploadSettings.INTERVAL_CHOICES_SEC[i];
|
|
labels[i] = sec >= 60
|
|
? getResources().getQuantityString(R.plurals.interval_minutes, sec / 60, sec / 60)
|
|
: getResources().getQuantityString(R.plurals.interval_seconds, sec, sec);
|
|
if (sec == current.intervalSec) selected = i;
|
|
}
|
|
android.widget.ArrayAdapter<String> adapter = new android.widget.ArrayAdapter<>(
|
|
this, R.layout.spinner_value, labels);
|
|
adapter.setDropDownViewResource(R.layout.spinner_dropdown);
|
|
intervalSpinner.setAdapter(adapter);
|
|
intervalSpinner.setSelection(selected);
|
|
intervalSpinner.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() {
|
|
@Override
|
|
public void onItemSelected(android.widget.AdapterView<?> parent, View view, int position, long id) {
|
|
prefs.edit().putInt(UploadSettings.KEY_INTERVAL_SEC,
|
|
UploadSettings.INTERVAL_CHOICES_SEC[position]).apply();
|
|
}
|
|
@Override public void onNothingSelected(android.widget.AdapterView<?> parent) { }
|
|
});
|
|
|
|
boostSwitch.setOnCheckedChangeListener(null);
|
|
boostSwitch.setChecked(current.boostLowSoc);
|
|
lowSocLayout.setEnabled(current.boostLowSoc);
|
|
lowSocInput.setEnabled(current.boostLowSoc);
|
|
boostSwitch.setOnCheckedChangeListener((button, checked) -> {
|
|
prefs.edit().putBoolean(UploadSettings.KEY_BOOST_LOW_SOC, checked).apply();
|
|
lowSocLayout.setEnabled(checked);
|
|
lowSocInput.setEnabled(checked);
|
|
});
|
|
|
|
lowSocInput.setText(String.valueOf(current.lowSocPercent));
|
|
lowSocInput.setOnFocusChangeListener((view, hasFocus) -> {
|
|
if (!hasFocus) saveLowSocThreshold();
|
|
});
|
|
}
|
|
|
|
/** Clamped to 1-99: 0 would never trigger and 100 would boost permanently. */
|
|
private void saveLowSocThreshold() {
|
|
CharSequence raw = lowSocInput.getText();
|
|
int value;
|
|
try {
|
|
value = Integer.parseInt(raw == null ? "" : raw.toString().trim());
|
|
} catch (NumberFormatException e) {
|
|
value = UploadSettings.DEFAULT_LOW_SOC_PERCENT;
|
|
}
|
|
value = Math.max(1, Math.min(99, value));
|
|
lowSocInput.setText(String.valueOf(value));
|
|
prefs.edit().putInt(UploadSettings.KEY_LOW_SOC_PERCENT, value).apply();
|
|
}
|
|
|
|
/**
|
|
* GPS for the telemetry position. On the car the app runs as the system user and holds
|
|
* it already; this only asks where it does not, such as a phone running the stable build.
|
|
*/
|
|
private void requestLocationPermissionIfNeeded() {
|
|
if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION)
|
|
!= PackageManager.PERMISSION_GRANTED) {
|
|
requestPermissions(new String[]{android.Manifest.permission.ACCESS_FINE_LOCATION,
|
|
android.Manifest.permission.ACCESS_COARSE_LOCATION}, 1);
|
|
}
|
|
}
|
|
|
|
// ---------- Wi-Fi page ----------
|
|
|
|
private void bindWifiPane() {
|
|
wifi = new WifiAutoConnect(getApplicationContext());
|
|
wifiSsid = wifiPane.findViewById(R.id.wifi_ssid);
|
|
wifiState = wifiPane.findViewById(R.id.wifi_state);
|
|
wifiSavedList = wifiPane.findViewById(R.id.wifi_saved_list);
|
|
|
|
SwitchMaterial auto = wifiPane.findViewById(R.id.wifi_auto_switch);
|
|
auto.setChecked(prefs.getBoolean(WIFI_AUTO_KEY, true));
|
|
auto.setOnCheckedChangeListener((btn, checked) ->
|
|
prefs.edit().putBoolean(WIFI_AUTO_KEY, checked).apply());
|
|
|
|
wifiPane.findViewById(R.id.wifi_connect_button).setOnClickListener(v -> {
|
|
// force: the driver is asking now, so the rate limit does not apply.
|
|
WifiAutoConnect.Result r = wifi.ensureConnected(true);
|
|
wifiState.setText(r.message);
|
|
refreshWifi();
|
|
});
|
|
}
|
|
|
|
private void refreshWifi() {
|
|
if (wifi == null) return;
|
|
String ssid = wifi.currentSsid();
|
|
wifiSsid.setText(ssid == null ? getString(R.string.wifi_not_connected) : ssid);
|
|
if (ssid != null) {
|
|
wifiState.setText("Connected ♥");
|
|
} else if (!wifi.isWifiEnabled()) {
|
|
wifiState.setText("Wi-Fi off");
|
|
}
|
|
// Left alone otherwise: a message the button just set says more about what is
|
|
// happening than "not connected" would.
|
|
|
|
java.util.List<String> saved = wifi.savedNetworkNames();
|
|
wifiSavedList.setText(saved.isEmpty()
|
|
? getString(R.string.wifi_saved_empty)
|
|
: android.text.TextUtils.join("\n", saved));
|
|
}
|
|
|
|
// ---------- Vehicle page ----------
|
|
|
|
/** Looks up the value cells once; the labels are static and never touched from code. */
|
|
private void bindVehiclePane() {
|
|
vehSoc = vehiclePane.findViewById(R.id.vehicle_soc);
|
|
vehRange = vehiclePane.findViewById(R.id.vehicle_range);
|
|
vehPower = vehiclePane.findViewById(R.id.vehicle_power);
|
|
vehSpeed = vehiclePane.findViewById(R.id.vehicle_speed);
|
|
vehOdometer = vehiclePane.findViewById(R.id.vehicle_odometer);
|
|
vehCharging = vehiclePane.findViewById(R.id.vehicle_charging);
|
|
vehHvac = vehiclePane.findViewById(R.id.vehicle_hvac);
|
|
vehTireFl = vehiclePane.findViewById(R.id.vehicle_tire_fl);
|
|
vehTireFr = vehiclePane.findViewById(R.id.vehicle_tire_fr);
|
|
vehTireRl = vehiclePane.findViewById(R.id.vehicle_tire_rl);
|
|
vehTireRr = vehiclePane.findViewById(R.id.vehicle_tire_rr);
|
|
vehRangePower = vehiclePane.findViewById(R.id.vehicle_range_power);
|
|
vehRangePowerNote = vehiclePane.findViewById(R.id.vehicle_range_power_note);
|
|
vehRangeAverage = vehiclePane.findViewById(R.id.vehicle_range_average);
|
|
vehRangeAverageNote = vehiclePane.findViewById(R.id.vehicle_range_average_note);
|
|
}
|
|
|
|
private double usableKwh() {
|
|
return prefs.getFloat(USABLE_KWH_KEY, (float) RangeEstimator.DEFAULT_USABLE_KWH);
|
|
}
|
|
|
|
/**
|
|
* The two range estimates. Both need the car to be moving on battery power; the average
|
|
* also needs a minute or more of driving, which the service has usually collected already.
|
|
*/
|
|
private void refreshRangeEstimates(EnergySnapshot s, long nowMs) {
|
|
RangeEstimator estimator = RangeEstimator.shared();
|
|
estimator.add(nowMs, s.getBatteryPowerKw(), s.getSpeedKmh());
|
|
Double remaining = RangeEstimator.remainingKwh(s.getSocPercent(), usableKwh());
|
|
showEstimate(vehRangePower, vehRangePowerNote,
|
|
RangeEstimator.instant(remaining, s.getBatteryPowerKw(), s.getSpeedKmh()),
|
|
R.string.range_not_driving);
|
|
showEstimate(vehRangeAverage, vehRangeAverageNote,
|
|
estimator.average(remaining, nowMs), R.string.range_waiting);
|
|
}
|
|
|
|
private void showEstimate(TextView value, TextView note, RangeEstimator.Estimate e, int whyNot) {
|
|
if (e == null) {
|
|
value.setText("—");
|
|
note.setText(whyNot);
|
|
return;
|
|
}
|
|
// Coasting at speed costs almost nothing, which would read as thousands of km.
|
|
value.setText(e.rangeKm > 999 ? getString(R.string.range_over, 999)
|
|
: String.format(java.util.Locale.US, "%.0f km", e.rangeKm));
|
|
note.setText(getString(R.string.range_consumption, e.kwhPer100Km));
|
|
}
|
|
|
|
/**
|
|
* Reads one snapshot and writes it to the page.
|
|
*
|
|
* The reader is built lazily and kept: constructing it binds the vendor services, which
|
|
* is not work to repeat every two seconds. A read that throws leaves the cells as they
|
|
* were rather than blanking the page — a transient miss is not news worth showing.
|
|
*/
|
|
private void refreshVehicle() {
|
|
try {
|
|
if (vehicleReader == null) {
|
|
vehicleReader = new EnergyTelemetryReader(getApplicationContext());
|
|
}
|
|
// The timestamp is passed explicitly: its Kotlin default is not visible from Java.
|
|
EnergySnapshot s = vehicleReader.read(System.currentTimeMillis());
|
|
ConsumptionTracker.get(this).sample(s);
|
|
refreshCharging(s);
|
|
|
|
vehSoc.setText(fmt(s.getSocPercent(), "%.0f %%"));
|
|
vehRange.setText(fmt(s.getRangeKm(), "%.0f km"));
|
|
vehPower.setText(fmt(s.getBatteryPowerKw(), "%.1f kW"));
|
|
vehSpeed.setText(fmt(s.getSpeedKmh(), "%.0f km/h"));
|
|
vehOdometer.setText(fmt(s.getOdometerKm(), "%.0f km"));
|
|
vehHvac.setText(fmt(s.getClimate().getDriverTargetCelsius(), "%.0f °C"));
|
|
|
|
vehCharging.setText(chargingLabel(s));
|
|
refreshRangeEstimates(s, System.currentTimeMillis());
|
|
|
|
vehTireFl.setText(fmt(s.getTirePressures().getFrontLeftKpa(), "%.0f kPa"));
|
|
vehTireFr.setText(fmt(s.getTirePressures().getFrontRightKpa(), "%.0f kPa"));
|
|
vehTireRl.setText(fmt(s.getTirePressures().getRearLeftKpa(), "%.0f kPa"));
|
|
vehTireRr.setText(fmt(s.getTirePressures().getRearRightKpa(), "%.0f kPa"));
|
|
} catch (Throwable ignored) {
|
|
// Leave the last good reading on screen.
|
|
}
|
|
}
|
|
|
|
private void bindConsumptionPane() {
|
|
consumptionTracker = ConsumptionTracker.get(this);
|
|
consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext());
|
|
consumptionDistance = consumptionPane.findViewById(R.id.consumption_distance);
|
|
consumptionEnergy = consumptionPane.findViewById(R.id.consumption_energy);
|
|
consumptionAverage = consumptionPane.findViewById(R.id.consumption_average);
|
|
consumptionSpeed = consumptionPane.findViewById(R.id.consumption_speed);
|
|
consumptionTime = consumptionPane.findViewById(R.id.consumption_time);
|
|
consumptionSoc = consumptionPane.findViewById(R.id.consumption_soc);
|
|
consumptionDailyHistory = consumptionPane.findViewById(R.id.consumption_daily_history);
|
|
consumptionDateButton = consumptionPane.findViewById(R.id.consumption_date_button);
|
|
consumptionDateButton.setText(selectedConsumptionDate.toString());
|
|
consumptionDateButton.setOnClickListener(v -> showConsumptionDatePicker());
|
|
consumptionPane.findViewById(R.id.consumption_refresh).setOnClickListener(v -> refreshFromCloud());
|
|
consumptionReset = consumptionPane.findViewById(R.id.consumption_reset);
|
|
consumptionSohLayout = consumptionPane.findViewById(R.id.consumption_soh_layout);
|
|
consumptionSohInput = consumptionPane.findViewById(R.id.consumption_soh_input);
|
|
Float savedSoh = consumptionTracker.sohPercent();
|
|
if (savedSoh != null) consumptionSohInput.setText(String.format(
|
|
java.util.Locale.US, "%.1f", savedSoh));
|
|
consumptionPane.findViewById(R.id.consumption_soh_save).setOnClickListener(v -> saveSoh());
|
|
usableCapacityLayout = consumptionPane.findViewById(R.id.usable_capacity_layout);
|
|
usableCapacityInput = consumptionPane.findViewById(R.id.usable_capacity_input);
|
|
usableCapacityInput.setText(String.format(java.util.Locale.US, "%.1f", usableKwh()));
|
|
consumptionPane.findViewById(R.id.usable_capacity_save).setOnClickListener(v -> saveUsableCapacity());
|
|
bindPeriodButton(R.id.consumption_lifetime, ConsumptionTracker.Period.LIFETIME);
|
|
bindPeriodButton(R.id.consumption_week, ConsumptionTracker.Period.WEEK);
|
|
bindPeriodButton(R.id.consumption_month, ConsumptionTracker.Period.MONTH);
|
|
bindPeriodButton(R.id.consumption_trip_a, ConsumptionTracker.Period.TRIP_A);
|
|
bindPeriodButton(R.id.consumption_trip_b, ConsumptionTracker.Period.TRIP_B);
|
|
selectPeriodButton(R.id.consumption_lifetime);
|
|
consumptionReset.setOnClickListener(v -> {
|
|
if (consumptionPeriod != ConsumptionTracker.Period.TRIP_A && consumptionPeriod != ConsumptionTracker.Period.TRIP_B) return;
|
|
String name = consumptionPeriod == ConsumptionTracker.Period.TRIP_A ? "Trip A" : "Trip B";
|
|
new MaterialAlertDialogBuilder(this).setTitle("Reset " + name + "?")
|
|
.setMessage("This counter's recorded consumption history will be permanently deleted.")
|
|
.setNegativeButton("Cancel", null)
|
|
.setPositiveButton("Reset", (d, w) -> {
|
|
consumptionTracker.reset(consumptionPeriod);
|
|
refreshConsumption();
|
|
}).show();
|
|
});
|
|
}
|
|
|
|
private void saveUsableCapacity() {
|
|
try {
|
|
float value = Float.parseFloat(textOf(usableCapacityInput).replace(',', '.'));
|
|
if (!(value >= 10f && value <= 150f)) throw new NumberFormatException();
|
|
prefs.edit().putFloat(USABLE_KWH_KEY, value).apply();
|
|
usableCapacityLayout.setError(null);
|
|
usableCapacityInput.setText(String.format(java.util.Locale.US, "%.1f", value));
|
|
Toast.makeText(this, R.string.usable_capacity_saved, Toast.LENGTH_SHORT).show();
|
|
} catch (NumberFormatException e) {
|
|
usableCapacityLayout.setError(getString(R.string.usable_capacity_invalid));
|
|
}
|
|
}
|
|
|
|
private void saveSoh() {
|
|
String raw = textOf(consumptionSohInput).replace(',', '.');
|
|
try {
|
|
float value = Float.parseFloat(raw);
|
|
if (!consumptionTracker.setSohPercent(value)) throw new NumberFormatException();
|
|
consumptionSohLayout.setError(null);
|
|
consumptionSohInput.setText(String.format(java.util.Locale.US, "%.1f", value));
|
|
Toast.makeText(this, "SoH saved", Toast.LENGTH_SHORT).show();
|
|
} catch (NumberFormatException e) {
|
|
consumptionSohLayout.setError("Enter a value between 1 and 100");
|
|
}
|
|
}
|
|
|
|
private void bindPeriodButton(int id, ConsumptionTracker.Period period) {
|
|
View button = consumptionPane.findViewById(id);
|
|
button.setOnClickListener(v -> {
|
|
selectPeriodButton(id);
|
|
consumptionPeriod = period;
|
|
consumptionReset.setVisibility(period == ConsumptionTracker.Period.TRIP_A ||
|
|
period == ConsumptionTracker.Period.TRIP_B ? View.VISIBLE : View.GONE);
|
|
refreshConsumption();
|
|
});
|
|
}
|
|
|
|
private void selectPeriodButton(int selectedId) {
|
|
int[] ids = {R.id.consumption_lifetime, R.id.consumption_week,
|
|
R.id.consumption_month, R.id.consumption_trip_a, R.id.consumption_trip_b};
|
|
for (int id : ids) {
|
|
MaterialButton button = consumptionPane.findViewById(id);
|
|
boolean selected = id == selectedId;
|
|
button.setSelected(selected);
|
|
button.setTextColor(selected ? 0xFFE0424C : COLOR_PENDING);
|
|
button.setStrokeColor(android.content.res.ColorStateList.valueOf(
|
|
selected ? 0xFFE0424C : 0xFF3A4143));
|
|
}
|
|
}
|
|
|
|
private void bindChargingPane() {
|
|
chargeSessionTracker = ChargeSessionTracker.get(this);
|
|
chargeStatus = chargingPane.findViewById(R.id.charge_status);
|
|
chargeSoc = chargingPane.findViewById(R.id.charge_soc);
|
|
chargeStartSoc = chargingPane.findViewById(R.id.charge_start_soc);
|
|
chargeVoltage = chargingPane.findViewById(R.id.charge_voltage);
|
|
chargeCurrent = chargingPane.findViewById(R.id.charge_current);
|
|
chargePower = chargingPane.findViewById(R.id.charge_power);
|
|
chargeEnergy = chargingPane.findViewById(R.id.charge_energy);
|
|
chargeGridEnergy = chargingPane.findViewById(R.id.charge_grid_energy);
|
|
chargeDuration = chargingPane.findViewById(R.id.charge_duration);
|
|
chargeTotalCost = chargingPane.findViewById(R.id.charge_total_cost);
|
|
chargeGraph = chargingPane.findViewById(R.id.charge_graph);
|
|
chargeCurveTable = chargingPane.findViewById(R.id.charge_curve_table);
|
|
chargePriceLayout = chargingPane.findViewById(R.id.charge_price_layout);
|
|
chargePriceInput = chargingPane.findViewById(R.id.charge_price_input);
|
|
chargePriceInput.setText(String.format(java.util.Locale.US, "%.2f",
|
|
chargeSessionTracker.pricePerKwh()));
|
|
chargingPane.findViewById(R.id.charge_price_save).setOnClickListener(v -> saveChargePrice());
|
|
}
|
|
|
|
private void saveChargePrice() {
|
|
String raw = textOf(chargePriceInput).replace(',', '.');
|
|
try {
|
|
double value = Double.parseDouble(raw);
|
|
if (!chargeSessionTracker.setPricePerKwh(value)) throw new NumberFormatException();
|
|
chargePriceLayout.setError(null);
|
|
chargePriceInput.setText(String.format(java.util.Locale.US, "%.2f", value));
|
|
Toast.makeText(this, "Charging price saved", Toast.LENGTH_SHORT).show();
|
|
} catch (NumberFormatException e) {
|
|
chargePriceLayout.setError("Enter a valid €/kWh value");
|
|
}
|
|
}
|
|
|
|
private void refreshCharging(EnergySnapshot s) {
|
|
if (chargeStatus == null) return;
|
|
chargeSessionTracker.sample(s);
|
|
Integer code = s.getChargingStatus();
|
|
boolean charging = ChargingState.isCharging(
|
|
code, s.getChargePortConnected(), s.getBatteryPowerKw(), s.getSpeedKmh());
|
|
ChargeSessionTracker.Snapshot session = chargeSessionTracker.snapshot();
|
|
// Only DC is metered, so an AC charge must say so rather than let the readouts
|
|
// below — which then describe the previous DC session — be read as this charge.
|
|
boolean dc = ChargingState.isDcCharging(
|
|
code, s.getChargePortConnected(), s.getBatteryPowerKw(), s.getSpeedKmh());
|
|
chargeStatus.setText(dc ? "DC fast charging"
|
|
: charging ? "AC charging (not metered)"
|
|
: (session.startedAtMs > 0 ? "Last charge" : "Not charging"));
|
|
chargeSoc.setText(fmt(s.getSocPercent(), "%.1f %%"));
|
|
chargeStartSoc.setText(fmt(session.startSocPercent, "%.1f %%"));
|
|
chargeVoltage.setText(fmt(s.getBatteryVoltageV(), "%.1f V"));
|
|
chargeCurrent.setText(fmt(s.getBatteryCurrentA(), "%.1f A"));
|
|
chargePower.setText(s.getBatteryPowerKw() == null ? "—" : String.format(java.util.Locale.getDefault(), "%.1f kW", Math.abs(s.getBatteryPowerKw())));
|
|
chargeEnergy.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "%.2f kWh", session.energyKwh) : "—");
|
|
chargeGridEnergy.setText(session.startedAtMs > 0 ? String.format(
|
|
java.util.Locale.getDefault(), "%.2f kWh", session.gridEnergyKwh) : "—");
|
|
long sec = session.durationSeconds;
|
|
chargeDuration.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "%02d:%02d:%02d", sec/3600,(sec%3600)/60,sec%60) : "—");
|
|
chargeTotalCost.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "€%.2f", session.totalCost) : "—");
|
|
chargeGraph.setPoints(session.points);
|
|
chargeCurveTable.setText(chargeGraph.tableText());
|
|
}
|
|
|
|
private void refreshConsumption() {
|
|
if (consumptionTracker == null) return;
|
|
// Trip counters are local-only (cloud has no trip concept); everything else reads
|
|
// back from the cloud so an APK wipe does not blank the display.
|
|
ConsumptionTracker.Totals t;
|
|
if (consumptionPeriod == ConsumptionTracker.Period.TRIP_A
|
|
|| consumptionPeriod == ConsumptionTracker.Period.TRIP_B) {
|
|
t = consumptionTracker.totals(consumptionPeriod);
|
|
} else {
|
|
t = cloudTotals(consumptionPeriod);
|
|
}
|
|
consumptionDistance.setText(String.format(java.util.Locale.getDefault(), "%.1f km", t.km));
|
|
consumptionEnergy.setText(String.format(java.util.Locale.getDefault(), "%.2f kWh", t.kwh));
|
|
consumptionAverage.setText(t.km < 0.1 ? "—" : String.format(java.util.Locale.getDefault(),
|
|
"%.1f kWh/100 km", t.kwh * 100d / t.km));
|
|
consumptionSpeed.setText(t.hours <= 0 ? "—" : String.format(java.util.Locale.getDefault(),
|
|
"%.1f km/h", t.km / t.hours));
|
|
long minutes = Math.round(t.hours * 60d);
|
|
consumptionTime.setText(String.format(java.util.Locale.getDefault(), "%d h %02d min",
|
|
minutes / 60, minutes % 60));
|
|
consumptionSoc.setText(String.format(java.util.Locale.getDefault(), "%.1f %%", t.soc));
|
|
// Daily history: cloud first, local fallback for today (not yet uploaded).
|
|
ConsumptionTracker.Totals day = cloudDay(selectedConsumptionDate);
|
|
if (day == null) day = consumptionTracker.totalsForDay(selectedConsumptionDate);
|
|
if (day.km == 0 && day.kwh == 0 && day.hours == 0 && day.soc == 0) {
|
|
consumptionDailyHistory.setText("No records for this date");
|
|
} else {
|
|
String average = day.km < 0.1 ? "—" : String.format(java.util.Locale.getDefault(),
|
|
"%.1f kWh/100 km", day.kwh * 100d / day.km);
|
|
long dayMinutes = Math.round(day.hours * 60d);
|
|
consumptionDailyHistory.setText(String.format(java.util.Locale.getDefault(),
|
|
"Distance %.1f km\nEnergy %.2f kWh\nAverage %s\nDuration %d h %02d min\nSOC change %.1f %%",
|
|
day.km, day.kwh, average, dayMinutes / 60, dayMinutes % 60, day.soc));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Period totals from the cloud history. LIFETIME = the latest record's lifetime field;
|
|
* WEEK/MONTH = sum of the last 7/30 days' day-totals. Falls back to the local tracker
|
|
* when the cloud is empty or not yet fetched, so the UI is never blank on a cold start.
|
|
*/
|
|
private ConsumptionTracker.Totals cloudTotals(ConsumptionTracker.Period period) {
|
|
List<ConsumptionCloudClient.DayRecord> history = consumptionCloudClient != null
|
|
? consumptionCloudClient.fetchHistoryIfDue() : java.util.Collections.emptyList();
|
|
if (history.isEmpty()) return consumptionTracker.totals(period);
|
|
if (period == ConsumptionTracker.Period.LIFETIME) {
|
|
// history is sorted date-desc, so index 0 is the latest day carrying lifetime.
|
|
return history.get(0).lifetime;
|
|
}
|
|
int days = period == ConsumptionTracker.Period.WEEK ? 7 : 30;
|
|
double km = 0, kwh = 0, hours = 0, soc = 0;
|
|
LocalDate today = LocalDate.now();
|
|
for (int i = 0; i < days; i++) {
|
|
ConsumptionTracker.Totals d = cloudDay(today.minusDays(i), history);
|
|
if (d != null) { km += d.km; kwh += d.kwh; hours += d.hours; soc += d.soc; }
|
|
}
|
|
return new ConsumptionTracker.Totals(km, kwh, hours, soc);
|
|
}
|
|
|
|
/** Looks up one day in the cloud cache; null when that date is not present. */
|
|
private ConsumptionTracker.Totals cloudDay(LocalDate date) {
|
|
return cloudDay(date, consumptionCloudClient != null
|
|
? consumptionCloudClient.fetchHistoryIfDue() : java.util.Collections.emptyList());
|
|
}
|
|
|
|
private static ConsumptionTracker.Totals cloudDay(LocalDate date,
|
|
List<ConsumptionCloudClient.DayRecord> history) {
|
|
for (ConsumptionCloudClient.DayRecord r : history) {
|
|
if (r.date.equals(date)) return r.day;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private void showConsumptionDatePicker() {
|
|
java.time.LocalDate selected = selectedConsumptionDate;
|
|
android.app.DatePickerDialog dialog = new android.app.DatePickerDialog(this,
|
|
(view, year, month, day) -> {
|
|
selectedConsumptionDate = java.time.LocalDate.of(year, month + 1, day);
|
|
consumptionDateButton.setText(selectedConsumptionDate.toString());
|
|
refreshConsumption();
|
|
}, selected.getYear(), selected.getMonthValue() - 1, selected.getDayOfMonth());
|
|
java.time.ZoneId zone = java.time.ZoneId.systemDefault();
|
|
dialog.getDatePicker().setMaxDate(System.currentTimeMillis());
|
|
dialog.getDatePicker().setMinDate(java.time.LocalDate.now().minusMonths(4)
|
|
.atStartOfDay(zone).toInstant().toEpochMilli());
|
|
dialog.show();
|
|
}
|
|
|
|
/**
|
|
* The charging row: the session state when the car reports one, the port otherwise.
|
|
*
|
|
* The codes are the vehicle's own — see EVHardware.PROP_VENDOR_CHARGE_STATUS. An
|
|
* unrecognised code is shown as itself rather than guessed at.
|
|
*/
|
|
private static String chargingLabel(EnergySnapshot s) {
|
|
Integer status = s.getChargingStatus();
|
|
if (status != null) {
|
|
switch (status) {
|
|
case 1: return "AC charging ♥";
|
|
case 10: return "DC fast charging ♥";
|
|
case 5: return "Connecting…";
|
|
case 7: return "Plugged in, not charging";
|
|
case 8: return "Stopped";
|
|
case 0: return "Not charging";
|
|
default: return "Status " + status;
|
|
}
|
|
}
|
|
Boolean plugged = s.getChargePortConnected();
|
|
if (plugged != null) return plugged ? "Connected ♥" : "Not connected";
|
|
return "—";
|
|
}
|
|
|
|
/** A dash for a signal the car does not publish — never a zero. See pane_vehicle.xml. */
|
|
private static String fmt(Float value, String pattern) {
|
|
if (value == null || value.isNaN() || value.isInfinite()) return "—";
|
|
return String.format(java.util.Locale.US, pattern, value);
|
|
}
|
|
|
|
// ---------- Call log ----------
|
|
|
|
/**
|
|
* "Upload now" button — forces a cache drain + fresh chunk on a background thread,
|
|
* ignoring the 2-minute throttle. The result lands in the upload log (Logs tab).
|
|
*/
|
|
private void manualFlush() {
|
|
if (consumptionCloudClient == null || consumptionTracker == null) return;
|
|
AbrpUploadService.log().record(new UploadLog.Entry(
|
|
System.currentTimeMillis(), 0, true, "Manual sync started…"));
|
|
new Thread(() -> {
|
|
String result = consumptionCloudClient.flushNow(
|
|
consumptionTracker, System.currentTimeMillis());
|
|
runOnUiThread(() -> Toast.makeText(this, result, Toast.LENGTH_SHORT).show());
|
|
}, "manual-flush").start();
|
|
}
|
|
|
|
/**
|
|
* Consumption tab "Refresh" button — sends cached chunks + fresh delta, invalidates the
|
|
* history cache so the next poll fetches fresh data from the cloud, then refreshes the UI.
|
|
*/
|
|
private void refreshFromCloud() {
|
|
if (consumptionCloudClient == null || consumptionTracker == null) return;
|
|
AbrpUploadService.log().record(new UploadLog.Entry(
|
|
System.currentTimeMillis(), 0, true, "Refreshing consumption…"));
|
|
new Thread(() -> {
|
|
consumptionCloudClient.flushNow(consumptionTracker, System.currentTimeMillis());
|
|
consumptionCloudClient.invalidateHistory();
|
|
runOnUiThread(() -> {
|
|
refreshConsumption();
|
|
Toast.makeText(this, "Consumption refreshed", Toast.LENGTH_SHORT).show();
|
|
});
|
|
}, "cloud-refresh").start();
|
|
}
|
|
|
|
private void refreshCallLog() {
|
|
java.util.List<UploadLog.Entry> entries = AbrpUploadService.log().recent();
|
|
if (entries.isEmpty()) {
|
|
callLogText.setText(R.string.call_log_empty);
|
|
return;
|
|
}
|
|
java.text.SimpleDateFormat format =
|
|
new java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.US);
|
|
StringBuilder sb = new StringBuilder();
|
|
for (UploadLog.Entry entry : entries) {
|
|
if (sb.length() > 0) sb.append('\n');
|
|
sb.append(entry.success ? "OK " : "ERR ")
|
|
.append(format.format(new java.util.Date(entry.timestampMs)))
|
|
.append(" ")
|
|
// httpStatus 0 means the request never reached a server.
|
|
.append(entry.httpStatus > 0 ? String.valueOf(entry.httpStatus) : "---")
|
|
.append(" ")
|
|
.append(entry.detail);
|
|
// What the payload held, under the attempt it belongs to. This is the only view
|
|
// of it the driver has: the car runs no adb, so "the field is 0 on ABRP" and
|
|
// "the app never sent the field" look identical from the outside without it.
|
|
if (entry.summary != null && !entry.summary.isEmpty()) {
|
|
sb.append("\n ").append(entry.summary);
|
|
}
|
|
}
|
|
callLogText.setText(sb.toString());
|
|
}
|
|
|
|
// ---------- Service status ----------
|
|
|
|
private void refreshStatus() {
|
|
// The service is always running. Show the cloud sync state derived from the log.
|
|
UploadLog.State state = AbrpUploadService.state();
|
|
|
|
switch (state) {
|
|
case ERROR:
|
|
// Derived from the log: three consecutive cloud sync failures is a problem.
|
|
statusText.setText(getString(R.string.state_error,
|
|
AbrpUploadService.log().consecutiveFailures()));
|
|
statusText.setTextColor(COLOR_ERROR);
|
|
break;
|
|
case RUNNING:
|
|
case STARTING:
|
|
if (!ServerConfig.get(this).isConfigured()) {
|
|
statusText.setText(R.string.server_not_configured);
|
|
statusText.setTextColor(COLOR_ERROR);
|
|
} else if (!UploadSettings.from(prefs).abrpUploadEnabled) {
|
|
statusText.setText(R.string.state_tracking_locally);
|
|
statusText.setTextColor(COLOR_PENDING);
|
|
} else {
|
|
statusText.setText(R.string.state_running);
|
|
statusText.setTextColor(COLOR_OK);
|
|
}
|
|
break;
|
|
case STOPPED:
|
|
default:
|
|
statusText.setText(R.string.state_stopped);
|
|
statusText.setTextColor(COLOR_PENDING);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ---------- Helpers ----------
|
|
|
|
private String textOf(TextInputEditText field) {
|
|
CharSequence text = field.getText();
|
|
return text != null ? text.toString().trim() : "";
|
|
}
|
|
}
|