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
@@ -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));
}