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 ScheduledExecutorService scheduler; /** Sets the chosen drive mode at start, then ends (DriveModeWrite.atStart). */ private Thread driveModeAtStart; 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(); UpdateLog.noteVersion(this, BuildConfig.VERSION_NAME); 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()); telemetryUploader = new TelemetryUploader(getApplicationContext()); steeringKeys = new SteeringKeyListener(this, prefs); steeringKeys.register(); startDriveModeAtStart(); 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(); if (driveModeAtStart != null) driveModeAtStart.interrupt(); Log.i(TAG, "Service stopped"); } // ---------- The drive mode at start ---------- /** * The service starts with the head unit, i.e. when the car is switched on: the mode the * driver chose on Settings (and the car confirmed) is set again, once, while the car is in * P at 0 km/h — readings checked every second, for at most two minutes, and never after * the car has left P. Its own thread: the upload scheduler may be busy with the network. */ private void startDriveModeAtStart() { driveModeAtStart = new Thread(() -> { long started = android.os.SystemClock.elapsedRealtime(); try { while (true) { Integer chosen = prefs.contains(DriveModeWrite.PREF_CHOSEN) ? prefs.getInt(DriveModeWrite.PREF_CHOSEN, 0) : null; boolean enabled = prefs.getBoolean(DriveModeWrite.PREF_AT_START, true); Integer gear = com.evsuite.hardware.EVHardware.INSTANCE.getGearRaw(); Float speed = com.evsuite.hardware.EVHardware.INSTANCE.getVehicleSpeedKmh(); Integer current = com.evsuite.hardware.EVHardware.INSTANCE.getDriveModeRaw(); DriveModeWrite.StartStep step = DriveModeWrite.atStart(chosen, enabled, gear, speed, current, android.os.SystemClock.elapsedRealtime() - started); if (step == DriveModeWrite.StartStep.WAIT) { Thread.sleep(1000); continue; } if (step == DriveModeWrite.StartStep.WRITE) { DriveModeSetter.Result r = DriveModeSetter.set(chosen); noteDriveModeAtStart(chosen, !r.written ? "REFUSED" : DriveModeWrite.outcome(chosen, r.reported).name(), r.reported); } else if (step == DriveModeWrite.StartStep.ALREADY) { noteDriveModeAtStart(chosen, "ALREADY", current); } // GIVE_UP: nothing chosen, switched off, driven off or too late — nothing to say. return; } } catch (InterruptedException ignored) { // The service is stopping. } catch (Throwable t) { Log.w(TAG, "Drive mode at start failed: " + t); } }, "drive-mode-at-start"); driveModeAtStart.start(); } /** Kept for the status line on Settings and logged: "25.10. 20:31|4|APPLIED|4". */ private void noteDriveModeAtStart(int code, String outcome, Integer reported) { String line = new java.text.SimpleDateFormat("dd.MM. HH:mm", java.util.Locale.US).format(new java.util.Date()) + "|" + code + "|" + outcome + "|" + (reported == null ? "" : reported); prefs.edit().putString(DriveModeWrite.PREF_START_RESULT, line).apply(); Log.i(TAG, "Drive mode at start: " + line); } // ---------- 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); LogOutbox.sendIfDue(this); // recordings the car could not send when they ended 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)); } }