This commit is contained in:
TCFDONMEZ
2026-10-09 17:31:08 +03:00
parent 64811a1508
commit ea8032a813
46 changed files with 699 additions and 2230 deletions
+1 -13
View File
@@ -8,9 +8,6 @@
<!-- Required from API 33 to post the foreground-service notification, which is the
only status UI the service has. Without it the notification is silently dropped. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Wi-Fi auto-join. CHANGE_WIFI_STATE covers enabling the radio and asking the
supplicant to join a network the unit already has saved; ACCESS_WIFI_STATE covers
@@ -34,14 +31,6 @@
<uses-permission android:name="android.car.permission.CAR_TIRES" />
<uses-permission android:name="android.car.permission.CAR_INFO" />
<!-- The head unit's map stack, which owns the weather service the uploader asks for the
outside temperature. From API 30 an explicit bind to a package this app does not
declare here simply fails, and ext_temp would go back to being unreportable. -->
<queries>
<package android:name="com.saicmotor.launcher" />
<package android:name="com.saicmotor.mapservice" />
</queries>
<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -63,8 +52,7 @@
<service
android:name=".AbrpUploadService"
android:exported="false"
android:foregroundServiceType="location" />
android:exported="false" />
<!-- The head unit resumes far more often than it cold-boots, and announces the
resume with QUICKBOOT_POWERON. Listening only for BOOT_COMPLETED is why
@@ -1,123 +0,0 @@
package com.evsuite.abrp;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
/**
* ABRP telemetry endpoint.
*
* Credentials go in the POST body, never in the query string: a URL travels
* through proxies, CDN access logs and crash reports, a body does not.
*/
final class AbrpApi {
/** No query string: credentials travel in the body, so the URL is a constant. */
static final String API_URL = "https://api.iternio.com/1/tlm/send";
/**
* Read-only endpoint used to validate credentials. The connection test must never
* touch /tlm/send: posting a placeholder sample tells ABRP the car really is in that
* state and corrupts the user's live route plan.
*/
static final String VERIFY_URL = "https://api.iternio.com/1/tlm/get_next_charge";
private static final int TIMEOUT_MS = 8_000;
private AbrpApi() { }
/** HTTP status plus response body. */
static final class Response {
final int code;
final String body;
Response(int code, String body) {
this.code = code;
this.body = body;
}
}
/**
* Validates credentials without writing anything. Read-only by construction: it is a
* GET against {@link #VERIFY_URL}, so no telemetry is recorded whatever the outcome.
*
* Tension with T-912 (no credential in a URL): this endpoint only accepts the token as
* a query parameter. The api_key is moved to the Authorization header, which is the
* part we can keep out of access logs; the token cannot be, short of dropping the
* read-only check and going back to writing fake telemetry. Sending one token in one
* URL when the user presses Test beats corrupting their route plan on every press.
*/
static Response verifyCredentials(String apiKey, String token) throws IOException {
HttpURLConnection conn = null;
try {
String url = VERIFY_URL
+ "?token=" + URLEncoder.encode(token, StandardCharsets.UTF_8.name());
conn = (HttpURLConnection) new URL(url).openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Authorization", "APIKEY " + apiKey);
conn.setConnectTimeout(TIMEOUT_MS);
conn.setReadTimeout(TIMEOUT_MS);
int code = conn.getResponseCode();
return new Response(code, readBody(conn, code));
} finally {
if (conn != null) conn.disconnect();
}
}
/** Posts one telemetry sample. Throws on network failure. */
static Response send(String apiKey, String token, String tlmJson) throws IOException {
HttpURLConnection conn = null;
try {
conn = (HttpURLConnection) new URL(API_URL).openConnection();
conn.setRequestMethod("POST");
conn.setConnectTimeout(TIMEOUT_MS);
conn.setReadTimeout(TIMEOUT_MS);
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
byte[] payload = formBody(apiKey, token, tlmJson).getBytes(StandardCharsets.UTF_8);
conn.setFixedLengthStreamingMode(payload.length);
try (OutputStream out = conn.getOutputStream()) {
out.write(payload);
}
int code = conn.getResponseCode();
return new Response(code, readBody(conn, code));
} finally {
if (conn != null) conn.disconnect();
}
}
/** Form-encoded request body carrying the credentials and the telemetry sample. */
static String formBody(String apiKey, String token, String tlmJson) throws IOException {
return "api_key=" + URLEncoder.encode(apiKey, StandardCharsets.UTF_8.name())
+ "&token=" + URLEncoder.encode(token, StandardCharsets.UTF_8.name())
+ "&tlm=" + URLEncoder.encode(tlmJson, StandardCharsets.UTF_8.name());
}
private static String readBody(HttpURLConnection conn, int code) {
// getErrorStream() returns null on some non-200 responses; passing null to
// InputStreamReader throws. Checked rather than left to the catch-all below.
java.io.InputStream stream;
try {
stream = code == 200 ? conn.getInputStream() : conn.getErrorStream();
} catch (Exception e) {
return "";
}
if (stream == null) return "";
try (BufferedReader br = new BufferedReader(
new InputStreamReader(stream, StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) sb.append(line);
return sb.toString();
} catch (Exception e) {
return "";
}
}
}
@@ -5,55 +5,33 @@ import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.content.pm.ServiceInfo;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.os.SystemClock;
import android.util.Log;
import android.Manifest;
import androidx.core.app.NotificationCompat;
import androidx.core.app.ServiceCompat;
import androidx.core.content.ContextCompat;
import com.evsuite.hardware.saic.SaicWeather;
import com.evsuite.hardware.telemetry.EnergySnapshot;
import com.evsuite.hardware.telemetry.EnergyTelemetryReader;
import com.evsuite.hardware.telemetry.ChargingSessionMeter;
import com.evsuite.hardware.telemetry.TirePressureSnapshot;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* Foreground service that polls vehicle telemetry every 15 s and pushes it to ABRP.
* Foreground service that polls vehicle telemetry every 15 s, integrates consumption locally,
* and syncs it to the cloud (MongoDB) in 30-minute delta chunks. Never sends anything to ABRP.
*
* Reliability design:
* - Uses ScheduledExecutorService (not Handler chains) so exceptions inside one
* upload cycle cannot break the schedule. Even on a thrown RuntimeException
* the next tick still fires.
* - Uploads unconditionally: if vehicle telemetry is unavailable we still emit
* GPS + timestamp so ABRP at least sees the vehicle online.
* - EVHardware owns the vehicle connection, firmware routing and nullable reads.
* - All IPC + HTTP happens on the scheduler thread, never the main thread.
* Always-on by design: started at boot (BootReceiver) and on app open, never gated on
* credentials or a toggle. The only thing it needs is a network for the cloud sync, and a
* missing one is handled by caching chunks locally until the connection returns.
*/
public class AbrpUploadService extends Service {
public static final String ACTION_STOP = "com.evsuite.abrp.STOP";
/**
* Live in-process signal. The "service_running" preference cannot be trusted on its
* own: a force-kill (adb install -r, low-memory kill) skips onDestroy and leaves it
@@ -74,117 +52,33 @@ public class AbrpUploadService extends Service {
public static UploadLog.State state() { return uploadLog.state(running); }
/** Re-read after the user changes the cadence, without restarting the service. */
public static void reloadSettings() { settingsDirty = true; }
private static volatile boolean settingsDirty = false;
private static final String TAG = "AbrpUploadService";
private static final String CHANNEL_ID = "abrp_uploader";
private static final int NOTIF_ID = 1;
/** Scheduler tick. Whether a tick uploads is decided by {@link UploadCadence}. */
/** Scheduler tick — vehicle telemetry is sampled this often. */
private static final long UPLOAD_INTERVAL_SEC = 15;
/**
* Warm-up before the first upload. Was 45 s with no stated reason; the car adapter
* connects asynchronously and telemetry now omits whatever it cannot read (T-913), so
* an early first sample is useful rather than misleading.
*/
/** Warm-up before the first sample. */
private static final long FIRST_UPLOAD_DELAY_SEC = 20;
private static final long LOCATION_RETRY_INTERVAL_SEC = 30;
/**
* Floor for how old a GPS fix may be and still describe where the car is now.
*
* A fix has no expiry of its own, so the previous behaviour was to keep reporting the
* last one forever: the position saved when the car was parked at home was uploaded,
* stamped with the current time, for the whole first part of the next drive. ABRP then
* plans from a place the car left minutes ago. The subscription asks for a fix every
* upload interval, so anything older than several intervals means the receiver has
* stopped producing (garage, tunnel) and position is genuinely unknown.
*/
private static final long MIN_LOCATION_MAX_AGE_MS = 5 * 60 * 1000L;
/**
* How long a weather reading stands in for the car's own thermometer.
*
* The query can reach the network from inside the head unit and blocks the upload thread
* while it does, so it is not something to do every tick. Outside air does not move fast
* enough for ten minutes to matter to a consumption model.
*/
private static final long WEATHER_TTL_MS = 10 * 60 * 1000L;
/**
* How long a broadcast temperature keeps standing in for the car's thermometer.
*
* The weather app broadcasts when it refreshes itself, not on a cadence this app can ask
* for, so a reading has to outlive the moment it arrived or it would be useless between
* refreshes. Two hours is long enough to bridge them and short enough that the value has
* not stopped describing the day.
*/
private static final long WEATHER_MAX_AGE_MS = 2 * 60 * 60 * 1000L;
private static final String KEY_WEATHER_TEMP = "weather_temp_c";
private static final String KEY_WEATHER_PLACE = "weather_place";
private static final String KEY_WEATHER_MS = "weather_received_ms";
// Set from the main thread (LocationListener uses Main looper), read from
// the scheduler thread inside doUpload. volatile is sufficient since Location
// is effectively immutable for our purposes.
private volatile Location lastLocation;
private EnergyTelemetryReader energyReader;
private ConsumptionTracker consumptionTracker;
private ConsumptionCloudClient consumptionCloudClient;
private ChargeSessionTracker chargeSessionTracker;
/**
* Keeps the unit on the phone hotspot without a trip into Settings. Attempted on the
* upload tick because that is exactly when being offline costs something, and it is
* rate-limited internally so a tick every few seconds does not hammer the supplicant.
*/
private WifiAutoConnect wifi;
private LocationManager locationManager;
private WifiAutoConnect wifi;
private ScheduledExecutorService scheduler;
private SharedPreferences prefs;
/** Credentials only — encrypted at rest, see {@link SecurePrefs}. */
private SharedPreferences securePrefs;
private Handler mainHandler;
private SharedPreferences prefs;
private volatile long lastSuccessfulUploadMs = 0L;
/** Previous tick's state, to detect a transition worth reporting immediately. */
private volatile Boolean lastParked = null;
private volatile Boolean lastCharging = null;
private volatile long lastUploadAttemptMs = 0L;
/** Charge detection state. Touched only from the scheduler thread, inside doUpload. */
private final ChargingSignal chargingSignal = new ChargingSignal();
private final ChargingSessionMeter chargeMeter = new ChargingSessionMeter();
private volatile UploadSettings settings = UploadSettings.defaults();
/** False while no GPS subscription is delivering — drives the watchdog re-arm. */
/** What the foreground notification currently says, so re-asserting it does not blank it. */
private volatile String lastStatus = "Starting…";
private volatile boolean locationUpdatesActive = false;
private volatile long lastLocationRequestMs = 0L;
/** Last head-unit weather reading and when it was taken — see {@link #WEATHER_TTL_MS}. */
private volatile SaicWeather.Reading lastWeather = null;
private volatile long lastWeatherMs = 0L;
/** The head unit's own weather broadcast — the source its status bar uses. */
private final HeadUnitWeather headUnitWeather = new HeadUnitWeather();
private final BroadcastReceiver weatherReceiver = new BroadcastReceiver() {
@Override public void onReceive(Context context, Intent intent) {
headUnitWeather.accept(intent);
saveWeather();
}
};
private volatile boolean weatherReceiverRegistered = false;
/** How long the last map-service query took, or -1 when it was never made. */
private volatile long lastQueryMs = -1L;
// ---------- Lifecycle ----------
@Override
public void onCreate() {
super.onCreate();
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
securePrefs = SecurePrefs.get(this);
settings = UploadSettings.from(prefs);
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
uploadLog.clear();
mainHandler = new Handler(Looper.getMainLooper());
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "abrp-upload");
t.setDaemon(false);
return t;
@@ -195,73 +89,40 @@ public class AbrpUploadService extends Service {
running = true;
prefs.edit().putBoolean("service_running", true).apply();
// One shared, firmware-aware EVHardware snapshot owns every vehicle signal.
energyReader = new EnergyTelemetryReader(getApplicationContext());
consumptionTracker = ConsumptionTracker.get(this);
consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext());
chargeSessionTracker = ChargeSessionTracker.get(getApplicationContext());
wifi = new WifiAutoConnect(getApplicationContext());
// The head unit's own weather service, bound for one reason: ENV_OUTSIDE_TEMPERATURE
// is not implemented on this vehicle, so the car cannot say how warm it is outside
// and something else has to. Idempotent, asynchronous, and absent on a car that does
// not have the map stack — in which case ext_temp simply stays unreported.
if (settings.abrpUploadEnabled) requestLocationUpdates();
// Fire the first upload after a short warm-up, then every UPLOAD_INTERVAL_SEC.
// scheduleWithFixedDelay ensures we always get at least UPLOAD_INTERVAL_SEC
// between cycles even if a previous upload was slow.
scheduler.scheduleWithFixedDelay(
this::safeUploadCycle,
FIRST_UPLOAD_DELAY_SEC, UPLOAD_INTERVAL_SEC, TimeUnit.SECONDS);
Log.i(TAG, "Service started, upload scheduler armed");
uploadLog.record(new UploadLog.Entry(System.currentTimeMillis(), 0, true,
"Tüketim servisi başlatıldı"));
Log.i(TAG, "Service started, consumption tracking armed");
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null && ACTION_STOP.equals(intent.getAction())) {
prefs.edit().putBoolean("service_enabled", false).apply();
stopSelf();
return START_NOT_STICKY;
}
// Re-asserted on every start: a position grant that arrives after the service is up
// changes which foreground types it may hold, and the activity starts it again as
// soon as the user answers. Without this the grant only took effect at the next boot.
startInForeground();
return START_STICKY;
}
/**
* Enters the foreground holding only the service types this app currently has the
* permissions for.
*
* The service declares {@code foregroundServiceType="location"}, and since API 34 the
* platform validates every declared type against what is held at the moment
* startForeground runs — the plain two-argument call claims them all. On a car where
* position had not been granted yet (a fresh install, or the boot receiver starting the
* service before the app was ever opened) that threw a SecurityException and the uploader
* died before its first tick. Without the permission the service still runs and still
* uploads; it simply has no position to send, exactly as before.
* Enters the foreground. No location type — this service no longer reads GPS. Foreground
* service types do not exist before API 29, so on this API-28 head unit a plain
* startForeground is the only correct call.
*/
private void startInForeground() {
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.Q) {
// Service types do not exist before API 29: nothing is declared and nothing is
// checked, so the plain call is the only correct one there.
startForeground(NOTIF_ID, buildNotification(lastStatus));
return;
Notification notification = buildNotification(lastStatus);
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
ServiceCompat.startForeground(
this, NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
} else {
startForeground(NOTIF_ID, notification);
}
int types = hasLocationPermission()
? ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
: ServiceInfo.FOREGROUND_SERVICE_TYPE_NONE;
ServiceCompat.startForeground(this, NOTIF_ID, buildNotification(lastStatus), types);
}
private boolean hasLocationPermission() {
return ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
== PackageManager.PERMISSION_GRANTED
|| ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION)
== PackageManager.PERMISSION_GRANTED;
}
@Override
@@ -269,57 +130,18 @@ public class AbrpUploadService extends Service {
@Override
public void onDestroy() {
Log.i(TAG, "Service stopping");
if (scheduler != null) scheduler.shutdownNow();
if (consumptionTracker != null) consumptionTracker.persist();
if (locationManager != null) {
try { locationManager.removeUpdates(locationListener); } catch (Exception ignored) {}
}
if (weatherReceiverRegistered) {
try { unregisterReceiver(weatherReceiver); } catch (Exception ignored) {}
weatherReceiverRegistered = false;
}
if (mainHandler != null) mainHandler.removeCallbacksAndMessages(null);
running = false;
prefs.edit().putBoolean("service_running", false).apply();
super.onDestroy();
if (scheduler != null) scheduler.shutdownNow();
Log.i(TAG, "Service stopped");
}
// ---------- Upload cycle (runs on scheduler thread) ----------
// ---------- Sampling cycle (runs on scheduler thread) ----------
private void safeUploadCycle() {
try {
if (settingsDirty) {
settingsDirty = false;
settings = UploadSettings.from(prefs);
// The GPS subscription is a power cost of its own; keep it in step with
// how often we actually send.
mainHandler.post(this::requestLocationUpdates);
Log.i(TAG, "Settings reloaded: interval=" + settings.intervalSec + "s"
+ " boostLowSoc=" + settings.boostLowSoc
+ " lowSoc=" + settings.lowSocPercent + "%"
+ " abrpUpload=" + settings.abrpUploadEnabled);
}
// Same watchdog for GPS: requestLocationUpdates used to run once in onCreate,
// so a provider that was off at start-up — or dropped later — meant
// position-less telemetry for the rest of the session.
if (settings.abrpUploadEnabled && !locationUpdatesActive
&& System.currentTimeMillis() - lastLocationRequestMs
> LOCATION_RETRY_INTERVAL_SEC * 1000) {
Log.w(TAG, "Location updates inactive, re-arming");
mainHandler.post(this::requestLocationUpdates);
}
// Before the upload, not after: an upload that fails for want of a network is
// the thing this is here to prevent. Cheap and rate-limited when already on.
if (settings.abrpUploadEnabled && wifi != null
&& prefs.getBoolean(MainActivity.WIFI_AUTO_KEY, true)) {
wifi.ensureConnected(false);
}
doUpload();
} catch (Throwable t) {
// Absolutely never let an exception escape — would not break the
// schedule with scheduleWithFixedDelay's executor, but we want a
// clean log line anyway.
Log.e(TAG, "upload cycle threw", t);
}
}
@@ -331,388 +153,13 @@ public class AbrpUploadService extends Service {
chargeSessionTracker.sample(vehicle);
consumptionCloudClient.syncIfDue(consumptionTracker, chargeSessionTracker, sampleMs);
// Keep local consumption history alive while another app owns the ABRP feed.
if (!settings.abrpUploadEnabled) {
updateNotification(getString(R.string.notif_tracking_locally));
return;
// Keep the head unit on the phone hotspot — the sync needs a network, and this is
// exactly when being offline costs something. Rate-limited internally.
if (wifi != null && prefs.getBoolean(MainActivity.WIFI_AUTO_KEY, true)) {
wifi.ensureConnected(false);
}
String token = securePrefs.getString(SecurePrefs.KEY_TOKEN, "").trim();
String apiKey = securePrefs.getString(SecurePrefs.KEY_API_KEY, "").trim();
if (token.isEmpty()) {
updateNotification("No ABRP token — open app to configure");
return;
}
if (apiKey.isEmpty()) {
updateNotification("No API key — open app to configure");
return;
}
// Read whatever the car will give us. Every getter returns null when the read
// fails, and a null field is omitted from the payload — never sent as 0.
boolean carUp = vehicle.getHasVehicleData();
Float speedKmh = vehicle.getSpeedKmh();
Integer soc = vehicle.getSocPercent() == null
? null : Math.round(vehicle.getSocPercent());
Integer rangeKm = vehicle.getRangeKm() == null
? null : Math.round(vehicle.getRangeKm());
Float powerKw = vehicle.getBatteryPowerKw();
Float chargeRateKw = powerKw == null ? null : -powerKw;
Boolean portConnected = vehicle.getChargePortConnected();
Boolean parked = vehicle.getParked();
// The charge port property is standard AAOS and this VHAL need not implement it.
// Trusting it alone meant a plugged-in car that reads "unplugged" was treated as
// parked-and-idle and throttled to one sample every 15 minutes for the whole
// charge — see ChargingSignal for the fallbacks.
Boolean charging = chargingSignal.evaluate(
sampleMs, portConnected, chargeRateKw, parked, soc);
// DCFC heuristic: charging at AC speeds (3-22 kW) is type-2; above ~25 kW
// it can only be DC fast. Undecidable if either input is missing.
Boolean dcfc = (charging == null || chargeRateKw == null)
? null : (charging && chargeRateKw > 25f);
Float cabinTemp = vehicle.getCabinTempCelsius();
Float battTemp = vehicle.getBatteryTempCelsius();
Float capacityKwh = vehicle.getBatteryCapacityKwh();
Float soeKwh = vehicle.getBatteryEnergyKwh();
Float odometerKm = vehicle.getOdometerKm();
Float hvacSetpoint = vehicle.getClimate().getDriverTargetCelsius();
TirePressureSnapshot tires = vehicle.getTirePressures();
// Sampled before the cadence check below: the meter integrates the charge rate
// over time, so it has to see every tick, not only the ones that get uploaded.
Double kwhCharged = chargeMeter.sample(
sampleMs, charging, chargeRateKw);
// Thin out uploads while the car is parked and not charging. Evaluated here, once
// parked/charging are known, so a transition is never delayed.
boolean stateChanged = !java.util.Objects.equals(parked, lastParked)
|| !java.util.Objects.equals(charging, lastCharging);
lastParked = parked;
lastCharging = charging;
// Cadence is measured on ATTEMPTS, not successes: keying it on the last success
// would retry every tick while offline, which is exactly when saving power matters.
if (!UploadCadence.shouldUpload(System.currentTimeMillis(), lastUploadAttemptMs,
parked, charging, soc, stateChanged, settings)) {
return;
}
lastUploadAttemptMs = System.currentTimeMillis();
Location loc = freshLocation();
// ext_temp was reaching ABRP as 0 degC, and omitting the unreadable value only turned
// that into ABRP's own default 0. The car is the problem: this VHAL does not implement
// ENV_OUTSIDE_TEMPERATURE, so there is no vehicle reading to correct. The head unit's
// weather service answers for a position, and ambient air at the car is what ext_temp
// means — a better figure than a bumper sensor in the sun would give anyway.
TelemetryPayload tlm = new TelemetryPayload(System.currentTimeMillis() / 1000);
tlm.soc = soc;
tlm.speedKmh = speedKmh;
tlm.rangeKm = rangeKm;
tlm.powerKw = powerKw;
tlm.charging = charging;
tlm.dcfc = dcfc;
tlm.parked = parked;
tlm.cabinTemp = cabinTemp;
tlm.battTempC = battTemp;
tlm.capacityKwh = capacityKwh;
tlm.soeKwh = soeKwh;
tlm.kwhCharged = kwhCharged;
tlm.odometerKm = odometerKm;
tlm.hvacSetpointC = hvacSetpoint;
tlm.tirePressureFlKpa = tires.getFrontLeftKpa();
tlm.tirePressureFrKpa = tires.getFrontRightKpa();
tlm.tirePressureRlKpa = tires.getRearLeftKpa();
tlm.tirePressureRrKpa = tires.getRearRightKpa();
if (loc != null) {
tlm.lat = loc.getLatitude();
tlm.lon = loc.getLongitude();
tlm.elevation = loc.hasAltitude() ? loc.getAltitude() : null;
tlm.heading = loc.hasBearing() ? loc.getBearing() : null;
}
String tlmJson = tlm.build();
String summary = TelemetryPayload.summarize(tlmJson);
if (loc != null) {
summary += " (fix " + locationAgeMs(loc) / 1000 + "s old"
+ " +/-" + (loc.hasAccuracy() ? Math.round(loc.getAccuracy()) + "m" : "?")
+ " via " + loc.getProvider();
// The weather service names the place it answered for. ABRP shows an address it
// reverse-geocodes from the same coordinates, so the two disagreeing says the
// coordinates are wrong, and the two agreeing says ABRP is showing something old.
summary += ")";
}
// What actually went on the wire. Kept on the log entry as well as in logcat: the
// head unit this runs on has no adb attached, so the in-app log is the only place
// the driver can see it.
Log.i(TAG, "TLM " + summary);
sendToAbrp(apiKey, token, tlmJson, summary, soc, speedKmh, carUp);
}
/**
* Listens for the head unit's weather updates.
*
* Exported on purpose: the broadcast comes from another application, and a receiver
* declared not-exported would never hear it. The flag is required from API 33 and does
* not exist before it — the car this runs on is API 28 — so the registration goes
* through ContextCompat, which picks the right overload for the platform underneath.
*/
private void registerWeatherReceiver() {
restoreWeather();
try {
androidx.core.content.ContextCompat.registerReceiver(
this, weatherReceiver, new IntentFilter(HeadUnitWeather.ACTION),
androidx.core.content.ContextCompat.RECEIVER_EXPORTED);
weatherReceiverRegistered = true;
// A null receiver asks the platform for the last broadcast it is holding without
// subscribing to anything. If the weather app sends its update sticky, this is the
// reading straight away rather than after however long until its next refresh —
// and the app is started to look at the log, which is exactly the wrong moment to
// begin waiting.
probeStickyWeather();
} catch (Exception e) {
// A car whose weather app broadcasts nothing is a car without ext_temp, not a
// reason to lose the upload service.
Log.w(TAG, "Weather broadcast unavailable: " + e.getMessage());
}
}
/** Asks for the last held broadcast, if the platform is holding one. Harmless when not. */
private void probeStickyWeather() {
try {
headUnitWeather.accept(
registerReceiver(null, new IntentFilter(HeadUnitWeather.ACTION)));
saveWeather();
} catch (Exception e) {
Log.w(TAG, "Sticky weather probe failed: " + e.getMessage());
}
}
/**
* Keeps the last reading across a restart.
*
* The broadcast arrives when the weather app refreshes, so a service that forgets on every
* start would spend most of its life with no temperature at all — and the app is restarted
* every time the driver opens it.
*/
private void saveWeather() {
Float celsius = headUnitWeather.temperatureC();
if (celsius == null) return;
prefs.edit()
.putFloat(KEY_WEATHER_TEMP, celsius)
.putString(KEY_WEATHER_PLACE, headUnitWeather.place())
.putLong(KEY_WEATHER_MS, headUnitWeather.receivedMs())
.apply();
}
private void restoreWeather() {
long whenMs = prefs.getLong(KEY_WEATHER_MS, 0L);
if (whenMs <= 0L || System.currentTimeMillis() - whenMs > WEATHER_MAX_AGE_MS) return;
headUnitWeather.restore(prefs.getFloat(KEY_WEATHER_TEMP, 0f),
prefs.getString(KEY_WEATHER_PLACE, ""), whenMs);
}
/** True while the stored reading still describes the day — see {@link #WEATHER_MAX_AGE_MS}. */
private boolean weatherIsFresh() {
long whenMs = headUnitWeather.receivedMs();
return whenMs > 0L && System.currentTimeMillis() - whenMs <= WEATHER_MAX_AGE_MS;
}
/**
* Current conditions where the car is, or null when nothing can answer.
*
* Cached for {@link #WEATHER_TTL_MS}: the query blocks the upload thread on what may be a
* network round-trip inside the head unit, and it is asked on every upload the car's own
* thermometer cannot answer — which, on this vehicle, is all of them. A stale reading is
* kept when a refresh fails, since ten-minute-old air is a better answer than none.
*/
private SaicWeather.Reading weatherAt(Location loc) {
if (loc == null) return null;
long now = System.currentTimeMillis();
SaicWeather.Reading cached = lastWeather;
if (cached != null && now - lastWeatherMs < WEATHER_TTL_MS) return cached;
if (!SaicWeather.INSTANCE.isAvailable()) { lastQueryMs = -1L; return cached; }
// How long the call took is the only thing that separates its failure modes from out
// here: the query is bounded by a two-second wait inside the library, so a null that
// comes back at once was refused or answered undecodably, and a null that comes back
// at the bound is a service that did not answer in time. The library reports neither.
long startedMs = System.currentTimeMillis();
SaicWeather.Reading reading = SaicWeather.INSTANCE.currentAt(
loc.getLatitude(), loc.getLongitude(), weatherLanguage());
lastQueryMs = System.currentTimeMillis() - startedMs;
if (reading == null) return cached;
lastWeather = reading;
lastWeatherMs = now;
return reading;
}
/** The provider answers in the language it is asked in; the driver's is the right one. */
private static String weatherLanguage() {
java.util.Locale locale = java.util.Locale.getDefault();
String language = locale.getLanguage().toLowerCase(java.util.Locale.US);
String country = locale.getCountry().toLowerCase(java.util.Locale.US);
return country.isEmpty() ? language : language + "-" + country;
}
private void sendToAbrp(String apiKey, String token, String tlmJson, String summary,
Integer soc, Float speedKmh, boolean carUp) {
try {
AbrpApi.Response response = AbrpApi.send(apiKey, token, tlmJson);
int code = response.code;
// The response body can echo request details — debug builds only.
if (BuildConfig.DEBUG) Log.d(TAG, "ABRP [" + code + "]: " + response.body);
uploadLog.record(new UploadLog.Entry(System.currentTimeMillis(), code,
code == 200, code == 200 ? "OK" : ("HTTP " + code), summary));
if (code == 200) {
lastSuccessfulUploadMs = System.currentTimeMillis();
String time = android.text.format.DateFormat
.format("HH:mm:ss", lastSuccessfulUploadMs).toString();
prefs.edit()
.putString("last_upload_time", time)
.putString("last_upload_status", "OK")
.apply();
// Only claim a value in the notification if we actually read one.
String detail = (soc != null && speedKmh != null)
? ("SOC " + soc + "% · " + Math.round(speedKmh) + " km/h · " + time)
: (carUp ? ("Car data partial · " + time) : ("GPS only · " + time));
updateNotification(detail);
} else {
prefs.edit().putString("last_upload_status", "HTTP " + code).apply();
int failures = uploadLog.consecutiveFailures();
updateNotification(failures >= UploadLog.FAILURES_FOR_ERROR
? ("Upload failing (" + failures + "x) — HTTP " + code)
: ("Upload error: HTTP " + code));
}
} catch (java.net.UnknownHostException e) {
// httpStatus 0: the request never reached a server.
uploadLog.record(new UploadLog.Entry(
System.currentTimeMillis(), 0, false, "No internet", summary));
prefs.edit().putString("last_upload_status", "No internet").apply();
updateNotification("Offline — will retry");
} catch (Exception e) {
Log.e(TAG, "Upload failed", e);
uploadLog.record(new UploadLog.Entry(System.currentTimeMillis(), 0, false,
e.getClass().getSimpleName(), summary));
prefs.edit().putString("last_upload_status",
e.getClass().getSimpleName() + ": " + e.getMessage()).apply();
}
}
// ---------- Location ----------
private final LocationListener locationListener = new LocationListener() {
@Override public void onLocationChanged(Location location) {
lastLocation = location;
locationUpdatesActive = true;
}
@Override public void onStatusChanged(String p, int s, Bundle e) {}
@Override public void onProviderEnabled(String p) {
Log.i(TAG, "GPS provider enabled — re-arming location updates");
// The subscription made while the provider was off delivers nothing; ask again.
requestLocationUpdates();
}
@Override public void onProviderDisabled(String p) {
Log.w(TAG, "GPS provider disabled — telemetry will omit position");
locationUpdatesActive = false;
// A stale fix is worse than none: ABRP would think the car is parked there.
lastLocation = null;
}
};
/**
* The last GPS fix if it still describes where the car is, otherwise null so the
* payload omits position entirely. Omitting it leaves ABRP on its own last known
* point; sending a stale one actively moves the car back to where it used to be.
*/
private Location freshLocation() {
Location loc = lastLocation;
if (loc == null) return null;
long ageMs = locationAgeMs(loc);
long maxAgeMs = maxLocationAgeMs();
if (ageMs > maxAgeMs) {
Log.w(TAG, "Dropping stale GPS fix: " + ageMs / 1000 + "s old (max "
+ maxAgeMs / 1000 + "s) — position omitted");
return null;
}
return loc;
}
/**
* Age of a fix, measured on the monotonic clock. Location.getTime() is wall-clock and
* the head unit adjusts its own clock from GPS, which can make a fresh fix look like
* it came from the future — or from hours ago — the moment that correction lands.
*/
private static long locationAgeMs(Location loc) {
long fixNanos = loc.getElapsedRealtimeNanos();
// A provider that does not stamp the monotonic clock leaves this at zero, which would
// date every fix to the last boot and drop all of them — turning a staleness guard
// into a position blackout. An unstamped fix is one whose age cannot be known, and
// guessing "ancient" there is the worse of the two guesses.
if (fixNanos <= 0L) return 0L;
long ageMs = (SystemClock.elapsedRealtimeNanos() - fixNanos) / 1_000_000L;
// A fix from the future is a clock that moved, not a fix worth dropping.
return Math.max(ageMs, 0L);
}
/** Several subscription intervals, so a single missed fix never blanks the position. */
private long maxLocationAgeMs() {
return Math.max(MIN_LOCATION_MAX_AGE_MS, 3 * settings.intervalMs());
}
/**
* Subscribes to GPS updates. Safe to call repeatedly: the previous subscription is
* removed first, so the watchdog can re-arm without stacking listeners.
*/
private void requestLocationUpdates() {
try {
locationManager = (LocationManager) getSystemService(LOCATION_SERVICE);
try { locationManager.removeUpdates(locationListener); } catch (Exception ignored) {}
locationUpdatesActive = false;
if (!settings.abrpUploadEnabled) {
lastLocation = null;
Log.i(TAG, "GPS updates stopped while ABRP upload is disabled");
return;
}
if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
// Match the upload cadence: a 10 s GPS subscription burned power for
// fixes that were thrown away between uploads.
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER, settings.intervalMs(), 0f,
locationListener, Looper.getMainLooper());
locationUpdatesActive = true;
lastLocationRequestMs = System.currentTimeMillis();
// Seed from the last known fix so the first upload is not position-less —
// but only if it is recent. This is where the car's overnight parking spot
// used to come from: cold GPS at start-up delivers nothing for the first
// minutes, and the fix from the previous drive filled the gap.
Location last = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER);
if (last != null && locationAgeMs(last) <= maxLocationAgeMs()) {
lastLocation = last;
} else if (last != null) {
Log.i(TAG, "Ignoring last known fix: " + locationAgeMs(last) / 1000
+ "s old, waiting for a live one");
}
} else {
Log.w(TAG, "GPS provider disabled — will retry");
lastLocationRequestMs = System.currentTimeMillis();
}
} catch (SecurityException e) {
Log.w(TAG, "Location permission not granted: " + e.getMessage());
lastLocationRequestMs = System.currentTimeMillis();
} catch (Exception e) {
Log.w(TAG, "Location unavailable: " + e.getMessage());
lastLocationRequestMs = System.currentTimeMillis();
}
updateNotification("Tüketim takibi aktif");
}
// ---------- Notification ----------
@@ -731,22 +178,17 @@ public class AbrpUploadService extends Service {
new Intent(this, MainActivity.class),
PendingIntent.FLAG_IMMUTABLE);
Intent stopIntent = new Intent(this, AbrpUploadService.class);
stopIntent.setAction(ACTION_STOP);
PendingIntent stopPi = PendingIntent.getService(this, 1,
stopIntent, PendingIntent.FLAG_IMMUTABLE);
return new NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(getString(R.string.notif_title))
.setContentText(status)
.setContentIntent(openApp)
.addAction(android.R.drawable.ic_delete, getString(R.string.notif_action_stop), stopPi)
.setOngoing(true)
.build();
}
private void updateNotification(String status) {
if (status.equals(lastStatus)) return;
lastStatus = status;
getSystemService(NotificationManager.class).notify(NOTIF_ID, buildNotification(status));
}
@@ -3,7 +3,6 @@ package com.evsuite.abrp;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.util.Log;
import java.util.Arrays;
@@ -11,16 +10,14 @@ import java.util.HashSet;
import java.util.Set;
/**
* Restarts uploading when the head unit powers up.
* Starts the consumption tracking service when the head unit powers up.
*
* BOOT_COMPLETED alone was not enough. An MG4's head unit rarely cold-boots: switching the
* car on usually resumes it, and the resume is announced with QUICKBOOT_POWERON instead.
* Users reported uploading never starting with the car, and this is why — the app was
* waiting for a broadcast their car does not always send. The other EVSuite apps already
* listen to the whole family; this one now does too.
* car on usually resumed it, and the resume is announced with QUICKBOOT_POWERON instead.
* MY_PACKAGE_REPLACED brings the service back after an update stops it.
*
* MY_PACKAGE_REPLACED is in the list for a different reason: an update stops the service,
* and nothing else would bring it back until the next power cycle.
* Always-on: no credential or toggle gate. The service tracks consumption and syncs to the
* cloud unconditionally — that is its entire purpose.
*/
public class BootReceiver extends BroadcastReceiver {
@@ -39,32 +36,10 @@ public class BootReceiver extends BroadcastReceiver {
String action = intent.getAction();
if (action == null || !START_ACTIONS.contains(action)) return;
SharedPreferences prefs = context.getSharedPreferences("abrp_prefs", Context.MODE_PRIVATE);
boolean enabled = prefs.getBoolean("service_enabled", false);
boolean autostart = prefs.getBoolean(UploadSettings.KEY_AUTOSTART, UploadSettings.DEFAULT_AUTOSTART);
// Credentials come from SecurePrefs, not from the plaintext file. This receiver used
// to read "token" out of abrp_prefs — where it has not lived since the credentials
// were encrypted, because the migration deletes the plaintext copy. The check
// therefore always failed, and uploading never started by itself on any car.
SharedPreferences secure = SecurePrefs.get(context);
boolean haveCreds = !secure.getString(SecurePrefs.KEY_TOKEN, "").trim().isEmpty()
&& !secure.getString(SecurePrefs.KEY_API_KEY, "").trim().isEmpty();
boolean uploadEnabled = prefs.getBoolean(UploadSettings.KEY_ABRP_UPLOAD_ENABLED,
UploadSettings.DEFAULT_ABRP_UPLOAD_ENABLED);
if (!enabled || !autostart || (uploadEnabled && !haveCreds)) {
Log.i(TAG, action + " ignored (enabled=" + enabled
+ " autostart=" + autostart + " credentials=" + haveCreds + ")");
return;
}
Log.i(TAG, action + " — starting upload service");
Log.i(TAG, action + " — starting consumption service");
try {
context.startForegroundService(new Intent(context, AbrpUploadService.class));
} catch (Exception e) {
// A start refused this early is not worth crashing the boot broadcast over: the
// next power cycle, or opening the app, starts it just the same.
Log.w(TAG, "startForegroundService failed: " + e.getMessage());
}
}
@@ -1,119 +0,0 @@
package com.evsuite.abrp;
/**
* Parses an ABRP configuration text file so a user can set the app up without typing on the
* car's on-screen keyboard — the api_key and token in particular are long and error-prone to
* enter by hand. The file is a plain {@code key = value} list, one entry per line:
*
* <pre>
* # ABRP Uploader config
* api_key = 1234abcd-...
* token = 5678efgh-...
* interval_sec = 60
* boost_low_soc = true
* low_soc_percent = 20
* </pre>
*
* Only api_key and token are commonly needed; the cadence keys are optional. Blank lines and
* lines starting with {@code #} are ignored, keys are case-insensitive, and surrounding
* whitespace is trimmed. Every field is nullable: the caller applies only what the file set,
* leaving anything absent untouched.
*/
final class ConfigImport {
final String apiKey;
final String token;
final Integer intervalSec;
final Boolean boostLowSoc;
final Integer lowSocPercent;
private ConfigImport(String apiKey, String token, Integer intervalSec,
Boolean boostLowSoc, Integer lowSocPercent) {
this.apiKey = apiKey;
this.token = token;
this.intervalSec = intervalSec;
this.boostLowSoc = boostLowSoc;
this.lowSocPercent = lowSocPercent;
}
/** True when the file yielded nothing usable — an empty or malformed file, not a config. */
boolean isEmpty() {
return apiKey == null && token == null && intervalSec == null
&& boostLowSoc == null && lowSocPercent == null;
}
static ConfigImport parse(String text) {
String apiKey = null, token = null;
Integer intervalSec = null, lowSocPercent = null;
Boolean boostLowSoc = null;
for (String line : text.split("\n", -1)) {
line = line.trim();
if (line.isEmpty() || line.charAt(0) == '#') continue;
int sep = line.indexOf('=');
if (sep <= 0) continue;
String key = line.substring(0, sep).trim().toLowerCase(java.util.Locale.US);
String value = line.substring(sep + 1).trim();
if (value.isEmpty()) continue;
switch (key) {
case "api_key":
case "apikey":
apiKey = value;
break;
case "token":
case "user_token":
token = value;
break;
case "interval_sec":
case "upload_interval_sec":
intervalSec = nearestInterval(value);
break;
case "boost_low_soc":
boostLowSoc = parseBool(value);
break;
case "low_soc_percent":
lowSocPercent = clampPercent(value);
break;
default:
// Unknown keys are ignored rather than rejected, so a file can carry
// comments or future keys without breaking an older build.
break;
}
}
return new ConfigImport(apiKey, token, intervalSec, boostLowSoc, lowSocPercent);
}
/** Snaps to the nearest offered choice: the UI spinner has no free-form interval. */
private static Integer nearestInterval(String value) {
int requested;
try {
requested = Integer.parseInt(value);
} catch (NumberFormatException e) {
return null;
}
int best = UploadSettings.INTERVAL_CHOICES_SEC[0];
for (int choice : UploadSettings.INTERVAL_CHOICES_SEC) {
if (Math.abs(choice - requested) < Math.abs(best - requested)) best = choice;
}
return best;
}
/** Same 1–99 clamp the manual field enforces, so an import cannot set an unusable value. */
private static Integer clampPercent(String value) {
try {
return Math.max(1, Math.min(99, Integer.parseInt(value)));
} catch (NumberFormatException e) {
return null;
}
}
private static Boolean parseBool(String value) {
value = value.toLowerCase(java.util.Locale.US);
if (value.equals("true") || value.equals("on") || value.equals("1")) return Boolean.TRUE;
if (value.equals("false") || value.equals("off") || value.equals("0")) return Boolean.FALSE;
return null;
}
}
@@ -4,27 +4,37 @@ import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
/** Best-effort MongoDB sync through the web API. Never blocks local persistence. */
/**
* Best-effort MongoDB sync through the web API. Never blocks local persistence.
*
* The installationId is a fixed value baked into BuildConfig — never a random UUID — so an
* APK wipe cannot orphan the cloud history under a new identity. The consumption UI reads
* back its own uploaded totals via {@link #fetchHistoryIfDue}, which means an APK reinstall
* picks up right where the old one left off instead of showing zeroes.
*/
final class ConsumptionCloudClient {
private static final String TAG = "FatihsMG4.Mongo";
private static final long CONSUMPTION_SYNC_INTERVAL_MS = 30 * 60_000L;
private static final long CHARGE_RETRY_INTERVAL_MS = 5 * 60_000L;
private static final long HISTORY_FETCH_INTERVAL_MS = 5 * 60_000L;
private static final int TIMEOUT_MS = 8_000;
private static final String PREFS = "consumption_cloud";
private static final String INSTALLATION_ID = "installation_id";
private static final String LAST_CONSUMPTION_DATE = "last_consumption_date";
private static final int MAX_DAYS_PER_SYNC = 10;
private static final String CHUNK_CACHE = "chunk_cache"; // JSON array of pending chunks
private static final int MAX_CHARGES_PER_SYNC = 10;
private final String endpoint;
@@ -35,57 +45,156 @@ final class ConsumptionCloudClient {
private long lastChargeAttemptMs;
private String lastAttemptedChargeId = "";
/** Cached history read back from the cloud, refreshed on a 5-minute throttle. */
private volatile List<DayRecord> cachedHistory = Collections.emptyList();
private volatile long lastHistoryFetchMs;
/** One day's uploaded totals, as read back from the cloud. */
static final class DayRecord {
final LocalDate date;
final ConsumptionTracker.Totals day;
final ConsumptionTracker.Totals lifetime;
final Float sohPercent;
DayRecord(LocalDate date, ConsumptionTracker.Totals day, ConsumptionTracker.Totals lifetime, Float sohPercent) {
this.date = date;
this.day = day;
this.lifetime = lifetime;
this.sohPercent = sohPercent;
}
}
ConsumptionCloudClient(Context context) {
endpoint = BuildConfig.CONSUMPTION_API_URL.trim();
token = BuildConfig.CONSUMPTION_API_TOKEN.trim();
prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
String existing = prefs.getString(INSTALLATION_ID, "");
if (existing == null || existing.isEmpty()) {
existing = UUID.randomUUID().toString();
prefs.edit().putString(INSTALLATION_ID, existing).commit();
installationId = BuildConfig.CONSUMPTION_INSTALLATION_ID.trim();
if (endpoint.isEmpty() || token.isEmpty()) {
Log.w(TAG, "Consumption API not configured: endpoint=" + (endpoint.isEmpty() ? "empty" : "ok")
+ ", token=" + (token.isEmpty() ? "empty" : "ok"));
}
installationId = existing;
}
/**
* Every 30 minutes, takes a delta chunk from the tracker and sends it. Chunks that fail
* to send (offline, server error) accumulate in a local cache and are retried on the
* next cycle — so a drive through a dead-signal area still reaches the cloud once the
* head unit is back on the hotspot. Every attempt is logged to the in-app "Kayıtlar" tab.
*
* @param nowMs wall clock, recorded with the payload — the throttle below runs on a
* monotonic clock instead, so a backwards NTP correction cannot stall
* syncing for however long the jump was.
*/
void syncIfDue(ConsumptionTracker tracker, ChargeSessionTracker charges, long nowMs) {
long elapsed = android.os.SystemClock.elapsedRealtime();
if (endpoint.isEmpty() || token.isEmpty()) return;
if (endpoint.isEmpty() || token.isEmpty()) {
Log.d(TAG, "Sync skipped: API not configured");
return;
}
syncPendingChargesIfDue(charges, elapsed);
if (lastConsumptionAttemptMs != 0L
&& elapsed - lastConsumptionAttemptMs < CONSUMPTION_SYNC_INTERVAL_MS) return;
// Drain any cached chunks first — a backlog from an offline period has priority.
int cached = drainChunkCache();
if (cached > 0) {
logEvent(true, 200, "Cache boşaltıldı: " + cached + " chunk gönderildi");
invalidateHistory();
}
boolean due = lastConsumptionAttemptMs == 0L
|| elapsed - lastConsumptionAttemptMs >= CONSUMPTION_SYNC_INTERVAL_MS;
if (!due) return;
lastConsumptionAttemptMs = elapsed;
ConsumptionTracker.CloudSnapshot chunk = tracker.takeChunkDelta();
if (chunk == null) {
Log.d(TAG, "Sync due but no new consumption since last chunk");
return; // no driving since the last chunk
}
Log.d(TAG, "New chunk for " + chunk.date + ": +" + String.format("%.1f", chunk.day.km)
+ "km, +" + String.format("%.2f", chunk.day.kwh) + "kWh");
try {
syncDailyConsumption(tracker, nowMs);
JSONObject body = consumptionBody(chunk, nowMs);
if (send("/v1/consumption/daily", "PUT", body)) {
logEvent(true, 200, "Chunk " + chunk.date + " gönderildi (+"
+ String.format("%.1f", chunk.day.km) + "km, +"
+ String.format("%.2f", chunk.day.kwh) + "kWh)");
invalidateHistory();
} else {
cacheChunk(body);
int pending = pendingChunkCount();
logEvent(false, 0, "Chunk gönderilemedi — cache'de bekliyor (" + pending + " chunk)");
}
} catch (Exception e) {
Log.w(TAG, "Consumption sync unavailable");
// Network failure: cache the chunk for the next cycle.
try {
JSONObject body = consumptionBody(chunk, nowMs);
cacheChunk(body);
} catch (Exception ignored) { }
int pending = pendingChunkCount();
logEvent(false, 0, "Çevrimdışı — cache'de bekliyor (" + pending + " chunk): " + e.getMessage());
Log.w(TAG, "Chunk sync failed, cached: " + e.getMessage());
}
}
/** Drains the backlog a few days per cycle; one a cycle took an hour per fortnight. */
private void syncDailyConsumption(ConsumptionTracker tracker, long nowMs) throws Exception {
String saved = prefs.getString(LAST_CONSUMPTION_DATE, "");
LocalDate lastUploaded = null;
try { if (saved != null && !saved.isEmpty()) lastUploaded = LocalDate.parse(saved); }
catch (RuntimeException ignored) { }
for (int sent = 0; sent < MAX_DAYS_PER_SYNC; sent++) {
LocalDate target = tracker.nextStoredDayAfter(lastUploaded);
if (target == null) break;
ConsumptionTracker.CloudSnapshot snapshot = tracker.cloudSnapshot(target);
JSONObject body = consumptionBody(snapshot, nowMs);
if (!send("/v1/consumption/daily", "PUT", body)) return;
prefs.edit().putString(LAST_CONSUMPTION_DATE, target.toString()).commit();
lastUploaded = target;
/**
* Sends every cached chunk; removes each one that succeeds. Returns how many were sent.
* A chunk that fails stops the drain — the rest stay in order for the next cycle.
*/
private int drainChunkCache() {
JSONArray cache = loadChunkCache();
if (cache.length() == 0) return 0;
JSONArray remaining = new JSONArray();
int sent = 0;
for (int i = 0; i < cache.length(); i++) {
try {
JSONObject body = cache.getJSONObject(i);
String date = body.optString("date", "?");
if (send("/v1/consumption/daily", "PUT", body)) {
sent++;
Log.d(TAG, "Cached chunk for " + date + " sent");
} else {
// Server rejected — keep this and everything after it.
for (int j = i; j < cache.length(); j++) remaining.put(cache.get(j));
break;
}
} catch (Exception e) {
// Network error — keep this and everything after it, stop draining.
for (int j = i; j < cache.length(); j++) {
try { remaining.put(cache.get(j)); } catch (Exception ignored) { }
}
Log.w(TAG, "Cache drain stopped: " + e.getMessage());
break;
}
}
// Today's cumulative counters overwrite the same installation/date document every
// 30 minutes. Sending totals rather than increments makes retries idempotent.
ConsumptionTracker.CloudSnapshot today = tracker.cloudSnapshot(LocalDate.now());
JSONObject current = consumptionBody(today, nowMs);
send("/v1/consumption/daily", "PUT", current);
saveChunkCache(remaining);
return sent;
}
private JSONArray loadChunkCache() {
String raw = prefs.getString(CHUNK_CACHE, "");
if (raw == null || raw.isEmpty()) return new JSONArray();
try { return new JSONArray(raw); } catch (Exception e) { return new JSONArray(); }
}
private void saveChunkCache(JSONArray cache) {
prefs.edit().putString(CHUNK_CACHE, cache.toString()).commit();
}
private void cacheChunk(JSONObject body) {
JSONArray cache = loadChunkCache();
cache.put(body);
saveChunkCache(cache);
}
private int pendingChunkCount() {
return loadChunkCache().length();
}
/** Records a cloud-sync attempt to the in-app log (the "Kayıtlar" tab). */
private void logEvent(boolean success, int httpStatus, String detail) {
UploadLog log = AbrpUploadService.log();
if (log == null) return;
log.record(new UploadLog.Entry(System.currentTimeMillis(), httpStatus, success, detail));
}
private JSONObject consumptionBody(ConsumptionTracker.CloudSnapshot snapshot, long nowMs)
@@ -112,18 +221,28 @@ final class ConsumptionCloudClient {
try {
syncPendingCharges(charges, pending);
} catch (Exception e) {
Log.w(TAG, "Charging-session sync unavailable");
logEvent(false, 0, "Şarj session sync hatası: " + e.getMessage());
Log.w(TAG, "Charging-session sync unavailable: " + e.getMessage());
}
}
private void syncPendingCharges(ChargeSessionTracker charges, List<JSONObject> pending)
throws Exception {
Log.d(TAG, "Found " + pending.size() + " pending charge sessions");
int sent = 0;
for (JSONObject session : pending) {
JSONObject body = new JSONObject(session.toString()).put("installationId", installationId);
String id = session.getString("sessionId");
if (!send("/v1/charging-sessions/" + id, "PUT", body)) return;
charges.markUploaded(id);
Log.d(TAG, "Uploading charge session: " + id);
if (send("/v1/charging-sessions/" + id, "PUT", body)) {
charges.markUploaded(id);
logEvent(true, 200, "Şarj session gönderildi: " + id);
Log.d(TAG, "Successfully uploaded charge session: " + id);
} else {
logEvent(false, 0, "Şarj session gönderilemedi: " + id);
Log.w(TAG, "Failed to upload charge session: " + id);
return;
}
if (++sent >= MAX_CHARGES_PER_SYNC) return;
}
}
@@ -132,7 +251,9 @@ final class ConsumptionCloudClient {
HttpURLConnection connection = null;
try {
byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8);
connection = (HttpURLConnection) new URL(trimSlash(endpoint) + path).openConnection();
String url = trimSlash(endpoint) + path;
Log.d(TAG, "Sending " + method + " request to " + url + " (" + bytes.length + " bytes)");
connection = (HttpURLConnection) new URL(url).openConnection();
connection.setRequestMethod(method);
connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(TIMEOUT_MS);
@@ -142,13 +263,18 @@ final class ConsumptionCloudClient {
connection.setRequestProperty("Authorization", "Bearer " + token);
try (OutputStream out = connection.getOutputStream()) { out.write(bytes); }
int status = connection.getResponseCode();
InputStream response = status < 400 ? connection.getInputStream() : connection.getErrorStream();
if (response != null) try (InputStream ignored = response) { }
Log.d(TAG, "Response: HTTP " + status);
if (status < 200 || status >= 300) {
Log.w(TAG, "Sync rejected with HTTP " + status);
String errBody = readErrorBody(connection);
Log.w(TAG, "Sync rejected with HTTP " + status + (errBody.isEmpty() ? "" : ": " + errBody));
return false;
}
InputStream response = connection.getInputStream();
if (response != null) try (InputStream ignored = response) { }
return true;
} catch (Exception e) {
Log.w(TAG, "Network error: " + e.getMessage());
throw e;
} finally {
if (connection != null) connection.disconnect();
}
@@ -159,6 +285,107 @@ final class ConsumptionCloudClient {
.put("hours", totals.hours).put("socDrop", totals.soc);
}
// ---------- Read-back for the consumption UI ----------
/**
* Returns the cached cloud history, fetching a fresh copy at most every 5 minutes.
* Called from the UI poll loop (every 2 s), so the throttle is what keeps the head unit
* off the network. On a fetch failure the previous cache is kept — a transient outage
* must not blank the display.
*/
List<DayRecord> fetchHistoryIfDue() {
if (endpoint.isEmpty() || token.isEmpty()) return cachedHistory;
long elapsed = android.os.SystemClock.elapsedRealtime();
if (lastHistoryFetchMs != 0L && elapsed - lastHistoryFetchMs < HISTORY_FETCH_INTERVAL_MS) {
return cachedHistory;
}
lastHistoryFetchMs = elapsed;
try {
List<DayRecord> fresh = fetchHistory();
if (!fresh.isEmpty()) cachedHistory = fresh;
Log.d(TAG, "Fetched " + fresh.size() + " history records from cloud");
} catch (Exception e) {
String msg = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
Log.w(TAG, "History fetch unavailable: " + msg);
logEvent(false, 0, "History fetch hatası: " + msg);
}
return cachedHistory;
}
/** Forces a refresh on the next {@link #fetchHistoryIfDue} — call after an upload. */
void invalidateHistory() {
lastHistoryFetchMs = 0L;
}
private List<DayRecord> fetchHistory() throws Exception {
HttpURLConnection connection = null;
try {
String url = trimSlash(endpoint) + "/v1/consumption/history/" + installationId;
connection = (HttpURLConnection) new URL(url).openConnection();
connection.setRequestMethod("GET");
connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(TIMEOUT_MS);
connection.setRequestProperty("Authorization", "Bearer " + token);
int status = connection.getResponseCode();
if (status < 200 || status >= 300) {
Log.w(TAG, "History fetch rejected with HTTP " + status);
return cachedHistory;
}
String body = readBody(connection);
JSONObject json = new JSONObject(body);
JSONArray records = json.optJSONArray("records");
if (records == null) return Collections.emptyList();
List<DayRecord> result = new ArrayList<>(records.length());
for (int i = 0; i < records.length(); i++) {
JSONObject r = records.getJSONObject(i);
LocalDate date = LocalDate.parse(r.getString("date"));
ConsumptionTracker.Totals day = parseTotals(r.getJSONObject("day"));
ConsumptionTracker.Totals life = parseTotals(r.getJSONObject("lifetime"));
Float soh = r.has("sohPercent") && !r.isNull("sohPercent")
? (float) r.getDouble("sohPercent") : null;
result.add(new DayRecord(date, day, life, soh));
}
return result;
} finally {
if (connection != null) connection.disconnect();
}
}
private static ConsumptionTracker.Totals parseTotals(JSONObject o) {
return new ConsumptionTracker.Totals(
o.optDouble("km", 0d), o.optDouble("kwh", 0d),
o.optDouble("hours", 0d), o.optDouble("socDrop", 0d));
}
private static String readBody(HttpURLConnection connection) throws Exception {
InputStream stream = connection.getInputStream();
try (BufferedReader br = new BufferedReader(
new InputStreamReader(stream, StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) sb.append(line);
return sb.toString();
}
}
/** Reads the error response body (truncated) for logging — never throws. */
private static String readErrorBody(HttpURLConnection connection) {
try {
InputStream stream = connection.getErrorStream();
if (stream == null) return "";
try (BufferedReader br = new BufferedReader(
new InputStreamReader(stream, StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) sb.append(line);
String body = sb.toString();
return body.length() > 300 ? body.substring(0, 300) + "…" : body;
}
} catch (Exception e) {
return "";
}
}
private static String trimSlash(String value) {
int end = value.length();
while (end > 0 && value.charAt(end - 1) == '/') end--;
@@ -35,6 +35,8 @@ final class ConsumptionTracker {
private static final String PREFS = "consumption_counters_v2";
private static final String LEGACY_PREFS = "consumption_counters_v1";
private static final String SOH_PERCENT = "manual_soh_percent";
/** Snapshot of today's counter at the last chunk send — the baseline for the next delta. */
private static final String CHUNK_CHECKPOINT = "chunk_checkpoint_";
/** How often the in-memory accumulators reach disk. Matches DriveHub_Dort. */
private static final long PERSIST_INTERVAL_MS = 30_000L;
@@ -51,6 +53,8 @@ final class ConsumptionTracker {
private final Counter tripB = new Counter();
private final Counter today = new Counter();
private LocalDate todayDate;
/** Today's totals as of the last chunk send, per field. Delta = today - this. */
private double chunkCpKm, chunkCpKwh, chunkCpHours, chunkCpSoc;
/**
* Monotonic. {@code EnergySnapshot.timestampMs} is wall clock, and the head unit's
@@ -70,6 +74,7 @@ final class ConsumptionTracker {
tripB.load(prefs, "b");
todayDate = LocalDate.now();
today.load(prefs, dayKey(todayDate));
loadChunkCheckpoint();
pruneOldDays();
}
@@ -148,6 +153,49 @@ final class ConsumptionTracker {
return new CloudSnapshot(date.toString(), totalsForDay(date), lifetimeAtEndOf(date), sohPercent());
}
/**
* The delta accumulated since the last chunk send — what a 30-minute consumption chunk
* should carry. Returns null when nothing has accumulated (no driving in this window).
* Calling this advances the checkpoint, so the same delta is never sent twice.
*/
synchronized CloudSnapshot takeChunkDelta() {
rollDayIfNeeded();
Totals current = today.snapshot();
double dKm = current.km - chunkCpKm;
double dKwh = current.kwh - chunkCpKwh;
double dHours = current.hours - chunkCpHours;
double dSoc = current.soc - chunkCpSoc;
if (Math.abs(dKm) < 0.001 && Math.abs(dKwh) < 0.001
&& Math.abs(dHours) < 0.001 && Math.abs(dSoc) < 0.001) {
return null; // nothing new since the last chunk
}
saveChunkCheckpoint(current);
Totals delta = new Totals(dKm, dKwh, dHours, dSoc);
return new CloudSnapshot(todayDate.toString(), delta, lifetime.snapshot(), sohPercent());
}
private void loadChunkCheckpoint() {
String p = CHUNK_CHECKPOINT + todayDate;
chunkCpKm = bits(p, "km");
chunkCpKwh = bits(p, "kwh");
chunkCpHours = bits(p, "h");
chunkCpSoc = bits(p, "soc");
}
private void saveChunkCheckpoint(Totals current) {
chunkCpKm = current.km;
chunkCpKwh = current.kwh;
chunkCpHours = current.hours;
chunkCpSoc = current.soc;
String p = CHUNK_CHECKPOINT + todayDate;
SharedPreferences.Editor e = prefs.edit();
e.putLong(p + "_km", Double.doubleToRawLongBits(current.km));
e.putLong(p + "_kwh", Double.doubleToRawLongBits(current.kwh));
e.putLong(p + "_h", Double.doubleToRawLongBits(current.hours));
e.putLong(p + "_soc", Double.doubleToRawLongBits(current.soc));
e.commit();
}
/** Lifetime as of the close of {@code date}, falling back to the live counter. */
private Totals lifetimeAtEndOf(LocalDate date) {
if (date.equals(todayDate)) return lifetime.snapshot();
@@ -201,6 +249,7 @@ final class ConsumptionTracker {
persist();
todayDate = date;
today.load(prefs, dayKey(date));
loadChunkCheckpoint(); // new day → fresh checkpoint baseline
pruneOldDays();
}
@@ -1,239 +0,0 @@
package com.evsuite.abrp;
import android.content.Intent;
import android.os.Bundle;
import org.json.JSONArray;
import org.json.JSONObject;
/**
* Outside temperature from the head unit's own weather app.
*
* The car does not implement ENV_OUTSIDE_TEMPERATURE, so there is no vehicle sensor to read,
* and the vendor map service that answers a weather query by position never bound on this
* firmware — {@code ext_temp} was simply absent from every upload. What demonstrably works is
* the head unit's status bar, which shows a temperature all the time. It gets it from a
* broadcast, and so does this.
*
* **Everything about the payload here is unverified.** The action and the extra name come from
* reading the head unit, not from a published interface, and the shape inside is read by
* searching rather than by field offsets: any key whose name mentions a temperature, at any
* depth. That is deliberate. A parser written against a guessed layout fails silently and
* looks exactly like the bug it was meant to fix, whereas a search either finds a number or
* reports what it was given — see {@link #diagnostic()}, which reaches the app's own log.
*
* Stays in this app rather than in EVHardware for the same reason: the shared library carries
* vendor knowledge that has been confirmed on a car. Once a driver's log shows this reading a
* real broadcast, it belongs there instead, next to SaicWeather.
*/
final class HeadUnitWeather {
/** The head unit's weather app announces each update with this. */
static final String ACTION = "com.saicmotor.weather";
/** Enough of an unreadable payload to recognise its shape, short enough for a log line. */
private static final int RAW_SAMPLE_CHARS = 200;
/** How deep the search descends. A weather payload is nested, not deeply nested. */
private static final int MAX_DEPTH = 6;
private volatile Float temperatureC;
/** The place the head unit named — what ABRP's address is checked against. */
private volatile String place = "";
/** Why the last broadcast produced nothing, for the log. Null once one produced something. */
private volatile String problem = "no broadcast yet";
private volatile long receivedMs = 0L;
/** Outside temperature in °C, or null while no broadcast has carried one. */
Float temperatureC() {
return temperatureC;
}
String place() {
return place;
}
long receivedMs() {
return receivedMs;
}
/**
* One short token for the upload log: the place a reading came from, or the reason there
* is none. "no broadcast yet" and "no temperature in {...}" call for different fixes and
* are indistinguishable from the ABRP side.
*/
String diagnostic() {
if (temperatureC == null) return problem;
String age = (System.currentTimeMillis() - receivedMs) / 60000 + "min";
return (place.isEmpty() ? "ok" : place) + "/" + age;
}
/** Reads whatever the head unit just broadcast. Never throws: this runs in a receiver. */
void accept(Intent intent) {
if (intent == null) {
// Only reached from the sticky probe, which answers null when the platform is
// holding no such broadcast. Says nothing about whether one will ever be sent.
if (temperatureC == null) problem = "none held, none sent yet";
return;
}
acceptPayload(payloadOf(intent));
}
/** Restores the last reading across a restart — see the service's prefs. */
void restore(float celsius, String named, long whenMs) {
temperatureC = celsius;
place = named == null ? "" : named;
receivedMs = whenMs;
problem = null;
}
/**
* The half of {@link #accept} that has no Android in it, so the search below is testable
* against the payloads it might be handed rather than only against the one it expects.
*/
void acceptPayload(String payload) {
try {
if (payload == null) {
problem = "broadcast carried no JSON";
return;
}
JSONObject json = new JSONObject(payload);
Float celsius = toCelsius(findNumber(json, "temp", 0), findUnit(json, 0));
if (celsius == null) {
problem = "no temperature in " + truncate(payload);
return;
}
temperatureC = celsius;
String named = findText(json, 0);
place = named == null ? "" : named;
receivedMs = System.currentTimeMillis();
problem = null;
} catch (Exception e) {
// A payload that is not the JSON this expects is a finding, not a crash.
problem = e.getClass().getSimpleName() + " reading broadcast";
}
}
/**
* The JSON the broadcast carries. Named extras first, then any string extra that looks
* like an object — the extra's name is as unverified as the rest of the payload, and a
* renamed key is not a reason to give up on a broadcast that is otherwise readable.
*/
private static String payloadOf(Intent intent) {
Bundle extras = intent.getExtras();
if (extras == null) return null;
String named = asJson(extras.get("weather"));
if (named != null) return named;
for (String key : extras.keySet()) {
String candidate = asJson(extras.get(key));
if (candidate != null) return candidate;
}
return null;
}
private static String asJson(Object value) {
if (!(value instanceof String)) return null;
String text = ((String) value).trim();
return text.startsWith("{") ? text : null;
}
/**
* The first number under a key whose name mentions [needle], at any depth.
*
* A search rather than a path: the payload's own field names are the only thing here that
* is self-describing, and they survive a layout change that a field order would not.
*/
private static Double findNumber(Object node, String needle, int depth) {
if (depth > MAX_DEPTH) return null;
if (node instanceof JSONObject) {
JSONObject object = (JSONObject) node;
for (java.util.Iterator<String> keys = object.keys(); keys.hasNext(); ) {
String key = keys.next();
Object value = object.opt(key);
if (key.toLowerCase(java.util.Locale.US).contains(needle)) {
Double number = asNumber(value);
if (number != null) return number;
}
Double nested = findNumber(value, needle, depth + 1);
if (nested != null) return nested;
}
} else if (node instanceof JSONArray) {
JSONArray array = (JSONArray) node;
for (int i = 0; i < array.length(); i++) {
Double nested = findNumber(array.opt(i), needle, depth + 1);
if (nested != null) return nested;
}
}
return null;
}
/** Numbers arrive as numbers or as strings depending on the producer; both count. */
private static Double asNumber(Object value) {
if (value instanceof Number) return ((Number) value).doubleValue();
if (value instanceof String) {
try {
return Double.valueOf(((String) value).trim());
} catch (NumberFormatException ignored) {
return null;
}
}
return null;
}
/** The unit the payload names, if it names one at all. */
private static String findUnit(Object node, int depth) {
if (depth > MAX_DEPTH) return null;
if (node instanceof JSONObject) {
JSONObject object = (JSONObject) node;
for (java.util.Iterator<String> keys = object.keys(); keys.hasNext(); ) {
String key = keys.next();
Object value = object.opt(key);
if (key.toLowerCase(java.util.Locale.US).contains("unit")
&& value instanceof String) {
return (String) value;
}
String nested = findUnit(value, depth + 1);
if (nested != null) return nested;
}
}
return null;
}
/** The place the reading is for — whatever the payload calls it. */
private static String findText(Object node, int depth) {
if (depth > MAX_DEPTH || !(node instanceof JSONObject)) return null;
JSONObject object = (JSONObject) node;
for (java.util.Iterator<String> keys = object.keys(); keys.hasNext(); ) {
String key = keys.next();
String lower = key.toLowerCase(java.util.Locale.US);
Object value = object.opt(key);
if ((lower.contains("address") || lower.contains("city") || lower.contains("poi"))
&& value instanceof String && !((String) value).trim().isEmpty()) {
return ((String) value).trim();
}
String nested = findText(value, depth + 1);
if (nested != null) return nested;
}
return null;
}
/**
* Celsius, or null when the payload named a unit this does not know.
*
* An unnamed unit is taken as Celsius: the head unit displays °C on this car, and the
* value is sanity-checked by the caller anyway. A named Fahrenheit is converted, because
* passing 81 °F through as 81 °C is worse than reporting nothing.
*/
private static Float toCelsius(Double value, String unit) {
if (value == null) return null;
String name = unit == null ? "" : unit.trim().toUpperCase(java.util.Locale.US);
if (name.contains("F")) return (float) ((value - 32.0) * 5.0 / 9.0);
return value.floatValue();
}
private static String truncate(String payload) {
return payload.length() <= RAW_SAMPLE_CHARS
? payload
: payload.substring(0, RAW_SAMPLE_CHARS) + "…";
}
}
@@ -1,6 +1,5 @@
package com.evsuite.abrp;
import android.Manifest;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
@@ -9,21 +8,17 @@ import android.graphics.drawable.ColorDrawable;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.Toast;
import android.widget.ImageView;
import android.widget.Spinner;
import android.widget.TextView;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import java.time.LocalDate;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager2.widget.ViewPager2;
@@ -32,30 +27,27 @@ 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.EVHardware;
import com.evsuite.hardware.FirmwareInfo;
import com.evsuite.hardware.telemetry.EnergySnapshot;
import com.evsuite.hardware.telemetry.EnergyTelemetryReader;
public class MainActivity extends AppCompatActivity {
private static final int LOCATION_PERMISSION_REQUEST = 100;
/** 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.tabAbrp, R.id.tabService, R.id.tabWifi,
{ R.id.tabVehicle, R.id.tabConsumption, R.id.tabCharging, R.id.tabService, 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_abrp_title,
R.string.design_overview_title, R.string.consumption_title, R.string.charging_title,
R.string.design_service_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_abrp_subtitle,
R.string.design_overview_subtitle, R.string.consumption_subtitle, R.string.charging_subtitle,
R.string.design_service_subtitle, R.string.design_wifi_subtitle, R.string.design_log_subtitle
};
@@ -66,24 +58,8 @@ public class MainActivity extends AppCompatActivity {
private static final int COLOR_ERROR = 0xFFFFB4AB;
private static final int COLOR_PENDING = 0xFFBDC4C6;
private TextInputLayout apiKeyLayout;
private TextInputEditText apiKeyInput;
private TextInputLayout tokenLayout;
private TextInputEditText tokenInput;
private SwitchMaterial serviceSwitch;
private SwitchMaterial abrpUploadSwitch;
private SwitchMaterial autostartSwitch;
private TextView statusText;
private Button testButton;
private View connectionStatusRow;
private View connectionIndicator;
private TextView connectionStatusText;
private Spinner intervalSpinner;
private SwitchMaterial boostSwitch;
private TextInputEditText lowSocInput;
private TextInputLayout lowSocLayout;
private TextView callLogText;
private View abrpPane;
private View servicePane;
private View logPane;
private View vehiclePane;
@@ -91,7 +67,7 @@ public class MainActivity extends AppCompatActivity {
private View chargingPane;
private View wifiPane;
/** The five pages in navigation order. Parallel to {@link #TAB_IDS}. */
/** The six 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. */
@@ -110,6 +86,7 @@ public class MainActivity extends AppCompatActivity {
vehCharging, vehHvac,
vehTireFl, vehTireFr, vehTireRl, vehTireRr;
private ConsumptionTracker consumptionTracker;
private ConsumptionCloudClient consumptionCloudClient;
private ConsumptionTracker.Period consumptionPeriod = ConsumptionTracker.Period.LIFETIME;
private TextView consumptionDistance, consumptionEnergy, consumptionAverage,
consumptionSpeed, consumptionTime, consumptionSoc, consumptionDailyHistory;
@@ -162,7 +139,7 @@ public class MainActivity extends AppCompatActivity {
// state, exactly as they did when they were siblings in the layout.
}
});
// Keep all pages alive: the service switch, log and vehicle readings are refreshed
// 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() {
@@ -207,18 +184,6 @@ public class MainActivity extends AppCompatActivity {
};
private SharedPreferences prefs;
/** Credentials only — encrypted at rest, see {@link SecurePrefs}. */
private SharedPreferences securePrefs;
/**
* Picks an ABRP 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-specific folders first and only falls back to this.
*/
private final ActivityResultLauncher<String[]> configPicker =
registerForActivityResult(new ActivityResultContracts.OpenDocument(), uri -> {
if (uri != null) importConfig(uri);
});
@Override
protected void onCreate(Bundle savedInstanceState) {
@@ -229,129 +194,40 @@ public class MainActivity extends AppCompatActivity {
((TextView) findViewById(R.id.version_badge)).setText("v" + BuildConfig.VERSION_NAME);
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
securePrefs = SecurePrefs.get(this);
// 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.
abrpPane = inflatePane(R.layout.pane_abrp);
servicePane = inflatePane(R.layout.pane_service);
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, abrpPane, servicePane, wifiPane, logPane };
panes = new View[] { vehiclePane, consumptionPane, chargingPane, servicePane, wifiPane, logPane };
bindVehiclePane();
bindConsumptionPane();
bindChargingPane();
bindWifiPane();
apiKeyLayout = abrpPane.findViewById(R.id.api_key_layout);
apiKeyInput = abrpPane.findViewById(R.id.api_key_input);
tokenLayout = abrpPane.findViewById(R.id.token_layout);
tokenInput = abrpPane.findViewById(R.id.token_input);
serviceSwitch = servicePane.findViewById(R.id.service_switch);
abrpUploadSwitch = servicePane.findViewById(R.id.abrp_upload_switch);
autostartSwitch = servicePane.findViewById(R.id.autostart_switch);
statusText = servicePane.findViewById(R.id.status_text);
testButton = abrpPane.findViewById(R.id.test_button);
connectionStatusRow = abrpPane.findViewById(R.id.connection_status_row);
connectionIndicator = abrpPane.findViewById(R.id.connection_indicator);
connectionStatusText = abrpPane.findViewById(R.id.connection_status_text);
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);
callLogText = logPane.findViewById(R.id.call_log_text);
setUpPager();
findViewById(R.id.about_button).setOnClickListener(v -> showAbout());
findViewById(R.id.update_button).setOnClickListener(v -> checkForUpdate(v));
bindCadenceControls();
// 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);
apiKeyInput.setText(savedApiKeyOrDefault());
tokenInput.setText(securePrefs.getString(SecurePrefs.KEY_TOKEN, ""));
serviceSwitch.setChecked(prefs.getBoolean("service_enabled", false));
abrpUploadSwitch.setChecked(prefs.getBoolean(
UploadSettings.KEY_ABRP_UPLOAD_ENABLED,
UploadSettings.DEFAULT_ABRP_UPLOAD_ENABLED));
autostartSwitch.setChecked(
prefs.getBoolean(UploadSettings.KEY_AUTOSTART, UploadSettings.DEFAULT_AUTOSTART));
autostartSwitch.setOnCheckedChangeListener((btn, checked) ->
prefs.edit().putBoolean(UploadSettings.KEY_AUTOSTART, checked).apply());
// If the user wants the service running, kick it on every activity launch.
// The service is always-on: it tracks consumption and syncs to MongoDB cloud.
// startForegroundService is idempotent — if the service is already up this
// is a no-op aside from delivering a new intent. We don't trust
// service_running as a sole indicator because it can be stale-true if a
// previous process was force-killed (e.g. by `adb install -r`) without
// onDestroy running.
boolean enabled = prefs.getBoolean("service_enabled", false);
boolean haveCreds = !securePrefs.getString(SecurePrefs.KEY_TOKEN, "").trim().isEmpty()
&& !securePrefs.getString(SecurePrefs.KEY_API_KEY, "").trim().isEmpty();
boolean uploadEnabled = prefs.getBoolean(UploadSettings.KEY_ABRP_UPLOAD_ENABLED,
UploadSettings.DEFAULT_ABRP_UPLOAD_ENABLED);
if (enabled && (!uploadEnabled || haveCreds)) {
// Asked for first, and deliberately: the service declares the `location`
// foreground type, and since API 34 starting it without the permission behind
// that type is a SecurityException in its own onCreate. Started this way round,
// the very first launch after an install could never bring the uploader up.
requestLocationPermissionIfNeeded();
startForegroundService(new Intent(this, AbrpUploadService.class));
}
abrpPane.findViewById(R.id.save_button).setOnClickListener(v -> saveCredentials());
testButton.setOnClickListener(v -> testConnection());
abrpPane.findViewById(R.id.import_button).setOnClickListener(v -> startConfigImport());
wireProbeIfDebug();
serviceSwitch.setOnCheckedChangeListener((btn, checked) -> {
if (checked) {
String apiKey = textOf(apiKeyInput);
String token = textOf(tokenInput);
if (abrpUploadSwitch.isChecked() && (apiKey.isEmpty() || token.isEmpty())) {
serviceSwitch.setChecked(false);
if (apiKey.isEmpty()) apiKeyLayout.setError(getString(R.string.api_key_required));
if (token.isEmpty()) tokenLayout.setError(getString(R.string.token_required));
return;
}
apiKeyLayout.setError(null);
tokenLayout.setError(null);
prefs.edit().putBoolean("service_enabled", true).apply();
startForegroundService(new Intent(this, AbrpUploadService.class));
requestLocationPermissionIfNeeded();
} else {
prefs.edit().putBoolean("service_enabled", false).apply();
stopService(new Intent(this, AbrpUploadService.class));
}
refreshStatus();
});
abrpUploadSwitch.setOnCheckedChangeListener((btn, checked) -> {
if (checked) {
String apiKey = textOf(apiKeyInput);
String token = textOf(tokenInput);
if (apiKey.isEmpty() || token.isEmpty()) {
abrpUploadSwitch.setChecked(false);
if (apiKey.isEmpty()) apiKeyLayout.setError(getString(R.string.api_key_required));
if (token.isEmpty()) tokenLayout.setError(getString(R.string.token_required));
return;
}
}
prefs.edit().putBoolean(UploadSettings.KEY_ABRP_UPLOAD_ENABLED, checked).apply();
AbrpUploadService.reloadSettings();
refreshStatus();
});
// is a no-op aside from delivering a new intent.
startForegroundService(new Intent(this, AbrpUploadService.class));
}
private void showAbout() {
@@ -386,250 +262,10 @@ public class MainActivity extends AppCompatActivity {
@Override
protected void onResume() {
super.onResume();
apiKeyInput.setText(savedApiKeyOrDefault());
tokenInput.setText(securePrefs.getString(SecurePrefs.KEY_TOKEN, ""));
serviceSwitch.setChecked(prefs.getBoolean("service_enabled", false));
abrpUploadSwitch.setChecked(prefs.getBoolean(
UploadSettings.KEY_ABRP_UPLOAD_ENABLED,
UploadSettings.DEFAULT_ABRP_UPLOAD_ENABLED));
autostartSwitch.setChecked(
prefs.getBoolean(UploadSettings.KEY_AUTOSTART, UploadSettings.DEFAULT_AUTOSTART));
// Poll state and log only while the screen is up; onPause cancels it.
uiHandler.post(uiRefresh);
}
// ---------- Credentials ----------
/**
* The SAIC open-source gateway publishes this shared ABRP key for compatible clients.
* A user-supplied key saved in encrypted preferences always takes precedence.
*/
private String savedApiKeyOrDefault() {
String savedApiKey = securePrefs.getString(SecurePrefs.KEY_API_KEY, "");
return savedApiKey == null || savedApiKey.trim().isEmpty()
? getString(R.string.default_abrp_api_key)
: savedApiKey;
}
private void saveCredentials() {
String apiKey = textOf(apiKeyInput);
String token = textOf(tokenInput);
boolean valid = true;
if (apiKey.isEmpty()) {
apiKeyLayout.setError(getString(R.string.api_key_required));
valid = false;
} else {
apiKeyLayout.setError(null);
}
if (token.isEmpty()) {
tokenLayout.setError(getString(R.string.token_required));
valid = false;
} else {
tokenLayout.setError(null);
}
if (!valid) return;
securePrefs.edit()
.putString(SecurePrefs.KEY_API_KEY, apiKey)
.putString(SecurePrefs.KEY_TOKEN, token)
.apply();
if (serviceSwitch.isChecked()) {
startForegroundService(new Intent(this, AbrpUploadService.class));
}
refreshStatus();
}
// ---------- 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 "";
}
}
/**
* Cap the read so a wrong file (a huge binary picked by mistake) can't be slurped whole
* into memory before we find out it isn't a config.
*/
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, shown when nothing was found. */
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(android.net.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 config text sets. Credentials go straight to the encrypted store;
* cadence keys go to the plain prefs the same way the manual controls write them. Absent
* keys are left untouched, so a file carrying only the credentials does not wipe a cadence
* the user already tuned.
*/
private void applyConfig(String text) {
ConfigImport config = ConfigImport.parse(text);
if (config.isEmpty()) {
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_empty));
return;
}
if (config.apiKey != null) {
securePrefs.edit().putString(SecurePrefs.KEY_API_KEY, config.apiKey).apply();
}
if (config.token != null) {
securePrefs.edit().putString(SecurePrefs.KEY_TOKEN, config.token).apply();
}
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();
// Re-read every control from prefs so the screen shows what was imported.
apiKeyInput.setText(savedApiKeyOrDefault());
tokenInput.setText(securePrefs.getString(SecurePrefs.KEY_TOKEN, ""));
apiKeyLayout.setError(null);
tokenLayout.setError(null);
bindCadenceControls();
AbrpUploadService.reloadSettings();
if (serviceSwitch.isChecked()) {
startForegroundService(new Intent(this, AbrpUploadService.class));
}
setConnectionStatus(COLOR_OK, getString(R.string.import_ok));
refreshStatus();
}
// ---------- Connection test ----------
private void testConnection() {
String apiKey = textOf(apiKeyInput);
String token = textOf(tokenInput);
if (apiKey.isEmpty() || token.isEmpty()) {
if (apiKey.isEmpty()) apiKeyLayout.setError(getString(R.string.api_key_required));
if (token.isEmpty()) tokenLayout.setError(getString(R.string.token_required));
return;
}
setConnectionStatus(COLOR_PENDING, getString(R.string.conn_testing));
testButton.setEnabled(false);
new Thread(() -> {
String result = pingAbrp(apiKey, token);
runOnUiThread(() -> {
testButton.setEnabled(true);
if (result == null) {
setConnectionStatus(COLOR_OK, getString(R.string.conn_ok));
} else {
setConnectionStatus(COLOR_ERROR, result);
}
});
}).start();
}
/**
* Sends a minimal test request to the ABRP API.
* Returns null on success, or a short error string on failure.
*/
private String pingAbrp(String apiKey, String token) {
try {
// Read-only check. The previous version POSTed {"utc":...,"soc":0} to the LIVE
// /tlm/send endpoint, i.e. told ABRP the car was at 0% every time the user
// pressed Test, wrecking the route plan it had computed.
AbrpApi.Response response = AbrpApi.verifyCredentials(apiKey, token);
if (response.code == 200) return null;
if (response.code == 401) return getString(R.string.conn_err_auth);
return getString(R.string.conn_err_http, response.code);
} catch (java.net.UnknownHostException e) {
return getString(R.string.conn_err_no_internet);
} catch (java.net.SocketTimeoutException e) {
return getString(R.string.conn_err_timeout);
} catch (Exception e) {
return e.getMessage();
}
}
@Override
protected void onPause() {
super.onPause();
@@ -637,67 +273,6 @@ public class MainActivity extends AppCompatActivity {
uiHandler.removeCallbacks(uiRefresh);
}
// ---------- Cadence configuration ----------
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;
}
ArrayAdapter<String> adapter = new ArrayAdapter<>(
this, R.layout.spinner_value, labels);
adapter.setDropDownViewResource(R.layout.spinner_dropdown);
intervalSpinner.setAdapter(adapter);
intervalSpinner.setSelection(selected);
intervalSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
prefs.edit().putInt(UploadSettings.KEY_INTERVAL_SEC,
UploadSettings.INTERVAL_CHOICES_SEC[position]).apply();
// Takes effect on the next cycle — no service restart needed.
AbrpUploadService.reloadSettings();
}
@Override public void onNothingSelected(AdapterView<?> parent) { }
});
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);
AbrpUploadService.reloadSettings();
});
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();
AbrpUploadService.reloadSettings();
}
// ---------- Update check ----------
/**
@@ -854,6 +429,7 @@ public class MainActivity extends AppCompatActivity {
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);
@@ -992,7 +568,15 @@ public class MainActivity extends AppCompatActivity {
private void refreshConsumption() {
if (consumptionTracker == null) return;
ConsumptionTracker.Totals t = consumptionTracker.totals(consumptionPeriod);
// 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(),
@@ -1003,7 +587,9 @@ public class MainActivity extends AppCompatActivity {
consumptionTime.setText(String.format(java.util.Locale.getDefault(), "%d sa %02d dk",
minutes / 60, minutes % 60));
consumptionSoc.setText(String.format(java.util.Locale.getDefault(), "%.1f %%", t.soc));
ConsumptionTracker.Totals day = consumptionTracker.totalsForDay(selectedConsumptionDate);
// 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("Bu tarih için kayıt yok");
} else {
@@ -1016,6 +602,43 @@ public class MainActivity extends AppCompatActivity {
}
}
/**
* 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,
@@ -1091,86 +714,22 @@ public class MainActivity extends AppCompatActivity {
callLogText.setText(sb.toString());
}
// ---------- VHAL probe ----------
/**
* Wires the "VHAL Probe" button that dumps the vehicle's own telemetry property
* surface into the in-app log. The head unit runs no adb, so the screen's existing
* log view is the only channel a diagnostic can reach the driver through.
*
* Present in every build, not just debug: identifying the SWI69 property ids needs to
* happen on a production APK on the car, and EVHardware's probe is read-only, so it is
* safe to ship. It deliberately holds no device credentials. Keep it until the
* per-generation property map for SWI69 is confirmed, then gate it on debug.
*/
private void wireProbeIfDebug() {
View probeButton = abrpPane.findViewById(R.id.probe_button);
if (probeButton == null) return;
probeButton.setVisibility(View.VISIBLE);
probeButton.setOnClickListener(v -> runProbe());
}
private void runProbe() {
// Binder reads are slow enough to be worth keeping off the UI thread; the result
// is recorded through the upload log, which the screen already renders.
new Thread(() -> {
try {
EVHardware.INSTANCE.init(getApplicationContext());
java.util.List<EVHardware.PropertyReport> reports = EVHardware.INSTANCE.probeTelemetryProperties();
String firmware = String.valueOf(FirmwareInfo.INSTANCE.getGeneration());
StringBuilder sb = new StringBuilder("VHAL probe on ").append(firmware).append('\n');
for (EVHardware.PropertyReport r : reports) sb.append(" ").append(r).append('\n');
sb.append("TOTAL ").append(reports.size()).append(" properties probed");
runOnUiThread(() -> AbrpUploadService.log().record(
new UploadLog.Entry(System.currentTimeMillis(), 200, true, "VHAL Probe", sb.toString())));
} catch (Throwable t) {
String msg = "Probe failed: " + (t.getMessage() != null ? t.getMessage() : t.getClass().getSimpleName());
runOnUiThread(() -> AbrpUploadService.log().record(
new UploadLog.Entry(System.currentTimeMillis(), 0, false, "VHAL Probe", msg)));
}
}).start();
}
private void setConnectionStatus(int color, String message) {
connectionStatusRow.setVisibility(View.VISIBLE);
connectionIndicator.getBackground().setTint(color);
connectionStatusText.setText(message);
connectionStatusText.setTextColor(color);
}
// ---------- Service status ----------
private void refreshStatus() {
// Live signal, not the preference: "service_running" stays stale-true after a
// force-kill because onDestroy never ran.
// The service is always running. Show the cloud sync state derived from the log.
UploadLog.State state = AbrpUploadService.state();
if (AbrpUploadService.isRunning() && !prefs.getBoolean(
UploadSettings.KEY_ABRP_UPLOAD_ENABLED,
UploadSettings.DEFAULT_ABRP_UPLOAD_ENABLED)) {
statusText.setText(R.string.state_tracking_locally);
statusText.setTextColor(COLOR_OK);
return;
}
switch (state) {
case ERROR:
// Derived from the log, not from the last status alone: a single failed
// upload is normal (tunnel, dead spot), three in a row is a problem.
// 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:
String lastTime = prefs.getString("last_upload_time", null);
statusText.setText(lastTime != null
? getString(R.string.status_last_upload, lastTime,
getString(R.string.status_ok))
: getString(R.string.state_running));
statusText.setTextColor(COLOR_OK);
break;
case STARTING:
statusText.setText(R.string.state_starting);
statusText.setText("Tüketim takibi aktif");
statusText.setTextColor(COLOR_OK);
break;
case STOPPED:
@@ -1187,90 +746,4 @@ public class MainActivity extends AppCompatActivity {
CharSequence text = field.getText();
return text != null ? text.toString().trim() : "";
}
/**
* Permissions that need a runtime grant (vs being granted at install time
* via the platform signature). CAR_SPEED and CAR_ENERGY are dangerous-level
* AAOS permissions — without these, the corresponding property reads throw
* SecurityException and we have no SOC / speed data.
*/
private static final String[] RUNTIME_PERMISSIONS = {
Manifest.permission.ACCESS_FINE_LOCATION,
"android.car.permission.CAR_SPEED",
"android.car.permission.CAR_ENERGY",
"android.car.permission.CAR_ENERGY_PORTS",
// Outside temperature and cabin temperature: declared and read since 2.1.0, but
// never asked for, so both came back unreadable and were dropped from every
// payload on a car that would have answered them.
"android.car.permission.CAR_EXTERIOR_ENVIRONMENT",
"android.car.permission.CONTROL_CAR_CLIMATE",
};
/**
* Asked for alongside the telemetry permissions from API 33, and separately because a
* denial costs something different: the foreground notification is the only status this
* service has, and without this it is dropped silently while the service runs on.
*/
private static String[] runtimePermissions() {
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) {
return RUNTIME_PERMISSIONS;
}
String[] all = java.util.Arrays.copyOf(RUNTIME_PERMISSIONS, RUNTIME_PERMISSIONS.length + 1);
all[RUNTIME_PERMISSIONS.length] = Manifest.permission.POST_NOTIFICATIONS;
return all;
}
private void requestLocationPermissionIfNeeded() {
java.util.List<String> missing = new java.util.ArrayList<>();
for (String p : runtimePermissions()) {
if (ContextCompat.checkSelfPermission(this, p)
!= PackageManager.PERMISSION_GRANTED) {
missing.add(p);
}
}
if (!missing.isEmpty()) {
ActivityCompat.requestPermissions(this,
missing.toArray(new String[0]),
LOCATION_PERMISSION_REQUEST);
}
}
/**
* A denied permission used to be silent: the service kept running and simply uploaded
* nothing useful, with the UI claiming everything was fine. Say what was denied and
* what it costs.
*/
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
@NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode != LOCATION_PERMISSION_REQUEST) return;
boolean locationDenied = false;
boolean carDataDenied = false;
for (int i = 0; i < permissions.length && i < grantResults.length; i++) {
if (grantResults[i] == PackageManager.PERMISSION_GRANTED) continue;
if (Manifest.permission.ACCESS_FINE_LOCATION.equals(permissions[i])) {
locationDenied = true;
} else {
carDataDenied = true;
}
}
// A grant that arrives after the service is already up changes which foreground
// types it may hold, and which properties it may read. Starting it again is what
// applies both without waiting for the next boot.
if (prefs.getBoolean("service_enabled", false)) {
startForegroundService(new Intent(this, AbrpUploadService.class));
}
if (locationDenied && carDataDenied) {
setConnectionStatus(COLOR_ERROR, getString(R.string.perm_denied_all));
} else if (locationDenied) {
setConnectionStatus(COLOR_ERROR, getString(R.string.perm_denied_location));
} else if (carDataDenied) {
setConnectionStatus(COLOR_ERROR, getString(R.string.perm_denied_car));
}
refreshStatus();
}
}
@@ -1,70 +0,0 @@
package com.evsuite.abrp;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import androidx.security.crypto.EncryptedSharedPreferences;
import androidx.security.crypto.MasterKey;
/**
* Storage for the ABRP credentials (api_key, token).
*
* They used to sit in plaintext in the regular SharedPreferences file, which
* `adb backup` hands out. They now live in an AndroidKeyStore-backed encrypted
* file, and the plaintext copy is wiped on first access.
*/
final class SecurePrefs {
private static final String TAG = "SecurePrefs";
private static final String FILE_NAME = "abrp_secure_prefs";
private static final String PLAIN_NAME = "abrp_prefs";
static final String KEY_API_KEY = "api_key";
static final String KEY_TOKEN = "token";
private static SharedPreferences instance;
private SecurePrefs() { }
/**
* Encrypted preferences, or the plaintext file if the crypto stack is
* unavailable. Falling back keeps the app usable on a device with a broken
* keystore instead of crashing it in a vehicle.
*/
static synchronized SharedPreferences get(Context context) {
if (instance != null) return instance;
Context app = context.getApplicationContext();
try {
MasterKey masterKey = new MasterKey.Builder(app)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build();
instance = EncryptedSharedPreferences.create(
app,
FILE_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM);
migratePlaintext(app, instance);
} catch (Exception e) {
Log.e(TAG, "Encrypted preferences unavailable, falling back to plaintext", e);
instance = app.getSharedPreferences(PLAIN_NAME, Context.MODE_PRIVATE);
}
return instance;
}
/** Moves credentials written by an older version, then deletes the plaintext copy. */
private static void migratePlaintext(Context context, SharedPreferences secure) {
SharedPreferences plain = context.getSharedPreferences(PLAIN_NAME, Context.MODE_PRIVATE);
String apiKey = plain.getString(KEY_API_KEY, null);
String token = plain.getString(KEY_TOKEN, null);
if (apiKey == null && token == null) return;
SharedPreferences.Editor secureEdit = secure.edit();
if (apiKey != null) secureEdit.putString(KEY_API_KEY, apiKey);
if (token != null) secureEdit.putString(KEY_TOKEN, token);
secureEdit.commit();
plain.edit().remove(KEY_API_KEY).remove(KEY_TOKEN).commit();
}
}
@@ -1,197 +0,0 @@
package com.evsuite.abrp;
import org.json.JSONException;
import org.json.JSONObject;
/**
* Builds the ABRP telemetry JSON payload.
*
* Kept free of Android types so it can be unit-tested on the JVM: the caller
* pulls the values out of the car adapter and the Location, this class only
* decides what ends up in the payload.
*
* Every vehicle field is nullable and a null field is OMITTED. A property that
* cannot be read is not a zero: sending soc:0 because a getter threw tells ABRP
* the battery is empty and wrecks the user's route plan.
*
* Fields are set by name rather than passed positionally. The payload now carries
* 24 optional values and a positional build(...) call had become a wall of nulls
* that no reader could check against the ABRP field list.
*/
final class TelemetryPayload {
/** Only value that is always present. Epoch SECONDS, not milliseconds. */
private final long utc;
// --- High priority (ABRP: "many features will only be usable with enough data") ---
Integer soc; // %
Float speedKmh; // km/h
Float powerKw; // kW, ABRP sign: positive leaving the battery
Boolean charging;
Boolean dcfc;
Boolean parked;
Double lat; // °
Double lon; // °
// --- Lower priority ---
Integer rangeKm; // km, vehicle's own estimate
Float cabinTemp; // °C
Float battTempC; // °C, traction battery
Float capacityKwh; // kWh, usable pack capacity
Float soeKwh; // kWh, energy currently in the pack
Double kwhCharged; // kWh, taken by the pack during the current charge session
Float odometerKm; // km
Float hvacSetpointC; // °C, climate setpoint
Float tirePressureFlKpa;
Float tirePressureFrKpa;
Float tirePressureRlKpa;
Float tirePressureRrKpa;
Double elevation; // m
Float heading; // °
TelemetryPayload(long utc) {
this.utc = utc;
}
/** Build the {@code tlm} JSON object. Only utc is always present. */
String build() {
JSONObject tlm = new JSONObject();
try {
tlm.put("utc", utc);
putIfPresent(tlm, "soc", soc);
putIfPresent(tlm, "speed", speedKmh == null ? null : Math.round(speedKmh));
putIfPresent(tlm, "est_battery_range", rangeKm);
if (powerKw != null) tlm.put("power", round2(powerKw));
putIfPresent(tlm, "is_charging", boolToInt(charging));
putIfPresent(tlm, "is_dcfc", boolToInt(dcfc));
putIfPresent(tlm, "is_parked", boolToInt(parked));
// Only send cabin_temp if we got a plausible reading — 0.0 likely
// means the property isn't supported on this VHAL.
if (isPlausibleTemp(cabinTemp)) {
tlm.put("cabin_temp", Math.round(cabinTemp));
}
// Same guard for the pack: a VHAL that does not implement the property
// answers 0.0, and "battery at 0 °C" is a reading ABRP acts on.
if (isPlausibleTemp(battTempC)) {
tlm.put("batt_temp", Math.round(battTempC));
}
// hvac_setpoint is a user setting, so 0 °C is not a real value either —
// no cabin climate control is ever set to freezing.
if (isPlausibleTemp(hvacSetpointC)) {
tlm.put("hvac_setpoint", Math.round(hvacSetpointC));
}
// A pack cannot hold 0 kWh and a car cannot have driven 0 km by the time
// it runs this app: both zeros mean "property not implemented".
if (capacityKwh != null && capacityKwh > 0f) {
tlm.put("capacity", round2(capacityKwh));
}
if (odometerKm != null && odometerKm > 0f) {
tlm.put("odometer", Math.round(odometerKm));
}
// Same reasoning as capacity: a car that is running this app has some energy
// in its pack, so 0 kWh is the VHAL saying it does not implement the property.
if (soeKwh != null && soeKwh > 0f) {
tlm.put("soe", round2(soeKwh));
}
// 0.0 IS meaningful here — it is a charge session that has just begun — so
// This one is guarded on null only. EVHardware's charging-session meter sends
// null between sessions.
if (kwhCharged != null) {
tlm.put("kwh_charged", kwhCharged.doubleValue());
}
putIfPresent(tlm, "tire_pressure_fl", kpa(tirePressureFlKpa));
putIfPresent(tlm, "tire_pressure_fr", kpa(tirePressureFrKpa));
putIfPresent(tlm, "tire_pressure_rl", kpa(tirePressureRlKpa));
putIfPresent(tlm, "tire_pressure_rr", kpa(tirePressureRrKpa));
if (lat != null && lon != null) {
tlm.put("lat", lat.doubleValue());
tlm.put("lon", lon.doubleValue());
putIfPresent(tlm, "elevation", elevation == null ? null : Math.round(elevation));
putIfPresent(tlm, "heading", heading == null ? null : Math.round(heading));
}
} catch (JSONException e) {
// JSONObject.put only throws on NaN/Infinity values, which the guards above
// already exclude. Returning just the timestamp keeps the upload alive.
return "{\"utc\":" + utc + "}";
}
return tlm.toString();
}
/**
* True for a temperature that a sensor could actually have measured. Excludes exactly
* 0.0, which is what an unimplemented VHAL property returns — see cabin_temp above.
*
* Package-private because the service asks the same question before deciding whether to
* look for an outside temperature somewhere other than the car.
*/
static boolean isPlausibleTemp(Float celsius) {
return celsius != null && celsius > -50f && celsius < 80f && celsius != 0f;
}
/**
* Rounded kPa, or null when the reading is outside anything a road tyre reports.
* A flat tyre still reads well above zero, so 0 means the wheel has no sensor.
*/
private static Integer kpa(Float pressure) {
if (pressure == null || pressure < 50f || pressure > 500f) return null;
return Math.round(pressure);
}
private static void putIfPresent(JSONObject json, String key, Object value)
throws JSONException {
if (value != null) json.put(key, value);
}
private static Integer boolToInt(Boolean value) {
return value == null ? null : (value ? 1 : 0);
}
/** ABRP expects kW with 2 decimals; avoids a locale-dependent String.format. */
private static double round2(float value) {
return Math.round(value * 100.0) / 100.0;
}
/**
* Every field {@link #build()} can emit, in the order the summary lists them.
* Used only to work out what was left out — see {@link #summarize(String)}.
*/
private static final String[] ALL_FIELDS = {
"utc", "soc", "speed", "power", "est_battery_range", "cabin_temp",
"batt_temp", "hvac_setpoint", "capacity", "soe", "kwh_charged", "odometer",
"is_charging", "is_dcfc", "is_parked", "lat", "lon", "elevation", "heading",
"tire_pressure_fl", "tire_pressure_fr", "tire_pressure_rl", "tire_pressure_rr",
};
/**
* One-line, human-readable digest of an upload: what was sent, and what was left out.
*
* Derived from the JSON that actually goes on the wire rather than from the fields
* above, so it cannot drift away from the payload it claims to describe — which is
* the whole point of having it when a value on the ABRP side looks wrong.
*/
static String summarize(String tlmJson) {
JSONObject tlm;
try {
tlm = new JSONObject(tlmJson);
} catch (JSONException e) {
return "unparseable payload: " + tlmJson;
}
StringBuilder sent = new StringBuilder();
StringBuilder omitted = new StringBuilder();
for (String field : ALL_FIELDS) {
if (tlm.has(field)) {
if (sent.length() > 0) sent.append(' ');
sent.append(field).append('=').append(tlm.opt(field));
} else {
if (omitted.length() > 0) omitted.append(',');
omitted.append(field);
}
}
return sent + (omitted.length() == 0 ? "" : " | omitted: " + omitted);
}
}
@@ -1,66 +0,0 @@
package com.evsuite.abrp;
/**
* Decides whether a given scheduler tick should actually upload.
*
* The service ticks at a fixed short rate; this class thins the uploads out. A car parked
* overnight and not charging does not need a sample every minute — that is wasted data and
* power — but nothing may be lost while driving or charging, where ABRP needs the
* resolution to keep a route plan accurate.
*
* Free of Android types so the policy can be unit-tested directly.
*/
final class UploadCadence {
/**
* Parked and not charging: one keep-alive sample every 15 minutes. The car is not
* moving and its SOC is not changing, so anything more is spent for nothing.
*/
static final long PARKED_INTERVAL_MS = 900_000L;
private UploadCadence() { }
/**
* @param nowMs current time
* @param lastUploadMs when the last upload was attempted, 0 if never
* @param parked null when the gear could not be read
* @param charging null when the charge port state could not be read
* @param socPercent null when the battery level could not be read
* @param stateChanged true if parked/charging differ from the previous tick
* @param settings user-configured cadence
*/
static boolean shouldUpload(long nowMs, long lastUploadMs,
Boolean parked, Boolean charging, Integer socPercent,
boolean stateChanged, UploadSettings settings) {
// Never uploaded yet: send immediately so ABRP sees the car come online.
if (lastUploadMs <= 0L) return true;
// A transition (just parked, plugged in, unplugged) is exactly what ABRP must
// learn straight away — never make it wait for the slow interval.
if (stateChanged) return true;
return nowMs - lastUploadMs >= intervalFor(parked, charging, socPercent, settings);
}
/**
* Interval that applies in the given state.
*
* Unknown state counts as active: if the gear or charge port could not be read we do
* not know the car is idle, and under-reporting a moving car is worse than a few
* extra requests from a stationary one.
*/
static long intervalFor(Boolean parked, Boolean charging, Integer socPercent,
UploadSettings settings) {
boolean idle = Boolean.TRUE.equals(parked) && Boolean.FALSE.equals(charging);
if (idle) return PARKED_INTERVAL_MS;
// Low battery: the driver is picking a charge stop and ABRP needs a tighter SOC
// curve to get the arrival estimate right. Only while actually in use — a car
// parked at 5 % is still parked and gains nothing from fast polling.
if (settings.boostLowSoc && socPercent != null
&& socPercent <= settings.lowSocPercent) {
return Math.min(settings.intervalMs(), UploadSettings.BOOSTED_INTERVAL_SEC * 1000L);
}
return settings.intervalMs();
}
}
@@ -1,79 +1,14 @@
package com.evsuite.abrp;
import android.content.SharedPreferences;
/**
* User-configurable upload policy.
*
* Defaults are deliberately conservative. The head unit is awake whenever the car is on,
* and both the upload tick and the GPS subscription cost power the whole time, so the
* default cadence is the slowest one that still keeps an ABRP route plan usable.
* Placeholder — the service is always-on and samples on a fixed 15-second scheduler, so
* there is no user-configurable upload policy left. Kept as an empty shell so any stray
* references compile during the ABRP removal; will be deleted once all callers are gone.
*/
final class UploadSettings {
static final String KEY_INTERVAL_SEC = "upload_interval_sec";
static final String KEY_BOOST_LOW_SOC = "boost_low_soc";
static final String KEY_LOW_SOC_PERCENT = "low_soc_percent";
static final String KEY_AUTOSTART = "autostart";
static final String KEY_ABRP_UPLOAD_ENABLED = "abrp_upload_enabled";
/**
* On by default: an ABRP plan is only useful if it is fed from the moment the drive
* starts, and nobody opens this app before setting off. Kept as a switch because a
* service that starts itself must also be switchable off.
*/
static final String KEY_AUTOSTART = "autostart";
static final boolean DEFAULT_AUTOSTART = true;
static final boolean DEFAULT_ABRP_UPLOAD_ENABLED = true;
/**
* Default cadence while driving. 60 s is roughly 1.5 km between points at motorway
* speed, which ABRP can still track; going much slower makes the live plan lag
* reality. Parked and idle backs off far further on its own — see UploadCadence.
*/
static final int DEFAULT_INTERVAL_SEC = 60;
/** Offered in the UI. Kept short so the list stays a single glance on a car screen. */
static final int[] INTERVAL_CHOICES_SEC = { 15, 30, 60, 120, 300 };
static final boolean DEFAULT_BOOST_LOW_SOC = true;
/** Below this, range planning gets tight and ABRP needs a finer SOC curve. */
static final int DEFAULT_LOW_SOC_PERCENT = 20;
/** Cadence used while SOC is low and the boost is enabled. */
static final int BOOSTED_INTERVAL_SEC = 15;
final int intervalSec;
final boolean boostLowSoc;
final int lowSocPercent;
final boolean abrpUploadEnabled;
UploadSettings(int intervalSec, boolean boostLowSoc, int lowSocPercent) {
this(intervalSec, boostLowSoc, lowSocPercent, DEFAULT_ABRP_UPLOAD_ENABLED);
}
UploadSettings(int intervalSec, boolean boostLowSoc, int lowSocPercent,
boolean abrpUploadEnabled) {
this.intervalSec = intervalSec;
this.boostLowSoc = boostLowSoc;
this.lowSocPercent = lowSocPercent;
this.abrpUploadEnabled = abrpUploadEnabled;
}
static UploadSettings from(SharedPreferences prefs) {
return new UploadSettings(
prefs.getInt(KEY_INTERVAL_SEC, DEFAULT_INTERVAL_SEC),
prefs.getBoolean(KEY_BOOST_LOW_SOC, DEFAULT_BOOST_LOW_SOC),
prefs.getInt(KEY_LOW_SOC_PERCENT, DEFAULT_LOW_SOC_PERCENT),
prefs.getBoolean(KEY_ABRP_UPLOAD_ENABLED, DEFAULT_ABRP_UPLOAD_ENABLED));
}
static UploadSettings defaults() {
return new UploadSettings(
DEFAULT_INTERVAL_SEC, DEFAULT_BOOST_LOW_SOC, DEFAULT_LOW_SOC_PERCENT);
}
long intervalMs() {
return intervalSec * 1000L;
}
private UploadSettings() { }
}
-23
View File
@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Flèche vers un plateau : onglet ABRP (envoi de télémétrie). Teinté par
@color/nav_tab_foreground. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:fillColor="#00000000"
android:pathData="M4,14V19H20V14" />
<path
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:fillColor="#00000000"
android:pathData="M12,3V13M8,7L12,3L16,7" />
</vector>
@@ -9,7 +9,6 @@
<com.google.android.material.button.MaterialButton android:id="@+id/tabVehicle" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_vehicle" app:icon="@drawable/ic_tab_vehicle" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabConsumption" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_consumption" app:icon="@drawable/ic_tab_vehicle" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabCharging" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_charging" app:icon="@drawable/ic_tab_service" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabAbrp" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_abrp" app:icon="@drawable/ic_tab_abrp" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabService" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_service" app:icon="@drawable/ic_tab_service" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabWifi" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_wifi" app:icon="@drawable/ic_tab_wifi" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabLog" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_log" app:icon="@drawable/ic_tab_log" />
@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:padding="24dp" android:baselineAligned="false">
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1.6" android:orientation="vertical" android:layout_marginEnd="16dp" android:padding="24dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_account" android:textAppearance="@style/Text.EV.Eyebrow" />
<com.google.android.material.textfield.TextInputLayout android:id="@+id/api_key_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_api_key" app:endIconMode="password_toggle" app:endIconMinSize="72dp" app:helperText="@string/helper_api_key">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/api_key_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="textPassword" android:singleLine="true" android:textSize="20sp" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout android:id="@+id/token_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_token" app:endIconMode="password_toggle" app:endIconMinSize="72dp" app:helperText="@string/helper_token">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/token_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="textPassword" android:singleLine="true" android:textSize="20sp" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/save_button" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_save" android:layout_marginTop="20dp" />
<com.google.android.material.button.MaterialButton android:id="@+id/test_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_test" android:layout_marginTop="8dp" />
<LinearLayout android:id="@+id/connection_status_row" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:gravity="center_vertical" android:orientation="horizontal" android:visibility="gone">
<View android:id="@+id/connection_indicator" android:layout_width="10dp" android:layout_height="10dp" android:layout_marginEnd="12dp" android:background="@drawable/circle_indicator" />
<TextView android:id="@+id/connection_status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:textAppearance="@style/Text.EV.Body" />
</LinearLayout>
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_private_title" android:textAppearance="@style/Text.EV.Title" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_private_body" android:textAppearance="@style/Text.EV.Secondary" android:layout_marginTop="16dp" android:lineSpacingMultiplier="1.3" />
<com.google.android.material.button.MaterialButton android:id="@+id/import_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_import" android:layout_marginTop="24dp" />
<com.google.android.material.button.MaterialButton android:id="@+id/probe_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/design_probe" />
</LinearLayout>
</LinearLayout>
</ScrollView>
@@ -16,7 +16,6 @@
<com.google.android.material.button.MaterialButton android:id="@+id/tabVehicle" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_vehicle" app:icon="@drawable/ic_tab_vehicle"/>
<com.google.android.material.button.MaterialButton android:id="@+id/tabConsumption" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_consumption" app:icon="@drawable/ic_tab_vehicle"/>
<com.google.android.material.button.MaterialButton android:id="@+id/tabCharging" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_charging" app:icon="@drawable/ic_tab_service"/>
<com.google.android.material.button.MaterialButton android:id="@+id/tabAbrp" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_abrp" app:icon="@drawable/ic_tab_abrp"/>
<com.google.android.material.button.MaterialButton android:id="@+id/tabService" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_service" app:icon="@drawable/ic_tab_service"/>
<com.google.android.material.button.MaterialButton android:id="@+id/tabWifi" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_wifi" app:icon="@drawable/ic_tab_wifi"/>
<com.google.android.material.button.MaterialButton android:id="@+id/tabLog" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_log" app:icon="@drawable/ic_tab_log"/>
-27
View File
@@ -1,27 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_account" android:textAppearance="@style/Text.EV.Eyebrow" />
<com.google.android.material.textfield.TextInputLayout android:id="@+id/api_key_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_api_key" app:endIconMode="password_toggle" app:endIconMinSize="72dp" app:helperText="@string/helper_api_key">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/api_key_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="textPassword" android:singleLine="true" android:textSize="20sp" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout android:id="@+id/token_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_token" app:endIconMode="password_toggle" app:endIconMinSize="72dp" app:helperText="@string/helper_token">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/token_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="textPassword" android:singleLine="true" android:textSize="20sp" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/save_button" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_save" android:layout_marginTop="20dp" />
<com.google.android.material.button.MaterialButton android:id="@+id/test_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_test" android:layout_marginTop="8dp" />
<LinearLayout android:id="@+id/connection_status_row" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:gravity="center_vertical" android:orientation="horizontal" android:visibility="gone">
<View android:id="@+id/connection_indicator" android:layout_width="10dp" android:layout_height="10dp" android:layout_marginEnd="12dp" android:background="@drawable/circle_indicator" />
<TextView android:id="@+id/connection_status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:textAppearance="@style/Text.EV.Body" />
</LinearLayout>
</LinearLayout>
<Space android:layout_width="1dp" android:layout_height="20dp" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_private_title" android:textAppearance="@style/Text.EV.Title" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_private_body" android:textAppearance="@style/Text.EV.Secondary" android:layout_marginTop="16dp" android:lineSpacingMultiplier="1.3" />
<com.google.android.material.button.MaterialButton android:id="@+id/import_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_import" android:layout_marginTop="24dp" />
<com.google.android.material.button.MaterialButton android:id="@+id/probe_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/design_probe" />
</LinearLayout>
</LinearLayout>
</ScrollView>