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
View File
@@ -0,0 +1 @@
[{"eventName":"NEXT_CLI_SESSION_STOPPED","payload":{"nextVersion":"16.4.0","nodeVersion":"v24.12.0","cliCommand":"dev","durationMilliseconds":348,"turboFlag":true,"pagesDir":false,"appDir":true,"isRspack":false}}]
+1
View File
@@ -0,0 +1 @@
[{"eventName":"NEXT_CLI_SESSION_STOPPED","payload":{"nextVersion":"16.4.0","nodeVersion":"v24.12.0","cliCommand":"dev","durationMilliseconds":345,"turboFlag":true,"pagesDir":false,"appDir":true,"isRspack":false}}]
+15
View File
@@ -0,0 +1,15 @@
{
"pages": {
"/_app": []
},
"devFiles": [],
"polyfillFiles": [],
"lowPriorityFiles": [
"static/development/_buildManifest.js",
"static/development/_ssgManifest.js",
"static/development/_clientMiddlewareManifest.js"
],
"rootMainFiles": [],
"rootMainFilesTree": {},
"pagesChunkGroupBootstrapParams": {}
}
+1
View File
@@ -0,0 +1 @@
{"encryption.key":"UT9PfJliySgklhhPCIM4wpEhmx9PMD9GPB4iuPIamyM=","encryption.expire_at":1792761221411}
+1
View File
@@ -0,0 +1 @@
{"max_sequence_number":0,"commit_time":"2026-10-09T13:13:41.418251Z"}
+15
View File
@@ -0,0 +1,15 @@
{
"pages": {
"/_app": []
},
"devFiles": [],
"polyfillFiles": [],
"lowPriorityFiles": [
"static/development/_buildManifest.js",
"static/development/_ssgManifest.js",
"static/development/_clientMiddlewareManifest.js"
],
"rootMainFiles": [],
"rootMainFilesTree": {},
"pagesChunkGroupBootstrapParams": {}
}
+1
View File
@@ -0,0 +1 @@
{"pid":79123,"port":3001,"hostname":"localhost","appUrl":"http://localhost:3001","startedAt":1791551621304}
+3
View File
@@ -0,0 +1,3 @@
{"timestamp":"00:00:01.802","source":"Server","level":"ERROR","message":"Turbopack build encountered 1 error:\n./app\nError: Could not find the Next.js package (next/package.json)\nResolved from: /Users/tcfdonmez/Desktop/Coding/MG/EVABRPUploader/app\nFilesystem root used for resolution: /Users/tcfdonmez/Desktop/Coding/MG/EVABRPUploader\n\nPossible causes:\n - node_modules is being reorganized by a concurrent install (e.g. pnpm adding a package with a `next` peer dependency). This is transient and should clear once the install completes.\n - node_modules/next was removed, renamed, or has a broken symlink.\n - The workspace root is incorrect — see turbopack.root in the Next.js config docs for how to configure it.\n - In a monorepo, the Next.js package may only exist in a directory above the closest directory containing a package manager lockfile. The workspace root is detected by locating the nearest package manager lockfile.\n - Next.js is installed globally rather than as a project dependency. This is not supported; install it locally.\n\nNote: To ensure a hermetic build and a portable cache, files outside of the workspace root are not compiled.\n\nhttps://nextjs.org/docs/app/api-reference/config/next-config-js/turbopack#root-directory\n\n"}
{"timestamp":"00:00:02.079","source":"Server","level":"LOG","message":""}
{"timestamp":"00:00:02.080","source":"Server","level":"LOG","message":"✓ Generated AGENTS.md for AI agents. Set `agentRules: false` in next.config to disable."}
+3
View File
@@ -0,0 +1,3 @@
{
"type": "commonjs"
}
+6
View File
@@ -0,0 +1,6 @@
{
"version": 4,
"routes": {},
"dynamicRoutes": {},
"notFoundRoutes": []
}
+1
View File
@@ -0,0 +1 @@
{"version":3,"caseSensitive":false,"basePath":"","rewrites":{"beforeFiles":[],"afterFiles":[],"fallback":[]},"redirects":[{"source":"/:path+/","destination":"/:path+","permanent":true,"internal":true,"priority":true,"regex":"^(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))\\/$"}],"headers":[],"onMatchHeaders":[]}
+1
View File
@@ -0,0 +1 @@
{}
@@ -0,0 +1 @@
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]";
@@ -0,0 +1,15 @@
globalThis.__BUILD_MANIFEST = {
"pages": {
"/_app": []
},
"devFiles": [],
"polyfillFiles": [],
"lowPriorityFiles": [
"static/development/_buildManifest.js",
"static/development/_ssgManifest.js",
"static/development/_clientMiddlewareManifest.js"
],
"rootMainFiles": [],
"rootMainFilesTree": {},
"pagesChunkGroupBootstrapParams": {}
};
@@ -0,0 +1,6 @@
{
"version": 3,
"middleware": {},
"sortedMiddleware": [],
"functions": {}
}
+1
View File
@@ -0,0 +1 @@
self.__NEXT_FONT_MANIFEST="{\n \"app\": {},\n \"appUsingSizeAdjust\": false,\n \"pages\": {},\n \"pagesUsingSizeAdjust\": false\n}"
+6
View File
@@ -0,0 +1,6 @@
{
"app": {},
"appUsingSizeAdjust": false,
"pages": {},
"pagesUsingSizeAdjust": false
}
+1
View File
@@ -0,0 +1 @@
{}
+5
View File
@@ -0,0 +1,5 @@
{
"previewModeId": "a508c818777951bed52c0170453ef7e3",
"previewModeSigningKey": "591a19b72d1a845a81057e56fb15a2ced8409045c0ed580013f217b8c4a4ea61",
"previewModeEncryptionKey": "ded0359fbc3be263ec7ff240e7fb98fc5b836958070834f5bc954f1092afa91c"
}
@@ -0,0 +1 @@
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"UT9PfJliySgklhhPCIM4wpEhmx9PMD9GPB4iuPIamyM=\"\n}"
@@ -0,0 +1,5 @@
{
"node": {},
"edge": {},
"encryptionKey": "UT9PfJliySgklhhPCIM4wpEhmx9PMD9GPB4iuPIamyM="
}
@@ -0,0 +1,8 @@
self.__BUILD_MANIFEST = {
"__rewrites": {
"afterFiles": [],
"beforeFiles": [],
"fallback": []
},
"sortedPages": []
};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()
@@ -0,0 +1 @@
self.__MIDDLEWARE_MATCHERS = [];self.__MIDDLEWARE_MATCHERS_CB && self.__MIDDLEWARE_MATCHERS_CB()
@@ -0,0 +1 @@
self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
+55
View File
@@ -0,0 +1,55 @@
// This file is generated automatically by Next.js
// Do not edit this file manually
type AppRoutes = never
type PageRoutes = never
type LayoutRoutes = never
type RedirectRoutes = never
type RewriteRoutes = never
type Routes = AppRoutes | PageRoutes | LayoutRoutes | RedirectRoutes | RewriteRoutes
interface ParamMap {
}
export type ParamsOf<Route extends Routes> = ParamMap[Route]
interface LayoutSlotMap {
}
export type { AppRoutes, PageRoutes, LayoutRoutes, RedirectRoutes, RewriteRoutes, ParamMap }
declare global {
/**
* Props for Next.js App Router page components
* @example
* ```tsx
* export default function Page(props: PageProps<'/blog/[slug]'>) {
* const { slug } = await props.params
* return <div>Blog post: {slug}</div>
* }
* ```
*/
interface PageProps<AppRoute extends AppRoutes> {
params: Promise<ParamMap[AppRoute]>
searchParams: Promise<Record<string, string | string[] | undefined>>
}
/**
* Props for Next.js App Router layout components
* @example
* ```tsx
* export default function Layout(props: LayoutProps<'/dashboard'>) {
* return <div>{props.children}</div>
* }
* ```
*/
type LayoutProps<LayoutRoute extends LayoutRoutes> = {
params: Promise<ParamMap[LayoutRoute]>
children: React.ReactNode
} & {
[K in LayoutSlotMap[LayoutRoute]]: React.ReactNode
}
}
+10
View File
@@ -120,3 +120,13 @@ a stale Gradle daemon started by VS Code's Java extension is being reused —
Most of this cannot be verified off a vehicle. When reporting work, state plainly what was Most of this cannot be verified off a vehicle. When reporting work, state plainly what was
tested and what was not. "Unit tests pass, not tried on the car" is the expected and tested and what was not. "Unit tests pass, not tried on the car" is the expected and
acceptable answer. Do not imply road-testing that did not happen. acceptable answer. Do not imply road-testing that did not happen.
<!-- BEGIN:nextjs-agent-rules -->
## This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+4
View File
@@ -41,6 +41,10 @@ android {
// authenticated API in server/, which owns the database connection. // authenticated API in server/, which owns the database connection.
buildConfigField "String", "CONSUMPTION_API_URL", "\"${System.getenv('CONSUMPTION_API_URL') ?: ''}\"" buildConfigField "String", "CONSUMPTION_API_URL", "\"${System.getenv('CONSUMPTION_API_URL') ?: ''}\""
buildConfigField "String", "CONSUMPTION_API_TOKEN", "\"${System.getenv('CONSUMPTION_API_TOKEN') ?: ''}\"" buildConfigField "String", "CONSUMPTION_API_TOKEN", "\"${System.getenv('CONSUMPTION_API_TOKEN') ?: ''}\""
// Fixed installation identity: baked into every build so an APK wipe cannot orphan
// the cloud history under a new random UUID. The car's own consumption history lives
// under this id in the daily_consumption collection.
buildConfigField "String", "CONSUMPTION_INSTALLATION_ID", "\"226b1614-4d18-48f0-8938-3609cfa3db93\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
} }
+1 -13
View File
@@ -8,9 +8,6 @@
<!-- Required from API 33 to post the foreground-service notification, which is the <!-- 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. --> 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.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 <!-- 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 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_TIRES" />
<uses-permission android:name="android.car.permission.CAR_INFO" /> <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 <application
android:allowBackup="false" android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
@@ -63,8 +52,7 @@
<service <service
android:name=".AbrpUploadService" android:name=".AbrpUploadService"
android:exported="false" android:exported="false" />
android:foregroundServiceType="location" />
<!-- The head unit resumes far more often than it cold-boots, and announces the <!-- 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 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.NotificationManager;
import android.app.PendingIntent; import android.app.PendingIntent;
import android.app.Service; import android.app.Service;
import android.content.BroadcastReceiver;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.content.pm.ServiceInfo; 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.IBinder;
import android.os.Looper;
import android.os.SystemClock;
import android.util.Log; import android.util.Log;
import android.Manifest;
import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationCompat;
import androidx.core.app.ServiceCompat; 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.EnergySnapshot;
import com.evsuite.hardware.telemetry.EnergyTelemetryReader; 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.Executors;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit; 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: * Always-on by design: started at boot (BootReceiver) and on app open, never gated on
* - Uses ScheduledExecutorService (not Handler chains) so exceptions inside one * credentials or a toggle. The only thing it needs is a network for the cloud sync, and a
* upload cycle cannot break the schedule. Even on a thrown RuntimeException * missing one is handled by caching chunks locally until the connection returns.
* 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.
*/ */
public class AbrpUploadService extends Service { 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 * 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 * 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); } 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 TAG = "AbrpUploadService";
private static final String CHANNEL_ID = "abrp_uploader"; private static final String CHANNEL_ID = "abrp_uploader";
private static final int NOTIF_ID = 1; 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; private static final long UPLOAD_INTERVAL_SEC = 15;
/** /** Warm-up before the first sample. */
* 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.
*/
private static final long FIRST_UPLOAD_DELAY_SEC = 20; 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 EnergyTelemetryReader energyReader;
private ConsumptionTracker consumptionTracker; private ConsumptionTracker consumptionTracker;
private ConsumptionCloudClient consumptionCloudClient; private ConsumptionCloudClient consumptionCloudClient;
private ChargeSessionTracker chargeSessionTracker; private ChargeSessionTracker chargeSessionTracker;
/** private WifiAutoConnect wifi;
* 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 ScheduledExecutorService scheduler; private ScheduledExecutorService scheduler;
private SharedPreferences prefs; private SharedPreferences prefs;
/** Credentials only — encrypted at rest, see {@link SecurePrefs}. */
private SharedPreferences securePrefs;
private Handler mainHandler;
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. */ /** What the foreground notification currently says, so re-asserting it does not blank it. */
private volatile String lastStatus = "Starting…"; 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 ---------- // ---------- Lifecycle ----------
@Override @Override
public void onCreate() { public void onCreate() {
super.onCreate(); super.onCreate();
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE); prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
securePrefs = SecurePrefs.get(this);
settings = UploadSettings.from(prefs);
uploadLog.clear(); uploadLog.clear();
mainHandler = new Handler(Looper.getMainLooper()); scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "abrp-upload"); Thread t = new Thread(r, "abrp-upload");
t.setDaemon(false); t.setDaemon(false);
return t; return t;
@@ -195,73 +89,40 @@ public class AbrpUploadService extends Service {
running = true; running = true;
prefs.edit().putBoolean("service_running", true).apply(); prefs.edit().putBoolean("service_running", true).apply();
// One shared, firmware-aware EVHardware snapshot owns every vehicle signal.
energyReader = new EnergyTelemetryReader(getApplicationContext()); energyReader = new EnergyTelemetryReader(getApplicationContext());
consumptionTracker = ConsumptionTracker.get(this); consumptionTracker = ConsumptionTracker.get(this);
consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext()); consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext());
chargeSessionTracker = ChargeSessionTracker.get(getApplicationContext()); chargeSessionTracker = ChargeSessionTracker.get(getApplicationContext());
wifi = new WifiAutoConnect(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( scheduler.scheduleWithFixedDelay(
this::safeUploadCycle, this::safeUploadCycle,
FIRST_UPLOAD_DELAY_SEC, UPLOAD_INTERVAL_SEC, TimeUnit.SECONDS); 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 @Override
public int onStartCommand(Intent intent, int flags, int startId) { 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(); startInForeground();
return START_STICKY; return START_STICKY;
} }
/** /**
* Enters the foreground holding only the service types this app currently has the * Enters the foreground. No location type — this service no longer reads GPS. Foreground
* permissions for. * service types do not exist before API 29, so on this API-28 head unit a plain
* * startForeground is the only correct call.
* 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.
*/ */
private void startInForeground() { private void startInForeground() {
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.Q) { Notification notification = buildNotification(lastStatus);
// Service types do not exist before API 29: nothing is declared and nothing is if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
// checked, so the plain call is the only correct one there. ServiceCompat.startForeground(
startForeground(NOTIF_ID, buildNotification(lastStatus)); this, NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
return; } 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 @Override
@@ -269,57 +130,18 @@ public class AbrpUploadService extends Service {
@Override @Override
public void onDestroy() { 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; running = false;
prefs.edit().putBoolean("service_running", false).apply(); 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() { private void safeUploadCycle() {
try { 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(); doUpload();
} catch (Throwable t) { } 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); Log.e(TAG, "upload cycle threw", t);
} }
} }
@@ -331,388 +153,13 @@ public class AbrpUploadService extends Service {
chargeSessionTracker.sample(vehicle); chargeSessionTracker.sample(vehicle);
consumptionCloudClient.syncIfDue(consumptionTracker, chargeSessionTracker, sampleMs); consumptionCloudClient.syncIfDue(consumptionTracker, chargeSessionTracker, sampleMs);
// Keep local consumption history alive while another app owns the ABRP feed. // Keep the head unit on the phone hotspot — the sync needs a network, and this is
if (!settings.abrpUploadEnabled) { // exactly when being offline costs something. Rate-limited internally.
updateNotification(getString(R.string.notif_tracking_locally)); if (wifi != null && prefs.getBoolean(MainActivity.WIFI_AUTO_KEY, true)) {
return; wifi.ensureConnected(false);
} }
String token = securePrefs.getString(SecurePrefs.KEY_TOKEN, "").trim(); updateNotification("Tüketim takibi aktif");
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();
}
} }
// ---------- Notification ---------- // ---------- Notification ----------
@@ -731,22 +178,17 @@ public class AbrpUploadService extends Service {
new Intent(this, MainActivity.class), new Intent(this, MainActivity.class),
PendingIntent.FLAG_IMMUTABLE); 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) return new NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher) .setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(getString(R.string.notif_title)) .setContentTitle(getString(R.string.notif_title))
.setContentText(status) .setContentText(status)
.setContentIntent(openApp) .setContentIntent(openApp)
.addAction(android.R.drawable.ic_delete, getString(R.string.notif_action_stop), stopPi)
.setOngoing(true) .setOngoing(true)
.build(); .build();
} }
private void updateNotification(String status) { private void updateNotification(String status) {
if (status.equals(lastStatus)) return;
lastStatus = status; lastStatus = status;
getSystemService(NotificationManager.class).notify(NOTIF_ID, buildNotification(status)); getSystemService(NotificationManager.class).notify(NOTIF_ID, buildNotification(status));
} }
@@ -3,7 +3,6 @@ package com.evsuite.abrp;
import android.content.BroadcastReceiver; import android.content.BroadcastReceiver;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.SharedPreferences;
import android.util.Log; import android.util.Log;
import java.util.Arrays; import java.util.Arrays;
@@ -11,16 +10,14 @@ import java.util.HashSet;
import java.util.Set; 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 * 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. * car on usually resumed 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 * MY_PACKAGE_REPLACED brings the service back after an update stops it.
* 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.
* *
* MY_PACKAGE_REPLACED is in the list for a different reason: an update stops the service, * Always-on: no credential or toggle gate. The service tracks consumption and syncs to the
* and nothing else would bring it back until the next power cycle. * cloud unconditionally — that is its entire purpose.
*/ */
public class BootReceiver extends BroadcastReceiver { public class BootReceiver extends BroadcastReceiver {
@@ -39,32 +36,10 @@ public class BootReceiver extends BroadcastReceiver {
String action = intent.getAction(); String action = intent.getAction();
if (action == null || !START_ACTIONS.contains(action)) return; if (action == null || !START_ACTIONS.contains(action)) return;
SharedPreferences prefs = context.getSharedPreferences("abrp_prefs", Context.MODE_PRIVATE); Log.i(TAG, action + " — starting consumption service");
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");
try { try {
context.startForegroundService(new Intent(context, AbrpUploadService.class)); context.startForegroundService(new Intent(context, AbrpUploadService.class));
} catch (Exception e) { } 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()); 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.content.SharedPreferences;
import android.util.Log; import android.util.Log;
import org.json.JSONArray;
import org.json.JSONObject; import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.InputStream; import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream; import java.io.OutputStream;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.URL; import java.net.URL;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List; 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 { final class ConsumptionCloudClient {
private static final String TAG = "FatihsMG4.Mongo"; private static final String TAG = "FatihsMG4.Mongo";
private static final long CONSUMPTION_SYNC_INTERVAL_MS = 30 * 60_000L; 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 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 int TIMEOUT_MS = 8_000;
private static final String PREFS = "consumption_cloud"; private static final String PREFS = "consumption_cloud";
private static final String INSTALLATION_ID = "installation_id"; private static final String CHUNK_CACHE = "chunk_cache"; // JSON array of pending chunks
private static final String LAST_CONSUMPTION_DATE = "last_consumption_date";
private static final int MAX_DAYS_PER_SYNC = 10;
private static final int MAX_CHARGES_PER_SYNC = 10; private static final int MAX_CHARGES_PER_SYNC = 10;
private final String endpoint; private final String endpoint;
@@ -35,57 +45,156 @@ final class ConsumptionCloudClient {
private long lastChargeAttemptMs; private long lastChargeAttemptMs;
private String lastAttemptedChargeId = ""; 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) { ConsumptionCloudClient(Context context) {
endpoint = BuildConfig.CONSUMPTION_API_URL.trim(); endpoint = BuildConfig.CONSUMPTION_API_URL.trim();
token = BuildConfig.CONSUMPTION_API_TOKEN.trim(); token = BuildConfig.CONSUMPTION_API_TOKEN.trim();
prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
String existing = prefs.getString(INSTALLATION_ID, ""); installationId = BuildConfig.CONSUMPTION_INSTALLATION_ID.trim();
if (existing == null || existing.isEmpty()) { if (endpoint.isEmpty() || token.isEmpty()) {
existing = UUID.randomUUID().toString(); Log.w(TAG, "Consumption API not configured: endpoint=" + (endpoint.isEmpty() ? "empty" : "ok")
prefs.edit().putString(INSTALLATION_ID, existing).commit(); + ", 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 * @param nowMs wall clock, recorded with the payload — the throttle below runs on a
* monotonic clock instead, so a backwards NTP correction cannot stall * monotonic clock instead, so a backwards NTP correction cannot stall
* syncing for however long the jump was. * syncing for however long the jump was.
*/ */
void syncIfDue(ConsumptionTracker tracker, ChargeSessionTracker charges, long nowMs) { void syncIfDue(ConsumptionTracker tracker, ChargeSessionTracker charges, long nowMs) {
long elapsed = android.os.SystemClock.elapsedRealtime(); 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); 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; 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 { 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) { } 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 { * Sends every cached chunk; removes each one that succeeds. Returns how many were sent.
String saved = prefs.getString(LAST_CONSUMPTION_DATE, ""); * A chunk that fails stops the drain — the rest stay in order for the next cycle.
LocalDate lastUploaded = null; */
try { if (saved != null && !saved.isEmpty()) lastUploaded = LocalDate.parse(saved); } private int drainChunkCache() {
catch (RuntimeException ignored) { } JSONArray cache = loadChunkCache();
for (int sent = 0; sent < MAX_DAYS_PER_SYNC; sent++) { if (cache.length() == 0) return 0;
LocalDate target = tracker.nextStoredDayAfter(lastUploaded); JSONArray remaining = new JSONArray();
if (target == null) break; int sent = 0;
ConsumptionTracker.CloudSnapshot snapshot = tracker.cloudSnapshot(target); for (int i = 0; i < cache.length(); i++) {
JSONObject body = consumptionBody(snapshot, nowMs); try {
if (!send("/v1/consumption/daily", "PUT", body)) return; JSONObject body = cache.getJSONObject(i);
prefs.edit().putString(LAST_CONSUMPTION_DATE, target.toString()).commit(); String date = body.optString("date", "?");
lastUploaded = target; 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 saveChunkCache(remaining);
// 30 minutes. Sending totals rather than increments makes retries idempotent. return sent;
ConsumptionTracker.CloudSnapshot today = tracker.cloudSnapshot(LocalDate.now()); }
JSONObject current = consumptionBody(today, nowMs);
send("/v1/consumption/daily", "PUT", current); 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) private JSONObject consumptionBody(ConsumptionTracker.CloudSnapshot snapshot, long nowMs)
@@ -112,18 +221,28 @@ final class ConsumptionCloudClient {
try { try {
syncPendingCharges(charges, pending); syncPendingCharges(charges, pending);
} catch (Exception e) { } 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) private void syncPendingCharges(ChargeSessionTracker charges, List<JSONObject> pending)
throws Exception { throws Exception {
Log.d(TAG, "Found " + pending.size() + " pending charge sessions");
int sent = 0; int sent = 0;
for (JSONObject session : pending) { for (JSONObject session : pending) {
JSONObject body = new JSONObject(session.toString()).put("installationId", installationId); JSONObject body = new JSONObject(session.toString()).put("installationId", installationId);
String id = session.getString("sessionId"); String id = session.getString("sessionId");
if (!send("/v1/charging-sessions/" + id, "PUT", body)) return; Log.d(TAG, "Uploading charge session: " + id);
charges.markUploaded(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; if (++sent >= MAX_CHARGES_PER_SYNC) return;
} }
} }
@@ -132,7 +251,9 @@ final class ConsumptionCloudClient {
HttpURLConnection connection = null; HttpURLConnection connection = null;
try { try {
byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8); 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.setRequestMethod(method);
connection.setConnectTimeout(TIMEOUT_MS); connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(TIMEOUT_MS); connection.setReadTimeout(TIMEOUT_MS);
@@ -142,13 +263,18 @@ final class ConsumptionCloudClient {
connection.setRequestProperty("Authorization", "Bearer " + token); connection.setRequestProperty("Authorization", "Bearer " + token);
try (OutputStream out = connection.getOutputStream()) { out.write(bytes); } try (OutputStream out = connection.getOutputStream()) { out.write(bytes); }
int status = connection.getResponseCode(); int status = connection.getResponseCode();
InputStream response = status < 400 ? connection.getInputStream() : connection.getErrorStream(); Log.d(TAG, "Response: HTTP " + status);
if (response != null) try (InputStream ignored = response) { }
if (status < 200 || status >= 300) { 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; return false;
} }
InputStream response = connection.getInputStream();
if (response != null) try (InputStream ignored = response) { }
return true; return true;
} catch (Exception e) {
Log.w(TAG, "Network error: " + e.getMessage());
throw e;
} finally { } finally {
if (connection != null) connection.disconnect(); if (connection != null) connection.disconnect();
} }
@@ -159,6 +285,107 @@ final class ConsumptionCloudClient {
.put("hours", totals.hours).put("socDrop", totals.soc); .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) { private static String trimSlash(String value) {
int end = value.length(); int end = value.length();
while (end > 0 && value.charAt(end - 1) == '/') end--; 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 PREFS = "consumption_counters_v2";
private static final String LEGACY_PREFS = "consumption_counters_v1"; private static final String LEGACY_PREFS = "consumption_counters_v1";
private static final String SOH_PERCENT = "manual_soh_percent"; 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. */ /** How often the in-memory accumulators reach disk. Matches DriveHub_Dort. */
private static final long PERSIST_INTERVAL_MS = 30_000L; 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 tripB = new Counter();
private final Counter today = new Counter(); private final Counter today = new Counter();
private LocalDate todayDate; 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 * Monotonic. {@code EnergySnapshot.timestampMs} is wall clock, and the head unit's
@@ -70,6 +74,7 @@ final class ConsumptionTracker {
tripB.load(prefs, "b"); tripB.load(prefs, "b");
todayDate = LocalDate.now(); todayDate = LocalDate.now();
today.load(prefs, dayKey(todayDate)); today.load(prefs, dayKey(todayDate));
loadChunkCheckpoint();
pruneOldDays(); pruneOldDays();
} }
@@ -148,6 +153,49 @@ final class ConsumptionTracker {
return new CloudSnapshot(date.toString(), totalsForDay(date), lifetimeAtEndOf(date), sohPercent()); 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. */ /** Lifetime as of the close of {@code date}, falling back to the live counter. */
private Totals lifetimeAtEndOf(LocalDate date) { private Totals lifetimeAtEndOf(LocalDate date) {
if (date.equals(todayDate)) return lifetime.snapshot(); if (date.equals(todayDate)) return lifetime.snapshot();
@@ -201,6 +249,7 @@ final class ConsumptionTracker {
persist(); persist();
todayDate = date; todayDate = date;
today.load(prefs, dayKey(date)); today.load(prefs, dayKey(date));
loadChunkCheckpoint(); // new day → fresh checkpoint baseline
pruneOldDays(); 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; package com.evsuite.abrp;
import android.Manifest;
import android.content.Intent; import android.content.Intent;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
@@ -9,21 +8,17 @@ import android.graphics.drawable.ColorDrawable;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.view.View; import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.Button; import android.widget.Button;
import android.widget.Toast; import android.widget.Toast;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.Spinner;
import android.widget.TextView; import android.widget.TextView;
import androidx.activity.result.ActivityResultLauncher; import java.time.LocalDate;
import androidx.activity.result.contract.ActivityResultContracts; import java.util.List;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.app.AppCompatDelegate; import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager2.widget.ViewPager2; 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.button.MaterialButton;
import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputEditText;
import com.google.android.material.textfield.TextInputLayout; 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.EnergySnapshot;
import com.evsuite.hardware.telemetry.EnergyTelemetryReader; import com.evsuite.hardware.telemetry.EnergyTelemetryReader;
public class MainActivity extends AppCompatActivity { public class MainActivity extends AppCompatActivity {
private static final int LOCATION_PERMISSION_REQUEST = 100;
/** Shared with {@link AbrpUploadService}, which reads it on every tick. */ /** Shared with {@link AbrpUploadService}, which reads it on every tick. */
static final String WIFI_AUTO_KEY = "wifi_auto_connect"; static final String WIFI_AUTO_KEY = "wifi_auto_connect";
private static final String REPOSITORY_URL = "https://github.com/fatihdonmezdev/FatihsMG4"; private static final String REPOSITORY_URL = "https://github.com/fatihdonmezdev/FatihsMG4";
/** Navigation destinations in page order. Parallel to {@link #panes}. */ /** Navigation destinations in page order. Parallel to {@link #panes}. */
private static final int[] TAB_IDS = 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 }; R.id.tabLog };
private static final int[] PAGE_TITLES = { 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 R.string.design_service_title, R.string.design_wifi_title, R.string.design_log_title
}; };
private static final int[] PAGE_SUBTITLES = { 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 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_ERROR = 0xFFFFB4AB;
private static final int COLOR_PENDING = 0xFFBDC4C6; 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 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 TextView callLogText;
private View abrpPane;
private View servicePane; private View servicePane;
private View logPane; private View logPane;
private View vehiclePane; private View vehiclePane;
@@ -91,7 +67,7 @@ public class MainActivity extends AppCompatActivity {
private View chargingPane; private View chargingPane;
private View wifiPane; 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; private View[] panes;
/** Wi-Fi page. The helper is the same one the upload service uses on its own tick. */ /** 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, vehCharging, vehHvac,
vehTireFl, vehTireFr, vehTireRl, vehTireRr; vehTireFl, vehTireFr, vehTireRl, vehTireRr;
private ConsumptionTracker consumptionTracker; private ConsumptionTracker consumptionTracker;
private ConsumptionCloudClient consumptionCloudClient;
private ConsumptionTracker.Period consumptionPeriod = ConsumptionTracker.Period.LIFETIME; private ConsumptionTracker.Period consumptionPeriod = ConsumptionTracker.Period.LIFETIME;
private TextView consumptionDistance, consumptionEnergy, consumptionAverage, private TextView consumptionDistance, consumptionEnergy, consumptionAverage,
consumptionSpeed, consumptionTime, consumptionSoc, consumptionDailyHistory; 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. // 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. // by the tick even when their page is off screen.
pager.setOffscreenPageLimit(panes.length - 1); pager.setOffscreenPageLimit(panes.length - 1);
pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
@@ -207,18 +184,6 @@ public class MainActivity extends AppCompatActivity {
}; };
private SharedPreferences prefs; 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 @Override
protected void onCreate(Bundle savedInstanceState) { 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); ((TextView) findViewById(R.id.version_badge)).setText("v" + BuildConfig.VERSION_NAME);
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE); prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
securePrefs = SecurePrefs.get(this);
// The pages are inflated here, once, and handed to the pager as fixed, // 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 // 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 // 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 // in activity_main.xml — a recycled page would invalidate these references as
// soon as the user swiped away from it. // soon as the user swiped away from it.
abrpPane = inflatePane(R.layout.pane_abrp);
servicePane = inflatePane(R.layout.pane_service); servicePane = inflatePane(R.layout.pane_service);
vehiclePane = inflatePane(R.layout.pane_vehicle); vehiclePane = inflatePane(R.layout.pane_vehicle);
consumptionPane = inflatePane(R.layout.pane_consumption); consumptionPane = inflatePane(R.layout.pane_consumption);
chargingPane = inflatePane(R.layout.pane_charging); chargingPane = inflatePane(R.layout.pane_charging);
wifiPane = inflatePane(R.layout.pane_wifi); wifiPane = inflatePane(R.layout.pane_wifi);
logPane = inflatePane(R.layout.pane_log); 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(); bindVehiclePane();
bindConsumptionPane(); bindConsumptionPane();
bindChargingPane(); bindChargingPane();
bindWifiPane(); 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); 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); callLogText = logPane.findViewById(R.id.call_log_text);
setUpPager(); setUpPager();
findViewById(R.id.about_button).setOnClickListener(v -> showAbout()); findViewById(R.id.about_button).setOnClickListener(v -> showAbout());
findViewById(R.id.update_button).setOnClickListener(v -> checkForUpdate(v)); findViewById(R.id.update_button).setOnClickListener(v -> checkForUpdate(v));
bindCadenceControls();
// Unstable builds check for a newer pre-release; the stable flavor's UpdateHook is // Unstable builds check for a newer pre-release; the stable flavor's UpdateHook is
// a no-op and does not even contain the updater. // a no-op and does not even contain the updater.
UpdateHook.checkInBackground(this); UpdateHook.checkInBackground(this);
apiKeyInput.setText(savedApiKeyOrDefault()); // The service is always-on: it tracks consumption and syncs to MongoDB cloud.
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.
// startForegroundService is idempotent — if the service is already up this // startForegroundService is idempotent — if the service is already up this
// is a no-op aside from delivering a new intent. We don't trust // is a no-op aside from delivering a new intent.
// service_running as a sole indicator because it can be stale-true if a startForegroundService(new Intent(this, AbrpUploadService.class));
// 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();
});
} }
private void showAbout() { private void showAbout() {
@@ -386,250 +262,10 @@ public class MainActivity extends AppCompatActivity {
@Override @Override
protected void onResume() { protected void onResume() {
super.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. // Poll state and log only while the screen is up; onPause cancels it.
uiHandler.post(uiRefresh); 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 @Override
protected void onPause() { protected void onPause() {
super.onPause(); super.onPause();
@@ -637,67 +273,6 @@ public class MainActivity extends AppCompatActivity {
uiHandler.removeCallbacks(uiRefresh); 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 ---------- // ---------- Update check ----------
/** /**
@@ -854,6 +429,7 @@ public class MainActivity extends AppCompatActivity {
private void bindConsumptionPane() { private void bindConsumptionPane() {
consumptionTracker = ConsumptionTracker.get(this); consumptionTracker = ConsumptionTracker.get(this);
consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext());
consumptionDistance = consumptionPane.findViewById(R.id.consumption_distance); consumptionDistance = consumptionPane.findViewById(R.id.consumption_distance);
consumptionEnergy = consumptionPane.findViewById(R.id.consumption_energy); consumptionEnergy = consumptionPane.findViewById(R.id.consumption_energy);
consumptionAverage = consumptionPane.findViewById(R.id.consumption_average); consumptionAverage = consumptionPane.findViewById(R.id.consumption_average);
@@ -992,7 +568,15 @@ public class MainActivity extends AppCompatActivity {
private void refreshConsumption() { private void refreshConsumption() {
if (consumptionTracker == null) return; 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)); consumptionDistance.setText(String.format(java.util.Locale.getDefault(), "%.1f km", t.km));
consumptionEnergy.setText(String.format(java.util.Locale.getDefault(), "%.2f kWh", t.kwh)); consumptionEnergy.setText(String.format(java.util.Locale.getDefault(), "%.2f kWh", t.kwh));
consumptionAverage.setText(t.km < 0.1 ? "—" : String.format(java.util.Locale.getDefault(), 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", consumptionTime.setText(String.format(java.util.Locale.getDefault(), "%d sa %02d dk",
minutes / 60, minutes % 60)); minutes / 60, minutes % 60));
consumptionSoc.setText(String.format(java.util.Locale.getDefault(), "%.1f %%", t.soc)); 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) { if (day.km == 0 && day.kwh == 0 && day.hours == 0 && day.soc == 0) {
consumptionDailyHistory.setText("Bu tarih için kayıt yok"); consumptionDailyHistory.setText("Bu tarih için kayıt yok");
} else { } 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() { private void showConsumptionDatePicker() {
java.time.LocalDate selected = selectedConsumptionDate; java.time.LocalDate selected = selectedConsumptionDate;
android.app.DatePickerDialog dialog = new android.app.DatePickerDialog(this, android.app.DatePickerDialog dialog = new android.app.DatePickerDialog(this,
@@ -1091,86 +714,22 @@ public class MainActivity extends AppCompatActivity {
callLogText.setText(sb.toString()); 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 ---------- // ---------- Service status ----------
private void refreshStatus() { private void refreshStatus() {
// Live signal, not the preference: "service_running" stays stale-true after a // The service is always running. Show the cloud sync state derived from the log.
// force-kill because onDestroy never ran.
UploadLog.State state = AbrpUploadService.state(); 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) { switch (state) {
case ERROR: case ERROR:
// Derived from the log, not from the last status alone: a single failed // Derived from the log: three consecutive cloud sync failures is a problem.
// upload is normal (tunnel, dead spot), three in a row is a problem.
statusText.setText(getString(R.string.state_error, statusText.setText(getString(R.string.state_error,
AbrpUploadService.log().consecutiveFailures())); AbrpUploadService.log().consecutiveFailures()));
statusText.setTextColor(COLOR_ERROR); statusText.setTextColor(COLOR_ERROR);
break; break;
case RUNNING: 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: case STARTING:
statusText.setText(R.string.state_starting); statusText.setText("Tüketim takibi aktif");
statusText.setTextColor(COLOR_OK); statusText.setTextColor(COLOR_OK);
break; break;
case STOPPED: case STOPPED:
@@ -1187,90 +746,4 @@ public class MainActivity extends AppCompatActivity {
CharSequence text = field.getText(); CharSequence text = field.getText();
return text != null ? text.toString().trim() : ""; 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; package com.evsuite.abrp;
import android.content.SharedPreferences;
/** /**
* User-configurable upload policy. * 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
* Defaults are deliberately conservative. The head unit is awake whenever the car is on, * references compile during the ABRP removal; will be deleted once all callers are gone.
* 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.
*/ */
final class UploadSettings { final class UploadSettings {
static final String KEY_INTERVAL_SEC = "upload_interval_sec"; static final String KEY_AUTOSTART = "autostart";
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 boolean DEFAULT_AUTOSTART = true; static final boolean DEFAULT_AUTOSTART = true;
static final boolean DEFAULT_ABRP_UPLOAD_ENABLED = true;
/** private UploadSettings() { }
* 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;
}
} }
-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/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/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/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/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/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" /> <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/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/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/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/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/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"/> <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>
+103
View File
@@ -0,0 +1,103 @@
# Installing a Platform-Signed APK on the MG4 Head Unit
This documents the reliable way to install a (platform-signed) APK onto the
MG4 SWI69 head unit (Android 9 / SAIC MT2712) over ADB.
The head unit rejects the standard `adb install` / `pm install` flow with:
```
java.io.IOException: Not allowed to install non-system apps on internal storage
at com.android.internal.content.PackageHelper.resolveInstallVolume(...)
```
This is not a signature problem — the same `platform.pk8` / `platform.x509.pem`
that signs the already-installed `com.mg4.*` apps signs our APK too (verified by
matching SHA-256 cert digests). The PackageManager simply will not let the
shell UID (2000) stage a non-system package onto internal storage through its
default install path, and `--install-location`, `--force-uuid internal`, `-l`
(forward lock) and session-based `install-create`/`install-write`/`install-commit`
all hit the same wall or fail to open the file (SELinux denies `system_server`
read access to shell-owned files in some flag combinations).
## Prerequisites
- The MG4 head unit connected over ADB (`adb devices` lists it).
- A built APK. For a platform-signed build see
[`MG4_PLATFORM_BUILD.md`](./MG4_PLATFORM_BUILD.md) (DiPlay) — the same
`zipalign` + `apksigner sign --key tools/platform.pk8 --cert
tools/platform.x509.pem` step applies here.
- The platform keys in `tools/` (`platform.pk8`, `platform.x509.pem`).
## The Install Command
The only flag combination that works on this unit is:
```bash
adb push <your-app>.apk /data/local/tmp/update.apk
adb shell "pm install -r -f -d -t /data/local/tmp/update.apk"
```
Flags:
- `-r` — replace the existing package if one is installed.
- `-f` — force install on internal flash (`/data/app/`).
- `-d` — allow a version-code downgrade (debuggable packages only). The
unstable channel bumps `versionCode` per CI build, so a locally built APK
is almost always lower than whatever the OTA last pushed; without `-d` the
install silently fails on a version mismatch.
- `-t` — allow test packages. Unstable/debug builds are marked debuggable,
which the PackageManager treats as a test package.
The file **must** be pushed as (or renamed to) something under
`/data/local/tmp/`. Other locations (`/sdcard/`, a custom cache dir) fail with
`Can't open file` because `system_server` has no SELinux read access to those
contexts. A failed `pm install` attempt can delete the staged file, so if a
later install says `No such file or directory`, re-push.
## Verifying the Install
```bash
adb shell pm path com.evsuite.abrp.unstable
# -> package:/data/app/com.evsuite.abrp.unstable-.../base.apk
```
To confirm the platform signature matches the system:
```bash
BUILD_TOOLS="$ANDROID_HOME/build-tools/36.0.0"
adb pull "$(adb shell pm path com.evsuite.abrp.unstable | sed 's/package://')" /tmp/installed.apk
"$BUILD_TOOLS/apksigner" verify --print-certs /tmp/installed.apk
# SHA-256 cert digest should match tools/platform.x509.pem
```
## Uninstalling
```bash
adb uninstall com.evsuite.abrp.unstable
```
Add `-k` to keep data/cache around (useful when re-installing to test without
wiping the local consumption store).
## End-to-End: Build → Sign → Install
From the repository root:
```bash
# 1. Build
./gradlew :app:assembleUnstableDebug
# 2. Align + platform-sign
SDK="$HOME/Library/Android/sdk"
BT="$SDK/build-tools/36.0.0"
IN=app/build/outputs/apk/unstable/debug/app-unstable-debug.apk
ALIGNED=app/build/outputs/apk/unstable/debug/app-unstable-debug-aligned.apk
OUT=FatihsMG4-unstable-platform.apk
"$BT/zipalign" -f 4 "$IN" "$ALIGNED"
"$BT/apksigner" sign --key tools/platform.pk8 --cert tools/platform.x509.pem --out "$OUT" "$ALIGNED"
"$BT/apksigner" verify --verbose "$OUT"
# 3. Install on the head unit
adb push "$OUT" /data/local/tmp/update.apk
adb shell "pm install -r -f -d -t /data/local/tmp/update.apk"
```