Files
mg4/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java
T
nilsandClaude Opus 5.5 f88249427f App 2.15.0: queued live data survives restarts
Unsent live samples were kept in memory only, so a reboot or a killed
process during a dead spot lost them. The queue is now saved to
files/telemetry_queue.json with AtomicFile, at most every 5 minutes and
only after a change (the flash is not written every 15 s), and at once
when the service stops; it is restored, oldest first and within the
2000-sample cap, when the service starts. A damaged file restores
nothing rather than failing. Tested on the emulator across a reboot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 17:56:25 +02:00

225 lines
9.1 KiB
Java

package com.evsuite.abrp;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ServiceInfo;
import android.os.IBinder;
import android.util.Log;
import androidx.core.app.NotificationCompat;
import androidx.core.app.ServiceCompat;
import com.evsuite.hardware.telemetry.EnergySnapshot;
import com.evsuite.hardware.telemetry.EnergyTelemetryReader;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* Foreground service that polls vehicle telemetry every 15 s, integrates consumption locally,
* syncs it to the car server in 2-minute delta chunks, and sends live telemetry samples that
* the server stores and, with ABRP credentials set, that go straight to ABRP
* ({@link TelemetryUploader}).
*
* 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, and a missing one is
* handled by queueing locally until the connection returns.
*/
public class AbrpUploadService extends Service {
/**
* 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
* stale-true forever. This field dies with the process, so it is only ever true while
* the service really is up.
*/
private static volatile boolean running = false;
public static boolean isRunning() { return running; }
/**
* Upload history, shared with the UI. Static because the activity reads it while the
* service owns it; it lives and dies with the process, like {@link #running}.
*/
private static final UploadLog uploadLog = new UploadLog();
public static UploadLog log() { return uploadLog; }
public static UploadLog.State state() { return uploadLog.state(running); }
private static final String TAG = "AbrpUploadService";
private static final String CHANNEL_ID = "abrp_uploader";
private static final int NOTIF_ID = 1;
/** Scheduler tick — vehicle telemetry is sampled this often. */
private static final long UPLOAD_INTERVAL_SEC = 15;
/** Warm-up before the first sample. */
private static final long FIRST_UPLOAD_DELAY_SEC = 20;
private EnergyTelemetryReader energyReader;
private ConsumptionTracker consumptionTracker;
private ConsumptionCloudClient consumptionCloudClient;
private ChargeSessionTracker chargeSessionTracker;
private TelemetryUploader telemetryUploader;
private SteeringKeyListener steeringKeys;
private WifiAutoConnect wifi;
private ScheduledExecutorService scheduler;
private SharedPreferences prefs;
/** What the foreground notification currently says, so re-asserting it does not blank it. */
private volatile String lastStatus = "Starting…";
// ---------- Lifecycle ----------
@Override
public void onCreate() {
super.onCreate();
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
uploadLog.clear();
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "abrp-upload");
t.setDaemon(false);
return t;
});
createNotificationChannel();
startInForeground();
running = true;
prefs.edit().putBoolean("service_running", true).apply();
energyReader = new EnergyTelemetryReader(getApplicationContext());
consumptionTracker = ConsumptionTracker.get(this);
consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext());
chargeSessionTracker = ChargeSessionTracker.get(getApplicationContext());
wifi = new WifiAutoConnect(getApplicationContext());
telemetryUploader = new TelemetryUploader(getApplicationContext());
steeringKeys = new SteeringKeyListener(this, prefs);
steeringKeys.register();
scheduler.scheduleWithFixedDelay(
this::safeUploadCycle,
FIRST_UPLOAD_DELAY_SEC, UPLOAD_INTERVAL_SEC, TimeUnit.SECONDS);
uploadLog.record(new UploadLog.Entry(System.currentTimeMillis(), 0, true,
"Consumption service started"));
Log.i(TAG, "Service started, consumption tracking armed");
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
startInForeground();
return START_STICKY;
}
/**
* Enters the foreground. Foreground service types do not exist before API 29, so on this
* API-28 head unit a plain startForeground is the only correct call; from API 29 the
* location type is what lets the GPS subscription run in the background.
*/
private void startInForeground() {
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 | ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION);
} else {
startForeground(NOTIF_ID, notification);
}
}
@Override
public IBinder onBind(Intent intent) { return null; }
@Override
public void onDestroy() {
running = false;
prefs.edit().putBoolean("service_running", false).apply();
if (scheduler != null) scheduler.shutdownNow();
if (telemetryUploader != null) {
telemetryUploader.persist(); // what is still queued survives the restart
telemetryUploader.stop();
}
if (steeringKeys != null) steeringKeys.unregister();
Log.i(TAG, "Service stopped");
}
// ---------- Sampling cycle (runs on scheduler thread) ----------
private void safeUploadCycle() {
try {
doUpload();
} catch (Throwable t) {
Log.e(TAG, "upload cycle threw", t);
}
}
private void doUpload() {
long sampleMs = System.currentTimeMillis();
EnergySnapshot vehicle = energyReader.read(sampleMs);
// Keeps the 5-minute range average going while the app is in the background.
RangeEstimator.shared().add(android.os.SystemClock.elapsedRealtime(),
vehicle.getBatteryPowerKw(), vehicle.getSpeedKmh());
consumptionTracker.sample(vehicle);
chargeSessionTracker.sample(vehicle);
consumptionCloudClient.syncIfDue(consumptionTracker, chargeSessionTracker, sampleMs);
// 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);
}
UploadSettings settings = UploadSettings.from(prefs);
// Live data goes to the server, to ABRP directly, or both; with neither set up there is
// nowhere to send it.
if (!ServerConfig.get(this).isConfigured() && !AbrpConfig.get(this).isConfigured()) {
telemetryUploader.stop(); // no live telemetry, so no GPS either
updateNotification(getString(R.string.server_not_configured));
} else if (!settings.abrpUploadEnabled) {
telemetryUploader.stop();
updateNotification(getString(R.string.notif_tracking_locally));
} else {
telemetryUploader.tick(vehicle, chargeSessionTracker.snapshot(), settings);
int queued = telemetryUploader.queued();
updateNotification(queued > 0 ? "Sending telemetry — " + queued + " queued" : "Sending telemetry");
}
}
// ---------- Notification ----------
private void createNotificationChannel() {
NotificationChannel ch = new NotificationChannel(
CHANNEL_ID,
getString(R.string.notif_channel_name),
NotificationManager.IMPORTANCE_LOW);
ch.setDescription(getString(R.string.notif_channel_desc));
getSystemService(NotificationManager.class).createNotificationChannel(ch);
}
private Notification buildNotification(String status) {
PendingIntent openApp = PendingIntent.getActivity(this, 0,
new Intent(this, MainActivity.class),
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)
.setOngoing(true)
.build();
}
private void updateNotification(String status) {
if (status.equals(lastStatus)) return;
lastStatus = status;
getSystemService(NotificationManager.class).notify(NOTIF_ID, buildNotification(status));
}
}