Fatih'sMG4 — ABRP telemetry for an MG4 EH32 on SWI69
A fork of malys/EVABRPUploader (itself a fork of Leon Kernan's ABRP_Uploader) carrying the changes that make it work on this car. History starts here: the tree is what matters, and the upstream projects are where the earlier work belongs. EVHardware is vendored rather than tracked as a submodule, so one clone builds. Telemetry. SOC, battery power and range read as unavailable on SWI69 because the vendor fallbacks were gated to SWI68 alone, while the property ids they use are the ones this car publishes — the gates now admit SWI69. Tyre pressure, the odometer and charge status come from the vendor block too, since the generic properties for all three are declared here and never publish. Tyre pressure is confirmed on the vehicle; the other two are not yet. Vehicle page. Every signal the car answers, in a table. One that it does not publish shows a dash: an unimplemented VHAL property returns zero, and "0 °C" would read as a measurement. Wi-Fi page. Joins a network the unit already has saved, so a phone hotspot does not need a trip into Settings on every drive. Tried on the upload tick and from a button, skipped while already associated. This leans on the unit being API 28 and the build being platform-signed — getConfiguredNetworks and enableNetwork are closed to ordinary apps from API 29 — and fails quietly where that does not hold. The OTA updater points at this repository. An APK from upstream is signed with a different key, which Android will not install over a platform-signed build. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
/**
|
||||
* VHAL discovery helpers — DEBUG BUILDS ONLY.
|
||||
*
|
||||
* These enumerate and log the entire vehicle abstraction layer. That is exactly the
|
||||
* surface a release APK on a customer vehicle should not carry, so the class lives in
|
||||
* src/debug and simply does not exist in a release build. It was previously part of
|
||||
* CarPropertyAdapter and shipped to production.
|
||||
*
|
||||
* Neither method is called by the service; they are attached from a debug session when
|
||||
* identifying which vendor property ID maps to a given sensor.
|
||||
*/
|
||||
final class VhalProbe {
|
||||
|
||||
private static final String TAG = "VhalProbe";
|
||||
|
||||
private VhalProbe() { }
|
||||
|
||||
/**
|
||||
* Reads every vendor Float property visible in the car_service dump and logs non-zero values.
|
||||
* Use this to identify which vendor property ID maps to a given sensor.
|
||||
*/
|
||||
static void probeVendorFloatProperties(CarPropertyAdapter adapter) {
|
||||
// Full Float-type property ID list from `dumpsys car_service` on this vehicle.
|
||||
int[] globalIds = {
|
||||
// Standard AAOS
|
||||
291504647, 291504901, 291518465, 291518466, 291518467, 291518468,
|
||||
// Vendor 0x2160xxxx global
|
||||
559945057, 559945059, 559945060, 559945125, 559945126,
|
||||
559971102, 559971105,
|
||||
559980820, 559980821, 559980822, 559980824, 559980825, 559980826, 559980827,
|
||||
559980833, 559980834, 559980835, 559980837, 559980838, 559980839, 559980840,
|
||||
559980844, 559980845, 559980846, 559980847, 559980848,
|
||||
559980850, 559980851, 559980852, 559980853, 559980854,
|
||||
559980857, 559980858, 559980859, 559980860, 559980861,
|
||||
559980864, 559980865, 559980866, 559980867, 559980868,
|
||||
559980872, 559980873, 559980875, 559980876, 559980877, 559980878,
|
||||
559980880, 559980881, 559980882, 559980883, 559980891,
|
||||
559980933, 559980951, 559980952, 559980953, 559980954, 559980955,
|
||||
559980961, 559980962, 559980963, 559980964, 559980968,
|
||||
559980987, 559980988, 559980989, 559980990, 559980991, 559980992, 559980993, 559980994,
|
||||
559981004, 559981005, 559981006, 559981007,
|
||||
559987029,
|
||||
559990304, 559990308,
|
||||
560002052, 560002053, 560002054, 560002055, 560002058,
|
||||
560002075, 560002077, 560002081,
|
||||
560002108, 560002109, 560002114,
|
||||
};
|
||||
// HVAC / climate properties use zone area ID 117
|
||||
int[] hvacIds = {
|
||||
358622475, 358622476, 358622481, 358622494, 358622495,
|
||||
358622496, 358622497, 358622506, 358622518,
|
||||
};
|
||||
|
||||
Log.d(TAG, "probeVendorFloatProperties: scanning " + (globalIds.length + hvacIds.length) + " IDs");
|
||||
for (int id : globalIds) {
|
||||
Float val = adapter.getFloatProperty(id, 0);
|
||||
if (val != null && val != 0f) Log.i(TAG, String.format(" GLOBAL 0x%08X = %.4f", id, val));
|
||||
}
|
||||
for (int id : hvacIds) {
|
||||
Float val = adapter.getFloatProperty(id, 117);
|
||||
if (val != null && val != 0f) Log.i(TAG, String.format(" HVAC[117] 0x%08X = %.4f", id, val));
|
||||
Float val0 = adapter.getFloatProperty(id, 0);
|
||||
if (val0 != null && val0 != 0f) Log.i(TAG, String.format(" HVAC[0] 0x%08X = %.4f", id, val0));
|
||||
}
|
||||
Log.d(TAG, "probeVendorFloatProperties: done");
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerates every supported property and logs its current value. Used for
|
||||
* discovery: match logged values against the dashboard to identify which
|
||||
* vendor property ID maps to a given sensor (battery %, range, etc).
|
||||
*/
|
||||
static void logAllProperties(CarPropertyAdapter adapter) {
|
||||
if (adapter.cpm == null) { Log.w(TAG, "logAllProperties: not connected"); return; }
|
||||
Log.d(TAG, "logAllProperties: starting");
|
||||
try {
|
||||
java.lang.reflect.Method listMethod = adapter.cpmClass.getMethod("getPropertyList");
|
||||
java.util.List<?> configs = (java.util.List<?>) listMethod.invoke(adapter.cpm);
|
||||
Log.d(TAG, "getPropertyList returned: " + (configs == null ? "null" : configs.size() + " entries"));
|
||||
if (configs == null || configs.isEmpty()) return;
|
||||
Class<?> configClass = Class.forName("android.car.hardware.CarPropertyConfig", true, adapter.cpmClass.getClassLoader());
|
||||
java.lang.reflect.Method getPropId = configClass.getMethod("getPropertyId");
|
||||
java.lang.reflect.Method getPropType = configClass.getMethod("getPropertyType");
|
||||
java.lang.reflect.Method getAreaIds = configClass.getMethod("getAreaIds");
|
||||
for (Object cfg : configs) {
|
||||
int propId = (int) getPropId.invoke(cfg);
|
||||
Class<?> type = (Class<?>) getPropType.invoke(cfg);
|
||||
int[] areas = (int[]) getAreaIds.invoke(cfg);
|
||||
int area = (areas != null && areas.length > 0) ? areas[0] : 0;
|
||||
try {
|
||||
if (type == Float.class) {
|
||||
Log.i(TAG, String.format("FLOAT 0x%08X area=%d = %s", propId, area,
|
||||
adapter.getFloatProperty(propId, area)));
|
||||
} else if (type == Integer.class) {
|
||||
Log.i(TAG, String.format("INT 0x%08X area=%d = %s", propId, area,
|
||||
adapter.getIntProperty(propId, area)));
|
||||
} else if (type == Boolean.class) {
|
||||
Log.i(TAG, String.format("BOOL 0x%08X area=%d = %s", propId, area,
|
||||
adapter.getBooleanProperty(propId, area)));
|
||||
} else {
|
||||
Log.i(TAG, String.format("OTHER 0x%08X area=%d type=%s", propId, area, type));
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, String.format("read 0x%08X area=%d failed: %s",
|
||||
propId, area, t.getClass().getSimpleName()));
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "logAllProperties: done");
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "logAllProperties failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<!-- Required from API 33 to post the foreground-service notification, which is the
|
||||
only status UI the service has. Without it the notification is silently dropped. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<!-- Wi-Fi auto-join. CHANGE_WIFI_STATE covers enabling the radio and asking the
|
||||
supplicant to join a network the unit already has saved; ACCESS_WIFI_STATE covers
|
||||
reading which one it landed on. No password is ever handled here — see
|
||||
WifiAutoConnect for why this works on this Android 9 unit and not on a newer one. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
|
||||
|
||||
<!-- Android Automotive / CarPropertyManager permissions -->
|
||||
<uses-permission android:name="android.car.permission.CAR_SPEED" />
|
||||
<uses-permission android:name="android.car.permission.CAR_ENERGY" />
|
||||
<uses-permission android:name="android.car.permission.CAR_ENERGY_PORTS" />
|
||||
<uses-permission android:name="android.car.permission.CAR_EXTERIOR_ENVIRONMENT" />
|
||||
<uses-permission android:name="android.car.permission.CONTROL_CAR_CLIMATE" />
|
||||
<uses-permission android:name="android.car.permission.CAR_VENDOR_EXTENSION" />
|
||||
<!-- Optional telemetry: odometer, tyre pressures and the nominal pack capacity.
|
||||
All three are privileged on AAOS, so an install that is not platform-signed
|
||||
simply reads null and the fields are omitted — declaring them costs nothing
|
||||
and lets a platform-signed install send them. -->
|
||||
<uses-permission android:name="android.car.permission.CAR_MILEAGE" />
|
||||
<uses-permission android:name="android.car.permission.CAR_TIRES" />
|
||||
<uses-permission android:name="android.car.permission.CAR_INFO" />
|
||||
|
||||
<!-- The head unit's map stack, which owns the weather service the uploader asks for the
|
||||
outside temperature. From API 30 an explicit bind to a package this app does not
|
||||
declare here simply fails, and ext_temp would go back to being unreportable. -->
|
||||
<queries>
|
||||
<package android:name="com.saicmotor.mapservice" />
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.EVABRPUploader">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:taskAffinity="">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".AbrpUploadService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="location" />
|
||||
|
||||
<!-- 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
|
||||
uploading did not start with the car. MY_PACKAGE_REPLACED covers the other
|
||||
gap: an update stops the service and nothing else restarts it. -->
|
||||
<receiver
|
||||
android:name=".BootReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
|
||||
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,123 @@
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.pm.ServiceInfo;
|
||||
import android.location.Location;
|
||||
import android.location.LocationListener;
|
||||
import android.location.LocationManager;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.IBinder;
|
||||
import android.os.Looper;
|
||||
import android.os.SystemClock;
|
||||
import android.util.Log;
|
||||
|
||||
import android.Manifest;
|
||||
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.ServiceCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.evsuite.hardware.saic.SaicWeather;
|
||||
import com.evsuite.hardware.telemetry.EnergySnapshot;
|
||||
import com.evsuite.hardware.telemetry.EnergyTelemetryReader;
|
||||
import com.evsuite.hardware.telemetry.ChargingSessionMeter;
|
||||
import com.evsuite.hardware.telemetry.TirePressureSnapshot;
|
||||
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Foreground service that polls vehicle telemetry every 15 s and pushes it to ABRP.
|
||||
*
|
||||
* Reliability design:
|
||||
* - Uses ScheduledExecutorService (not Handler chains) so exceptions inside one
|
||||
* upload cycle cannot break the schedule. Even on a thrown RuntimeException
|
||||
* the next tick still fires.
|
||||
* - Uploads unconditionally: if vehicle telemetry is unavailable we still emit
|
||||
* GPS + timestamp so ABRP at least sees the vehicle online.
|
||||
* - EVHardware owns the vehicle connection, firmware routing and nullable reads.
|
||||
* - All IPC + HTTP happens on the scheduler thread, never the main thread.
|
||||
*/
|
||||
public class AbrpUploadService extends Service {
|
||||
|
||||
public static final String ACTION_STOP = "com.evsuite.abrp.STOP";
|
||||
|
||||
/**
|
||||
* Live in-process signal. The "service_running" preference cannot be trusted on its
|
||||
* own: a force-kill (adb install -r, low-memory kill) skips onDestroy and leaves it
|
||||
* stale-true forever. This field dies with the process, so it is only ever true while
|
||||
* the service really is up.
|
||||
*/
|
||||
private static volatile boolean running = false;
|
||||
|
||||
public static boolean isRunning() { return running; }
|
||||
|
||||
/**
|
||||
* Upload history, shared with the UI. Static because the activity reads it while the
|
||||
* service owns it; it lives and dies with the process, like {@link #running}.
|
||||
*/
|
||||
private static final UploadLog uploadLog = new UploadLog();
|
||||
|
||||
public static UploadLog log() { return uploadLog; }
|
||||
|
||||
public static UploadLog.State state() { return uploadLog.state(running); }
|
||||
|
||||
/** Re-read after the user changes the cadence, without restarting the service. */
|
||||
public static void reloadSettings() { settingsDirty = true; }
|
||||
|
||||
private static volatile boolean settingsDirty = false;
|
||||
|
||||
private static final String TAG = "AbrpUploadService";
|
||||
private static final String CHANNEL_ID = "abrp_uploader";
|
||||
private static final int NOTIF_ID = 1;
|
||||
/** Scheduler tick. Whether a tick uploads is decided by {@link UploadCadence}. */
|
||||
private static final long UPLOAD_INTERVAL_SEC = 15;
|
||||
/**
|
||||
* Warm-up before the first upload. Was 45 s with no stated reason; the car adapter
|
||||
* connects asynchronously and telemetry now omits whatever it cannot read (T-913), so
|
||||
* an early first sample is useful rather than misleading.
|
||||
*/
|
||||
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;
|
||||
/**
|
||||
* 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 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. */
|
||||
private volatile String lastStatus = "Starting…";
|
||||
private volatile boolean locationUpdatesActive = false;
|
||||
private volatile long lastLocationRequestMs = 0L;
|
||||
/** Last head-unit weather reading and when it was taken — see {@link #WEATHER_TTL_MS}. */
|
||||
private volatile SaicWeather.Reading lastWeather = null;
|
||||
private volatile long lastWeatherMs = 0L;
|
||||
/** The head unit's own weather broadcast — the source its status bar uses. */
|
||||
private final HeadUnitWeather headUnitWeather = new HeadUnitWeather();
|
||||
private final BroadcastReceiver weatherReceiver = new BroadcastReceiver() {
|
||||
@Override public void onReceive(Context context, Intent intent) {
|
||||
headUnitWeather.accept(intent);
|
||||
saveWeather();
|
||||
}
|
||||
};
|
||||
private volatile boolean weatherReceiverRegistered = false;
|
||||
/** How long the last map-service query took, or -1 when it was never made. */
|
||||
private volatile long lastQueryMs = -1L;
|
||||
|
||||
// ---------- Lifecycle ----------
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
|
||||
securePrefs = SecurePrefs.get(this);
|
||||
settings = UploadSettings.from(prefs);
|
||||
uploadLog.clear();
|
||||
mainHandler = new Handler(Looper.getMainLooper());
|
||||
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "abrp-upload");
|
||||
t.setDaemon(false);
|
||||
return t;
|
||||
});
|
||||
|
||||
createNotificationChannel();
|
||||
startInForeground();
|
||||
running = true;
|
||||
prefs.edit().putBoolean("service_running", true).apply();
|
||||
|
||||
// One shared, firmware-aware EVHardware snapshot owns every vehicle signal.
|
||||
energyReader = new EnergyTelemetryReader(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.
|
||||
SaicWeather.INSTANCE.connect(getApplicationContext());
|
||||
registerWeatherReceiver();
|
||||
|
||||
requestLocationUpdates();
|
||||
|
||||
// Fire the first upload after a short warm-up, then every UPLOAD_INTERVAL_SEC.
|
||||
// scheduleWithFixedDelay ensures we always get at least UPLOAD_INTERVAL_SEC
|
||||
// between cycles even if a previous upload was slow.
|
||||
scheduler.scheduleWithFixedDelay(
|
||||
this::safeUploadCycle,
|
||||
FIRST_UPLOAD_DELAY_SEC, UPLOAD_INTERVAL_SEC, TimeUnit.SECONDS);
|
||||
|
||||
Log.i(TAG, "Service started, upload scheduler armed");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
if (intent != null && ACTION_STOP.equals(intent.getAction())) {
|
||||
prefs.edit().putBoolean("service_enabled", false).apply();
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
// Re-asserted on every start: a position grant that arrives after the service is up
|
||||
// changes which foreground types it may hold, and the activity starts it again as
|
||||
// soon as the user answers. Without this the grant only took effect at the next boot.
|
||||
startInForeground();
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enters the foreground holding only the service types this app currently has the
|
||||
* permissions for.
|
||||
*
|
||||
* The service declares {@code foregroundServiceType="location"}, and since API 34 the
|
||||
* platform validates every declared type against what is held at the moment
|
||||
* startForeground runs — the plain two-argument call claims them all. On a car where
|
||||
* position had not been granted yet (a fresh install, or the boot receiver starting the
|
||||
* service before the app was ever opened) that threw a SecurityException and the uploader
|
||||
* died before its first tick. Without the permission the service still runs and still
|
||||
* uploads; it simply has no position to send, exactly as before.
|
||||
*/
|
||||
private void startInForeground() {
|
||||
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.Q) {
|
||||
// Service types do not exist before API 29: nothing is declared and nothing is
|
||||
// checked, so the plain call is the only correct one there.
|
||||
startForeground(NOTIF_ID, buildNotification(lastStatus));
|
||||
return;
|
||||
}
|
||||
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
|
||||
public IBinder onBind(Intent intent) { return null; }
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
Log.i(TAG, "Service stopping");
|
||||
if (scheduler != null) scheduler.shutdownNow();
|
||||
if (locationManager != null) {
|
||||
try { locationManager.removeUpdates(locationListener); } catch (Exception ignored) {}
|
||||
}
|
||||
if (weatherReceiverRegistered) {
|
||||
try { unregisterReceiver(weatherReceiver); } catch (Exception ignored) {}
|
||||
weatherReceiverRegistered = false;
|
||||
}
|
||||
if (mainHandler != null) mainHandler.removeCallbacksAndMessages(null);
|
||||
running = false;
|
||||
prefs.edit().putBoolean("service_running", false).apply();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
// ---------- Upload cycle (runs on scheduler thread) ----------
|
||||
|
||||
private void safeUploadCycle() {
|
||||
try {
|
||||
if (settingsDirty) {
|
||||
settingsDirty = false;
|
||||
settings = UploadSettings.from(prefs);
|
||||
// The GPS subscription is a power cost of its own; keep it in step with
|
||||
// how often we actually send.
|
||||
mainHandler.post(this::requestLocationUpdates);
|
||||
Log.i(TAG, "Settings reloaded: interval=" + settings.intervalSec + "s"
|
||||
+ " boostLowSoc=" + settings.boostLowSoc
|
||||
+ " lowSoc=" + settings.lowSocPercent + "%");
|
||||
}
|
||||
// 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 (!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 (wifi != null && prefs.getBoolean(MainActivity.WIFI_AUTO_KEY, true)) {
|
||||
wifi.ensureConnected(false);
|
||||
}
|
||||
doUpload();
|
||||
} catch (Throwable t) {
|
||||
// Absolutely never let an exception escape — would not break the
|
||||
// schedule with scheduleWithFixedDelay's executor, but we want a
|
||||
// clean log line anyway.
|
||||
Log.e(TAG, "upload cycle threw", t);
|
||||
}
|
||||
}
|
||||
|
||||
private void doUpload() {
|
||||
String token = securePrefs.getString(SecurePrefs.KEY_TOKEN, "").trim();
|
||||
String apiKey = securePrefs.getString(SecurePrefs.KEY_API_KEY, "").trim();
|
||||
|
||||
if (token.isEmpty()) {
|
||||
updateNotification("No ABRP token — open app to configure");
|
||||
return;
|
||||
}
|
||||
if (apiKey.isEmpty()) {
|
||||
updateNotification("No API key — open app to configure");
|
||||
return;
|
||||
}
|
||||
|
||||
// Read whatever the car will give us. Every getter returns null when the read
|
||||
// fails, and a null field is omitted from the payload — never sent as 0.
|
||||
long sampleMs = System.currentTimeMillis();
|
||||
EnergySnapshot vehicle = energyReader.read(sampleMs);
|
||||
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 extTemp = vehicle.getOutsideTempCelsius();
|
||||
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.
|
||||
String tempSource = "car";
|
||||
SaicWeather.Reading sky = null;
|
||||
if (!TelemetryPayload.isPlausibleTemp(extTemp)) {
|
||||
// The broadcast first. It is what fills the head unit's own status bar, so on this
|
||||
// car it is the source known to be producing a number, where the map service's
|
||||
// query never even bound.
|
||||
// The broadcast arrives when the weather app feels like it, so a reading that
|
||||
// never came is retried here rather than only at start-up.
|
||||
if (headUnitWeather.temperatureC() == null) probeStickyWeather();
|
||||
Float broadcast = weatherIsFresh() ? headUnitWeather.temperatureC() : null;
|
||||
if (TelemetryPayload.isPlausibleTemp(broadcast)) {
|
||||
extTemp = broadcast;
|
||||
tempSource = "bcast:" + headUnitWeather.diagnostic();
|
||||
} else {
|
||||
sky = weatherAt(loc);
|
||||
if (sky != null && sky.getTemperatureCelsius() != null) {
|
||||
extTemp = sky.getTemperatureCelsius().floatValue();
|
||||
tempSource = "map:" + sky.getCity();
|
||||
} else {
|
||||
// Neither answered. Which one failed, and how, is the difference between
|
||||
// "the broadcast never arrives" and "it arrives in a shape this cannot
|
||||
// read" — and nothing on the ABRP side tells them apart.
|
||||
tempSource = "none bcast=" + headUnitWeather.diagnostic()
|
||||
+ " map=" + (SaicWeather.INSTANCE.isAvailable() ? "bound" : "unbound")
|
||||
+ (lastQueryMs < 0 ? "" : "/" + lastQueryMs + "ms");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TelemetryPayload tlm = new TelemetryPayload(System.currentTimeMillis() / 1000);
|
||||
tlm.soc = soc;
|
||||
tlm.speedKmh = speedKmh;
|
||||
tlm.rangeKm = rangeKm;
|
||||
tlm.extTemp = extTemp;
|
||||
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) + " [temp " + tempSource + "]";
|
||||
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.
|
||||
if (sky != null && !sky.getCity().isEmpty()) summary += ", near " + sky.getCity();
|
||||
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 (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 ----------
|
||||
|
||||
private void createNotificationChannel() {
|
||||
NotificationChannel ch = new NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.notif_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW);
|
||||
ch.setDescription(getString(R.string.notif_channel_desc));
|
||||
getSystemService(NotificationManager.class).createNotificationChannel(ch);
|
||||
}
|
||||
|
||||
private Notification buildNotification(String status) {
|
||||
PendingIntent openApp = PendingIntent.getActivity(this, 0,
|
||||
new Intent(this, MainActivity.class),
|
||||
PendingIntent.FLAG_IMMUTABLE);
|
||||
|
||||
Intent stopIntent = new Intent(this, AbrpUploadService.class);
|
||||
stopIntent.setAction(ACTION_STOP);
|
||||
PendingIntent stopPi = PendingIntent.getService(this, 1,
|
||||
stopIntent, PendingIntent.FLAG_IMMUTABLE);
|
||||
|
||||
return new NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(getString(R.string.notif_title))
|
||||
.setContentText(status)
|
||||
.setContentIntent(openApp)
|
||||
.addAction(android.R.drawable.ic_delete, getString(R.string.notif_action_stop), stopPi)
|
||||
.setOngoing(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
private void updateNotification(String status) {
|
||||
lastStatus = status;
|
||||
getSystemService(NotificationManager.class).notify(NOTIF_ID, buildNotification(status));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Restarts uploading when the head unit powers up.
|
||||
*
|
||||
* BOOT_COMPLETED alone was not enough. An MG4's head unit rarely cold-boots: switching the
|
||||
* car on usually resumes it, and the resume is announced with QUICKBOOT_POWERON instead.
|
||||
* Users reported uploading never starting with the car, and this is why — the app was
|
||||
* waiting for a broadcast their car does not always send. The other EVSuite apps already
|
||||
* listen to the whole family; this one now does too.
|
||||
*
|
||||
* MY_PACKAGE_REPLACED is in the list for a different reason: an update stops the service,
|
||||
* and nothing else would bring it back until the next power cycle.
|
||||
*/
|
||||
public class BootReceiver extends BroadcastReceiver {
|
||||
|
||||
private static final String TAG = "EVABRP.Boot";
|
||||
|
||||
private static final Set<String> START_ACTIONS = new HashSet<>(Arrays.asList(
|
||||
Intent.ACTION_BOOT_COMPLETED,
|
||||
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||
"android.intent.action.LOCKED_BOOT_COMPLETED",
|
||||
"android.intent.action.QUICKBOOT_POWERON",
|
||||
"com.htc.intent.action.QUICKBOOT_POWERON"
|
||||
));
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
String action = intent.getAction();
|
||||
if (action == null || !START_ACTIONS.contains(action)) return;
|
||||
|
||||
SharedPreferences prefs = context.getSharedPreferences("abrp_prefs", Context.MODE_PRIVATE);
|
||||
boolean enabled = prefs.getBoolean("service_enabled", false);
|
||||
boolean autostart = prefs.getBoolean(UploadSettings.KEY_AUTOSTART, UploadSettings.DEFAULT_AUTOSTART);
|
||||
|
||||
// Credentials come from SecurePrefs, not from the plaintext file. This receiver used
|
||||
// to read "token" out of abrp_prefs — where it has not lived since the credentials
|
||||
// were encrypted, because the migration deletes the plaintext copy. The check
|
||||
// therefore always failed, and uploading never started by itself on any car.
|
||||
SharedPreferences secure = SecurePrefs.get(context);
|
||||
boolean haveCreds = !secure.getString(SecurePrefs.KEY_TOKEN, "").trim().isEmpty()
|
||||
&& !secure.getString(SecurePrefs.KEY_API_KEY, "").trim().isEmpty();
|
||||
|
||||
if (!enabled || !autostart || !haveCreds) {
|
||||
Log.i(TAG, action + " ignored (enabled=" + enabled
|
||||
+ " autostart=" + autostart + " credentials=" + haveCreds + ")");
|
||||
return;
|
||||
}
|
||||
|
||||
Log.i(TAG, action + " — starting upload service");
|
||||
try {
|
||||
context.startForegroundService(new Intent(context, AbrpUploadService.class));
|
||||
} catch (Exception e) {
|
||||
// A start refused this early is not worth crashing the boot broadcast over: the
|
||||
// next power cycle, or opening the app, starts it just the same.
|
||||
Log.w(TAG, "startForegroundService failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.ServiceConnection;
|
||||
import android.os.IBinder;
|
||||
import android.util.Log;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
/**
|
||||
* Connects to Android Automotive's CarPropertyManager via reflection.
|
||||
*
|
||||
* Targets AAOS 9 (API 28) where android.car is a platform library not in the
|
||||
* compile-time SDK — direct imports are unavailable, so all access is via reflection.
|
||||
*
|
||||
* Debug discovery transport for {@code VhalProbe}. Production telemetry goes exclusively
|
||||
* through EVHardware; this class deliberately owns no vehicle property ids.
|
||||
*/
|
||||
public class CarPropertyAdapter {
|
||||
|
||||
private static final String TAG = "CarPropertyAdapter";
|
||||
|
||||
public interface Listener {
|
||||
void onConnected();
|
||||
void onDisconnected();
|
||||
}
|
||||
|
||||
private final Listener listener;
|
||||
private Context appContext;
|
||||
// Written on the binder/main thread, read from the abrp-upload scheduler thread.
|
||||
// Without volatile the scheduler can observe a stale null or a half-connected adapter.
|
||||
private volatile Object car; // android.car.Car
|
||||
volatile Object cpm; // android.car.hardware.property.CarPropertyManager (package-private: VhalProbe, debug builds only)
|
||||
private Class<?> carClass;
|
||||
Class<?> cpmClass;
|
||||
|
||||
// Reflected getters resolved once at connect time. getMethod() on every read meant
|
||||
// 8 lookups every 15 s, each allocating a Method and its parameter array.
|
||||
private volatile Method getIntMethod;
|
||||
private volatile Method getFloatMethod;
|
||||
private volatile Method getBooleanMethod;
|
||||
|
||||
/**
|
||||
* Incremented on every disconnect. A polling runnable captures the value it started
|
||||
* with and stops as soon as it no longer matches, so a reconnect cannot leave an
|
||||
* orphan polling loop running alongside the new one.
|
||||
*/
|
||||
private volatile int connectionGeneration = 0;
|
||||
|
||||
public CarPropertyAdapter(Listener listener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
public void connect(Context context) {
|
||||
appContext = context.getApplicationContext();
|
||||
|
||||
try {
|
||||
carClass = Class.forName("android.car.Car");
|
||||
cpmClass = Class.forName("android.car.hardware.property.CarPropertyManager");
|
||||
} catch (ClassNotFoundException e) {
|
||||
Log.e(TAG, "android.car not available — not running on AAOS", e);
|
||||
return;
|
||||
}
|
||||
|
||||
ServiceConnection sc = new ServiceConnection() {
|
||||
@Override
|
||||
public void onServiceConnected(ComponentName name, IBinder binder) {
|
||||
try {
|
||||
String svcName = (String) carClass.getField("PROPERTY_SERVICE").get(null);
|
||||
Method getManager = carClass.getMethod("getCarManager", String.class);
|
||||
Object manager = getManager.invoke(car, svcName);
|
||||
if (manager != null) {
|
||||
getIntMethod = cpmClass.getMethod("getIntProperty", int.class, int.class);
|
||||
getFloatMethod = cpmClass.getMethod("getFloatProperty", int.class, int.class);
|
||||
getBooleanMethod = cpmClass.getMethod("getBooleanProperty", int.class, int.class);
|
||||
// Publish last: isConnected() must never be true before the
|
||||
// cached methods are in place.
|
||||
cpm = manager;
|
||||
Log.i(TAG, "CarPropertyManager ready");
|
||||
listener.onConnected();
|
||||
} else {
|
||||
Log.e(TAG, "getCarManager returned null for: " + svcName);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Failed to get CarPropertyManager", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceDisconnected(ComponentName name) {
|
||||
connectionGeneration++;
|
||||
cpm = null;
|
||||
car = null;
|
||||
Log.w(TAG, "Car service disconnected");
|
||||
listener.onDisconnected();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
Method createCar = carClass.getMethod("createCar", Context.class, ServiceConnection.class);
|
||||
car = createCar.invoke(null, appContext, sc);
|
||||
carClass.getMethod("connect").invoke(car);
|
||||
Log.d(TAG, "Connecting to Car service...");
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Failed to create/connect Car", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void disconnect() {
|
||||
// Bump first: any in-flight polling runnable must stop even if the calls below throw.
|
||||
connectionGeneration++;
|
||||
cpm = null;
|
||||
if (car == null) return;
|
||||
try {
|
||||
carClass.getMethod("disconnect").invoke(car);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "disconnect error", e);
|
||||
}
|
||||
car = null;
|
||||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
return cpm != null;
|
||||
}
|
||||
|
||||
public interface PropertyCallback {
|
||||
void onValue(int propId, int areaId, Object value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls a float property on the given handler at the specified interval.
|
||||
* Stops automatically when disconnect() is called.
|
||||
* This is the preferred mechanism on AAOS 9 where CarPropertyValue is not
|
||||
* accessible via any standard classloader path, making Proxy-based listeners fail.
|
||||
*/
|
||||
public void startFloatPolling(int propId, int areaId, long intervalMs,
|
||||
android.os.Handler handler, PropertyCallback cb) {
|
||||
if (cpm == null) { Log.w(TAG, "startFloatPolling: not connected"); return; }
|
||||
final int generation = connectionGeneration;
|
||||
Runnable[] r = {null};
|
||||
r[0] = () -> {
|
||||
// Checking cpm alone was not enough: a reconnect makes it non-null again and
|
||||
// the orphan loop resumed forever next to the new one.
|
||||
if (cpm == null || connectionGeneration != generation) return;
|
||||
Float val = getFloatProperty(propId, areaId);
|
||||
if (val != null) cb.onValue(propId, areaId, val);
|
||||
handler.postDelayed(r[0], intervalMs);
|
||||
};
|
||||
handler.post(r[0]);
|
||||
Log.d(TAG, String.format("started polling 0x%08X every %dms", propId, intervalMs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to updates for any car property (float, int, bool — type-agnostic).
|
||||
* The callback receives the raw {@link Object} value; cast to the expected type.
|
||||
*
|
||||
* AAOS 9 quirks handled here:
|
||||
* - Method is named registerListener (not registerCallback as in later versions).
|
||||
* - The event callback interface and CarPropertyValue class are discovered from
|
||||
* the register method's signature — on this SAIC build CarPropertyValue lives
|
||||
* at android.car.hardware.CarPropertyValue (NOT android.car.hardware.property.*).
|
||||
* - The Proxy is defined with this class's loader so the framework can verify it.
|
||||
*
|
||||
* @param rateHz for CONTINUOUS properties, the requested rate in Hz.
|
||||
* For ON_CHANGE properties (e.g. gear), this is ignored — pass 0f.
|
||||
*/
|
||||
public void registerPropertyCallback(int propId, int areaId, float rateHz, PropertyCallback cb) {
|
||||
if (cpm == null) { Log.w(TAG, "registerPropertyCallback: not connected"); return; }
|
||||
try {
|
||||
// Find a 3-arg register* method on CarPropertyManager: (Listener, propId, rateHz).
|
||||
// Match by exact name: getMethods() has no defined order, so "the first
|
||||
// 3-arg register* method" could pick a different overload between runs.
|
||||
java.lang.reflect.Method registerMethod = null;
|
||||
for (java.lang.reflect.Method m : cpmClass.getMethods()) {
|
||||
String n = m.getName();
|
||||
if (("registerListener".equals(n) || "registerCallback".equals(n))
|
||||
&& m.getParameterCount() == 3) {
|
||||
Log.d(TAG, "register candidate: " + m.toGenericString());
|
||||
registerMethod = m;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (registerMethod == null) {
|
||||
Log.e(TAG, "no register method found on CarPropertyManager");
|
||||
return;
|
||||
}
|
||||
|
||||
Class<?> cbInterface = registerMethod.getParameterTypes()[0];
|
||||
Log.d(TAG, "callback interface: " + cbInterface.getName());
|
||||
|
||||
// Discover CarPropertyValue directly from the interface's onChangeEvent
|
||||
// parameter type. This sidesteps hardcoding a package path that differs
|
||||
// between AAOS builds.
|
||||
Class<?> cpvClass = null;
|
||||
for (java.lang.reflect.Method m : cbInterface.getMethods()) {
|
||||
Log.d(TAG, " interface method: " + m.toGenericString());
|
||||
if ("onChangeEvent".equals(m.getName()) && m.getParameterCount() == 1) {
|
||||
cpvClass = m.getParameterTypes()[0];
|
||||
}
|
||||
}
|
||||
if (cpvClass == null) {
|
||||
Log.e(TAG, "no onChangeEvent(CarPropertyValue) method on " + cbInterface.getName());
|
||||
return;
|
||||
}
|
||||
Log.d(TAG, "CarPropertyValue resolved to: " + cpvClass.getName());
|
||||
|
||||
java.lang.reflect.Method getPropId = cpvClass.getMethod("getPropertyId");
|
||||
java.lang.reflect.Method getAreaId = cpvClass.getMethod("getAreaId");
|
||||
java.lang.reflect.Method getValue = cpvClass.getMethod("getValue");
|
||||
|
||||
// Use the app classloader (delegates to boot for android.car.* classes)
|
||||
// so the generated Proxy class can actually be defined.
|
||||
ClassLoader proxyLoader = CarPropertyAdapter.class.getClassLoader();
|
||||
|
||||
Object proxy = java.lang.reflect.Proxy.newProxyInstance(
|
||||
proxyLoader,
|
||||
new Class[]{cbInterface},
|
||||
(proxyObj, method, args) -> {
|
||||
String mname = method.getName();
|
||||
if ("onChangeEvent".equals(mname) && args != null && args.length == 1) {
|
||||
int pId = (int) getPropId.invoke(args[0]);
|
||||
int aId = (int) getAreaId.invoke(args[0]);
|
||||
Object val = getValue.invoke(args[0]);
|
||||
cb.onValue(pId, aId, val);
|
||||
} else if ("onErrorEvent".equals(mname)) {
|
||||
Log.e(TAG, "onErrorEvent propId=" + args[0] + " area=" + args[1]);
|
||||
} else if ("equals".equals(mname)) {
|
||||
return proxyObj == args[0];
|
||||
} else if ("hashCode".equals(mname)) {
|
||||
return System.identityHashCode(proxyObj);
|
||||
} else if ("toString".equals(mname)) {
|
||||
return "CarPropertyProxy@" + Integer.toHexString(System.identityHashCode(proxyObj));
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
registerMethod.invoke(cpm, proxy, propId, rateHz);
|
||||
Log.i(TAG, String.format("registered callback for 0x%08X area=%d rate=%.1fHz",
|
||||
propId, areaId, rateHz));
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "registerPropertyCallback failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Getters return null when the property cannot be read. They used to return 0,
|
||||
// which is indistinguishable from a genuine zero: one SecurityException on the SOC
|
||||
// property was enough to tell ABRP the battery was empty, every 15 seconds.
|
||||
// Callers must omit a null field from the payload rather than substituting a value.
|
||||
|
||||
/** Area 0 covers global/non-zoned properties. Null when unreadable. */
|
||||
public Integer getIntProperty(int propId, int areaId) {
|
||||
Object manager = cpm;
|
||||
Method method = getIntMethod;
|
||||
if (manager == null || method == null) return null;
|
||||
try {
|
||||
return (Integer) method.invoke(manager, propId, areaId);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "getIntProperty(" + Integer.toHexString(propId) + ") failed", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Null when unreadable. */
|
||||
public Float getFloatProperty(int propId, int areaId) {
|
||||
Object manager = cpm;
|
||||
Method method = getFloatMethod;
|
||||
if (manager == null || method == null) return null;
|
||||
try {
|
||||
return (Float) method.invoke(manager, propId, areaId);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "getFloatProperty(" + Integer.toHexString(propId) + ") failed", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Null when unreadable. */
|
||||
public Boolean getBooleanProperty(int propId, int areaId) {
|
||||
Object manager = cpm;
|
||||
Method method = getBooleanMethod;
|
||||
if (manager == null || method == null) return null;
|
||||
try {
|
||||
return (Boolean) method.invoke(manager, propId, areaId);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "getBooleanProperty(" + Integer.toHexString(propId) + ") failed", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
/**
|
||||
* Works out whether the car is charging from whichever signals the VHAL actually answers.
|
||||
*
|
||||
* {@code EV_CHARGE_PORT_CONNECTED} is a standard AAOS property and this vehicle's VHAL is
|
||||
* not obliged to implement it. When it answers {@code false} on a plugged-in car, the
|
||||
* cadence policy sees "parked and not charging" and backs off to
|
||||
* {@link UploadCadence#PARKED_INTERVAL_MS} for the whole session — which is exactly when
|
||||
* ABRP needs a tight SOC curve to size a charging stop. Two fallbacks close that hole:
|
||||
*
|
||||
* 1. the instantaneous charge rate, which is positive only while the pack takes energy;
|
||||
* 2. a rising SOC on a parked car, which nothing but charging can produce.
|
||||
*
|
||||
* The SOC fallback is latched, because SOC only moves a whole percent every few minutes
|
||||
* and a signal that flapped between ticks would flap {@code is_charging} in the payload
|
||||
* too. The latch expires so a car left parked after a charge falls back to the idle
|
||||
* cadence instead of polling all night.
|
||||
*
|
||||
* Stateful and NOT thread-safe: only the {@code abrp-upload} scheduler thread calls it.
|
||||
*/
|
||||
final class ChargingSignal {
|
||||
|
||||
/** Above this the pack is taking energy; below it the reading is noise around zero. */
|
||||
static final float CHARGING_POWER_KW = 0.3f;
|
||||
|
||||
/**
|
||||
* How long a SOC rise keeps counting as "charging". Long enough to cover the slowest
|
||||
* realistic case — a 2 kW granny lead needs ~19 min per percent on a 64 kWh pack —
|
||||
* and short enough that a finished charge returns to the idle cadence promptly.
|
||||
*/
|
||||
static final long SOC_RISE_LATCH_MS = 1_800_000L;
|
||||
|
||||
private Integer lastSocPercent;
|
||||
/** When SOC last rose while parked; 0 when there is no live rise to trust. */
|
||||
private long socRoseAtMs;
|
||||
|
||||
/**
|
||||
* @param nowMs current time
|
||||
* @param portConnected charge port property, null when unreadable
|
||||
* @param chargeRateKw charge rate in kW as the VHAL signs it (+ve while charging),
|
||||
* null when unreadable
|
||||
* @param parked null when the gear could not be read
|
||||
* @param socPercent null when the battery level could not be read
|
||||
* @return TRUE / FALSE when the state can be established, null when nothing could be
|
||||
* read — null must keep counting as "unknown", never as "not charging"
|
||||
*/
|
||||
Boolean evaluate(long nowMs, Boolean portConnected, Float chargeRateKw,
|
||||
Boolean parked, Integer socPercent) {
|
||||
trackSoc(nowMs, parked, socPercent);
|
||||
|
||||
if (Boolean.TRUE.equals(portConnected)) return Boolean.TRUE;
|
||||
if (chargeRateKw != null && chargeRateKw > CHARGING_POWER_KW) return Boolean.TRUE;
|
||||
if (Boolean.TRUE.equals(parked)
|
||||
&& socRoseAtMs > 0L && nowMs - socRoseAtMs < SOC_RISE_LATCH_MS) {
|
||||
return Boolean.TRUE;
|
||||
}
|
||||
// Nothing overrode the port: pass its reading through, including its null.
|
||||
return portConnected;
|
||||
}
|
||||
|
||||
private void trackSoc(long nowMs, Boolean parked, Integer socPercent) {
|
||||
if (!Boolean.TRUE.equals(parked)) {
|
||||
// Regen while driving also raises SOC. Keep the latch clear so parking right
|
||||
// after a downhill run does not read as charging.
|
||||
socRoseAtMs = 0L;
|
||||
lastSocPercent = socPercent;
|
||||
return;
|
||||
}
|
||||
if (socPercent == null) return; // keep the previous reading to compare against
|
||||
if (lastSocPercent != null) {
|
||||
if (socPercent > lastSocPercent) socRoseAtMs = nowMs;
|
||||
else if (socPercent < lastSocPercent) socRoseAtMs = 0L; // draining, not charging
|
||||
}
|
||||
lastSocPercent = socPercent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
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) + "…";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,965 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.drawable.ColorDrawable;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.Button;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.Spinner;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.viewpager2.widget.ViewPager2;
|
||||
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.google.android.material.button.MaterialButton;
|
||||
import com.google.android.material.textfield.TextInputEditText;
|
||||
import com.google.android.material.textfield.TextInputLayout;
|
||||
import com.evsuite.hardware.EVHardware;
|
||||
import com.evsuite.hardware.FirmwareInfo;
|
||||
import com.evsuite.hardware.telemetry.EnergySnapshot;
|
||||
import com.evsuite.hardware.telemetry.EnergyTelemetryReader;
|
||||
|
||||
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private static final int LOCATION_PERMISSION_REQUEST = 100;
|
||||
/** Shared with {@link AbrpUploadService}, which reads it on every tick. */
|
||||
static final String WIFI_AUTO_KEY = "wifi_auto_connect";
|
||||
private static final String REPOSITORY_URL = "https://github.com/fatihdonmezdev/EVABRP";
|
||||
|
||||
/** Top-bar tabs, in page order. Parallel to {@link #panes}. */
|
||||
private static final int[] TAB_IDS =
|
||||
{ R.id.tabAbrp, R.id.tabService, R.id.tabVehicle, R.id.tabWifi, R.id.tabLog };
|
||||
|
||||
// Status colours come from the palette, not from the Material swatches: #4CAF50 and
|
||||
// #F44336 sit around 4:1 on this background, which disappears behind a sunlit
|
||||
// reflection. The palette entries are the lightened variants (7:1 and above).
|
||||
private static final int COLOR_OK = 0xFF8FE6A6;
|
||||
private static final int COLOR_ERROR = 0xFFFF9A9A;
|
||||
private static final int COLOR_PENDING = 0xFFC6CFD8;
|
||||
|
||||
private TextInputLayout apiKeyLayout;
|
||||
private TextInputEditText apiKeyInput;
|
||||
private TextInputLayout tokenLayout;
|
||||
private TextInputEditText tokenInput;
|
||||
private SwitchMaterial serviceSwitch;
|
||||
private SwitchMaterial autostartSwitch;
|
||||
private TextView statusText;
|
||||
private Button testButton;
|
||||
private View connectionStatusRow;
|
||||
private View connectionIndicator;
|
||||
private TextView connectionStatusText;
|
||||
private Spinner intervalSpinner;
|
||||
private SwitchMaterial boostSwitch;
|
||||
private TextInputEditText lowSocInput;
|
||||
private TextInputLayout lowSocLayout;
|
||||
private TextView callLogText;
|
||||
private View abrpPane;
|
||||
private View servicePane;
|
||||
private View logPane;
|
||||
private View vehiclePane;
|
||||
private View wifiPane;
|
||||
|
||||
/** The five pages, in the order the top bar lists them. Parallel to {@link #TAB_IDS}. */
|
||||
private View[] panes;
|
||||
|
||||
/** Wi-Fi page. The helper is the same one the upload service uses on its own tick. */
|
||||
private WifiAutoConnect wifi;
|
||||
private TextView wifiSsid, wifiState, wifiSavedList;
|
||||
|
||||
/**
|
||||
* Value cells of the Vehicle page, looked up once and refreshed on the UI tick.
|
||||
*
|
||||
* Read straight from {@link EnergyTelemetryReader} rather than from the uploader: the
|
||||
* page shows what the car reports, whether or not the upload service is running, and a
|
||||
* signal the vehicle does not publish must show a dash rather than a zero.
|
||||
*/
|
||||
private EnergyTelemetryReader vehicleReader;
|
||||
private TextView vehSoc, vehRange, vehPower, vehSpeed, vehOdometer,
|
||||
vehCharging, vehBattTemp, vehExtTemp, vehCabinTemp, vehHvac,
|
||||
vehCapacity, vehSoe,
|
||||
vehTireFl, vehTireFr, vehTireRl, vehTireRr;
|
||||
|
||||
/**
|
||||
* Inflates a page with no parent, then gives it the MATCH_PARENT/MATCH_PARENT layout
|
||||
* params ViewPager2 requires of its items — inflating detached leaves them null, and
|
||||
* the pager throws rather than guessing.
|
||||
*/
|
||||
private View inflatePane(int layoutRes) {
|
||||
View pane = getLayoutInflater().inflate(layoutRes, null, false);
|
||||
pane.setLayoutParams(new android.view.ViewGroup.LayoutParams(
|
||||
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
android.view.ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
return pane;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires the top bar to the pager: every tab is a page, and every page is a tab.
|
||||
*
|
||||
* Each page has its own view type, so the pager asks for it once and keeps it: these
|
||||
* three views are the activity's own, held in {@link #panes}, not rows to be recycled.
|
||||
*/
|
||||
private void setUpPager() {
|
||||
ViewPager2 pager = findViewById(R.id.content);
|
||||
pager.setAdapter(new RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
@Override public int getItemCount() { return panes.length; }
|
||||
@Override public int getItemViewType(int position) { return position; }
|
||||
|
||||
@NonNull @Override
|
||||
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull android.view.ViewGroup parent,
|
||||
int viewType) {
|
||||
return new RecyclerView.ViewHolder(panes[viewType]) {};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
|
||||
// Nothing to bind: the pages are built once in onCreate and keep their own
|
||||
// state, exactly as they did when they were siblings in the layout.
|
||||
}
|
||||
});
|
||||
// All four stay alive. They are cheap, and the service switch, the call log and
|
||||
// the vehicle readings are refreshed by the tick whether or not their page is on screen.
|
||||
pager.setOffscreenPageLimit(4);
|
||||
pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
|
||||
@Override public void onPageSelected(int position) { markCurrentPage(position); }
|
||||
});
|
||||
|
||||
for (int i = 0; i < TAB_IDS.length; i++) {
|
||||
final int index = i;
|
||||
// Animated, so the button does the same thing the swipe does: the direction of
|
||||
// travel is what tells the driver where they are in the row.
|
||||
findViewById(TAB_IDS[i]).setOnClickListener(v -> pager.setCurrentItem(index, true));
|
||||
}
|
||||
markCurrentPage(pager.getCurrentItem());
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the top-bar tab of the page on screen.
|
||||
*
|
||||
* Only isSelected is set: fill, text, icon and stroke come from the
|
||||
* res/color/nav_tab_*.xml selectors applied by the Widget.EV.NavTab style. isSelected
|
||||
* also serves TalkBack, which announces the current destination.
|
||||
*/
|
||||
private void markCurrentPage(int position) {
|
||||
for (int i = 0; i < TAB_IDS.length; i++) {
|
||||
findViewById(TAB_IDS[i]).setSelected(i == position);
|
||||
}
|
||||
}
|
||||
|
||||
/** Refreshes state + call log while the screen is visible. */
|
||||
private final android.os.Handler uiHandler = new android.os.Handler(android.os.Looper.getMainLooper());
|
||||
private final Runnable uiRefresh = new Runnable() {
|
||||
@Override public void run() {
|
||||
refreshStatus();
|
||||
refreshCallLog();
|
||||
refreshVehicle();
|
||||
refreshWifi();
|
||||
uiHandler.postDelayed(this, 2_000L);
|
||||
}
|
||||
};
|
||||
|
||||
private SharedPreferences prefs;
|
||||
/** Credentials only — encrypted at rest, see {@link SecurePrefs}. */
|
||||
private SharedPreferences securePrefs;
|
||||
|
||||
/**
|
||||
* Picks an ABRP config text file. Only reachable on devices that ship a document picker —
|
||||
* the MG4 head unit does not, which is why {@link #startConfigImport()} scans the
|
||||
* app-specific folders first and only falls back to this.
|
||||
*/
|
||||
private final ActivityResultLauncher<String[]> configPicker =
|
||||
registerForActivityResult(new ActivityResultContracts.OpenDocument(), uri -> {
|
||||
if (uri != null) importConfig(uri);
|
||||
});
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_main);
|
||||
|
||||
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
|
||||
securePrefs = SecurePrefs.get(this);
|
||||
|
||||
// The three pages are inflated here, once, and handed to the pager as fixed,
|
||||
// non-recycled items. That is what lets every widget below be looked up now and
|
||||
// held for the life of the activity, the way it was when the panes were siblings
|
||||
// in activity_main.xml — a recycled page would invalidate these references as
|
||||
// soon as the user swiped away from it.
|
||||
abrpPane = inflatePane(R.layout.pane_abrp);
|
||||
servicePane = inflatePane(R.layout.pane_service);
|
||||
vehiclePane = inflatePane(R.layout.pane_vehicle);
|
||||
wifiPane = inflatePane(R.layout.pane_wifi);
|
||||
logPane = inflatePane(R.layout.pane_log);
|
||||
panes = new View[] { abrpPane, servicePane, vehiclePane, wifiPane, logPane };
|
||||
|
||||
bindVehiclePane();
|
||||
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);
|
||||
autostartSwitch = servicePane.findViewById(R.id.autostart_switch);
|
||||
statusText = servicePane.findViewById(R.id.status_text);
|
||||
testButton = abrpPane.findViewById(R.id.test_button);
|
||||
connectionStatusRow = abrpPane.findViewById(R.id.connection_status_row);
|
||||
connectionIndicator = abrpPane.findViewById(R.id.connection_indicator);
|
||||
connectionStatusText = abrpPane.findViewById(R.id.connection_status_text);
|
||||
intervalSpinner = servicePane.findViewById(R.id.interval_spinner);
|
||||
boostSwitch = servicePane.findViewById(R.id.boost_switch);
|
||||
lowSocInput = servicePane.findViewById(R.id.low_soc_input);
|
||||
lowSocLayout = servicePane.findViewById(R.id.low_soc_layout);
|
||||
callLogText = logPane.findViewById(R.id.call_log_text);
|
||||
|
||||
setUpPager();
|
||||
findViewById(R.id.about_button).setOnClickListener(v -> showAbout());
|
||||
|
||||
bindCadenceControls();
|
||||
|
||||
// Unstable builds check for a newer pre-release; the stable flavor's UpdateHook is
|
||||
// a no-op and does not even contain the updater.
|
||||
UpdateHook.checkInBackground(this);
|
||||
|
||||
apiKeyInput.setText(savedApiKeyOrDefault());
|
||||
tokenInput.setText(securePrefs.getString(SecurePrefs.KEY_TOKEN, ""));
|
||||
serviceSwitch.setChecked(prefs.getBoolean("service_enabled", false));
|
||||
|
||||
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
|
||||
// is a no-op aside from delivering a new intent. We don't trust
|
||||
// service_running as a sole indicator because it can be stale-true if a
|
||||
// previous process was force-killed (e.g. by `adb install -r`) without
|
||||
// onDestroy running.
|
||||
boolean enabled = prefs.getBoolean("service_enabled", false);
|
||||
boolean haveCreds = !securePrefs.getString(SecurePrefs.KEY_TOKEN, "").trim().isEmpty()
|
||||
&& !securePrefs.getString(SecurePrefs.KEY_API_KEY, "").trim().isEmpty();
|
||||
if (enabled && 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 (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();
|
||||
});
|
||||
}
|
||||
|
||||
private void showAbout() {
|
||||
String version;
|
||||
try {
|
||||
version = getPackageManager().getPackageInfo(getPackageName(), 0).versionName;
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
version = getString(R.string.about_version_unknown);
|
||||
}
|
||||
View content = getLayoutInflater().inflate(R.layout.dialog_about, null);
|
||||
content.<TextView>findViewById(R.id.about_version)
|
||||
.setText(getString(R.string.about_version, version));
|
||||
ImageView qr = content.findViewById(R.id.about_qr_code);
|
||||
android.graphics.Bitmap bitmap = QrCode.generate(REPOSITORY_URL, 416);
|
||||
if (bitmap != null) qr.setImageBitmap(bitmap);
|
||||
content.findViewById(R.id.about_repository).setOnClickListener(v ->
|
||||
startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(REPOSITORY_URL))));
|
||||
androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(this)
|
||||
.setView(content)
|
||||
.create();
|
||||
content.<MaterialButton>findViewById(R.id.about_close).setOnClickListener(v -> dialog.dismiss());
|
||||
dialog.show();
|
||||
if (dialog.getWindow() != null) {
|
||||
dialog.getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
apiKeyInput.setText(savedApiKeyOrDefault());
|
||||
tokenInput.setText(securePrefs.getString(SecurePrefs.KEY_TOKEN, ""));
|
||||
serviceSwitch.setChecked(prefs.getBoolean("service_enabled", false));
|
||||
autostartSwitch.setChecked(
|
||||
prefs.getBoolean(UploadSettings.KEY_AUTOSTART, UploadSettings.DEFAULT_AUTOSTART));
|
||||
// Poll state and log only while the screen is up; onPause cancels it.
|
||||
uiHandler.post(uiRefresh);
|
||||
}
|
||||
|
||||
// ---------- Credentials ----------
|
||||
|
||||
/**
|
||||
* The SAIC open-source gateway publishes this shared ABRP key for compatible clients.
|
||||
* A user-supplied key saved in encrypted preferences always takes precedence.
|
||||
*/
|
||||
private String savedApiKeyOrDefault() {
|
||||
String savedApiKey = securePrefs.getString(SecurePrefs.KEY_API_KEY, "");
|
||||
return savedApiKey == null || savedApiKey.trim().isEmpty()
|
||||
? getString(R.string.default_abrp_api_key)
|
||||
: savedApiKey;
|
||||
}
|
||||
|
||||
private void saveCredentials() {
|
||||
String apiKey = textOf(apiKeyInput);
|
||||
String token = textOf(tokenInput);
|
||||
boolean valid = true;
|
||||
|
||||
if (apiKey.isEmpty()) {
|
||||
apiKeyLayout.setError(getString(R.string.api_key_required));
|
||||
valid = false;
|
||||
} else {
|
||||
apiKeyLayout.setError(null);
|
||||
}
|
||||
if (token.isEmpty()) {
|
||||
tokenLayout.setError(getString(R.string.token_required));
|
||||
valid = false;
|
||||
} else {
|
||||
tokenLayout.setError(null);
|
||||
}
|
||||
if (!valid) return;
|
||||
|
||||
securePrefs.edit()
|
||||
.putString(SecurePrefs.KEY_API_KEY, apiKey)
|
||||
.putString(SecurePrefs.KEY_TOKEN, token)
|
||||
.apply();
|
||||
|
||||
if (serviceSwitch.isChecked()) {
|
||||
startForegroundService(new Intent(this, AbrpUploadService.class));
|
||||
}
|
||||
refreshStatus();
|
||||
}
|
||||
|
||||
// ---------- Config import ----------
|
||||
|
||||
/**
|
||||
* The MG4 head unit ships no document picker — SAF answers "FileManagement is no supported
|
||||
* on this device" — so the file is found by scanning instead. getExternalFilesDirs() returns
|
||||
* this app's own folder on internal storage and on every mounted volume including a USB
|
||||
* stick, and those need no storage permission at any API level. The picker stays as the
|
||||
* fallback for phones and tablets, where the user may keep the file anywhere.
|
||||
*/
|
||||
private void startConfigImport() {
|
||||
java.io.File found = findConfigFile();
|
||||
if (found != null) {
|
||||
importConfig(found);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Any MIME: the MG4 Files app tags .txt inconsistently, so filtering by type hides
|
||||
// the very file the user is trying to pick. They select it by name instead.
|
||||
configPicker.launch(new String[]{"*/*"});
|
||||
} catch (android.content.ActivityNotFoundException e) {
|
||||
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_not_found, configDirHint()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First readable file in an app-specific folder that parses as a config. Names are not
|
||||
* filtered: the user copies one file to the folder, whatever they called it.
|
||||
*/
|
||||
private java.io.File findConfigFile() {
|
||||
for (java.io.File dir : getExternalFilesDirs(null)) {
|
||||
if (dir == null) continue;
|
||||
java.io.File[] files = dir.listFiles();
|
||||
if (files == null) continue;
|
||||
for (java.io.File f : files) {
|
||||
if (f.isFile() && f.canRead() && f.length() > 0 && f.length() <= 64 * 1024
|
||||
&& !ConfigImport.parse(readText(f)).isEmpty()) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** File text, or "" when unreadable — callers treat that as "not a config". */
|
||||
private String readText(java.io.File file) {
|
||||
try (java.io.InputStream in = new java.io.FileInputStream(file)) {
|
||||
return readCapped(in);
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap the read so a wrong file (a huge binary picked by mistake) can't be slurped whole
|
||||
* into memory before we find out it isn't a config.
|
||||
*/
|
||||
private String readCapped(java.io.InputStream in) throws java.io.IOException {
|
||||
java.io.ByteArrayOutputStream buf = new java.io.ByteArrayOutputStream();
|
||||
byte[] chunk = new byte[4096];
|
||||
int n;
|
||||
while ((n = in.read(chunk)) != -1 && buf.size() < 64 * 1024) buf.write(chunk, 0, n);
|
||||
return buf.toString(java.nio.charset.StandardCharsets.UTF_8.name());
|
||||
}
|
||||
|
||||
/** Where the user should drop the file, shown when nothing was found. */
|
||||
private String configDirHint() {
|
||||
java.io.File[] dirs = getExternalFilesDirs(null);
|
||||
return dirs.length > 0 && dirs[0] != null ? dirs[0].getAbsolutePath() : "";
|
||||
}
|
||||
|
||||
private void importConfig(java.io.File file) {
|
||||
String text = readText(file);
|
||||
if (text.isEmpty()) {
|
||||
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_read));
|
||||
return;
|
||||
}
|
||||
applyConfig(text);
|
||||
}
|
||||
|
||||
private void importConfig(android.net.Uri uri) {
|
||||
String text;
|
||||
try (java.io.InputStream in = getContentResolver().openInputStream(uri)) {
|
||||
if (in == null) throw new java.io.IOException("no stream");
|
||||
text = readCapped(in);
|
||||
} catch (Exception e) {
|
||||
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_read));
|
||||
return;
|
||||
}
|
||||
applyConfig(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies whatever the config text sets. Credentials go straight to the encrypted store;
|
||||
* cadence keys go to the plain prefs the same way the manual controls write them. Absent
|
||||
* keys are left untouched, so a file carrying only the credentials does not wipe a cadence
|
||||
* the user already tuned.
|
||||
*/
|
||||
private void applyConfig(String text) {
|
||||
ConfigImport config = ConfigImport.parse(text);
|
||||
if (config.isEmpty()) {
|
||||
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_empty));
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.apiKey != null) {
|
||||
securePrefs.edit().putString(SecurePrefs.KEY_API_KEY, config.apiKey).apply();
|
||||
}
|
||||
if (config.token != null) {
|
||||
securePrefs.edit().putString(SecurePrefs.KEY_TOKEN, config.token).apply();
|
||||
}
|
||||
SharedPreferences.Editor edit = prefs.edit();
|
||||
if (config.intervalSec != null) {
|
||||
edit.putInt(UploadSettings.KEY_INTERVAL_SEC, config.intervalSec);
|
||||
}
|
||||
if (config.boostLowSoc != null) {
|
||||
edit.putBoolean(UploadSettings.KEY_BOOST_LOW_SOC, config.boostLowSoc);
|
||||
}
|
||||
if (config.lowSocPercent != null) {
|
||||
edit.putInt(UploadSettings.KEY_LOW_SOC_PERCENT, config.lowSocPercent);
|
||||
}
|
||||
edit.apply();
|
||||
|
||||
// Re-read every control from prefs so the screen shows what was imported.
|
||||
apiKeyInput.setText(savedApiKeyOrDefault());
|
||||
tokenInput.setText(securePrefs.getString(SecurePrefs.KEY_TOKEN, ""));
|
||||
apiKeyLayout.setError(null);
|
||||
tokenLayout.setError(null);
|
||||
bindCadenceControls();
|
||||
AbrpUploadService.reloadSettings();
|
||||
|
||||
if (serviceSwitch.isChecked()) {
|
||||
startForegroundService(new Intent(this, AbrpUploadService.class));
|
||||
}
|
||||
setConnectionStatus(COLOR_OK, getString(R.string.import_ok));
|
||||
refreshStatus();
|
||||
}
|
||||
|
||||
// ---------- Connection test ----------
|
||||
|
||||
private void testConnection() {
|
||||
String apiKey = textOf(apiKeyInput);
|
||||
String token = textOf(tokenInput);
|
||||
|
||||
if (apiKey.isEmpty() || token.isEmpty()) {
|
||||
if (apiKey.isEmpty()) apiKeyLayout.setError(getString(R.string.api_key_required));
|
||||
if (token.isEmpty()) tokenLayout.setError(getString(R.string.token_required));
|
||||
return;
|
||||
}
|
||||
|
||||
setConnectionStatus(COLOR_PENDING, getString(R.string.conn_testing));
|
||||
testButton.setEnabled(false);
|
||||
|
||||
new Thread(() -> {
|
||||
String result = pingAbrp(apiKey, token);
|
||||
runOnUiThread(() -> {
|
||||
testButton.setEnabled(true);
|
||||
if (result == null) {
|
||||
setConnectionStatus(COLOR_OK, getString(R.string.conn_ok));
|
||||
} else {
|
||||
setConnectionStatus(COLOR_ERROR, result);
|
||||
}
|
||||
});
|
||||
}).start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a minimal test request to the ABRP API.
|
||||
* Returns null on success, or a short error string on failure.
|
||||
*/
|
||||
private String pingAbrp(String apiKey, String token) {
|
||||
try {
|
||||
// Read-only check. The previous version POSTed {"utc":...,"soc":0} to the LIVE
|
||||
// /tlm/send endpoint, i.e. told ABRP the car was at 0% every time the user
|
||||
// pressed Test, wrecking the route plan it had computed.
|
||||
AbrpApi.Response response = AbrpApi.verifyCredentials(apiKey, token);
|
||||
|
||||
if (response.code == 200) return null;
|
||||
if (response.code == 401) return getString(R.string.conn_err_auth);
|
||||
return getString(R.string.conn_err_http, response.code);
|
||||
|
||||
} catch (java.net.UnknownHostException e) {
|
||||
return getString(R.string.conn_err_no_internet);
|
||||
} catch (java.net.SocketTimeoutException e) {
|
||||
return getString(R.string.conn_err_timeout);
|
||||
} catch (Exception e) {
|
||||
return e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
super.onPause();
|
||||
// Stop polling off-screen: this activity has no reason to spend cycles then.
|
||||
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, android.R.layout.simple_spinner_item, labels);
|
||||
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||
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();
|
||||
}
|
||||
|
||||
// ---------- Wi-Fi page ----------
|
||||
|
||||
private void bindWifiPane() {
|
||||
wifi = new WifiAutoConnect(getApplicationContext());
|
||||
wifiSsid = wifiPane.findViewById(R.id.wifi_ssid);
|
||||
wifiState = wifiPane.findViewById(R.id.wifi_state);
|
||||
wifiSavedList = wifiPane.findViewById(R.id.wifi_saved_list);
|
||||
|
||||
SwitchMaterial auto = wifiPane.findViewById(R.id.wifi_auto_switch);
|
||||
auto.setChecked(prefs.getBoolean(WIFI_AUTO_KEY, true));
|
||||
auto.setOnCheckedChangeListener((btn, checked) ->
|
||||
prefs.edit().putBoolean(WIFI_AUTO_KEY, checked).apply());
|
||||
|
||||
wifiPane.findViewById(R.id.wifi_connect_button).setOnClickListener(v -> {
|
||||
// force: the driver is asking now, so the rate limit does not apply.
|
||||
WifiAutoConnect.Result r = wifi.ensureConnected(true);
|
||||
wifiState.setText(r.message);
|
||||
refreshWifi();
|
||||
});
|
||||
}
|
||||
|
||||
private void refreshWifi() {
|
||||
if (wifi == null) return;
|
||||
String ssid = wifi.currentSsid();
|
||||
wifiSsid.setText(ssid == null ? getString(R.string.wifi_not_connected) : ssid);
|
||||
if (ssid != null) {
|
||||
wifiState.setText("Bağlı ♥");
|
||||
} else if (!wifi.isWifiEnabled()) {
|
||||
wifiState.setText("Wi-Fi kapalı");
|
||||
}
|
||||
// Left alone otherwise: a message the button just set says more about what is
|
||||
// happening than "not connected" would.
|
||||
|
||||
java.util.List<String> saved = wifi.savedNetworkNames();
|
||||
wifiSavedList.setText(saved.isEmpty()
|
||||
? getString(R.string.wifi_saved_empty)
|
||||
: android.text.TextUtils.join("\n", saved));
|
||||
}
|
||||
|
||||
// ---------- Vehicle page ----------
|
||||
|
||||
/** Looks up the value cells once; the labels are static and never touched from code. */
|
||||
private void bindVehiclePane() {
|
||||
vehSoc = vehiclePane.findViewById(R.id.vehicle_soc);
|
||||
vehRange = vehiclePane.findViewById(R.id.vehicle_range);
|
||||
vehPower = vehiclePane.findViewById(R.id.vehicle_power);
|
||||
vehSpeed = vehiclePane.findViewById(R.id.vehicle_speed);
|
||||
vehOdometer = vehiclePane.findViewById(R.id.vehicle_odometer);
|
||||
vehCharging = vehiclePane.findViewById(R.id.vehicle_charging);
|
||||
vehBattTemp = vehiclePane.findViewById(R.id.vehicle_batt_temp);
|
||||
vehExtTemp = vehiclePane.findViewById(R.id.vehicle_ext_temp);
|
||||
vehCabinTemp = vehiclePane.findViewById(R.id.vehicle_cabin_temp);
|
||||
vehHvac = vehiclePane.findViewById(R.id.vehicle_hvac);
|
||||
vehCapacity = vehiclePane.findViewById(R.id.vehicle_capacity);
|
||||
vehSoe = vehiclePane.findViewById(R.id.vehicle_soe);
|
||||
vehTireFl = vehiclePane.findViewById(R.id.vehicle_tire_fl);
|
||||
vehTireFr = vehiclePane.findViewById(R.id.vehicle_tire_fr);
|
||||
vehTireRl = vehiclePane.findViewById(R.id.vehicle_tire_rl);
|
||||
vehTireRr = vehiclePane.findViewById(R.id.vehicle_tire_rr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one snapshot and writes it to the page.
|
||||
*
|
||||
* The reader is built lazily and kept: constructing it binds the vendor services, which
|
||||
* is not work to repeat every two seconds. A read that throws leaves the cells as they
|
||||
* were rather than blanking the page — a transient miss is not news worth showing.
|
||||
*/
|
||||
private void refreshVehicle() {
|
||||
try {
|
||||
if (vehicleReader == null) {
|
||||
vehicleReader = new EnergyTelemetryReader(getApplicationContext());
|
||||
}
|
||||
// The timestamp is passed explicitly: its Kotlin default is not visible from Java.
|
||||
EnergySnapshot s = vehicleReader.read(System.currentTimeMillis());
|
||||
|
||||
vehSoc.setText(fmt(s.getSocPercent(), "%.0f %%"));
|
||||
vehRange.setText(fmt(s.getRangeKm(), "%.0f km"));
|
||||
vehPower.setText(fmt(s.getBatteryPowerKw(), "%.1f kW"));
|
||||
vehSpeed.setText(fmt(s.getSpeedKmh(), "%.0f km/h"));
|
||||
vehOdometer.setText(fmt(s.getOdometerKm(), "%.0f km"));
|
||||
vehBattTemp.setText(fmt(s.getBatteryTempCelsius(), "%.0f °C"));
|
||||
vehExtTemp.setText(fmt(s.getOutsideTempCelsius(), "%.0f °C"));
|
||||
vehCabinTemp.setText(fmt(s.getCabinTempCelsius(), "%.0f °C"));
|
||||
vehHvac.setText(fmt(s.getClimate().getDriverTargetCelsius(), "%.0f °C"));
|
||||
vehCapacity.setText(fmt(s.getBatteryCapacityKwh(), "%.1f kWh"));
|
||||
vehSoe.setText(fmt(s.getBatteryEnergyKwh(), "%.1f kWh"));
|
||||
|
||||
vehCharging.setText(chargingLabel(s));
|
||||
|
||||
vehTireFl.setText(fmt(s.getTirePressures().getFrontLeftKpa(), "%.0f kPa"));
|
||||
vehTireFr.setText(fmt(s.getTirePressures().getFrontRightKpa(), "%.0f kPa"));
|
||||
vehTireRl.setText(fmt(s.getTirePressures().getRearLeftKpa(), "%.0f kPa"));
|
||||
vehTireRr.setText(fmt(s.getTirePressures().getRearRightKpa(), "%.0f kPa"));
|
||||
} catch (Throwable ignored) {
|
||||
// Leave the last good reading on screen.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The charging row: the session state when the car reports one, the port otherwise.
|
||||
*
|
||||
* The codes are the vehicle's own — see EVHardware.PROP_VENDOR_CHARGE_STATUS. An
|
||||
* unrecognised code is shown as itself rather than guessed at.
|
||||
*/
|
||||
private static String chargingLabel(EnergySnapshot s) {
|
||||
Integer status = s.getChargingStatus();
|
||||
if (status != null) {
|
||||
switch (status) {
|
||||
case 1: return "AC şarj ♥";
|
||||
case 10: return "DC hızlı şarj ♥";
|
||||
case 5: return "Bağlanıyor…";
|
||||
case 7: return "Kabloda, şarj yok";
|
||||
case 8: return "Durduruldu";
|
||||
case 0: return "Şarj yok";
|
||||
default: return "Durum " + status;
|
||||
}
|
||||
}
|
||||
Boolean plugged = s.getChargePortConnected();
|
||||
if (plugged != null) return plugged ? "Bağlı ♥" : "Bağlı değil";
|
||||
return "—";
|
||||
}
|
||||
|
||||
/** A dash for a signal the car does not publish — never a zero. See pane_vehicle.xml. */
|
||||
private static String fmt(Float value, String pattern) {
|
||||
if (value == null || value.isNaN() || value.isInfinite()) return "—";
|
||||
return String.format(java.util.Locale.US, pattern, value);
|
||||
}
|
||||
|
||||
// ---------- Call log ----------
|
||||
|
||||
private void refreshCallLog() {
|
||||
java.util.List<UploadLog.Entry> entries = AbrpUploadService.log().recent();
|
||||
if (entries.isEmpty()) {
|
||||
callLogText.setText(R.string.call_log_empty);
|
||||
return;
|
||||
}
|
||||
java.text.SimpleDateFormat format =
|
||||
new java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.US);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (UploadLog.Entry entry : entries) {
|
||||
if (sb.length() > 0) sb.append('\n');
|
||||
sb.append(entry.success ? "OK " : "ERR ")
|
||||
.append(format.format(new java.util.Date(entry.timestampMs)))
|
||||
.append(" ")
|
||||
// httpStatus 0 means the request never reached a server.
|
||||
.append(entry.httpStatus > 0 ? String.valueOf(entry.httpStatus) : "---")
|
||||
.append(" ")
|
||||
.append(entry.detail);
|
||||
// What the payload held, under the attempt it belongs to. This is the only view
|
||||
// of it the driver has: the car runs no adb, so "the field is 0 on ABRP" and
|
||||
// "the app never sent the field" look identical from the outside without it.
|
||||
if (entry.summary != null && !entry.summary.isEmpty()) {
|
||||
sb.append("\n ").append(entry.summary);
|
||||
}
|
||||
}
|
||||
callLogText.setText(sb.toString());
|
||||
}
|
||||
|
||||
// ---------- VHAL probe ----------
|
||||
|
||||
/**
|
||||
* Wires the "VHAL Probe" button that dumps the vehicle's own telemetry property
|
||||
* surface into the in-app log. The head unit runs no adb, so the screen's existing
|
||||
* log view is the only channel a diagnostic can reach the driver through.
|
||||
*
|
||||
* Present in every build, not just debug: identifying the SWI69 property ids needs to
|
||||
* happen on a production APK on the car, and EVHardware's probe is read-only, so it is
|
||||
* safe to ship. It deliberately holds no device credentials. Keep it until the
|
||||
* per-generation property map for SWI69 is confirmed, then gate it on debug.
|
||||
*/
|
||||
private void wireProbeIfDebug() {
|
||||
View probeButton = abrpPane.findViewById(R.id.probe_button);
|
||||
if (probeButton == null) return;
|
||||
probeButton.setVisibility(View.VISIBLE);
|
||||
probeButton.setOnClickListener(v -> runProbe());
|
||||
}
|
||||
|
||||
private void runProbe() {
|
||||
// Binder reads are slow enough to be worth keeping off the UI thread; the result
|
||||
// is recorded through the upload log, which the screen already renders.
|
||||
new Thread(() -> {
|
||||
try {
|
||||
EVHardware.INSTANCE.init(getApplicationContext());
|
||||
java.util.List<EVHardware.PropertyReport> reports = EVHardware.INSTANCE.probeTelemetryProperties();
|
||||
String firmware = String.valueOf(FirmwareInfo.INSTANCE.getGeneration());
|
||||
StringBuilder sb = new StringBuilder("VHAL probe on ").append(firmware).append('\n');
|
||||
for (EVHardware.PropertyReport r : reports) sb.append(" ").append(r).append('\n');
|
||||
sb.append("TOTAL ").append(reports.size()).append(" properties probed");
|
||||
runOnUiThread(() -> AbrpUploadService.log().record(
|
||||
new UploadLog.Entry(System.currentTimeMillis(), 200, true, "VHAL Probe", sb.toString())));
|
||||
} catch (Throwable t) {
|
||||
String msg = "Probe failed: " + (t.getMessage() != null ? t.getMessage() : t.getClass().getSimpleName());
|
||||
runOnUiThread(() -> AbrpUploadService.log().record(
|
||||
new UploadLog.Entry(System.currentTimeMillis(), 0, false, "VHAL Probe", msg)));
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void setConnectionStatus(int color, String message) {
|
||||
connectionStatusRow.setVisibility(View.VISIBLE);
|
||||
connectionIndicator.getBackground().setTint(color);
|
||||
connectionStatusText.setText(message);
|
||||
connectionStatusText.setTextColor(color);
|
||||
}
|
||||
|
||||
// ---------- Service status ----------
|
||||
|
||||
private void refreshStatus() {
|
||||
// Live signal, not the preference: "service_running" stays stale-true after a
|
||||
// force-kill because onDestroy never ran.
|
||||
UploadLog.State state = AbrpUploadService.state();
|
||||
|
||||
switch (state) {
|
||||
case ERROR:
|
||||
// Derived from the log, not from the last status alone: a single failed
|
||||
// upload is normal (tunnel, dead spot), three in a row is a problem.
|
||||
statusText.setText(getString(R.string.state_error,
|
||||
AbrpUploadService.log().consecutiveFailures()));
|
||||
statusText.setTextColor(COLOR_ERROR);
|
||||
break;
|
||||
case RUNNING:
|
||||
String lastTime = prefs.getString("last_upload_time", null);
|
||||
statusText.setText(lastTime != null
|
||||
? getString(R.string.status_last_upload, lastTime,
|
||||
getString(R.string.status_ok))
|
||||
: getString(R.string.state_running));
|
||||
statusText.setTextColor(COLOR_OK);
|
||||
break;
|
||||
case STARTING:
|
||||
statusText.setText(R.string.state_starting);
|
||||
statusText.setTextColor(COLOR_OK);
|
||||
break;
|
||||
case STOPPED:
|
||||
default:
|
||||
statusText.setText(R.string.state_stopped);
|
||||
statusText.setTextColor(COLOR_PENDING);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
private String textOf(TextInputEditText field) {
|
||||
CharSequence text = field.getText();
|
||||
return text != null ? text.toString().trim() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissions that need a runtime grant (vs being granted at install time
|
||||
* via the platform signature). CAR_SPEED and CAR_ENERGY are dangerous-level
|
||||
* AAOS permissions — without these, the corresponding property reads throw
|
||||
* SecurityException and we have no SOC / speed data.
|
||||
*/
|
||||
private static final String[] RUNTIME_PERMISSIONS = {
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
"android.car.permission.CAR_SPEED",
|
||||
"android.car.permission.CAR_ENERGY",
|
||||
"android.car.permission.CAR_ENERGY_PORTS",
|
||||
// Outside temperature and cabin temperature: declared and read since 2.1.0, but
|
||||
// never asked for, so both came back unreadable and were dropped from every
|
||||
// payload on a car that would have answered them.
|
||||
"android.car.permission.CAR_EXTERIOR_ENVIRONMENT",
|
||||
"android.car.permission.CONTROL_CAR_CLIMATE",
|
||||
};
|
||||
|
||||
/**
|
||||
* Asked for alongside the telemetry permissions from API 33, and separately because a
|
||||
* denial costs something different: the foreground notification is the only status this
|
||||
* service has, and without this it is dropped silently while the service runs on.
|
||||
*/
|
||||
private static String[] runtimePermissions() {
|
||||
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||
return RUNTIME_PERMISSIONS;
|
||||
}
|
||||
String[] all = java.util.Arrays.copyOf(RUNTIME_PERMISSIONS, RUNTIME_PERMISSIONS.length + 1);
|
||||
all[RUNTIME_PERMISSIONS.length] = Manifest.permission.POST_NOTIFICATIONS;
|
||||
return all;
|
||||
}
|
||||
|
||||
private void requestLocationPermissionIfNeeded() {
|
||||
java.util.List<String> missing = new java.util.ArrayList<>();
|
||||
for (String p : runtimePermissions()) {
|
||||
if (ContextCompat.checkSelfPermission(this, p)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
missing.add(p);
|
||||
}
|
||||
}
|
||||
if (!missing.isEmpty()) {
|
||||
ActivityCompat.requestPermissions(this,
|
||||
missing.toArray(new String[0]),
|
||||
LOCATION_PERMISSION_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A denied permission used to be silent: the service kept running and simply uploaded
|
||||
* nothing useful, with the UI claiming everything was fine. Say what was denied and
|
||||
* what it costs.
|
||||
*/
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
|
||||
@NonNull int[] grantResults) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
if (requestCode != LOCATION_PERMISSION_REQUEST) return;
|
||||
|
||||
boolean locationDenied = false;
|
||||
boolean carDataDenied = false;
|
||||
for (int i = 0; i < permissions.length && i < grantResults.length; i++) {
|
||||
if (grantResults[i] == PackageManager.PERMISSION_GRANTED) continue;
|
||||
if (Manifest.permission.ACCESS_FINE_LOCATION.equals(permissions[i])) {
|
||||
locationDenied = true;
|
||||
} else {
|
||||
carDataDenied = true;
|
||||
}
|
||||
}
|
||||
|
||||
// A grant that arrives after the service is already up changes which foreground
|
||||
// types it may hold, and which properties it may read. Starting it again is what
|
||||
// applies both without waiting for the next boot.
|
||||
if (prefs.getBoolean("service_enabled", false)) {
|
||||
startForegroundService(new Intent(this, AbrpUploadService.class));
|
||||
}
|
||||
|
||||
if (locationDenied && carDataDenied) {
|
||||
setConnectionStatus(COLOR_ERROR, getString(R.string.perm_denied_all));
|
||||
} else if (locationDenied) {
|
||||
setConnectionStatus(COLOR_ERROR, getString(R.string.perm_denied_location));
|
||||
} else if (carDataDenied) {
|
||||
setConnectionStatus(COLOR_ERROR, getString(R.string.perm_denied_car));
|
||||
}
|
||||
refreshStatus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Color;
|
||||
|
||||
import com.google.zxing.BarcodeFormat;
|
||||
import com.google.zxing.EncodeHintType;
|
||||
import com.google.zxing.MultiFormatWriter;
|
||||
import com.google.zxing.common.BitMatrix;
|
||||
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
|
||||
/** Generates the repository QR locally; no network capability is involved. */
|
||||
final class QrCode {
|
||||
private QrCode() {}
|
||||
|
||||
static Bitmap generate(String content, int sizePx) {
|
||||
try {
|
||||
Map<EncodeHintType, Object> hints = new EnumMap<>(EncodeHintType.class);
|
||||
hints.put(EncodeHintType.MARGIN, 1);
|
||||
hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
|
||||
BitMatrix matrix = new MultiFormatWriter().encode(
|
||||
content, BarcodeFormat.QR_CODE, sizePx, sizePx, hints);
|
||||
Bitmap bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.RGB_565);
|
||||
for (int x = 0; x < sizePx; x++) {
|
||||
for (int y = 0; y < sizePx; y++) {
|
||||
bitmap.setPixel(x, y, matrix.get(x, y) ? Color.BLACK : Color.WHITE);
|
||||
}
|
||||
}
|
||||
return bitmap;
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
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 extTemp; // °C, outside
|
||||
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);
|
||||
// Outside temp gets the same plausibility guard as the other temperatures:
|
||||
// a VHAL that does not implement ENV_OUTSIDE_TEMPERATURE answers 0.0, and
|
||||
// ABRP treats "0 °C outside" as a real reading that costs range in its plan.
|
||||
// A genuine 0 °C is lost with it, which is the cheaper of the two errors.
|
||||
if (isPlausibleTemp(extTemp)) {
|
||||
tlm.put("ext_temp", Math.round(extTemp));
|
||||
}
|
||||
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", "ext_temp", "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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Deque;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Rolling record of the last {@link #MAX_ENTRIES} upload attempts, and the service health
|
||||
* derived from them.
|
||||
*
|
||||
* Process-local on purpose: this is a diagnostic view, not persisted state. Written from
|
||||
* the scheduler thread and read from the UI thread, so every access is synchronized.
|
||||
*/
|
||||
final class UploadLog {
|
||||
|
||||
static final int MAX_ENTRIES = 20;
|
||||
|
||||
/** Consecutive failures that flip the service into the error state. */
|
||||
static final int FAILURES_FOR_ERROR = 3;
|
||||
|
||||
enum State {
|
||||
/** Service is off. */
|
||||
STOPPED,
|
||||
/** Running, no upload attempted yet. */
|
||||
STARTING,
|
||||
/** Running and the recent uploads are going through. */
|
||||
RUNNING,
|
||||
/** Running but the last {@link #FAILURES_FOR_ERROR} attempts failed. */
|
||||
ERROR
|
||||
}
|
||||
|
||||
/** One upload attempt. */
|
||||
static final class Entry {
|
||||
final long timestampMs;
|
||||
/** HTTP status, or 0 when the request never got a response (offline, timeout). */
|
||||
final int httpStatus;
|
||||
final boolean success;
|
||||
/** Short human-readable reason, e.g. "OK", "No internet", "HTTP 401". */
|
||||
final String detail;
|
||||
/**
|
||||
* What the payload actually contained — see TelemetryPayload.summarize(). Null for
|
||||
* an attempt recorded before a payload existed.
|
||||
*
|
||||
* This is the whole point of the log for anything other than "is it uploading":
|
||||
* a value that looks wrong on the ABRP side is either a field this app never sent,
|
||||
* or one it sent with the wrong number in it, and nothing else distinguishes them.
|
||||
*/
|
||||
final String summary;
|
||||
|
||||
Entry(long timestampMs, int httpStatus, boolean success, String detail) {
|
||||
this(timestampMs, httpStatus, success, detail, null);
|
||||
}
|
||||
|
||||
Entry(long timestampMs, int httpStatus, boolean success, String detail, String summary) {
|
||||
this.timestampMs = timestampMs;
|
||||
this.httpStatus = httpStatus;
|
||||
this.success = success;
|
||||
this.detail = detail;
|
||||
this.summary = summary;
|
||||
}
|
||||
}
|
||||
|
||||
private final Deque<Entry> entries = new ArrayDeque<>(MAX_ENTRIES);
|
||||
private final Object lock = new Object();
|
||||
|
||||
void record(Entry entry) {
|
||||
synchronized (lock) {
|
||||
while (entries.size() >= MAX_ENTRIES) entries.removeFirst();
|
||||
entries.addLast(entry);
|
||||
}
|
||||
}
|
||||
|
||||
void clear() {
|
||||
synchronized (lock) { entries.clear(); }
|
||||
}
|
||||
|
||||
/** Most recent first — that is the order the UI shows them in. */
|
||||
List<Entry> recent() {
|
||||
synchronized (lock) {
|
||||
List<Entry> copy = new ArrayList<>(entries);
|
||||
java.util.Collections.reverse(copy);
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
|
||||
Entry latest() {
|
||||
synchronized (lock) {
|
||||
return entries.isEmpty() ? null : entries.peekLast();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of failures at the end of the log. Reset by any success, so a single
|
||||
* recovered upload clears the error state.
|
||||
*/
|
||||
int consecutiveFailures() {
|
||||
synchronized (lock) {
|
||||
int count = 0;
|
||||
java.util.Iterator<Entry> it = entries.descendingIterator();
|
||||
while (it.hasNext()) {
|
||||
if (it.next().success) break;
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Service state.
|
||||
*
|
||||
* Needs [running] from the service itself: an empty log means "not started yet" while
|
||||
* running, but means nothing at all when stopped.
|
||||
*/
|
||||
State state(boolean running) {
|
||||
if (!running) return State.STOPPED;
|
||||
synchronized (lock) {
|
||||
if (entries.isEmpty()) return State.STARTING;
|
||||
}
|
||||
return consecutiveFailures() >= FAILURES_FOR_ERROR ? State.ERROR : State.RUNNING;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
/**
|
||||
* User-configurable upload policy.
|
||||
*
|
||||
* Defaults are deliberately conservative. The head unit is awake whenever the car is on,
|
||||
* and both the upload tick and the GPS subscription cost power the whole time, so the
|
||||
* default cadence is the slowest one that still keeps an ABRP route plan usable.
|
||||
*/
|
||||
final class UploadSettings {
|
||||
|
||||
static final String KEY_INTERVAL_SEC = "upload_interval_sec";
|
||||
static final String KEY_BOOST_LOW_SOC = "boost_low_soc";
|
||||
static final String KEY_LOW_SOC_PERCENT = "low_soc_percent";
|
||||
static final String KEY_AUTOSTART = "autostart";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
UploadSettings(int intervalSec, boolean boostLowSoc, int lowSocPercent) {
|
||||
this.intervalSec = intervalSec;
|
||||
this.boostLowSoc = boostLowSoc;
|
||||
this.lowSocPercent = lowSocPercent;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
static UploadSettings defaults() {
|
||||
return new UploadSettings(
|
||||
DEFAULT_INTERVAL_SEC, DEFAULT_BOOST_LOW_SOC, DEFAULT_LOW_SOC_PERCENT);
|
||||
}
|
||||
|
||||
long intervalMs() {
|
||||
return intervalSec * 1000L;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.wifi.WifiConfiguration;
|
||||
import android.net.wifi.WifiInfo;
|
||||
import android.net.wifi.WifiManager;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Brings Wi-Fi up and joins a network the head unit already knows.
|
||||
*
|
||||
* The point is the phone hotspot: the car has the credentials saved, but the driver still
|
||||
* has to walk into Settings and tap the network every time. This does that tap.
|
||||
*
|
||||
* <b>Why this works here and would not on a newer phone.</b> {@code getConfiguredNetworks}
|
||||
* and {@code enableNetwork} were deprecated in API 29 and, from that release, return an
|
||||
* empty list and refuse the call for ordinary apps — Android handed network choice to the
|
||||
* user through a system picker. This head unit runs Android 9 (API 28), where both are
|
||||
* still honoured, and the build is platform-signed, so it is the same UID family the
|
||||
* Settings app itself uses. Neither fact is portable: on a Pixel this class would find
|
||||
* nothing and change nothing. It is deliberately written to fail quietly if so.
|
||||
*
|
||||
* Nothing here creates or edits a network. It only enables one the unit was already
|
||||
* configured with, which is why no password ever passes through this app.
|
||||
*/
|
||||
final class WifiAutoConnect {
|
||||
|
||||
private static final String TAG = "EVABRP.Wifi";
|
||||
|
||||
/** Re-checking faster than this achieves nothing: a join takes seconds to settle. */
|
||||
private static final long MIN_ATTEMPT_INTERVAL_MS = 30_000L;
|
||||
|
||||
private final WifiManager wifi;
|
||||
private long lastAttemptMs;
|
||||
|
||||
WifiAutoConnect(Context context) {
|
||||
this.wifi = (WifiManager) context.getApplicationContext()
|
||||
.getSystemService(Context.WIFI_SERVICE);
|
||||
}
|
||||
|
||||
/** The SSID the unit is associated with, or null when it is not on Wi-Fi. */
|
||||
String currentSsid() {
|
||||
try {
|
||||
if (wifi == null || !wifi.isWifiEnabled()) return null;
|
||||
WifiInfo info = wifi.getConnectionInfo();
|
||||
if (info == null) return null;
|
||||
String ssid = info.getSSID();
|
||||
if (ssid == null) return null;
|
||||
// The framework quotes the SSID and reports this placeholder when it will not
|
||||
// say — usually because location is off. Neither is a network name.
|
||||
ssid = ssid.replace("\"", "").trim();
|
||||
if (ssid.isEmpty() || "<unknown ssid>".equals(ssid) || "0x".equals(ssid)) return null;
|
||||
return ssid;
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
boolean isWifiEnabled() {
|
||||
try {
|
||||
return wifi != null && wifi.isWifiEnabled();
|
||||
} catch (Throwable t) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Saved network names, for the UI to show what it can reach. Empty if the OS refuses. */
|
||||
List<String> savedNetworkNames() {
|
||||
java.util.List<String> names = new java.util.ArrayList<>();
|
||||
try {
|
||||
if (wifi == null) return names;
|
||||
List<WifiConfiguration> saved = wifi.getConfiguredNetworks();
|
||||
if (saved == null) return names;
|
||||
for (WifiConfiguration c : saved) {
|
||||
if (c == null || c.SSID == null) continue;
|
||||
String name = c.SSID.replace("\"", "").trim();
|
||||
if (!name.isEmpty() && !names.contains(name)) names.add(name);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "getConfiguredNetworks refused: " + t.getMessage());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* One attempt to get onto a saved network, rate-limited.
|
||||
*
|
||||
* @param force ignore the rate limit — for the button, which a driver only presses
|
||||
* because the automatic attempt has not worked yet.
|
||||
* @return what happened, for the log and the UI. Never null.
|
||||
*/
|
||||
Result ensureConnected(boolean force) {
|
||||
if (wifi == null) return Result.UNSUPPORTED;
|
||||
|
||||
String ssid = currentSsid();
|
||||
if (ssid != null) return Result.alreadyOn(ssid);
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if (!force && now - lastAttemptMs < MIN_ATTEMPT_INTERVAL_MS) return Result.WAITING;
|
||||
lastAttemptMs = now;
|
||||
|
||||
try {
|
||||
if (!wifi.isWifiEnabled()) {
|
||||
// Also gone for ordinary apps from API 29, and also still honoured here.
|
||||
if (!wifi.setWifiEnabled(true)) return Result.CANNOT_ENABLE;
|
||||
// The radio needs a moment before it will associate; the next tick retries.
|
||||
return Result.ENABLING;
|
||||
}
|
||||
|
||||
List<WifiConfiguration> saved = wifi.getConfiguredNetworks();
|
||||
if (saved == null || saved.isEmpty()) return Result.NO_SAVED_NETWORKS;
|
||||
|
||||
// A scan would let us prefer a network that is actually in range, but the results
|
||||
// are themselves throttled and location-gated. Asking the supplicant to pick from
|
||||
// what it knows is both simpler and what the Settings screen effectively does.
|
||||
wifi.reconnect();
|
||||
for (WifiConfiguration c : saved) {
|
||||
if (c == null) continue;
|
||||
// Second argument true: disable the others for this attempt, so the
|
||||
// supplicant commits to this one rather than drifting between them.
|
||||
if (wifi.enableNetwork(c.networkId, true)) {
|
||||
String name = c.SSID == null ? "?" : c.SSID.replace("\"", "").trim();
|
||||
Log.i(TAG, "enableNetwork accepted for " + name);
|
||||
return Result.attempting(name);
|
||||
}
|
||||
}
|
||||
return Result.REFUSED;
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "connect attempt failed: " + t.getMessage());
|
||||
return Result.REFUSED;
|
||||
}
|
||||
}
|
||||
|
||||
/** Outcome of one attempt. {@link #connected} is the only one that means we are online. */
|
||||
static final class Result {
|
||||
final boolean connected;
|
||||
final String ssid;
|
||||
final String message;
|
||||
|
||||
private Result(boolean connected, String ssid, String message) {
|
||||
this.connected = connected;
|
||||
this.ssid = ssid;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
static Result alreadyOn(String ssid) { return new Result(true, ssid, "Bağlı: " + ssid); }
|
||||
static Result attempting(String ssid) { return new Result(false, ssid, "Bağlanılıyor: " + ssid); }
|
||||
|
||||
static final Result UNSUPPORTED = new Result(false, null, "Wi-Fi yönetilemiyor");
|
||||
static final Result WAITING = new Result(false, null, "Bekleniyor…");
|
||||
static final Result ENABLING = new Result(false, null, "Wi-Fi açılıyor…");
|
||||
static final Result CANNOT_ENABLE = new Result(false, null, "Wi-Fi açılamadı");
|
||||
static final Result NO_SAVED_NETWORKS = new Result(false, null, "Kayıtlı ağ yok");
|
||||
static final Result REFUSED = new Result(false, null, "Sistem izin vermedi");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Nav tab background. The state comes from View.isSelected, set by
|
||||
MainActivity.markCurrentPage on the one tab whose page is on screen: no color is
|
||||
applied by hand in code. -->
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_selected="true" android:color="@color/ev_accent" />
|
||||
<item android:color="@color/ev_surface_raised" />
|
||||
</selector>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Nav tab text + icon. The selected tab sits on @color/ev_accent, so its foreground
|
||||
is ev_on_accent; unselected tabs use ev_text_primary. Both pairs clear 7:1. -->
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_selected="true" android:color="@color/ev_on_accent" />
|
||||
<item android:color="@color/ev_text_primary" />
|
||||
</selector>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Nav tab outline. The selected tab keeps a stroke the same color as its fill so its
|
||||
size doesn't jump a dp when the page changes. -->
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_selected="true" android:color="@color/ev_accent" />
|
||||
<item android:color="@color/ev_outline" />
|
||||
</selector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@color/ev_icon_tile" />
|
||||
<corners android:radius="@dimen/ev_corner" />
|
||||
</shape>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Top bar surface + a 2dp accent underline, so the bar reads as a bar even against
|
||||
a light page background. -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/ev_surface" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:gravity="bottom" android:height="2dp">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/ev_accent" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#888888" />
|
||||
</shape>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by tools/icon-gen.mjs — do not hand-edit. See DESIGN.md § Iconography. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<path
|
||||
android:strokeColor="#F4F7FA"
|
||||
android:strokeWidth="5"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M30,62 L43,49 L54,55 L74,35" />
|
||||
<path
|
||||
android:strokeColor="#F4F7FA"
|
||||
android:strokeWidth="5"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M65,35 L74,35 L74,44" />
|
||||
<path
|
||||
android:fillColor="#E0424C"
|
||||
android:pathData="M74,35m-5,0a5,5 0 1,0 10,0a5,5 0 1,0 -10,0" />
|
||||
<path
|
||||
android:strokeColor="#C6CFD8"
|
||||
android:strokeWidth="1.5"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M 40.96 79 L 43.42 72.44 L 45.88 79 M 41.89 76.54 L 44.96 76.54 M 48.01 72.44 L 48.01 79 M 48.01 72.44 L 50.8 72.44 C 52.61 72.44 52.61 75.56 50.8 75.56 L 48.01 75.56 M 48.01 75.88 L 50.97 75.88 C 52.93 75.88 52.93 79 50.97 79 L 48.01 79 M 55.07 79 L 55.07 72.44 L 57.85 72.44 C 59.82 72.44 59.82 75.88 57.85 75.88 L 55.07 75.88 M 57.2 75.88 L 59.99 79 M 62.12 79 L 62.12 72.44 L 64.91 72.44 C 66.87 72.44 66.87 75.88 64.91 75.88 L 62.12 75.88" />
|
||||
</vector>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by tools/icon-gen.mjs — do not hand-edit. See DESIGN.md § Iconography. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<path
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeWidth="5"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M30,62 L43,49 L54,55 L74,35" />
|
||||
<path
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeWidth="5"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M65,35 L74,35 L74,44" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M74,35m-5,0a5,5 0 1,0 10,0a5,5 0 1,0 -10,0" />
|
||||
<path
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeWidth="1.5"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M 40.96 79 L 43.42 72.44 L 45.88 79 M 41.89 76.54 L 44.96 76.54 M 48.01 72.44 L 48.01 79 M 48.01 72.44 L 50.8 72.44 C 52.61 72.44 52.61 75.56 50.8 75.56 L 48.01 75.56 M 48.01 75.88 L 50.97 75.88 C 52.93 75.88 52.93 79 50.97 79 L 48.01 79 M 55.07 79 L 55.07 72.44 L 57.85 72.44 C 59.82 72.44 59.82 75.88 57.85 75.88 L 55.07 75.88 M 57.2 75.88 L 59.99 79 M 62.12 79 L 62.12 72.44 L 64.91 72.44 C 66.87 72.44 66.87 75.88 64.91 75.88 L 62.12 75.88" />
|
||||
</vector>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Terminal : onglet Log. 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:fillColor="#00000000"
|
||||
android:pathData="M3,5H21V19H3Z" />
|
||||
<path
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M6,9l4,3l-4,3" />
|
||||
<path
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M12,16h6" />
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Engrenage : onglet Service. 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:fillColor="#FFFFFFFF"
|
||||
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Car, for the Vehicle page. Filled so it reads at a glance on a vehicle screen. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M18.92,6.01C18.72,5.42 18.16,5 17.5,5h-11c-0.66,0 -1.21,0.42 -1.42,1.01L3,12v8c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-1h12v1c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-8l-2.08,-5.99zM6.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5S5.67,13 6.5,13 8,13.67 8,14.5 7.33,16 6.5,16zM17.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5zM5,11l1.5,-4.5h11L19,11H5z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Wi-Fi arcs, for the Wi-Fi page. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M1,9l2,2c4.97,-4.97 13.03,-4.97 18,0l2,-2C16.93,2.93 7.08,2.93 1,9zM9,17l3,3 3,-3c-1.65,-1.66 -4.34,-1.66 -6,0zM5,13l2,2c2.76,-2.76 7.24,-2.76 10,0l2,-2C15.14,9.14 8.87,9.14 5,13z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,112 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
A fixed top bar above a single ViewPager2, same shape as EVProfile and EVTasker.
|
||||
|
||||
The three screens — ABRP, Service, Log — are the pages of that one pager, in the
|
||||
order the bar lists them. Each button goes to its page and a horizontal swipe walks
|
||||
through them all: the gesture and the button name the same thing. The pages
|
||||
themselves live in pane_abrp.xml / pane_service.xml / pane_log.xml, because a
|
||||
ViewPager2 takes its children from an adapter rather than from this file.
|
||||
|
||||
Button navigation rather than Material tabs: on a vehicle screen, always-visible
|
||||
64 dp targets beat a thin tab bar. The tabs share the width left by the title and
|
||||
the About button equally (weight 1), so the row can be aimed at from memory whatever
|
||||
the translated labels are.
|
||||
|
||||
No MaterialButtonToggleGroup: the group owns a checked state of its own, which would
|
||||
fight the pager for who decides the current screen. Here the pager decides alone and
|
||||
MainActivity.markCurrentPage posts isSelected — the fill, text, icon and stroke come
|
||||
from the res/color/nav_tab_*.xml selectors.
|
||||
-->
|
||||
<LinearLayout 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:orientation="vertical"
|
||||
android:background="@color/ev_background">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="80dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:baselineAligned="false"
|
||||
android:paddingStart="@dimen/spacing_md"
|
||||
android:paddingEnd="@dimen/spacing_md"
|
||||
android:background="@drawable/bg_topbar">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/app_name"
|
||||
android:textAppearance="@style/Text.EV.Title"
|
||||
android:layout_marginEnd="@dimen/spacing_lg" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/tabAbrp"
|
||||
style="@style/Widget.EV.NavTab"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="64dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="@dimen/spacing_sm"
|
||||
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="0dp"
|
||||
android:layout_height="64dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="@dimen/spacing_sm"
|
||||
android:text="@string/tab_service"
|
||||
app:icon="@drawable/ic_tab_service" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/tabVehicle"
|
||||
style="@style/Widget.EV.NavTab"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="64dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="@dimen/spacing_sm"
|
||||
android:text="@string/tab_vehicle"
|
||||
app:icon="@drawable/ic_tab_vehicle" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/tabWifi"
|
||||
style="@style/Widget.EV.NavTab"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="64dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="@dimen/spacing_sm"
|
||||
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="0dp"
|
||||
android:layout_height="64dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="@dimen/spacing_lg"
|
||||
android:text="@string/tab_log"
|
||||
app:icon="@drawable/ic_tab_log" />
|
||||
|
||||
<!-- About opens a dialog, not a page: it stays a plain button so it cannot be
|
||||
mistaken for a fourth destination of the pager. -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/about_button"
|
||||
style="@style/Widget.EV.Button.Outlined"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/about_action" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.viewpager2.widget.ViewPager2
|
||||
android:id="@+id/content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,142 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="760dp"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardBackgroundColor="@color/ev_surface"
|
||||
app:cardCornerRadius="@dimen/ev_corner"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeColor="@color/ev_outline"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="@dimen/spacing_lg"
|
||||
android:paddingTop="@dimen/spacing_md"
|
||||
android:paddingEnd="@dimen/spacing_lg"
|
||||
android:paddingBottom="@dimen/spacing_md"
|
||||
android:text="@string/about_title"
|
||||
android:textAppearance="@style/Text.EV.SectionHeader" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="@color/ev_outline" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:baselineAligned="false"
|
||||
android:orientation="horizontal"
|
||||
android:padding="@dimen/spacing_lg">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginEnd="@dimen/spacing_lg"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="128dp"
|
||||
android:layout_height="128dp"
|
||||
android:background="@drawable/about_logo_tile"
|
||||
android:contentDescription="@string/about_logo_description"
|
||||
android:padding="@dimen/spacing_sm"
|
||||
android:src="@drawable/ic_launcher_foreground" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/spacing_md"
|
||||
android:text="@string/app_name"
|
||||
android:textAppearance="@style/Text.EV.Title" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/about_version"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/spacing_xs"
|
||||
android:textAppearance="@style/Text.EV.Secondary" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/spacing_md"
|
||||
android:lineSpacingMultiplier="1.25"
|
||||
android:text="@string/about_attribution"
|
||||
android:textAppearance="@style/Text.EV.Body" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="1dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginEnd="@dimen/spacing_lg"
|
||||
android:background="@color/ev_outline" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="264dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/about_qr_code"
|
||||
android:layout_width="208dp"
|
||||
android:layout_height="208dp"
|
||||
android:background="#FFFFFFFF"
|
||||
android:contentDescription="@string/about_qr_description"
|
||||
android:padding="@dimen/spacing_sm"
|
||||
android:scaleType="fitCenter" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/about_repository"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/touch_target"
|
||||
android:layout_marginTop="@dimen/spacing_sm"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:text="@string/about_repository"
|
||||
android:textAppearance="@style/Text.EV.Secondary"
|
||||
android:textColor="@color/ev_accent" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="@color/ev_outline" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:padding="@dimen/spacing_md">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/about_made_with_love"
|
||||
android:textAppearance="@style/Text.EV.Body" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/about_close"
|
||||
style="@style/Widget.EV.Button.Outlined"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="@dimen/touch_target"
|
||||
android:text="@string/about_close" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
@@ -0,0 +1,153 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- ABRP page of the main pager. Inflated once by MainActivity and handed to the pager
|
||||
adapter as a fixed, non-recycled page, so the widget lookups below it stay valid for
|
||||
the whole life of the activity. -->
|
||||
<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">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/spacing_lg">
|
||||
|
||||
<!-- Configuration card -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardBackgroundColor="@color/ev_surface"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:strokeColor="@color/ev_outline"
|
||||
app:strokeWidth="1dp"
|
||||
app:cardElevation="3dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/spacing_lg">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="20dp"
|
||||
android:text="@string/card_config"
|
||||
android:textAppearance="@style/Text.EV.Title" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/api_key_layout"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:hint="@string/hint_api_key"
|
||||
app:endIconContentDescription="@string/desc_toggle_api_key"
|
||||
app:endIconMode="password_toggle"
|
||||
app:endIconMinSize="@dimen/touch_target"
|
||||
app:helperText="@string/helper_api_key"
|
||||
app:helperTextTextAppearance="@style/Text.EV.Secondary">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/api_key_input"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword"
|
||||
android:singleLine="true"
|
||||
android:textSize="@dimen/text_body" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/token_layout"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/hint_token"
|
||||
app:endIconContentDescription="@string/desc_toggle_token"
|
||||
app:endIconMode="password_toggle"
|
||||
app:endIconMinSize="@dimen/touch_target"
|
||||
app:helperText="@string/helper_token"
|
||||
app:helperTextTextAppearance="@style/Text.EV.Secondary">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/token_input"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword"
|
||||
android:singleLine="true"
|
||||
android:textSize="@dimen/text_body" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:gravity="end"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/import_button"
|
||||
style="@style/Widget.EV.Button.Outlined"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:text="@string/btn_import" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/test_button"
|
||||
style="@style/Widget.EV.Button.Outlined"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:text="@string/btn_test" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/save_button"
|
||||
style="@style/Widget.EV.Button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/btn_save" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/probe_button"
|
||||
style="@style/Widget.EV.Button.Outlined"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="VHAL Probe" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/connection_status_row"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:visibility="gone">
|
||||
|
||||
<View
|
||||
android:id="@+id/connection_indicator"
|
||||
android:layout_width="14dp"
|
||||
android:layout_height="14dp"
|
||||
android:layout_marginEnd="10dp"
|
||||
android:background="@drawable/circle_indicator" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/connection_status_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="@style/Text.EV.Body" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Log page of the main pager: status, time and reason for the last 20 upload calls.
|
||||
See pane_abrp.xml for how the pages are held. -->
|
||||
<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">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/spacing_lg">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardBackgroundColor="@color/ev_surface"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:strokeColor="@color/ev_outline"
|
||||
app:strokeWidth="1dp"
|
||||
app:cardElevation="3dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/spacing_md">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/label_call_log"
|
||||
android:textAppearance="@style/Text.EV.Title" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/call_log_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/spacing_sm"
|
||||
android:fontFamily="monospace"
|
||||
android:textSize="@dimen/text_secondary"
|
||||
android:textColor="@color/ev_text_secondary"
|
||||
android:text="@string/call_log_empty" />
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -0,0 +1,161 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Service page of the main pager. See pane_abrp.xml for how the pages are held. -->
|
||||
<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">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/spacing_lg">
|
||||
|
||||
<!-- Service control card -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardBackgroundColor="@color/ev_surface"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:strokeColor="@color/ev_outline"
|
||||
app:strokeWidth="1dp"
|
||||
app:cardElevation="3dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/spacing_lg">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="20dp"
|
||||
android:text="@string/card_service"
|
||||
android:textAppearance="@style/Text.EV.Title" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:minHeight="@dimen/touch_target">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/label_upload_enabled"
|
||||
android:textAppearance="@style/Text.EV.Body" />
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/service_switch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Redémarrage automatique. Personne n'ouvre cette application avant
|
||||
de partir : si l'envoi ne repart pas tout seul quand la voiture
|
||||
s'allume, il ne repart pas du tout. -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:minHeight="@dimen/touch_target">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/label_autostart"
|
||||
android:textAppearance="@style/Text.EV.Body" />
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/autostart_switch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/hint_autostart"
|
||||
android:textAppearance="@style/Text.EV.Secondary" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/status_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/status_stopped"
|
||||
android:textAppearance="@style/Text.EV.Body" />
|
||||
|
||||
<!-- Cadence. Defaults are conservative: the head unit is awake whenever
|
||||
the car is on, and both the upload tick and the GPS subscription
|
||||
cost power for as long as it is. -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:text="@string/label_frequency"
|
||||
android:textAppearance="@style/Text.EV.Body" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/interval_spinner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="@dimen/touch_target" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/hint_frequency"
|
||||
android:textAppearance="@style/Text.EV.Secondary" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:minHeight="@dimen/touch_target">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/label_boost_low_soc"
|
||||
android:textAppearance="@style/Text.EV.Body" />
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/boost_switch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/low_soc_layout"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/label_low_soc_threshold">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/low_soc_input"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number"
|
||||
android:maxLength="2" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -0,0 +1,173 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
The Vehicle page: every signal this build can read from the car, in the order a
|
||||
driver glances at them.
|
||||
|
||||
The rows are static views rather than a RecyclerView on purpose — the set is fixed
|
||||
and known at build time, and MainActivity holds the value TextViews the same way it
|
||||
holds every other widget on the other pages. A row whose signal the car does not
|
||||
publish shows a dash, never a zero: on this vehicle a zero from an unimplemented
|
||||
VHAL property is indistinguishable from a reading, and "0 °C" or "0 kWh" is worse
|
||||
than an honest blank.
|
||||
-->
|
||||
<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">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/spacing_lg">
|
||||
|
||||
<!-- Live vehicle data -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="@dimen/spacing_md"
|
||||
app:cardBackgroundColor="@color/ev_surface"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:strokeColor="@color/ev_outline"
|
||||
app:strokeWidth="1dp"
|
||||
app:cardElevation="3dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/spacing_lg">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="@dimen/spacing_md"
|
||||
android:text="@string/vehicle_live_data"
|
||||
android:textAppearance="@style/Text.EV.Title" />
|
||||
|
||||
<TableLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:stretchColumns="0">
|
||||
|
||||
<TableRow style="@style/DataRow">
|
||||
<TextView style="@style/DataLabel" android:text="@string/vehicle_row_soc" />
|
||||
<TextView android:id="@+id/vehicle_soc" style="@style/DataValue" />
|
||||
</TableRow>
|
||||
|
||||
<TableRow style="@style/DataRow">
|
||||
<TextView style="@style/DataLabel" android:text="@string/vehicle_row_range" />
|
||||
<TextView android:id="@+id/vehicle_range" style="@style/DataValue" />
|
||||
</TableRow>
|
||||
|
||||
<TableRow style="@style/DataRow">
|
||||
<TextView style="@style/DataLabel" android:text="@string/vehicle_row_power" />
|
||||
<TextView android:id="@+id/vehicle_power" style="@style/DataValue" />
|
||||
</TableRow>
|
||||
|
||||
<TableRow style="@style/DataRow">
|
||||
<TextView style="@style/DataLabel" android:text="@string/vehicle_row_speed" />
|
||||
<TextView android:id="@+id/vehicle_speed" style="@style/DataValue" />
|
||||
</TableRow>
|
||||
|
||||
<TableRow style="@style/DataRow">
|
||||
<TextView style="@style/DataLabel" android:text="@string/vehicle_row_odometer" />
|
||||
<TextView android:id="@+id/vehicle_odometer" style="@style/DataValue" />
|
||||
</TableRow>
|
||||
|
||||
<TableRow style="@style/DataRow">
|
||||
<TextView style="@style/DataLabel" android:text="@string/vehicle_row_charging" />
|
||||
<TextView android:id="@+id/vehicle_charging" style="@style/DataValue" />
|
||||
</TableRow>
|
||||
|
||||
<TableRow style="@style/DataRow">
|
||||
<TextView style="@style/DataLabel" android:text="@string/vehicle_row_batt_temp" />
|
||||
<TextView android:id="@+id/vehicle_batt_temp" style="@style/DataValue" />
|
||||
</TableRow>
|
||||
|
||||
<TableRow style="@style/DataRow">
|
||||
<TextView style="@style/DataLabel" android:text="@string/vehicle_row_ext_temp" />
|
||||
<TextView android:id="@+id/vehicle_ext_temp" style="@style/DataValue" />
|
||||
</TableRow>
|
||||
|
||||
<TableRow style="@style/DataRow">
|
||||
<TextView style="@style/DataLabel" android:text="@string/vehicle_row_cabin_temp" />
|
||||
<TextView android:id="@+id/vehicle_cabin_temp" style="@style/DataValue" />
|
||||
</TableRow>
|
||||
|
||||
<TableRow style="@style/DataRow">
|
||||
<TextView style="@style/DataLabel" android:text="@string/vehicle_row_hvac" />
|
||||
<TextView android:id="@+id/vehicle_hvac" style="@style/DataValue" />
|
||||
</TableRow>
|
||||
|
||||
<TableRow style="@style/DataRow">
|
||||
<TextView style="@style/DataLabel" android:text="@string/vehicle_row_capacity" />
|
||||
<TextView android:id="@+id/vehicle_capacity" style="@style/DataValue" />
|
||||
</TableRow>
|
||||
|
||||
<TableRow style="@style/DataRow">
|
||||
<TextView style="@style/DataLabel" android:text="@string/vehicle_row_soe" />
|
||||
<TextView android:id="@+id/vehicle_soe" style="@style/DataValue" />
|
||||
</TableRow>
|
||||
|
||||
</TableLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Tyre pressures -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardBackgroundColor="@color/ev_surface"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:strokeColor="@color/ev_outline"
|
||||
app:strokeWidth="1dp"
|
||||
app:cardElevation="3dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/spacing_lg">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="@dimen/spacing_md"
|
||||
android:text="@string/vehicle_tires"
|
||||
android:textAppearance="@style/Text.EV.Title" />
|
||||
|
||||
<TableLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:stretchColumns="0">
|
||||
|
||||
<TableRow style="@style/DataRow">
|
||||
<TextView style="@style/DataLabel" android:text="@string/vehicle_tire_fl" />
|
||||
<TextView android:id="@+id/vehicle_tire_fl" style="@style/DataValue" />
|
||||
</TableRow>
|
||||
|
||||
<TableRow style="@style/DataRow">
|
||||
<TextView style="@style/DataLabel" android:text="@string/vehicle_tire_fr" />
|
||||
<TextView android:id="@+id/vehicle_tire_fr" style="@style/DataValue" />
|
||||
</TableRow>
|
||||
|
||||
<TableRow style="@style/DataRow">
|
||||
<TextView style="@style/DataLabel" android:text="@string/vehicle_tire_rl" />
|
||||
<TextView android:id="@+id/vehicle_tire_rl" style="@style/DataValue" />
|
||||
</TableRow>
|
||||
|
||||
<TableRow style="@style/DataRow">
|
||||
<TextView style="@style/DataLabel" android:text="@string/vehicle_tire_rr" />
|
||||
<TextView android:id="@+id/vehicle_tire_rr" style="@style/DataValue" />
|
||||
</TableRow>
|
||||
|
||||
</TableLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -0,0 +1,122 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
The Wi-Fi page: what the unit is on, a button to join without a trip into Settings,
|
||||
and the list of networks it already has saved.
|
||||
|
||||
The button exists alongside the automatic attempt because the automatic one is
|
||||
rate-limited and only runs while the upload service is up. A driver who has just
|
||||
switched their hotspot on wants it now, not on the next tick.
|
||||
-->
|
||||
<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">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/spacing_lg">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="@dimen/spacing_md"
|
||||
app:cardBackgroundColor="@color/ev_surface"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:strokeColor="@color/ev_outline"
|
||||
app:strokeWidth="1dp"
|
||||
app:cardElevation="3dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/spacing_lg">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="@dimen/spacing_md"
|
||||
android:text="@string/wifi_card_title"
|
||||
android:textAppearance="@style/Text.EV.Title" />
|
||||
|
||||
<TableLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:stretchColumns="0">
|
||||
|
||||
<TableRow style="@style/DataRow">
|
||||
<TextView style="@style/DataLabel" android:text="@string/wifi_current" />
|
||||
<TextView android:id="@+id/wifi_ssid" style="@style/DataValue" />
|
||||
</TableRow>
|
||||
|
||||
<TableRow style="@style/DataRow">
|
||||
<TextView style="@style/DataLabel" android:text="@string/wifi_state" />
|
||||
<TextView android:id="@+id/wifi_state" style="@style/DataValue" />
|
||||
</TableRow>
|
||||
|
||||
</TableLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/wifi_connect_button"
|
||||
style="@style/Widget.EV.Button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/spacing_md"
|
||||
android:text="@string/wifi_connect_now" />
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/wifi_auto_switch"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/spacing_md"
|
||||
android:minHeight="@dimen/touch_target"
|
||||
android:text="@string/wifi_auto_label"
|
||||
android:textAppearance="@style/Text.EV.Body" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/spacing_xs"
|
||||
android:text="@string/wifi_auto_hint"
|
||||
android:textAppearance="@style/Text.EV.Secondary" />
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardBackgroundColor="@color/ev_surface"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:strokeColor="@color/ev_outline"
|
||||
app:strokeWidth="1dp"
|
||||
app:cardElevation="3dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/spacing_lg">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="@dimen/spacing_sm"
|
||||
android:text="@string/wifi_saved_title"
|
||||
android:textAppearance="@style/Text.EV.Title" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/wifi_saved_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="@style/Text.EV.Body"
|
||||
android:lineSpacingExtra="4dp" />
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ev_icon_tile" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- EVSuite tokens - DARK theme. See DESIGN.md section Colors.
|
||||
Generated by tools/sync-tokens.mjs and identical in all six apps.
|
||||
|
||||
Dark remains the mode that matters in a vehicle: a light background filling the
|
||||
windscreen at night is glare. The suite moved to DayNight by explicit decision,
|
||||
which does not cancel that constraint - it is the light palette that had to rise
|
||||
to the same 7:1 floor, not the other way round.
|
||||
|
||||
Values not redefined here fall back to values/, which is why ev_icon_tile is
|
||||
declared once, in the light file. -->
|
||||
<resources>
|
||||
<color name="ev_background">#FF0B0D0F</color>
|
||||
<color name="ev_surface">#FF15181C</color>
|
||||
<color name="ev_surface_raised">#FF272C33</color>
|
||||
<!-- 4.70:1 on ev_surface. The previous #4A525B sat at 2.25:1: a border below
|
||||
the 3:1 non-text floor is a border that is not there. -->
|
||||
<color name="ev_outline">#FF7A8492</color>
|
||||
<color name="ev_text_primary">#FFF4F7FA</color>
|
||||
<!-- 8.91:1 worst case (on ev_surface_raised) - the previous #9EA6AE fell to
|
||||
6:1, unreadable in sunlight. -->
|
||||
<color name="ev_text_secondary">#FFC6CFD8</color>
|
||||
<color name="ev_accent">#FF7FD4FA</color>
|
||||
<color name="ev_on_accent">#FF04121A</color>
|
||||
|
||||
<!-- Status. Never the sole carrier of meaning: a text label always accompanies
|
||||
the colour. -->
|
||||
<color name="ev_ok">#FF8FE6A6</color>
|
||||
<color name="ev_warn">#FFFFC978</color>
|
||||
<color name="ev_error">#FFFFACAC</color>
|
||||
|
||||
<!-- Pressed: an alpha veil over the surface, not a different hue. -->
|
||||
<color name="ev_pressed">#1FFFFFFF</color>
|
||||
<color name="ev_pressed_strong">#33FFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- EVSuite tokens - LIGHT theme. See DESIGN.md section Colors.
|
||||
Generated by tools/sync-tokens.mjs and identical in all six apps: edit
|
||||
tools/tokens/colors.light.xml, not this copy, and re-check the ratios.
|
||||
|
||||
Contrast: this screen is read in daylight, usually through a reflection. Every
|
||||
text colour clears 7:1 (AAA) on every surface it can land on, not the 4.5:1 AA
|
||||
minimum, which collapses as soon as outside brightness rises. The AAA floor
|
||||
applies to the light theme too - a badly contrasted light theme is no more
|
||||
readable than a badly contrasted dark one. -->
|
||||
<resources>
|
||||
<color name="ev_background">#FFF2F4F7</color>
|
||||
<color name="ev_surface">#FFFFFFFF</color>
|
||||
<color name="ev_surface_raised">#FFE4E8ED</color>
|
||||
<!-- Card border. Without it, surface and surface_raised merge in sunlight.
|
||||
4.07:1 on ev_surface - above the 3:1 floor for non-text UI (WCAG 1.4.11),
|
||||
which the previous #4A525B (2.25:1) did not clear. -->
|
||||
<color name="ev_outline">#FF737F8F</color>
|
||||
<color name="ev_text_primary">#FF101418</color>
|
||||
<!-- 7.22:1 worst case (on ev_surface_raised). -->
|
||||
<color name="ev_text_secondary">#FF414B55</color>
|
||||
<!-- A deep cyan, not the dark theme's pale one: a pale cyan on white falls to
|
||||
1.5:1 and disappears outright in daylight. 7.69:1 worst case. -->
|
||||
<color name="ev_accent">#FF044C63</color>
|
||||
<color name="ev_on_accent">#FFFFFFFF</color>
|
||||
|
||||
<!-- Status. Never the sole carrier of meaning: a text label always accompanies
|
||||
the colour. -->
|
||||
<color name="ev_ok">#FF09501F</color>
|
||||
<color name="ev_warn">#FF6B3A00</color>
|
||||
<color name="ev_error">#FF8F0F18</color>
|
||||
|
||||
<!-- Pressed: an alpha veil over the surface, not a different hue, so a pressed
|
||||
card still reads as the same card. -->
|
||||
<color name="ev_pressed">#14000000</color>
|
||||
<color name="ev_pressed_strong">#29000000</color>
|
||||
|
||||
<!-- Launcher icon tile. The same in light and dark: it is the suite's mark, not
|
||||
a surface. See DESIGN.md section Iconography. -->
|
||||
<color name="ev_icon_tile">#FF2A2A2A</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- EVSuite tokens - metrics. See DESIGN.md sections Layout and Typography.
|
||||
Generated by tools/sync-tokens.mjs and identical in all six apps. App-specific
|
||||
dimensions are declared alongside, in dimens_app.xml, with the reason. -->
|
||||
<resources>
|
||||
<!-- Vehicle screen: 12.8", ~70 cm from the driver, used while driving, sometimes
|
||||
in full sun, by fingers that aim badly. Everything is larger than on a
|
||||
hand-held phone. DPI is user-adjustable per app on this platform, so nothing
|
||||
assumes a pixel size. -->
|
||||
<dimen name="touch_target">72dp</dimen>
|
||||
<dimen name="spacing_xs">4dp</dimen>
|
||||
<dimen name="spacing_sm">8dp</dimen>
|
||||
<dimen name="spacing_md">16dp</dimen>
|
||||
<dimen name="spacing_lg">24dp</dimen>
|
||||
|
||||
<!-- One radius across the whole suite: it is what makes six apps written by
|
||||
different hands read as one. -->
|
||||
<dimen name="ev_corner">12dp</dimen>
|
||||
|
||||
<!-- Text sizes. The floor is text_label: below 16sp, contrast no longer
|
||||
compensates for reflection and reading distance. -->
|
||||
<dimen name="text_display">32sp</dimen>
|
||||
<dimen name="text_title">26sp</dimen>
|
||||
<dimen name="text_body">20sp</dimen>
|
||||
<dimen name="text_secondary">17sp</dimen>
|
||||
<dimen name="text_label">16sp</dimen>
|
||||
</resources>
|
||||
@@ -0,0 +1,122 @@
|
||||
<resources>
|
||||
<string name="app_name">Fatih\'sMG4</string>
|
||||
|
||||
<!-- Main screen -->
|
||||
<string name="tab_abrp">ABRP</string>
|
||||
<string name="tab_service">Upload Service</string>
|
||||
<string name="tab_vehicle">Araç</string>
|
||||
<string name="tab_wifi">Wi-Fi</string>
|
||||
<string name="tab_log">Log</string>
|
||||
|
||||
<!-- Wi-Fi page -->
|
||||
<string name="wifi_card_title">Wi-Fi Bağlantısı</string>
|
||||
<string name="wifi_current">Bağlı ağ</string>
|
||||
<string name="wifi_state">Durum</string>
|
||||
<string name="wifi_connect_now">Şimdi Bağlan</string>
|
||||
<string name="wifi_auto_label">Arka planda otomatik bağlan</string>
|
||||
<string name="wifi_auto_hint">Yükleme servisi çalışırken bağlantı koptuğunda kayıtlı ağlara kendi kendine bağlanmayı dener.</string>
|
||||
<string name="wifi_saved_title">Kayıtlı Ağlar</string>
|
||||
<string name="wifi_saved_empty">Kayıtlı ağ okunamadı</string>
|
||||
<string name="wifi_not_connected">Bağlı değil</string>
|
||||
|
||||
<!-- Vehicle page -->
|
||||
<string name="vehicle_live_data">Canlı Araç Verileri</string>
|
||||
<string name="vehicle_tires">Lastik Basınçları</string>
|
||||
<string name="vehicle_row_soc">Şarj (SOC)</string>
|
||||
<string name="vehicle_row_range">Menzil</string>
|
||||
<string name="vehicle_row_power">Güç</string>
|
||||
<string name="vehicle_row_speed">Hız</string>
|
||||
<string name="vehicle_row_odometer">Kilometre</string>
|
||||
<string name="vehicle_row_batt_temp">Batarya Sıcaklığı</string>
|
||||
<string name="vehicle_row_ext_temp">Dış Sıcaklık</string>
|
||||
<string name="vehicle_row_cabin_temp">Kabin Sıcaklığı</string>
|
||||
<string name="vehicle_row_hvac">Klima Ayarı</string>
|
||||
<string name="vehicle_row_capacity">Batarya Kapasitesi</string>
|
||||
<string name="vehicle_row_soe">Kalan Enerji</string>
|
||||
<string name="vehicle_row_charging">Şarj Durumu</string>
|
||||
<string name="vehicle_tire_fl">Ön Sol</string>
|
||||
<string name="vehicle_tire_fr">Ön Sağ</string>
|
||||
<string name="vehicle_tire_rl">Arka Sol</string>
|
||||
<string name="vehicle_tire_rr">Arka Sağ</string>
|
||||
<string name="card_config">Configuration</string>
|
||||
<string name="hint_api_key">ABRP API Key</string>
|
||||
<string name="default_abrp_api_key" translatable="false">8cfc314b-03cd-4efe-ab7d-4431cd8f2e2d</string>
|
||||
<string name="helper_api_key">Default: open-source ABRP telemetry key. You may replace it with your own.</string>
|
||||
<string name="api_key_required">Enter your ABRP API key first</string>
|
||||
<string name="hint_token">ABRP User Token</string>
|
||||
<string name="helper_token">Find your token in the ABRP app: Settings → Connections → Generic (Manual Entry)</string>
|
||||
<string name="desc_toggle_api_key">Show or hide API key</string>
|
||||
<string name="desc_toggle_token">Show or hide user token</string>
|
||||
<string name="btn_save">Save</string>
|
||||
<string name="btn_test">Test Connection</string>
|
||||
<string name="btn_import">Import file</string>
|
||||
|
||||
<!-- Config file import -->
|
||||
<string name="import_ok">Config imported — press Test to verify</string>
|
||||
<string name="import_err_read">Could not read that file</string>
|
||||
<string name="import_err_empty">No ABRP settings found in that file</string>
|
||||
<string name="import_err_not_found">No config file found. Copy it to %1$s</string>
|
||||
|
||||
<!-- Connection test -->
|
||||
<string name="conn_testing">Testing…</string>
|
||||
<string name="conn_ok">Connected — API responding OK</string>
|
||||
<string name="conn_err_auth">Auth failed — check your API key and token</string>
|
||||
<string name="perm_denied_location">Location denied — telemetry will have no position. Grant it in Settings to send GPS to ABRP.</string>
|
||||
<string name="perm_denied_car">Vehicle data denied — no battery %%, speed or range will be sent. Grant it in Settings.</string>
|
||||
<string name="perm_denied_all">Location and vehicle data denied — only a timestamp will be sent. Grant both in Settings.</string>
|
||||
<string name="conn_err_no_internet">No internet connection</string>
|
||||
<string name="conn_err_timeout">Connection timed out</string>
|
||||
<string name="conn_err_http">Server error (HTTP %1$d)</string>
|
||||
<string name="token_required">Enter your ABRP token first</string>
|
||||
|
||||
<string name="card_service">Upload Service</string>
|
||||
<string name="label_upload_enabled">Enable background uploading</string>
|
||||
<string name="label_autostart">Start automatically with the car</string>
|
||||
<string name="hint_autostart">Uploading resumes on its own when the head unit powers up. Turn this off to start it by hand from this screen.</string>
|
||||
|
||||
<string name="status_stopped">Service not running</string>
|
||||
|
||||
<!-- Cadence configuration -->
|
||||
<string name="label_frequency">Upload frequency</string>
|
||||
<string name="hint_frequency">Applies while driving. Parked and unplugged, the app backs off to one upload every 15 minutes on its own.</string>
|
||||
<string name="label_boost_low_soc">Upload more often at low battery</string>
|
||||
<string name="label_low_soc_threshold">Low battery threshold (%)</string>
|
||||
<plurals name="interval_seconds">
|
||||
<item quantity="one">Every %1$d second</item>
|
||||
<item quantity="other">Every %1$d seconds</item>
|
||||
</plurals>
|
||||
<plurals name="interval_minutes">
|
||||
<item quantity="one">Every %1$d minute</item>
|
||||
<item quantity="other">Every %1$d minutes</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Service state -->
|
||||
<string name="state_stopped">Stopped</string>
|
||||
<string name="state_starting">Starting…</string>
|
||||
<string name="state_running">Running</string>
|
||||
<string name="state_error">In error — last %1$d uploads failed</string>
|
||||
|
||||
<!-- Call log -->
|
||||
<string name="label_call_log">Recent uploads</string>
|
||||
<string name="call_log_empty">No upload attempted yet.</string>
|
||||
<string name="status_starting">Starting…</string>
|
||||
<string name="status_running_no_upload">Running — waiting for first upload</string>
|
||||
<string name="status_last_upload">Last upload: %1$s (%2$s)</string>
|
||||
<string name="status_ok">OK</string>
|
||||
|
||||
<!-- Notification -->
|
||||
<string name="notif_channel_name">ABRP Upload Service</string>
|
||||
<string name="notif_channel_desc">Shows vehicle telemetry upload status</string>
|
||||
<string name="notif_title">ABRP Uploader</string>
|
||||
<string name="notif_action_stop">Stop</string>
|
||||
<string name="about_action">About</string>
|
||||
<string name="about_title">Fatih\'sMG4 Hakkında</string>
|
||||
<string name="about_version">Version %1$s</string>
|
||||
<string name="about_attribution">Fork of malys / EVABRPUploader\nand Leon Kernan / ABRP_Uploader</string>
|
||||
<string name="about_repository">github.com/fatihdonmezdev/EVABRP</string>
|
||||
<string name="about_made_with_love">MG4 EH32 · SWI69</string>
|
||||
<string name="about_logo_description">EVABRPUploader logo</string>
|
||||
<string name="about_qr_description">QR code for the project repository</string>
|
||||
<string name="about_version_unknown">unknown</string>
|
||||
<string name="about_close">Close</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,69 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Per-app styles (not synced by tools/sync-tokens.mjs — see styles_ev.xml for the
|
||||
shared set). -->
|
||||
<resources>
|
||||
|
||||
<!-- Icon-over-label nav tab, identical to EVProfile's and EVTasker's. The tabs
|
||||
share the bar's width equally, so the label has to shrink rather than push its
|
||||
neighbours around: hence autoSize and a single line. Selected state is
|
||||
View.isSelected, set by MainActivity.markCurrentPage, read by the
|
||||
res/color/nav_tab_*.xml selectors — no color is ever set from code. -->
|
||||
<style name="Widget.EV.NavTab" parent="Widget.Material3.Button.OutlinedButton">
|
||||
<item name="android:minWidth">0dp</item>
|
||||
<item name="android:minHeight">0dp</item>
|
||||
<item name="android:insetTop">0dp</item>
|
||||
<item name="android:insetBottom">0dp</item>
|
||||
<item name="android:paddingStart">@dimen/spacing_xs</item>
|
||||
<item name="android:paddingEnd">@dimen/spacing_xs</item>
|
||||
<item name="android:paddingTop">@dimen/spacing_xs</item>
|
||||
<item name="android:paddingBottom">@dimen/spacing_xs</item>
|
||||
<item name="android:maxLines">1</item>
|
||||
<item name="android:textStyle">bold</item>
|
||||
<item name="android:letterSpacing">0.02</item>
|
||||
<item name="android:textAllCaps">false</item>
|
||||
<item name="android:textColor">@color/nav_tab_foreground</item>
|
||||
<item name="autoSizeTextType">uniform</item>
|
||||
<item name="autoSizeMinTextSize">12sp</item>
|
||||
<item name="autoSizeMaxTextSize">@dimen/text_label</item>
|
||||
<item name="iconGravity">top</item>
|
||||
<item name="iconSize">28dp</item>
|
||||
<item name="iconPadding">@dimen/spacing_xs</item>
|
||||
<item name="iconTint">@color/nav_tab_foreground</item>
|
||||
<item name="backgroundTint">@color/nav_tab_background</item>
|
||||
<item name="strokeColor">@color/nav_tab_stroke</item>
|
||||
<item name="strokeWidth">1dp</item>
|
||||
<item name="cornerRadius">@dimen/ev_corner</item>
|
||||
<item name="elevation">0dp</item>
|
||||
</style>
|
||||
|
||||
<!-- Vehicle page. A label/value table: the label takes the slack (stretchColumns=0)
|
||||
and the value is right-aligned so the numbers line up down the column, which
|
||||
is what makes a reading legible at a glance from the driver's seat. -->
|
||||
<style name="DataRow" parent="">
|
||||
<item name="android:layout_width">match_parent</item>
|
||||
<item name="android:layout_height">wrap_content</item>
|
||||
<item name="android:paddingTop">@dimen/spacing_sm</item>
|
||||
<item name="android:paddingBottom">@dimen/spacing_sm</item>
|
||||
<item name="android:gravity">center_vertical</item>
|
||||
</style>
|
||||
|
||||
<style name="DataLabel" parent="Text.EV.Body">
|
||||
<item name="android:layout_width">wrap_content</item>
|
||||
<item name="android:layout_height">wrap_content</item>
|
||||
<item name="android:textColor">@color/ev_text_secondary</item>
|
||||
</style>
|
||||
|
||||
<style name="DataValue" parent="Text.EV.Body">
|
||||
<item name="android:layout_width">wrap_content</item>
|
||||
<item name="android:layout_height">wrap_content</item>
|
||||
<item name="android:paddingStart">@dimen/spacing_md</item>
|
||||
<item name="android:gravity">end</item>
|
||||
<item name="android:textStyle">bold</item>
|
||||
<item name="android:textColor">@color/ev_text_primary</item>
|
||||
<!-- Placeholder, replaced on the first refresh tick. A dash, never a zero:
|
||||
see the note at the top of pane_vehicle.xml. -->
|
||||
<item name="android:text">—</item>
|
||||
</style>
|
||||
|
||||
|
||||
</resources>
|
||||
@@ -0,0 +1,96 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- EVSuite - component styles. See DESIGN.md section Components.
|
||||
Generated by tools/sync-tokens.mjs: identical in all six apps, edit only
|
||||
tools/tokens/styles.xml. The application theme itself stays in values/themes.xml,
|
||||
because its name changes from one app to the next.
|
||||
|
||||
These styles carry the same names everywhere, so a screen can move between
|
||||
repositories without being renamed. -->
|
||||
<resources>
|
||||
|
||||
<!-- Dialogs inherit the same contrast as everything else: without this they fall
|
||||
back to Material's default palette, an indoor grey-mauve that is the first
|
||||
thing to wash out in sunlight. -->
|
||||
<style name="ThemeOverlay.EV.Dialog" parent="ThemeOverlay.Material3.MaterialAlertDialog">
|
||||
<item name="colorSurface">@color/ev_surface_raised</item>
|
||||
<item name="colorOnSurface">@color/ev_text_primary</item>
|
||||
<item name="colorOnSurfaceVariant">@color/ev_text_secondary</item>
|
||||
<item name="android:textColorPrimary">@color/ev_text_primary</item>
|
||||
<item name="buttonBarPositiveButtonStyle">@style/Widget.EV.DialogButton</item>
|
||||
<item name="buttonBarNegativeButtonStyle">@style/Widget.EV.DialogButton</item>
|
||||
</style>
|
||||
|
||||
<!-- 72dp touch target: the finger aims badly on the road and the screen is far
|
||||
away. This is the minimum used everywhere in the suite, including where
|
||||
Material offers something much smaller. -->
|
||||
<style name="Widget.EV.Button" parent="Widget.Material3.Button">
|
||||
<item name="shapeAppearanceOverlay">@style/ShapeAppearanceOverlay.EV.Button</item>
|
||||
<item name="android:minHeight">@dimen/touch_target</item>
|
||||
<item name="android:textSize">@dimen/text_body</item>
|
||||
<item name="android:paddingStart">@dimen/spacing_lg</item>
|
||||
<item name="android:paddingEnd">@dimen/spacing_lg</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.EV.Button.Outlined" parent="Widget.Material3.Button.OutlinedButton">
|
||||
<item name="shapeAppearanceOverlay">@style/ShapeAppearanceOverlay.EV.Button</item>
|
||||
<item name="android:minHeight">@dimen/touch_target</item>
|
||||
<item name="android:textSize">@dimen/text_body</item>
|
||||
<item name="android:paddingStart">@dimen/spacing_lg</item>
|
||||
<item name="android:paddingEnd">@dimen/spacing_lg</item>
|
||||
</style>
|
||||
|
||||
<!-- Dialog buttons are the smallest thing in any app and the easiest to miss:
|
||||
pulled up to the same target as the rest. -->
|
||||
<style name="Widget.EV.DialogButton" parent="Widget.Material3.Button.TextButton.Dialog">
|
||||
<item name="shapeAppearanceOverlay">@style/ShapeAppearanceOverlay.EV.Button</item>
|
||||
<item name="android:minHeight">@dimen/touch_target</item>
|
||||
<item name="android:minWidth">140dp</item>
|
||||
<item name="android:textSize">@dimen/text_body</item>
|
||||
</style>
|
||||
|
||||
<!-- A switch that needs a precise tap is a switch that gets missed at 90 km/h. -->
|
||||
<style name="Widget.EV.Switch" parent="Widget.Material3.CompoundButton.MaterialSwitch">
|
||||
<item name="android:minHeight">@dimen/touch_target</item>
|
||||
<item name="android:textSize">@dimen/text_body</item>
|
||||
<item name="android:textColor">@color/ev_text_primary</item>
|
||||
</style>
|
||||
|
||||
<!-- The only tracked type in the suite. The tracking is what lets it work at the
|
||||
16sp floor without shouting. -->
|
||||
<style name="Text.EV.SectionHeader" parent="TextAppearance.Material3.LabelLarge">
|
||||
<item name="android:textSize">@dimen/text_label</item>
|
||||
<item name="android:textColor">@color/ev_accent</item>
|
||||
<item name="android:letterSpacing">0.12</item>
|
||||
<item name="android:textStyle">bold</item>
|
||||
</style>
|
||||
|
||||
<!-- One Display element per screen: two 32sp numbers competing for attention are
|
||||
two glances, and the glance is the budget. -->
|
||||
<style name="Text.EV.Display" parent="TextAppearance.Material3.HeadlineSmall">
|
||||
<item name="android:textSize">@dimen/text_display</item>
|
||||
<item name="android:textColor">@color/ev_text_primary</item>
|
||||
</style>
|
||||
|
||||
<style name="Text.EV.Title" parent="TextAppearance.Material3.TitleLarge">
|
||||
<item name="android:textSize">@dimen/text_title</item>
|
||||
<item name="android:textColor">@color/ev_text_primary</item>
|
||||
</style>
|
||||
|
||||
<style name="Text.EV.Body" parent="TextAppearance.Material3.BodyLarge">
|
||||
<item name="android:textSize">@dimen/text_body</item>
|
||||
<item name="android:textColor">@color/ev_text_primary</item>
|
||||
</style>
|
||||
|
||||
<style name="Text.EV.Secondary" parent="TextAppearance.Material3.BodyMedium">
|
||||
<item name="android:textSize">@dimen/text_secondary</item>
|
||||
<item name="android:textColor">@color/ev_text_secondary</item>
|
||||
</style>
|
||||
|
||||
<!-- The suite radius applied to buttons. Material3 buttons are fully rounded by
|
||||
default, which reads as a different design language from every card and dialog
|
||||
around them. This lived in EVSwipe only, in a generated file, so the first sync
|
||||
deleted it — one radius across the suite has to be a token, not a local edit. -->
|
||||
<style name="ShapeAppearanceOverlay.EV.Button" parent="">
|
||||
<item name="cornerSize">@dimen/ev_corner</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- DayNight, not a forced dark theme: the suite follows the vehicle's day/night
|
||||
setting. Dark is still the mode that matters - a light background filling the
|
||||
windscreen at night is glare - which is why the light palette was raised to
|
||||
the same 7:1 floor as the dark one rather than the other way round.
|
||||
Shared component styles live in styles_ev.xml. -->
|
||||
<style name="Theme.EVABRPUploader" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="colorPrimary">@color/ev_accent</item>
|
||||
<item name="colorOnPrimary">@color/ev_on_accent</item>
|
||||
<item name="colorSurface">@color/ev_surface</item>
|
||||
<item name="colorSurfaceContainer">@color/ev_surface_raised</item>
|
||||
<item name="android:colorBackground">@color/ev_background</item>
|
||||
<item name="android:windowBackground">@color/ev_background</item>
|
||||
<item name="colorError">@color/ev_error</item>
|
||||
<item name="android:statusBarColor">@color/ev_background</item>
|
||||
<!-- Material3's "on surface" is a grey-mauve that is comfortable indoors and
|
||||
too weak in full sun: a hard contrast is imposed instead. -->
|
||||
<item name="colorOnSurface">@color/ev_text_primary</item>
|
||||
<item name="colorOnSurfaceVariant">@color/ev_text_secondary</item>
|
||||
<item name="colorOnBackground">@color/ev_text_primary</item>
|
||||
<item name="colorOutline">@color/ev_outline</item>
|
||||
<item name="android:textColorPrimary">@color/ev_text_primary</item>
|
||||
<item name="android:textColorSecondary">@color/ev_text_secondary</item>
|
||||
<item name="materialAlertDialogTheme">@style/ThemeOverlay.EV.Dialog</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Backups are disabled outright (android:allowBackup="false"). These rules are
|
||||
the belt-and-braces layer: the credential store must never leave the device
|
||||
even if backups are re-enabled by mistake.
|
||||
-->
|
||||
<full-backup-content>
|
||||
<exclude domain="sharedpref" path="abrp_secure_prefs.xml"/>
|
||||
<exclude domain="sharedpref" path="abrp_prefs.xml"/>
|
||||
</full-backup-content>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
See backup_rules.xml. Applies to cloud backup and device-to-device transfer
|
||||
on API 31+: the ABRP credentials are excluded from both.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="sharedpref" path="abrp_secure_prefs.xml"/>
|
||||
<exclude domain="sharedpref" path="abrp_prefs.xml"/>
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="sharedpref" path="abrp_secure_prefs.xml"/>
|
||||
<exclude domain="sharedpref" path="abrp_prefs.xml"/>
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
/**
|
||||
* Stable channel: no self-update, by construction.
|
||||
*
|
||||
* This is not a disabled feature — the updater class is not in the APK at all. A stable
|
||||
* build cannot be made to fetch and install code by flipping a preference, and there is
|
||||
* no update URL in it to attack.
|
||||
*/
|
||||
final class UpdateHook {
|
||||
|
||||
private UpdateHook() { }
|
||||
|
||||
/** Does nothing. Stable users install updates themselves. */
|
||||
static void checkInBackground(Context context) { }
|
||||
|
||||
static boolean isSupported() { return false; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* [T-912] Credentials must reach ABRP in the request body. A URL is logged by
|
||||
* proxies, CDNs and crash reporters; a body is not.
|
||||
*/
|
||||
public class AbrpApiTest {
|
||||
|
||||
@Test
|
||||
public void endpointUrlCarriesNoCredentials() {
|
||||
assertFalse(AbrpApi.API_URL.contains("?"));
|
||||
assertFalse(AbrpApi.API_URL.contains("api_key"));
|
||||
assertFalse(AbrpApi.API_URL.contains("token"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void endpointIsHttps() {
|
||||
assertTrue(AbrpApi.API_URL.startsWith("https://"));
|
||||
assertTrue(AbrpApi.VERIFY_URL.startsWith("https://"));
|
||||
}
|
||||
|
||||
/** [T-913] The Test button must not be able to write telemetry. */
|
||||
@Test
|
||||
public void credentialCheckDoesNotTargetTheTelemetryWriteEndpoint() {
|
||||
assertFalse("le test de connexion ne doit jamais viser /tlm/send",
|
||||
AbrpApi.VERIFY_URL.contains("/tlm/send"));
|
||||
assertTrue(AbrpApi.VERIFY_URL.contains("get_next_charge"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bodyCarriesAllThreeParameters() throws Exception {
|
||||
String body = AbrpApi.formBody("KEY", "TOK", "{\"utc\":1}");
|
||||
assertTrue(body.contains("api_key=KEY"));
|
||||
assertTrue(body.contains("token=TOK"));
|
||||
assertTrue(body.contains("tlm="));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bodyIsFormEncoded() throws Exception {
|
||||
// A token with URL-significant characters must not break the body layout.
|
||||
String body = AbrpApi.formBody("a+b/c", "d&e=f", "{\"utc\":1}");
|
||||
assertEquals("api_key=a%2Bb%2Fc&token=d%26e%3Df&tlm=%7B%22utc%22%3A1%7D", body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* Charge detection. The reported regression was a charging car being read as
|
||||
* "parked and not charging", which hands it to {@link UploadCadence#PARKED_INTERVAL_MS}
|
||||
* and costs ABRP the SOC curve it needs to size a charging stop.
|
||||
*/
|
||||
public class ChargingSignalTest {
|
||||
|
||||
private static final long T0 = 1_700_000_000_000L;
|
||||
|
||||
private final ChargingSignal signal = new ChargingSignal();
|
||||
|
||||
// ---- Charge port property, when the VHAL answers it ----
|
||||
|
||||
@Test
|
||||
public void aConnectedPortIsCharging() {
|
||||
assertTrue(signal.evaluate(T0, true, null, true, 50));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anUnpluggedParkedCarIsNotCharging() {
|
||||
assertFalse(signal.evaluate(T0, false, null, true, 50));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anUnreadablePortStaysUnknown() {
|
||||
// Unknown must not collapse to "not charging": UploadCadence treats null as active.
|
||||
assertNull(signal.evaluate(T0, null, null, true, 50));
|
||||
}
|
||||
|
||||
// ---- Fallback 1: charge rate ----
|
||||
|
||||
@Test
|
||||
public void aPositiveChargeRateOverridesAPortThatReadsUnplugged() {
|
||||
assertTrue(signal.evaluate(T0, false, 7.4f, true, 50));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aNegativeChargeRateIsDriving() {
|
||||
assertFalse(signal.evaluate(T0, false, -14.5f, false, 50));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void noiseAroundZeroIsNotCharging() {
|
||||
assertFalse(signal.evaluate(T0, false, 0.1f, true, 50));
|
||||
}
|
||||
|
||||
// ---- Fallback 2: rising SOC on a parked car ----
|
||||
|
||||
@Test
|
||||
public void aRisingSocOnAParkedCarIsCharging() {
|
||||
// Neither charge property readable — the only evidence left is the battery filling.
|
||||
assertNull(signal.evaluate(T0, null, null, true, 50));
|
||||
assertTrue(signal.evaluate(T0 + 300_000, null, null, true, 51));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theSocRiseIsLatchedAcrossFlatTicks() {
|
||||
// SOC moves a whole percent every few minutes; without the latch is_charging
|
||||
// would flap between every pair of ticks.
|
||||
signal.evaluate(T0, null, null, true, 50);
|
||||
signal.evaluate(T0 + 300_000, null, null, true, 51);
|
||||
assertTrue(signal.evaluate(T0 + 315_000, null, null, true, 51));
|
||||
assertTrue(signal.evaluate(T0 + 1_500_000, null, null, true, 51));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theLatchExpiresSoAParkedCarGoesBackToIdle() {
|
||||
signal.evaluate(T0, null, null, true, 50);
|
||||
signal.evaluate(T0 + 300_000, null, null, true, 51);
|
||||
assertNull(signal.evaluate(T0 + 300_000 + ChargingSignal.SOC_RISE_LATCH_MS,
|
||||
null, null, true, 51));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aFallingSocDropsTheLatchImmediately() {
|
||||
signal.evaluate(T0, null, null, true, 50);
|
||||
assertTrue(signal.evaluate(T0 + 300_000, null, null, true, 51));
|
||||
assertNull(signal.evaluate(T0 + 600_000, null, null, true, 50));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void regenWhileDrivingDoesNotLatch() {
|
||||
// SOC climbing on a downhill run, then the car parks: not a charging session.
|
||||
signal.evaluate(T0, false, null, false, 50);
|
||||
signal.evaluate(T0 + 60_000, false, null, false, 52);
|
||||
assertFalse(signal.evaluate(T0 + 120_000, false, null, true, 52));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anUnreadableSocKeepsThePreviousReadingToCompareAgainst() {
|
||||
signal.evaluate(T0, null, null, true, 50);
|
||||
signal.evaluate(T0 + 15_000, null, null, true, null); // one failed read
|
||||
assertTrue(signal.evaluate(T0 + 30_000, null, null, true, 51));
|
||||
}
|
||||
|
||||
// ---- The regression itself ----
|
||||
|
||||
@Test
|
||||
public void aChargingCarIsNeverHandedToTheParkedCadence() {
|
||||
// Charging at home with a VHAL that does not implement the charge port property:
|
||||
// before the fix this was parked + FALSE, i.e. one upload every 15 minutes.
|
||||
ChargingSignal s = new ChargingSignal();
|
||||
UploadSettings settings = UploadSettings.defaults();
|
||||
|
||||
Boolean charging = s.evaluate(T0, false, null, true, 50);
|
||||
assertEquals(UploadCadence.PARKED_INTERVAL_MS,
|
||||
UploadCadence.intervalFor(true, charging, 50, settings));
|
||||
|
||||
charging = s.evaluate(T0 + 300_000, false, null, true, 51);
|
||||
assertEquals(settings.intervalMs(),
|
||||
UploadCadence.intervalFor(true, charging, 51, settings));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* ABRP config file parsing — pure JVM, no Android needed.
|
||||
*
|
||||
* The file exists so a user does not have to type a long api_key and token on the car's
|
||||
* on-screen keyboard, so the parser must be forgiving about whitespace, case and comments
|
||||
* while never inventing a value the file did not set.
|
||||
*/
|
||||
public class ConfigImportTest {
|
||||
|
||||
@Test
|
||||
public void parsesCredentials() {
|
||||
ConfigImport c = ConfigImport.parse("api_key = abc123\ntoken = def456\n");
|
||||
assertEquals("abc123", c.apiKey);
|
||||
assertEquals("def456", c.token);
|
||||
assertFalse(c.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ignoresCommentsBlankLinesAndCase() {
|
||||
ConfigImport c = ConfigImport.parse(
|
||||
"# my config\n\n API_KEY = key \n# token below\nTOKEN=tok\n");
|
||||
assertEquals("key", c.apiKey);
|
||||
assertEquals("tok", c.token);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void absentKeysStayNullSoNothingIsWiped() {
|
||||
ConfigImport c = ConfigImport.parse("token = only-token\n");
|
||||
assertNull(c.apiKey);
|
||||
assertEquals("only-token", c.token);
|
||||
assertNull(c.intervalSec);
|
||||
assertNull(c.boostLowSoc);
|
||||
assertNull(c.lowSocPercent);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void emptyOrJunkFileYieldsEmptyConfig() {
|
||||
assertTrue(ConfigImport.parse("").isEmpty());
|
||||
assertTrue(ConfigImport.parse("just some prose\nno equals here\n").isEmpty());
|
||||
assertTrue(ConfigImport.parse("# only a comment\n").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void intervalSnapsToNearestOfferedChoice() {
|
||||
// 45 is not an offered value; the spinner only has 15/30/60/120/300.
|
||||
assertEquals(Integer.valueOf(30), ConfigImport.parse("interval_sec = 45").intervalSec);
|
||||
assertEquals(Integer.valueOf(60), ConfigImport.parse("interval_sec = 59").intervalSec);
|
||||
assertEquals(Integer.valueOf(300), ConfigImport.parse("interval_sec = 9999").intervalSec);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lowSocPercentIsClampedTo1To99() {
|
||||
assertEquals(Integer.valueOf(99), ConfigImport.parse("low_soc_percent = 250").lowSocPercent);
|
||||
assertEquals(Integer.valueOf(1), ConfigImport.parse("low_soc_percent = 0").lowSocPercent);
|
||||
assertEquals(Integer.valueOf(20), ConfigImport.parse("low_soc_percent = 20").lowSocPercent);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void boostAcceptsCommonBooleanSpellings() {
|
||||
assertEquals(Boolean.TRUE, ConfigImport.parse("boost_low_soc = on").boostLowSoc);
|
||||
assertEquals(Boolean.TRUE, ConfigImport.parse("boost_low_soc = TRUE").boostLowSoc);
|
||||
assertEquals(Boolean.FALSE, ConfigImport.parse("boost_low_soc = 0").boostLowSoc);
|
||||
assertNull(ConfigImport.parse("boost_low_soc = maybe").boostLowSoc);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onlyFirstEqualsSplitsSoValueMayContainEquals() {
|
||||
ConfigImport c = ConfigImport.parse("token = tok=with=equals\n");
|
||||
assertEquals("tok=with=equals", c.token);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aliasKeysAreAccepted() {
|
||||
ConfigImport c = ConfigImport.parse("apikey = k\nuser_token = t\n");
|
||||
assertEquals("k", c.apiKey);
|
||||
assertEquals("t", c.token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* The broadcast parser, on the JVM.
|
||||
*
|
||||
* What is under test is not the vendor's payload — that shape is unverified and may well be
|
||||
* something else on the car. It is that the parser finds a temperature wherever the payload
|
||||
* puts it, refuses to invent one, and says what it was given when it fails, because the
|
||||
* diagnostic is the only thing that reaches a driver with no adb.
|
||||
*/
|
||||
public class HeadUnitWeatherTest {
|
||||
|
||||
@Test
|
||||
public void readsATemperatureAndThePlaceItIsFor() {
|
||||
HeadUnitWeather weather = new HeadUnitWeather();
|
||||
weather.acceptPayload((
|
||||
"{\"code\":0,\"weather\":{\"temperature\":27,\"unit\":\"C\","
|
||||
+ "\"address\":\"Toulouse\",\"conditionType\":1}}"));
|
||||
assertEquals(27f, weather.temperatureC(), 0.01f);
|
||||
assertEquals("Toulouse", weather.place());
|
||||
// The diagnostic carries the reading's age: a broadcast arrives when the weather app
|
||||
// refreshes, so "where from" without "how long ago" does not say whether to trust it.
|
||||
assertEquals("Toulouse/0min", weather.diagnostic());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void findsTheValueWhereverThePayloadNests_it() {
|
||||
HeadUnitWeather weather = new HeadUnitWeather();
|
||||
weather.acceptPayload((
|
||||
"{\"data\":{\"current\":[{\"tempValue\":\"18.5\",\"tempUnit\":\"C\"}]}}"));
|
||||
assertEquals(18.5f, weather.temperatureC(), 0.01f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void convertsAFahrenheitPayload() {
|
||||
HeadUnitWeather weather = new HeadUnitWeather();
|
||||
weather.acceptPayload(("{\"temperature\":81,\"temperatureUnit\":\"F\"}"));
|
||||
assertEquals(27.2f, weather.temperatureC(), 0.1f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anUnnamedUnitIsTakenAsCelsius() {
|
||||
HeadUnitWeather weather = new HeadUnitWeather();
|
||||
weather.acceptPayload(("{\"temp\":21}"));
|
||||
assertEquals(21f, weather.temperatureC(), 0.01f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aPayloadWithoutATemperatureReportsWhatItWasGiven() {
|
||||
HeadUnitWeather weather = new HeadUnitWeather();
|
||||
weather.acceptPayload(("{\"conditionType\":3,\"address\":\"Toulouse\"}"));
|
||||
assertNull(weather.temperatureC());
|
||||
assertTrue(weather.diagnostic(), weather.diagnostic().startsWith("no temperature in {"));
|
||||
assertTrue(weather.diagnostic(), weather.diagnostic().contains("conditionType"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void readsThePlaceFromACityKeyToo() {
|
||||
HeadUnitWeather weather = new HeadUnitWeather();
|
||||
weather.acceptPayload("{\"temp\":14,\"city\":\"Auzeville\"}");
|
||||
assertEquals(14f, weather.temperatureC(), 0.01f);
|
||||
assertEquals("Auzeville", weather.place());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aBroadcastWithNothingReadableIsReportedNotGuessed() {
|
||||
HeadUnitWeather weather = new HeadUnitWeather();
|
||||
weather.acceptPayload(null);
|
||||
assertNull(weather.temperatureC());
|
||||
assertEquals("broadcast carried no JSON", weather.diagnostic());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void beforeAnyBroadcastTheDiagnosticSaysSo() {
|
||||
HeadUnitWeather weather = new HeadUnitWeather();
|
||||
assertNull(weather.temperatureC());
|
||||
assertEquals("no broadcast yet", weather.diagnostic());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void malformedJsonDoesNotEscapeTheReceiver() {
|
||||
HeadUnitWeather weather = new HeadUnitWeather();
|
||||
weather.acceptPayload(("{not json at all"));
|
||||
assertNull(weather.temperatureC());
|
||||
assertTrue(weather.diagnostic(), weather.diagnostic().contains("reading broadcast"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aRestoredReadingIsReportedWithItsAge() {
|
||||
HeadUnitWeather weather = new HeadUnitWeather();
|
||||
weather.restore(19.5f, "Toulouse", System.currentTimeMillis() - 45 * 60 * 1000L);
|
||||
assertEquals(19.5f, weather.temperatureC(), 0.01f);
|
||||
assertEquals("Toulouse/45min", weather.diagnostic());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anAbsentStickyBroadcastIsNotMistakenForAFailedParse() {
|
||||
HeadUnitWeather weather = new HeadUnitWeather();
|
||||
weather.accept(null);
|
||||
assertNull(weather.temperatureC());
|
||||
assertEquals("none held, none sent yet", weather.diagnostic());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* [T-913] Telemetry payload construction — pure JVM, no car and no Android needed.
|
||||
*
|
||||
* The rule under test: a property that could not be read is OMITTED. Emitting 0 instead
|
||||
* tells ABRP the car really is at 0 % / 0 km/h and corrupts the user's route plan.
|
||||
*/
|
||||
public class TelemetryPayloadTest {
|
||||
|
||||
/** Everything readable, no location. */
|
||||
private TelemetryPayload fullCar() {
|
||||
TelemetryPayload t = new TelemetryPayload(1_700_000_000L);
|
||||
t.soc = 63;
|
||||
t.speedKmh = 48.4f;
|
||||
t.rangeKm = 210;
|
||||
t.extTemp = 12f;
|
||||
t.powerKw = -14.5f;
|
||||
t.charging = false;
|
||||
t.dcfc = false;
|
||||
t.parked = false;
|
||||
t.cabinTemp = 21.5f;
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Nothing readable at all — the car link is down. */
|
||||
private String noCar() {
|
||||
return new TelemetryPayload(1_700_000_000L).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void utcIsAlwaysPresent() throws Exception {
|
||||
JSONObject json = new JSONObject(noCar());
|
||||
assertEquals(1, json.length());
|
||||
assertEquals(1_700_000_000L, json.getLong("utc"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unreadablePropertiesAreOmittedNotZeroed() {
|
||||
String json = noCar();
|
||||
for (String field : new String[]{
|
||||
"soc", "speed", "est_battery_range", "ext_temp", "power",
|
||||
"is_charging", "is_dcfc", "is_parked", "cabin_temp",
|
||||
"batt_temp", "capacity", "odometer", "hvac_setpoint",
|
||||
"tire_pressure_fl", "tire_pressure_fr",
|
||||
"tire_pressure_rl", "tire_pressure_rr"}) {
|
||||
assertFalse("le champ " + field + " aurait dû être omis", json.contains(field));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aSingleUnreadablePropertyDoesNotSuppressTheOthers() throws Exception {
|
||||
// SOC throws (null) but speed reads fine: speed must still be sent.
|
||||
TelemetryPayload t = new TelemetryPayload(1L);
|
||||
t.speedKmh = 48.4f;
|
||||
JSONObject json = new JSONObject(t.build());
|
||||
assertFalse("soc illisible aurait dû être omis", json.has("soc"));
|
||||
assertEquals(48, json.getInt("speed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aGenuineZeroIsStillSent() throws Exception {
|
||||
// 0 % battery is a real reading and must not be confused with "unreadable".
|
||||
TelemetryPayload t = new TelemetryPayload(1L);
|
||||
t.soc = 0;
|
||||
t.speedKmh = 0f;
|
||||
t.rangeKm = 0;
|
||||
JSONObject json = new JSONObject(t.build());
|
||||
assertEquals(0, json.getInt("soc"));
|
||||
assertEquals(0, json.getInt("speed"));
|
||||
assertEquals(0, json.getInt("est_battery_range"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void readablePropertiesAreEmitted() throws Exception {
|
||||
JSONObject json = new JSONObject(fullCar().build());
|
||||
assertEquals(63, json.getInt("soc"));
|
||||
assertEquals(48, json.getInt("speed")); // rounded
|
||||
assertEquals(210, json.getInt("est_battery_range"));
|
||||
assertEquals(12, json.getInt("ext_temp"));
|
||||
assertEquals(-14.5, json.getDouble("power"), 0.001);
|
||||
assertEquals(0, json.getInt("is_charging"));
|
||||
assertEquals(0, json.getInt("is_dcfc"));
|
||||
assertEquals(0, json.getInt("is_parked"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void powerUsesDotDecimalSeparatorRegardlessOfDefaultLocale() throws Exception {
|
||||
java.util.Locale previous = java.util.Locale.getDefault();
|
||||
try {
|
||||
java.util.Locale.setDefault(java.util.Locale.FRANCE); // comma locale
|
||||
assertTrue(fullCar().build().contains("-14.5"));
|
||||
} finally {
|
||||
java.util.Locale.setDefault(previous);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void implausibleCabinTempIsOmitted() {
|
||||
// 0.0 means the property is unsupported on this VHAL.
|
||||
TelemetryPayload zero = new TelemetryPayload(1L);
|
||||
zero.cabinTemp = 0f;
|
||||
assertFalse(zero.build().contains("cabin_temp"));
|
||||
|
||||
TelemetryPayload hot = new TelemetryPayload(1L);
|
||||
hot.cabinTemp = 120f;
|
||||
assertFalse(hot.build().contains("cabin_temp"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void plausibleCabinTempIsSent() throws Exception {
|
||||
assertEquals(22, new JSONObject(fullCar().build()).getInt("cabin_temp")); // 21.5 rounds up
|
||||
}
|
||||
|
||||
// ---------- Fields added for the ABRP lower-priority set ----------
|
||||
|
||||
@Test
|
||||
public void batteryTemperatureIsSentWhenPlausible() throws Exception {
|
||||
TelemetryPayload t = new TelemetryPayload(1L);
|
||||
t.battTempC = 24.6f;
|
||||
assertEquals(25, new JSONObject(t.build()).getInt("batt_temp"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unsupportedBatteryTemperatureIsOmitted() {
|
||||
// A VHAL without the property answers 0.0; "pack at 0 °C" changes ABRP's plan.
|
||||
TelemetryPayload t = new TelemetryPayload(1L);
|
||||
t.battTempC = 0f;
|
||||
assertFalse(t.build().contains("batt_temp"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void capacityIsSentInKwhWithTwoDecimals() throws Exception {
|
||||
TelemetryPayload t = new TelemetryPayload(1L);
|
||||
t.capacityKwh = 61.734f;
|
||||
assertEquals(61.73, new JSONObject(t.build()).getDouble("capacity"), 0.001);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void zeroCapacityIsOmitted() {
|
||||
TelemetryPayload t = new TelemetryPayload(1L);
|
||||
t.capacityKwh = 0f;
|
||||
assertFalse(t.build().contains("capacity"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void odometerIsRoundedToWholeKm() throws Exception {
|
||||
TelemetryPayload t = new TelemetryPayload(1L);
|
||||
t.odometerKm = 24518.7f;
|
||||
assertEquals(24519, new JSONObject(t.build()).getInt("odometer"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void zeroOdometerIsOmitted() {
|
||||
TelemetryPayload t = new TelemetryPayload(1L);
|
||||
t.odometerKm = 0f;
|
||||
assertFalse(t.build().contains("odometer"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void hvacSetpointIsSentWhenSet() throws Exception {
|
||||
TelemetryPayload t = new TelemetryPayload(1L);
|
||||
t.hvacSetpointC = 21f;
|
||||
assertEquals(21, new JSONObject(t.build()).getInt("hvac_setpoint"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unsupportedHvacSetpointIsOmitted() {
|
||||
TelemetryPayload t = new TelemetryPayload(1L);
|
||||
t.hvacSetpointC = 0f;
|
||||
assertFalse(t.build().contains("hvac_setpoint"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void stateOfEnergyIsSentInKwh() throws Exception {
|
||||
TelemetryPayload t = fullCar();
|
||||
t.soeKwh = 41.256f;
|
||||
assertEquals(41.26, new JSONObject(t.build()).getDouble("soe"), 0.001);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unsupportedStateOfEnergyIsOmitted() {
|
||||
TelemetryPayload t = fullCar();
|
||||
t.soeKwh = 0f;
|
||||
assertFalse(t.build().contains("soe"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void chargedEnergyIsSentIncludingZeroAtSessionStart() throws Exception {
|
||||
TelemetryPayload started = fullCar();
|
||||
started.kwhCharged = 0.0;
|
||||
assertEquals(0.0, new JSONObject(started.build()).getDouble("kwh_charged"), 0.001);
|
||||
|
||||
TelemetryPayload running = fullCar();
|
||||
running.kwhCharged = 12.34;
|
||||
assertEquals(12.34, new JSONObject(running.build()).getDouble("kwh_charged"), 0.001);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void chargedEnergyIsOmittedOutsideACharge() {
|
||||
assertFalse(fullCar().build().contains("kwh_charged"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tyrePressuresAreSentPerWheelInKpa() throws Exception {
|
||||
TelemetryPayload t = new TelemetryPayload(1L);
|
||||
t.tirePressureFlKpa = 240f;
|
||||
t.tirePressureFrKpa = 241.4f;
|
||||
t.tirePressureRlKpa = 250.6f;
|
||||
t.tirePressureRrKpa = 249f;
|
||||
JSONObject json = new JSONObject(t.build());
|
||||
assertEquals(240, json.getInt("tire_pressure_fl"));
|
||||
assertEquals(241, json.getInt("tire_pressure_fr"));
|
||||
assertEquals(251, json.getInt("tire_pressure_rl"));
|
||||
assertEquals(249, json.getInt("tire_pressure_rr"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aWheelWithoutASensorIsOmittedWithoutHidingTheOthers() throws Exception {
|
||||
// 0 kPa is not a flat tyre, it is a missing sensor: a flat still reads well above 0.
|
||||
TelemetryPayload t = new TelemetryPayload(1L);
|
||||
t.tirePressureFlKpa = 0f;
|
||||
t.tirePressureFrKpa = 240f;
|
||||
JSONObject json = new JSONObject(t.build());
|
||||
assertFalse(json.has("tire_pressure_fl"));
|
||||
assertEquals(240, json.getInt("tire_pressure_fr"));
|
||||
}
|
||||
|
||||
// ---------- Location ----------
|
||||
|
||||
@Test
|
||||
public void locationIsIndependentOfTheCarLink() throws Exception {
|
||||
// No car data at all, but a GPS fix: ABRP should still see the vehicle online.
|
||||
TelemetryPayload t = new TelemetryPayload(1L);
|
||||
t.lat = 48.85;
|
||||
t.lon = 2.35;
|
||||
JSONObject json = new JSONObject(t.build());
|
||||
assertEquals(48.85, json.getDouble("lat"), 0.0001);
|
||||
assertEquals(2.35, json.getDouble("lon"), 0.0001);
|
||||
assertFalse(json.has("soc"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void missingLocationOmitsAllLocationFields() {
|
||||
String json = fullCar().build();
|
||||
assertFalse(json.contains("lat"));
|
||||
assertFalse(json.contains("lon"));
|
||||
assertFalse(json.contains("elevation"));
|
||||
assertFalse(json.contains("heading"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void elevationAndHeadingAreOptional() throws Exception {
|
||||
TelemetryPayload flat = new TelemetryPayload(1L);
|
||||
flat.lat = 48.85;
|
||||
flat.lon = 2.35;
|
||||
JSONObject flatJson = new JSONObject(flat.build());
|
||||
assertFalse(flatJson.has("elevation"));
|
||||
assertFalse(flatJson.has("heading"));
|
||||
|
||||
TelemetryPayload full = new TelemetryPayload(1L);
|
||||
full.lat = 48.85;
|
||||
full.lon = 2.35;
|
||||
full.elevation = 35.4;
|
||||
full.heading = 271.6f;
|
||||
JSONObject fullJson = new JSONObject(full.build());
|
||||
assertEquals(35, fullJson.getInt("elevation"));
|
||||
assertEquals(272, fullJson.getInt("heading"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void booleanFlagsAreEmittedAsIntegers() throws Exception {
|
||||
TelemetryPayload t = new TelemetryPayload(1L);
|
||||
t.soc = 50;
|
||||
t.powerKw = 62f;
|
||||
t.charging = true;
|
||||
t.dcfc = true;
|
||||
t.parked = true;
|
||||
JSONObject json = new JSONObject(t.build());
|
||||
assertEquals(1, json.getInt("is_charging"));
|
||||
assertEquals(1, json.getInt("is_dcfc"));
|
||||
assertEquals(1, json.getInt("is_parked"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void thePayloadIsValidJson() throws Exception {
|
||||
// The hand-built string could not guarantee this; JSONObject can.
|
||||
new JSONObject(fullCar().build());
|
||||
new JSONObject(noCar());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unimplementedOutsideTempIsOmittedNotSentAsZero() {
|
||||
// A VHAL without ENV_OUTSIDE_TEMPERATURE answers 0.0. Sent as ext_temp:0 it is a
|
||||
// freezing day as far as ABRP's consumption model is concerned.
|
||||
TelemetryPayload t = fullCar();
|
||||
t.extTemp = 0f;
|
||||
assertFalse(t.build().contains("ext_temp"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void impossibleOutsideTempIsOmitted() {
|
||||
TelemetryPayload t = fullCar();
|
||||
t.extTemp = -273f;
|
||||
assertFalse(t.build().contains("ext_temp"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void summaryListsSentValuesAndOmittedFields() {
|
||||
String summary = TelemetryPayload.summarize(fullCar().build());
|
||||
assertTrue(summary, summary.contains("soc=63"));
|
||||
assertTrue(summary, summary.contains("ext_temp=12"));
|
||||
assertTrue(summary, summary.contains("cabin_temp=22"));
|
||||
// Nothing gave us a position, so lat/lon must show up as omitted, not as 0.
|
||||
assertFalse(summary, summary.contains("lat="));
|
||||
assertTrue(summary, summary.contains("| omitted: "));
|
||||
assertTrue(summary, summary.substring(summary.indexOf("| omitted: ")).contains("lat"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void summaryOfAnEmptyPayloadReportsEverythingOmitted() {
|
||||
String summary = TelemetryPayload.summarize(noCar());
|
||||
assertTrue(summary, summary.startsWith("utc=1700000000"));
|
||||
assertTrue(summary, summary.contains("| omitted: soc,speed,"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* Cadence policy. The head unit is awake for as long as the car is on, so every avoidable
|
||||
* upload and every avoidable GPS fix costs power — but nothing may be lost while driving,
|
||||
* charging, or running low on battery.
|
||||
*/
|
||||
public class UploadCadenceTest {
|
||||
|
||||
private static final long T0 = 1_700_000_000_000L;
|
||||
|
||||
private final UploadSettings defaults = UploadSettings.defaults();
|
||||
|
||||
private UploadSettings settings(int intervalSec, boolean boost, int lowSoc) {
|
||||
return new UploadSettings(intervalSec, boost, lowSoc);
|
||||
}
|
||||
|
||||
// ---- Basic cadence ----
|
||||
|
||||
@Test
|
||||
public void theFirstUploadIsImmediate() {
|
||||
assertTrue(UploadCadence.shouldUpload(T0, 0L, true, false, 80, false, defaults));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void drivingUsesTheConfiguredInterval() {
|
||||
UploadSettings s = settings(60, false, 20);
|
||||
assertFalse(UploadCadence.shouldUpload(T0 + 59_000, T0, false, false, 80, false, s));
|
||||
assertTrue(UploadCadence.shouldUpload(T0 + 60_000, T0, false, false, 80, false, s));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theDefaultIsOneMinute() {
|
||||
assertEquals(60, UploadSettings.DEFAULT_INTERVAL_SEC);
|
||||
assertEquals(60_000L, UploadCadence.intervalFor(false, false, 80, defaults));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void chargingKeepsTheActiveIntervalEvenWhenParked() {
|
||||
// Charging at a stop is exactly when ABRP needs a tight SOC curve.
|
||||
assertEquals(defaults.intervalMs(),
|
||||
UploadCadence.intervalFor(true, true, 50, defaults));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parkedAndIdleBacksOffToFifteenMinutes() {
|
||||
assertEquals(900_000L, UploadCadence.intervalFor(true, false, 50, defaults));
|
||||
assertFalse(UploadCadence.shouldUpload(T0 + 899_000, T0, true, false, 50, false, defaults));
|
||||
assertTrue(UploadCadence.shouldUpload(T0 + 900_000, T0, true, false, 50, false, defaults));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aStateChangeUploadsImmediately() {
|
||||
// Just parked, plugged in or unplugged: ABRP must not wait for the slow interval.
|
||||
assertTrue(UploadCadence.shouldUpload(T0 + 1_000, T0, true, false, 50, true, defaults));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unknownStateCountsAsActive() {
|
||||
// An unreadable gear or charge port does not prove the car is idle.
|
||||
assertEquals(defaults.intervalMs(), UploadCadence.intervalFor(null, null, 80, defaults));
|
||||
assertEquals(defaults.intervalMs(), UploadCadence.intervalFor(true, null, 80, defaults));
|
||||
assertEquals(defaults.intervalMs(), UploadCadence.intervalFor(null, false, 80, defaults));
|
||||
}
|
||||
|
||||
// ---- Low-SOC boost ----
|
||||
|
||||
@Test
|
||||
public void lowSocBoostsTheCadence() {
|
||||
UploadSettings s = settings(300, true, 20);
|
||||
assertEquals(15_000L, UploadCadence.intervalFor(false, false, 19, s));
|
||||
assertEquals(15_000L, UploadCadence.intervalFor(false, false, 20, s)); // inclusive
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aboveTheThresholdThereIsNoBoost() {
|
||||
UploadSettings s = settings(300, true, 20);
|
||||
assertEquals(300_000L, UploadCadence.intervalFor(false, false, 21, s));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theThresholdIsConfigurable() {
|
||||
UploadSettings s = settings(300, true, 35);
|
||||
assertEquals(15_000L, UploadCadence.intervalFor(false, false, 30, s));
|
||||
assertEquals(300_000L, UploadCadence.intervalFor(false, false, 40, s));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theBoostCanBeDisabled() {
|
||||
UploadSettings s = settings(300, false, 20);
|
||||
assertEquals(300_000L, UploadCadence.intervalFor(false, false, 5, s));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theBoostNeverSlowsAFasterConfiguredInterval() {
|
||||
// Configured 15 s with the boost on must not become slower at low SOC.
|
||||
UploadSettings s = settings(15, true, 20);
|
||||
assertEquals(15_000L, UploadCadence.intervalFor(false, false, 10, s));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anUnreadableSocDoesNotBoost() {
|
||||
// No reading is not a low reading; boosting on unknown would poll fast forever.
|
||||
UploadSettings s = settings(300, true, 20);
|
||||
assertEquals(300_000L, UploadCadence.intervalFor(false, false, null, s));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aParkedCarDoesNotBoostEvenAtLowSoc() {
|
||||
// Parked at 5 % and unplugged: still parked, still nothing to report.
|
||||
UploadSettings s = settings(60, true, 20);
|
||||
assertEquals(900_000L, UploadCadence.intervalFor(true, false, 5, s));
|
||||
}
|
||||
|
||||
// ---- Measured effect ----
|
||||
|
||||
@Test
|
||||
public void requestCountOverAnHourMatchesThePolicy() {
|
||||
// Counts exclude the baseline first upload, hence one short of the round number.
|
||||
assertEquals("parked and idle: one upload every 15 min",
|
||||
3, countUploadsOverAnHour(true, false, 50, defaults));
|
||||
assertEquals("driving at the default 60 s",
|
||||
59, countUploadsOverAnHour(false, false, 50, defaults));
|
||||
assertEquals("driving at low SOC: boosted to 15 s",
|
||||
239, countUploadsOverAnHour(false, false, 10, defaults));
|
||||
|
||||
// The point of the exercise: the old fixed 15 s cadence sent 239 uploads an hour
|
||||
// in every state, including parked overnight.
|
||||
assertTrue(countUploadsOverAnHour(true, false, 50, defaults)
|
||||
< countUploadsOverAnHour(false, false, 50, defaults));
|
||||
}
|
||||
|
||||
/** Simulates one hour of 15 s scheduler ticks with no state change. */
|
||||
private long countUploadsOverAnHour(Boolean parked, Boolean charging,
|
||||
Integer soc, UploadSettings settings) {
|
||||
long uploads = 0;
|
||||
long lastUpload = 0L;
|
||||
for (long tick = 0; tick < 3_600_000L; tick += 15_000L) {
|
||||
long now = T0 + tick;
|
||||
if (lastUpload == 0L) { // ignore the immediate first upload
|
||||
lastUpload = now;
|
||||
continue;
|
||||
}
|
||||
if (UploadCadence.shouldUpload(now, lastUpload, parked, charging, soc,
|
||||
false, settings)) {
|
||||
uploads++;
|
||||
lastUpload = now;
|
||||
}
|
||||
}
|
||||
return uploads;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Upload history and the service state derived from it. A single failed upload is normal
|
||||
* in a car (tunnel, dead spot); three in a row means something is actually wrong.
|
||||
*/
|
||||
public class UploadLogTest {
|
||||
|
||||
private UploadLog log;
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
log = new UploadLog();
|
||||
}
|
||||
|
||||
private UploadLog.Entry ok() {
|
||||
return new UploadLog.Entry(1L, 200, true, "OK");
|
||||
}
|
||||
|
||||
private UploadLog.Entry fail(int status, String detail) {
|
||||
return new UploadLog.Entry(1L, status, false, detail);
|
||||
}
|
||||
|
||||
// ---- Ring buffer ----
|
||||
|
||||
@Test
|
||||
public void keepsOnlyTheLastTwentyCalls() {
|
||||
for (int i = 0; i < 25; i++) {
|
||||
log.record(new UploadLog.Entry(i, 200, true, "call" + i));
|
||||
}
|
||||
List<UploadLog.Entry> recent = log.recent();
|
||||
assertEquals(UploadLog.MAX_ENTRIES, recent.size());
|
||||
// Most recent first.
|
||||
assertEquals("call24", recent.get(0).detail);
|
||||
assertEquals("call5", recent.get(recent.size() - 1).detail);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anEmptyLogHasNoLatestEntry() {
|
||||
assertNull(log.latest());
|
||||
assertTrue(log.recent().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void recordsTheHttpStatusAndDetail() {
|
||||
log.record(fail(401, "HTTP 401"));
|
||||
UploadLog.Entry latest = log.latest();
|
||||
assertEquals(401, latest.httpStatus);
|
||||
assertEquals("HTTP 401", latest.detail);
|
||||
assertEquals(false, latest.success);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aRequestThatNeverReachedAServerHasNoStatus() {
|
||||
// Status 0 is how the UI knows to show "---" rather than a bogus code.
|
||||
log.record(fail(0, "No internet"));
|
||||
assertEquals(0, log.latest().httpStatus);
|
||||
}
|
||||
|
||||
// ---- Error state ----
|
||||
|
||||
@Test
|
||||
public void stoppedWhenTheServiceIsNotRunning() {
|
||||
log.record(ok());
|
||||
assertEquals(UploadLog.State.STOPPED, log.state(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startingWhileRunningWithNoAttemptYet() {
|
||||
assertEquals(UploadLog.State.STARTING, log.state(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void runningAfterASuccess() {
|
||||
log.record(ok());
|
||||
assertEquals(UploadLog.State.RUNNING, log.state(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void oneOrTwoFailuresAreNotAnError() {
|
||||
// A tunnel or a dead spot must not alarm the driver.
|
||||
log.record(fail(0, "No internet"));
|
||||
assertEquals(UploadLog.State.RUNNING, log.state(true));
|
||||
log.record(fail(0, "No internet"));
|
||||
assertEquals(UploadLog.State.RUNNING, log.state(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void threeConsecutiveFailuresAreAnError() {
|
||||
for (int i = 0; i < 3; i++) log.record(fail(500, "HTTP 500"));
|
||||
assertEquals(UploadLog.State.ERROR, log.state(true));
|
||||
assertEquals(3, log.consecutiveFailures());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aSuccessClearsTheErrorState() {
|
||||
for (int i = 0; i < 5; i++) log.record(fail(500, "HTTP 500"));
|
||||
assertEquals(UploadLog.State.ERROR, log.state(true));
|
||||
|
||||
log.record(ok());
|
||||
assertEquals(UploadLog.State.RUNNING, log.state(true));
|
||||
assertEquals(0, log.consecutiveFailures());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failuresMustBeConsecutiveToCount() {
|
||||
log.record(fail(500, "a"));
|
||||
log.record(fail(500, "b"));
|
||||
log.record(ok());
|
||||
log.record(fail(500, "c"));
|
||||
// 2 failures, then a success, then 1: not three in a row.
|
||||
assertEquals(1, log.consecutiveFailures());
|
||||
assertEquals(UploadLog.State.RUNNING, log.state(true));
|
||||
}
|
||||
|
||||
// ---- Threading ----
|
||||
|
||||
@Test
|
||||
public void concurrentWritesAndReadsStayConsistent() throws Exception {
|
||||
// Written from the scheduler thread, read from the UI thread.
|
||||
int threads = 6;
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
CountDownLatch done = new CountDownLatch(threads);
|
||||
for (int t = 0; t < threads; t++) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
start.await();
|
||||
for (int i = 0; i < 200; i++) {
|
||||
log.record(ok());
|
||||
log.recent();
|
||||
log.state(true);
|
||||
}
|
||||
} catch (InterruptedException ignored) {
|
||||
} finally {
|
||||
done.countDown();
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
start.countDown();
|
||||
assertTrue(done.await(30, TimeUnit.SECONDS));
|
||||
assertEquals(UploadLog.MAX_ENTRIES, log.recent().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void entryCarriesThePayloadSummaryAndDefaultsItToNull() {
|
||||
UploadLog log = new UploadLog();
|
||||
log.record(new UploadLog.Entry(1L, 200, true, "OK", "soc=63 | omitted: lat,lon"));
|
||||
log.record(new UploadLog.Entry(2L, 0, false, "No internet"));
|
||||
java.util.List<UploadLog.Entry> recent = log.recent();
|
||||
assertNull(recent.get(0).summary);
|
||||
assertEquals("soc=63 | omitted: lat,lon", recent.get(1).summary);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* OTA update policy — unstable channel only. The updater installs code on a vehicle, so the
|
||||
* origin checks are the part that must not regress.
|
||||
*/
|
||||
public class OtaUpdaterTest {
|
||||
|
||||
// ---- URL allowlist ----
|
||||
|
||||
@Test
|
||||
public void httpsFromAnAllowedHostIsAccepted() {
|
||||
assertTrue(OtaUpdater.isAllowedUrl(
|
||||
"https://github.com/malys/EVABRPUploader/releases/download/v1.2.0/app.apk"));
|
||||
assertTrue(OtaUpdater.isAllowedUrl(
|
||||
"https://objects.githubusercontent.com/github-production-release-asset/x.apk"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void httpIsRejectedEvenOnAnAllowedHost() {
|
||||
assertFalse(OtaUpdater.isAllowedUrl("http://github.com/malys/app.apk"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void foreignHostsAreRejected() {
|
||||
assertFalse(OtaUpdater.isAllowedUrl("https://evil.example.com/app.apk"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lookalikeHostsAreRejected() {
|
||||
// Exact match, never a suffix test.
|
||||
assertFalse(OtaUpdater.isAllowedUrl("https://github.com.attacker.net/app.apk"));
|
||||
assertFalse(OtaUpdater.isAllowedUrl("https://evil-github.com/app.apk"));
|
||||
assertFalse(OtaUpdater.isAllowedUrl("https://notgithub.com/app.apk"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unparsableOrNonHttpUrlsAreRejected() {
|
||||
assertFalse(OtaUpdater.isAllowedUrl(""));
|
||||
assertFalse(OtaUpdater.isAllowedUrl("not a url"));
|
||||
assertFalse(OtaUpdater.isAllowedUrl("ftp://github.com/app.apk"));
|
||||
assertFalse(OtaUpdater.isAllowedUrl("file:///sdcard/Download/app.apk"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void hostMatchingIgnoresCase() {
|
||||
assertTrue(OtaUpdater.isAllowedUrl("https://GitHub.com/malys/app.apk"));
|
||||
assertTrue(OtaUpdater.isAllowedUrl("HTTPS://github.com/malys/app.apk"));
|
||||
}
|
||||
|
||||
// ---- Version comparison ----
|
||||
|
||||
@Test
|
||||
public void higherVersionsAreNewer() {
|
||||
assertTrue(OtaUpdater.isNewer("1.2.1", "1.2.0"));
|
||||
assertTrue(OtaUpdater.isNewer("1.3.0", "1.2.9"));
|
||||
assertTrue(OtaUpdater.isNewer("2.0.0", "1.99.99"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void equalOrLowerVersionsAreNotNewer() {
|
||||
assertFalse(OtaUpdater.isNewer("1.2.0", "1.2.0"));
|
||||
assertFalse(OtaUpdater.isNewer("1.1.9", "1.2.0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theUnstableSuffixIsIgnored() {
|
||||
// The installed unstable reports "1.0.42-unstable"; that must not read as older
|
||||
// than the "v1.0.42" tag it was built from, or it would update to itself forever.
|
||||
assertFalse(OtaUpdater.isNewer("v1.0.42", "1.0.42-unstable"));
|
||||
assertTrue(OtaUpdater.isNewer("v1.0.43", "1.0.42-unstable"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unstableBuildNumbersCompareAsVersions() {
|
||||
// The CI names unstable assets "<app>-unstable-<base>.<run>.apk" precisely so this
|
||||
// works. A name that parses to 0 would never look newer than an installed build,
|
||||
// so the channel would silently never update.
|
||||
assertTrue(OtaUpdater.isNewer("v1.0.100", "1.0.99-unstable"));
|
||||
assertFalse(OtaUpdater.isNewer("v1.0.41", "1.0.42-unstable"));
|
||||
assertArrayEquals(new int[]{0}, OtaUpdater.segments("unstable-43"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void versionIsReadFromTheAssetName() {
|
||||
// The release tag is the constant "unstable", so the asset name carries the build.
|
||||
assertEquals("1.0.42", OtaUpdater.versionFromAssetName("EVABRPUploader-unstable-1.0.42.apk"));
|
||||
assertEquals("1.0.100", OtaUpdater.versionFromAssetName("EVABRPUploader-unstable-1.0.100.APK"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void assetNameWithoutAVersionIsIgnored() {
|
||||
assertNull(OtaUpdater.versionFromAssetName("EVABRPUploader-unstable.apk"));
|
||||
assertNull(OtaUpdater.versionFromAssetName("unstable"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void versionCoreParsingKeepsSegmentPositions() {
|
||||
assertArrayEquals(new int[]{1, 2, 3}, OtaUpdater.segments("v1.2.3"));
|
||||
assertArrayEquals(new int[]{1, 2, 3}, OtaUpdater.segments("1.2.3-unstable"));
|
||||
assertArrayEquals(new int[]{1, 2, 3}, OtaUpdater.segments("1.2.3+build7"));
|
||||
// A non-numeric segment is 0, not dropped.
|
||||
assertArrayEquals(new int[]{1, 0, 5}, OtaUpdater.segments("1.x.5"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pmInstallRequiresBothSuccessTextAndZeroExit() {
|
||||
assertTrue(OtaUpdater.installSucceeded(0, "Success\n"));
|
||||
assertFalse(OtaUpdater.installSucceeded(1, "Success\n"));
|
||||
assertFalse(OtaUpdater.installSucceeded(0, "Failure [INSTALL_FAILED]"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:sharedUserId="android.uid.system" />
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.pm.Signature;
|
||||
import android.content.pm.SigningInfo;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/** SHA-256 fingerprints of the certificates signing an APK. Unstable builds only. */
|
||||
final class ApkSignature {
|
||||
|
||||
private static final String TAG = "ApkSignature";
|
||||
|
||||
private ApkSignature() { }
|
||||
|
||||
/** Fingerprints of the APK file at [path]; empty when it cannot be read. */
|
||||
static Set<String> of(Context context, String path) {
|
||||
try {
|
||||
PackageInfo info = context.getPackageManager()
|
||||
.getPackageArchiveInfo(path, flags());
|
||||
return digests(info);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Cannot read archive signature: " + e.getMessage());
|
||||
return Collections.emptySet();
|
||||
}
|
||||
}
|
||||
|
||||
/** Fingerprints of the running app; empty when they cannot be read. */
|
||||
static Set<String> ofPackage(Context context) {
|
||||
try {
|
||||
PackageInfo info = context.getPackageManager()
|
||||
.getPackageInfo(context.getPackageName(), flags());
|
||||
return digests(info);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Cannot read our own signature: " + e.getMessage());
|
||||
return Collections.emptySet();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
private static int flags() {
|
||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
|
||||
? PackageManager.GET_SIGNING_CERTIFICATES
|
||||
: PackageManager.GET_SIGNATURES;
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
private static Set<String> digests(PackageInfo info) {
|
||||
if (info == null) return Collections.emptySet();
|
||||
|
||||
Signature[] signatures;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
SigningInfo signingInfo = info.signingInfo;
|
||||
if (signingInfo == null) return Collections.emptySet();
|
||||
signatures = signingInfo.hasMultipleSigners()
|
||||
? signingInfo.getApkContentsSigners()
|
||||
: signingInfo.getSigningCertificateHistory();
|
||||
} else {
|
||||
signatures = info.signatures;
|
||||
}
|
||||
if (signatures == null) return Collections.emptySet();
|
||||
|
||||
Set<String> out = new HashSet<>();
|
||||
try {
|
||||
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
|
||||
for (Signature signature : signatures) {
|
||||
if (signature == null) continue;
|
||||
StringBuilder hex = new StringBuilder();
|
||||
for (byte b : sha256.digest(signature.toByteArray())) {
|
||||
hex.append(String.format("%02x", b));
|
||||
}
|
||||
out.add(hex.toString());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Digest failed: " + e.getMessage());
|
||||
return Collections.emptySet();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Over-the-air updater — UNSTABLE BUILDS ONLY.
|
||||
*
|
||||
* The stable channel deliberately has no self-update path: this class does not exist in a
|
||||
* stable build. Unstable testers get updates without manual work, and accept that channel's
|
||||
* risk.
|
||||
*
|
||||
* Security posture is the one EVProfile settled on in its own OTA work:
|
||||
* - the APK URL comes from a remote JSON document and is never trusted: https only,
|
||||
* exact-match host allowlist, checked again before it reaches the system downloader;
|
||||
* - the downloaded APK must be signed by the same certificate as the running app, or it
|
||||
* is deleted rather than offered for install;
|
||||
* - both checks fail closed.
|
||||
*/
|
||||
final class OtaUpdater {
|
||||
|
||||
private static final String TAG = "OtaUpdater";
|
||||
private static final String CACHE_PREFIX = "EVABRPUploader-ota-";
|
||||
|
||||
/**
|
||||
* Pre-releases live here; the unstable channel tracks them.
|
||||
*
|
||||
* This fork's own repository, deliberately: an update pulled from upstream would be
|
||||
* signed with a different key, and Android refuses to install over a platform-signed
|
||||
* build with anything but the same signature. Pointing here means the check finds
|
||||
* nothing until this repository publishes a release, which is the intended quiet.
|
||||
*/
|
||||
private static final String RELEASES_API =
|
||||
"https://api.github.com/repos/fatihdonmezdev/EVABRP/releases";
|
||||
|
||||
/**
|
||||
* Hosts an update may come from. The githubusercontent entries are the CDNs GitHub
|
||||
* redirects release-asset downloads to; without them the download fails.
|
||||
*/
|
||||
private static final List<String> ALLOWED_HOSTS = Arrays.asList(
|
||||
"api.github.com",
|
||||
"github.com",
|
||||
"objects.githubusercontent.com",
|
||||
"release-assets.githubusercontent.com");
|
||||
|
||||
private static final java.util.regex.Pattern ASSET_VERSION =
|
||||
java.util.regex.Pattern.compile("-(\\d[0-9.]*?)\\.apk$",
|
||||
java.util.regex.Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private static final int TIMEOUT_MS = 10_000;
|
||||
|
||||
private OtaUpdater() { }
|
||||
|
||||
static void purgeCachedApks(Context context) {
|
||||
File[] files = context.getCacheDir().listFiles((dir, name) ->
|
||||
name.startsWith(CACHE_PREFIX) && name.endsWith(".apk"));
|
||||
if (files == null) return;
|
||||
for (File file : files) {
|
||||
if (!file.delete()) Log.w(TAG, "Could not purge cached OTA APK: " + file.getName());
|
||||
}
|
||||
}
|
||||
|
||||
/** Result of a check: null when there is nothing newer or the check failed. */
|
||||
static final class Update {
|
||||
final String versionName;
|
||||
final String apkUrl;
|
||||
|
||||
Update(String versionName, String apkUrl) {
|
||||
this.versionName = versionName;
|
||||
this.apkUrl = apkUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True if [url] is https and points at an allowed host.
|
||||
*
|
||||
* Rejects http (including an https -> http downgrade), unknown hosts, unparsable
|
||||
* URLs, and lookalikes such as "github.com.attacker.net" — the host match is exact,
|
||||
* never a suffix test.
|
||||
*/
|
||||
static boolean isAllowedUrl(String url) {
|
||||
URI uri;
|
||||
try {
|
||||
uri = new URI(url);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
if (uri.getScheme() == null || !uri.getScheme().equalsIgnoreCase("https")) return false;
|
||||
String host = uri.getHost();
|
||||
return host != null && ALLOWED_HOSTS.contains(host.toLowerCase(java.util.Locale.US));
|
||||
}
|
||||
|
||||
/**
|
||||
* Numeric core of a version: "v1.2.3-unstable" -> [1, 2, 3].
|
||||
*
|
||||
* A segment with no digits becomes 0 rather than being dropped, so later segments do
|
||||
* not shift left and turn a patch into a minor.
|
||||
*/
|
||||
static int[] segments(String version) {
|
||||
String core = version.startsWith("v") || version.startsWith("V")
|
||||
? version.substring(1) : version;
|
||||
int cut = core.indexOf('+');
|
||||
if (cut >= 0) core = core.substring(0, cut);
|
||||
cut = core.indexOf('-');
|
||||
if (cut >= 0) core = core.substring(0, cut);
|
||||
|
||||
String[] parts = core.split("\\.");
|
||||
int[] out = new int[parts.length];
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
int digits = 0;
|
||||
while (digits < parts[i].length() && Character.isDigit(parts[i].charAt(digits))) digits++;
|
||||
try {
|
||||
out[i] = digits == 0 ? 0 : Integer.parseInt(parts[i].substring(0, digits));
|
||||
} catch (NumberFormatException e) {
|
||||
out[i] = 0;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Version carried by an unstable asset name:
|
||||
* "EVABRPUploader-unstable-1.0.42.apk" -> "1.0.42".
|
||||
*
|
||||
* The release tag is the fixed string "unstable" (one rolling pre-release), so the asset
|
||||
* name is what identifies a build. Returns null when the name carries no version.
|
||||
*/
|
||||
static String versionFromAssetName(String assetName) {
|
||||
java.util.regex.Matcher m = ASSET_VERSION.matcher(assetName);
|
||||
return m.find() ? m.group(1) : null;
|
||||
}
|
||||
|
||||
/** True if [remote] is a strictly higher version than [current]. */
|
||||
static boolean isNewer(String remote, String current) {
|
||||
int[] r = segments(remote);
|
||||
int[] c = segments(current);
|
||||
for (int i = 0; i < Math.max(r.length, c.length); i++) {
|
||||
int rv = i < r.length ? r[i] : 0;
|
||||
int cv = i < c.length ? c[i] : 0;
|
||||
if (rv > cv) return true;
|
||||
if (rv < cv) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks GitHub for the newest pre-release and returns it if it beats [currentVersion].
|
||||
* Runs on the caller's thread — never call from the main thread.
|
||||
*/
|
||||
static Update check(String currentVersion) {
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
conn = (HttpURLConnection) new URL(RELEASES_API).openConnection();
|
||||
conn.setInstanceFollowRedirects(false);
|
||||
conn.setRequestProperty("Accept", "application/vnd.github.v3+json");
|
||||
conn.setRequestProperty("User-Agent", "EVABRPUploader-Android");
|
||||
conn.setConnectTimeout(TIMEOUT_MS);
|
||||
conn.setReadTimeout(TIMEOUT_MS);
|
||||
if (conn.getResponseCode() != 200) {
|
||||
Log.w(TAG, "Release API returned " + conn.getResponseCode());
|
||||
return null;
|
||||
}
|
||||
|
||||
StringBuilder body = new StringBuilder();
|
||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(
|
||||
conn.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) body.append(line);
|
||||
}
|
||||
|
||||
JSONArray releases = new JSONArray(body.toString());
|
||||
|
||||
// Scan every entry and keep the HIGHEST version, not simply the first one
|
||||
// that beats the installed build: the API's ordering is by creation date, and
|
||||
// a re-published or back-dated release would otherwise win over a newer one.
|
||||
//
|
||||
// Stable releases are skipped on purpose. This channel tracks pre-releases
|
||||
// only — and a stable APK could not update a unstable install anyway, since
|
||||
// the unstable applicationId carries a .unstable suffix.
|
||||
//
|
||||
// The version comes from the asset name, not the tag: the unstable channel is
|
||||
// a single rolling pre-release tagged "unstable", overwritten on every build.
|
||||
Update best = null;
|
||||
for (int i = 0; i < releases.length(); i++) {
|
||||
JSONObject release = releases.getJSONObject(i);
|
||||
if (!release.optBoolean("prerelease", false)) continue;
|
||||
|
||||
JSONArray assets = release.optJSONArray("assets");
|
||||
if (assets == null) continue;
|
||||
for (int a = 0; a < assets.length(); a++) {
|
||||
JSONObject asset = assets.getJSONObject(a);
|
||||
String name = asset.optString("name", "");
|
||||
if (!name.toLowerCase(java.util.Locale.US).endsWith(".apk")) continue;
|
||||
if (!name.contains("unstable")) continue;
|
||||
|
||||
String version = versionFromAssetName(name);
|
||||
if (version == null) continue;
|
||||
if (!isNewer(version, currentVersion)) continue;
|
||||
if (best != null && !isNewer(version, best.versionName)) continue;
|
||||
|
||||
String url = asset.optString("browser_download_url", "");
|
||||
if (!isAllowedUrl(url)) {
|
||||
Log.w(TAG, "Rejected update URL from an unexpected host: " + url);
|
||||
continue;
|
||||
}
|
||||
best = new Update(version, url);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Update check failed: " + e.getMessage());
|
||||
return null;
|
||||
} finally {
|
||||
if (conn != null) conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe diagnostic name for the downloaded APK. The version comes from a remote
|
||||
* asset name, so it is reduced to a safe character set before it reaches a path. Callers that
|
||||
* look for an already-downloaded update must use this same name.
|
||||
*/
|
||||
static String downloadFileName(String versionName) {
|
||||
String safe = (versionName == null || versionName.isEmpty())
|
||||
? "unknown"
|
||||
: versionName.toLowerCase(Locale.US).replaceAll("[^a-z0-9._-]", "_");
|
||||
return "EVABRPUploader-unstable-" + safe + ".apk";
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads into private cache. Every redirect URL is validated before it is followed.
|
||||
*/
|
||||
static File download(Context context, Update update) {
|
||||
if (!isAllowedUrl(update.apkUrl)) {
|
||||
Log.w(TAG, "Refusing to download from " + update.apkUrl);
|
||||
return null;
|
||||
}
|
||||
File target = new File(context.getCacheDir(), CACHE_PREFIX
|
||||
+ java.util.UUID.randomUUID() + ".apk");
|
||||
File temporary = null;
|
||||
try {
|
||||
temporary = File.createTempFile(CACHE_PREFIX, ".apk", context.getCacheDir());
|
||||
URL current = new URL(update.apkUrl);
|
||||
for (int redirects = 0; redirects <= 5; redirects++) {
|
||||
if (!isAllowedUrl(current.toString())) return null;
|
||||
HttpURLConnection connection = (HttpURLConnection) current.openConnection();
|
||||
connection.setInstanceFollowRedirects(false);
|
||||
connection.setConnectTimeout(TIMEOUT_MS);
|
||||
connection.setReadTimeout(TIMEOUT_MS);
|
||||
try {
|
||||
int status = connection.getResponseCode();
|
||||
if (status >= 300 && status <= 399) {
|
||||
String location = connection.getHeaderField("Location");
|
||||
if (location == null) return null;
|
||||
current = current.toURI().resolve(location).toURL();
|
||||
if (!isAllowedUrl(current.toString())) return null;
|
||||
continue;
|
||||
}
|
||||
if (status != HttpURLConnection.HTTP_OK) return null;
|
||||
try (FileOutputStream output = new FileOutputStream(temporary)) {
|
||||
try (java.io.InputStream input = connection.getInputStream()) {
|
||||
byte[] buffer = new byte[32 * 1024];
|
||||
int count;
|
||||
while ((count = input.read(buffer)) != -1) output.write(buffer, 0, count);
|
||||
}
|
||||
output.getFD().sync();
|
||||
}
|
||||
if (!temporary.renameTo(target)) return null;
|
||||
return target;
|
||||
} finally {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Update download failed", e);
|
||||
} finally {
|
||||
if (temporary != null && temporary.exists()) temporary.delete();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static boolean install(Context context, File apk) {
|
||||
if (!signatureMatchesRunningApp(context, apk)) return false;
|
||||
try {
|
||||
Process process = new ProcessBuilder("/system/bin/pm", "install", "-r",
|
||||
apk.getAbsolutePath()).redirectErrorStream(true).start();
|
||||
StringBuilder output = new StringBuilder();
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(
|
||||
process.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) output.append(line).append('\n');
|
||||
}
|
||||
int exitCode = process.waitFor();
|
||||
return installSucceeded(exitCode, output.toString());
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Update install failed", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static boolean installSucceeded(int exitCode, String output) {
|
||||
return exitCode == 0 && output.contains("Success");
|
||||
}
|
||||
|
||||
/**
|
||||
* True if [apk] is signed by the same certificate as the running app.
|
||||
*
|
||||
* Fail closed: an unreadable archive, a missing signature or a failed API call all
|
||||
* return false. The caller must delete the file rather than offer it for install.
|
||||
*/
|
||||
static boolean signatureMatchesRunningApp(Context context, File apk) {
|
||||
java.util.Set<String> archive = ApkSignature.of(context, apk.getAbsolutePath());
|
||||
java.util.Set<String> installed = ApkSignature.ofPackage(context);
|
||||
boolean ok = !archive.isEmpty() && !installed.isEmpty() && archive.equals(installed);
|
||||
if (!ok) {
|
||||
Log.w(TAG, "Signature mismatch — refusing update ("
|
||||
+ archive.size() + " vs " + installed.size() + " cert(s))");
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
/**
|
||||
* OTA is suspended while the suite safety and legal audit is open.
|
||||
* Keep this flavour seam inert until a reviewed change explicitly re-enables it.
|
||||
*/
|
||||
final class UpdateHook {
|
||||
private UpdateHook() { }
|
||||
|
||||
static boolean isSupported() { return false; }
|
||||
static void checkInBackground(Context context) { }
|
||||
}
|
||||
Reference in New Issue
Block a user