chore
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
[{"eventName":"NEXT_CLI_SESSION_STOPPED","payload":{"nextVersion":"16.4.0","nodeVersion":"v24.12.0","cliCommand":"dev","durationMilliseconds":348,"turboFlag":true,"pagesDir":false,"appDir":true,"isRspack":false}}]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[{"eventName":"NEXT_CLI_SESSION_STOPPED","payload":{"nextVersion":"16.4.0","nodeVersion":"v24.12.0","cliCommand":"dev","durationMilliseconds":345,"turboFlag":true,"pagesDir":false,"appDir":true,"isRspack":false}}]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"pages": {
|
||||||
|
"/_app": []
|
||||||
|
},
|
||||||
|
"devFiles": [],
|
||||||
|
"polyfillFiles": [],
|
||||||
|
"lowPriorityFiles": [
|
||||||
|
"static/development/_buildManifest.js",
|
||||||
|
"static/development/_ssgManifest.js",
|
||||||
|
"static/development/_clientMiddlewareManifest.js"
|
||||||
|
],
|
||||||
|
"rootMainFiles": [],
|
||||||
|
"rootMainFilesTree": {},
|
||||||
|
"pagesChunkGroupBootstrapParams": {}
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"encryption.key":"UT9PfJliySgklhhPCIM4wpEhmx9PMD9GPB4iuPIamyM=","encryption.expire_at":1792761221411}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"max_sequence_number":0,"commit_time":"2026-10-09T13:13:41.418251Z"}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"pages": {
|
||||||
|
"/_app": []
|
||||||
|
},
|
||||||
|
"devFiles": [],
|
||||||
|
"polyfillFiles": [],
|
||||||
|
"lowPriorityFiles": [
|
||||||
|
"static/development/_buildManifest.js",
|
||||||
|
"static/development/_ssgManifest.js",
|
||||||
|
"static/development/_clientMiddlewareManifest.js"
|
||||||
|
],
|
||||||
|
"rootMainFiles": [],
|
||||||
|
"rootMainFilesTree": {},
|
||||||
|
"pagesChunkGroupBootstrapParams": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"pid":79123,"port":3001,"hostname":"localhost","appUrl":"http://localhost:3001","startedAt":1791551621304}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{"timestamp":"00:00:01.802","source":"Server","level":"ERROR","message":"Turbopack build encountered 1 error:\n./app\nError: Could not find the Next.js package (next/package.json)\nResolved from: /Users/tcfdonmez/Desktop/Coding/MG/EVABRPUploader/app\nFilesystem root used for resolution: /Users/tcfdonmez/Desktop/Coding/MG/EVABRPUploader\n\nPossible causes:\n - node_modules is being reorganized by a concurrent install (e.g. pnpm adding a package with a `next` peer dependency). This is transient and should clear once the install completes.\n - node_modules/next was removed, renamed, or has a broken symlink.\n - The workspace root is incorrect — see turbopack.root in the Next.js config docs for how to configure it.\n - In a monorepo, the Next.js package may only exist in a directory above the closest directory containing a package manager lockfile. The workspace root is detected by locating the nearest package manager lockfile.\n - Next.js is installed globally rather than as a project dependency. This is not supported; install it locally.\n\nNote: To ensure a hermetic build and a portable cache, files outside of the workspace root are not compiled.\n\nhttps://nextjs.org/docs/app/api-reference/config/next-config-js/turbopack#root-directory\n\n"}
|
||||||
|
{"timestamp":"00:00:02.079","source":"Server","level":"LOG","message":""}
|
||||||
|
{"timestamp":"00:00:02.080","source":"Server","level":"LOG","message":"✓ Generated AGENTS.md for AI agents. Set `agentRules: false` in next.config to disable."}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "commonjs"
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"version": 4,
|
||||||
|
"routes": {},
|
||||||
|
"dynamicRoutes": {},
|
||||||
|
"notFoundRoutes": []
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"caseSensitive":false,"basePath":"","rewrites":{"beforeFiles":[],"afterFiles":[],"fallback":[]},"redirects":[{"source":"/:path+/","destination":"/:path+","permanent":true,"internal":true,"priority":true,"regex":"^(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))\\/$"}],"headers":[],"onMatchHeaders":[]}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]";
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
globalThis.__BUILD_MANIFEST = {
|
||||||
|
"pages": {
|
||||||
|
"/_app": []
|
||||||
|
},
|
||||||
|
"devFiles": [],
|
||||||
|
"polyfillFiles": [],
|
||||||
|
"lowPriorityFiles": [
|
||||||
|
"static/development/_buildManifest.js",
|
||||||
|
"static/development/_ssgManifest.js",
|
||||||
|
"static/development/_clientMiddlewareManifest.js"
|
||||||
|
],
|
||||||
|
"rootMainFiles": [],
|
||||||
|
"rootMainFilesTree": {},
|
||||||
|
"pagesChunkGroupBootstrapParams": {}
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"version": 3,
|
||||||
|
"middleware": {},
|
||||||
|
"sortedMiddleware": [],
|
||||||
|
"functions": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
self.__NEXT_FONT_MANIFEST="{\n \"app\": {},\n \"appUsingSizeAdjust\": false,\n \"pages\": {},\n \"pagesUsingSizeAdjust\": false\n}"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"app": {},
|
||||||
|
"appUsingSizeAdjust": false,
|
||||||
|
"pages": {},
|
||||||
|
"pagesUsingSizeAdjust": false
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"previewModeId": "a508c818777951bed52c0170453ef7e3",
|
||||||
|
"previewModeSigningKey": "591a19b72d1a845a81057e56fb15a2ced8409045c0ed580013f217b8c4a4ea61",
|
||||||
|
"previewModeEncryptionKey": "ded0359fbc3be263ec7ff240e7fb98fc5b836958070834f5bc954f1092afa91c"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"UT9PfJliySgklhhPCIM4wpEhmx9PMD9GPB4iuPIamyM=\"\n}"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"node": {},
|
||||||
|
"edge": {},
|
||||||
|
"encryptionKey": "UT9PfJliySgklhhPCIM4wpEhmx9PMD9GPB4iuPIamyM="
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
self.__BUILD_MANIFEST = {
|
||||||
|
"__rewrites": {
|
||||||
|
"afterFiles": [],
|
||||||
|
"beforeFiles": [],
|
||||||
|
"fallback": []
|
||||||
|
},
|
||||||
|
"sortedPages": []
|
||||||
|
};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
self.__MIDDLEWARE_MATCHERS = [];self.__MIDDLEWARE_MATCHERS_CB && self.__MIDDLEWARE_MATCHERS_CB()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
|
||||||
Vendored
+55
@@ -0,0 +1,55 @@
|
|||||||
|
// This file is generated automatically by Next.js
|
||||||
|
// Do not edit this file manually
|
||||||
|
|
||||||
|
type AppRoutes = never
|
||||||
|
type PageRoutes = never
|
||||||
|
type LayoutRoutes = never
|
||||||
|
type RedirectRoutes = never
|
||||||
|
type RewriteRoutes = never
|
||||||
|
type Routes = AppRoutes | PageRoutes | LayoutRoutes | RedirectRoutes | RewriteRoutes
|
||||||
|
|
||||||
|
|
||||||
|
interface ParamMap {
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type ParamsOf<Route extends Routes> = ParamMap[Route]
|
||||||
|
|
||||||
|
interface LayoutSlotMap {
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type { AppRoutes, PageRoutes, LayoutRoutes, RedirectRoutes, RewriteRoutes, ParamMap }
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
/**
|
||||||
|
* Props for Next.js App Router page components
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* export default function Page(props: PageProps<'/blog/[slug]'>) {
|
||||||
|
* const { slug } = await props.params
|
||||||
|
* return <div>Blog post: {slug}</div>
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface PageProps<AppRoute extends AppRoutes> {
|
||||||
|
params: Promise<ParamMap[AppRoute]>
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for Next.js App Router layout components
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* export default function Layout(props: LayoutProps<'/dashboard'>) {
|
||||||
|
* return <div>{props.children}</div>
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
type LayoutProps<LayoutRoute extends LayoutRoutes> = {
|
||||||
|
params: Promise<ParamMap[LayoutRoute]>
|
||||||
|
children: React.ReactNode
|
||||||
|
} & {
|
||||||
|
[K in LayoutSlotMap[LayoutRoute]]: React.ReactNode
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,3 +120,13 @@ a stale Gradle daemon started by VS Code's Java extension is being reused —
|
|||||||
Most of this cannot be verified off a vehicle. When reporting work, state plainly what was
|
Most of this cannot be verified off a vehicle. When reporting work, state plainly what was
|
||||||
tested and what was not. "Unit tests pass, not tried on the car" is the expected and
|
tested and what was not. "Unit tests pass, not tried on the car" is the expected and
|
||||||
acceptable answer. Do not imply road-testing that did not happen.
|
acceptable answer. Do not imply road-testing that did not happen.
|
||||||
|
|
||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
|
||||||
|
## This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||||
|
|
||||||
|
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||||
|
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ android {
|
|||||||
// authenticated API in server/, which owns the database connection.
|
// authenticated API in server/, which owns the database connection.
|
||||||
buildConfigField "String", "CONSUMPTION_API_URL", "\"${System.getenv('CONSUMPTION_API_URL') ?: ''}\""
|
buildConfigField "String", "CONSUMPTION_API_URL", "\"${System.getenv('CONSUMPTION_API_URL') ?: ''}\""
|
||||||
buildConfigField "String", "CONSUMPTION_API_TOKEN", "\"${System.getenv('CONSUMPTION_API_TOKEN') ?: ''}\""
|
buildConfigField "String", "CONSUMPTION_API_TOKEN", "\"${System.getenv('CONSUMPTION_API_TOKEN') ?: ''}\""
|
||||||
|
// Fixed installation identity: baked into every build so an APK wipe cannot orphan
|
||||||
|
// the cloud history under a new random UUID. The car's own consumption history lives
|
||||||
|
// under this id in the daily_consumption collection.
|
||||||
|
buildConfigField "String", "CONSUMPTION_INSTALLATION_ID", "\"226b1614-4d18-48f0-8938-3609cfa3db93\""
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,6 @@
|
|||||||
<!-- Required from API 33 to post the foreground-service notification, which is the
|
<!-- Required from API 33 to post the foreground-service notification, which is the
|
||||||
only status UI the service has. Without it the notification is silently dropped. -->
|
only status UI the service has. Without it the notification is silently dropped. -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
|
||||||
|
|
||||||
<!-- Wi-Fi auto-join. CHANGE_WIFI_STATE covers enabling the radio and asking the
|
<!-- Wi-Fi auto-join. CHANGE_WIFI_STATE covers enabling the radio and asking the
|
||||||
supplicant to join a network the unit already has saved; ACCESS_WIFI_STATE covers
|
supplicant to join a network the unit already has saved; ACCESS_WIFI_STATE covers
|
||||||
@@ -34,14 +31,6 @@
|
|||||||
<uses-permission android:name="android.car.permission.CAR_TIRES" />
|
<uses-permission android:name="android.car.permission.CAR_TIRES" />
|
||||||
<uses-permission android:name="android.car.permission.CAR_INFO" />
|
<uses-permission android:name="android.car.permission.CAR_INFO" />
|
||||||
|
|
||||||
<!-- The head unit's map stack, which owns the weather service the uploader asks for the
|
|
||||||
outside temperature. From API 30 an explicit bind to a package this app does not
|
|
||||||
declare here simply fails, and ext_temp would go back to being unreportable. -->
|
|
||||||
<queries>
|
|
||||||
<package android:name="com.saicmotor.launcher" />
|
|
||||||
<package android:name="com.saicmotor.mapservice" />
|
|
||||||
</queries>
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
@@ -63,8 +52,7 @@
|
|||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".AbrpUploadService"
|
android:name=".AbrpUploadService"
|
||||||
android:exported="false"
|
android:exported="false" />
|
||||||
android:foregroundServiceType="location" />
|
|
||||||
|
|
||||||
<!-- The head unit resumes far more often than it cold-boots, and announces the
|
<!-- The head unit resumes far more often than it cold-boots, and announces the
|
||||||
resume with QUICKBOOT_POWERON. Listening only for BOOT_COMPLETED is why
|
resume with QUICKBOOT_POWERON. Listening only for BOOT_COMPLETED is why
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
package com.evsuite.abrp;
|
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStreamReader;
|
|
||||||
import java.io.OutputStream;
|
|
||||||
import java.net.HttpURLConnection;
|
|
||||||
import java.net.URL;
|
|
||||||
import java.net.URLEncoder;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ABRP telemetry endpoint.
|
|
||||||
*
|
|
||||||
* Credentials go in the POST body, never in the query string: a URL travels
|
|
||||||
* through proxies, CDN access logs and crash reports, a body does not.
|
|
||||||
*/
|
|
||||||
final class AbrpApi {
|
|
||||||
|
|
||||||
/** No query string: credentials travel in the body, so the URL is a constant. */
|
|
||||||
static final String API_URL = "https://api.iternio.com/1/tlm/send";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read-only endpoint used to validate credentials. The connection test must never
|
|
||||||
* touch /tlm/send: posting a placeholder sample tells ABRP the car really is in that
|
|
||||||
* state and corrupts the user's live route plan.
|
|
||||||
*/
|
|
||||||
static final String VERIFY_URL = "https://api.iternio.com/1/tlm/get_next_charge";
|
|
||||||
private static final int TIMEOUT_MS = 8_000;
|
|
||||||
|
|
||||||
private AbrpApi() { }
|
|
||||||
|
|
||||||
/** HTTP status plus response body. */
|
|
||||||
static final class Response {
|
|
||||||
final int code;
|
|
||||||
final String body;
|
|
||||||
|
|
||||||
Response(int code, String body) {
|
|
||||||
this.code = code;
|
|
||||||
this.body = body;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates credentials without writing anything. Read-only by construction: it is a
|
|
||||||
* GET against {@link #VERIFY_URL}, so no telemetry is recorded whatever the outcome.
|
|
||||||
*
|
|
||||||
* Tension with T-912 (no credential in a URL): this endpoint only accepts the token as
|
|
||||||
* a query parameter. The api_key is moved to the Authorization header, which is the
|
|
||||||
* part we can keep out of access logs; the token cannot be, short of dropping the
|
|
||||||
* read-only check and going back to writing fake telemetry. Sending one token in one
|
|
||||||
* URL when the user presses Test beats corrupting their route plan on every press.
|
|
||||||
*/
|
|
||||||
static Response verifyCredentials(String apiKey, String token) throws IOException {
|
|
||||||
HttpURLConnection conn = null;
|
|
||||||
try {
|
|
||||||
String url = VERIFY_URL
|
|
||||||
+ "?token=" + URLEncoder.encode(token, StandardCharsets.UTF_8.name());
|
|
||||||
conn = (HttpURLConnection) new URL(url).openConnection();
|
|
||||||
conn.setRequestMethod("GET");
|
|
||||||
conn.setRequestProperty("Authorization", "APIKEY " + apiKey);
|
|
||||||
conn.setConnectTimeout(TIMEOUT_MS);
|
|
||||||
conn.setReadTimeout(TIMEOUT_MS);
|
|
||||||
int code = conn.getResponseCode();
|
|
||||||
return new Response(code, readBody(conn, code));
|
|
||||||
} finally {
|
|
||||||
if (conn != null) conn.disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Posts one telemetry sample. Throws on network failure. */
|
|
||||||
static Response send(String apiKey, String token, String tlmJson) throws IOException {
|
|
||||||
HttpURLConnection conn = null;
|
|
||||||
try {
|
|
||||||
conn = (HttpURLConnection) new URL(API_URL).openConnection();
|
|
||||||
conn.setRequestMethod("POST");
|
|
||||||
conn.setConnectTimeout(TIMEOUT_MS);
|
|
||||||
conn.setReadTimeout(TIMEOUT_MS);
|
|
||||||
conn.setDoOutput(true);
|
|
||||||
conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
|
|
||||||
|
|
||||||
byte[] payload = formBody(apiKey, token, tlmJson).getBytes(StandardCharsets.UTF_8);
|
|
||||||
conn.setFixedLengthStreamingMode(payload.length);
|
|
||||||
try (OutputStream out = conn.getOutputStream()) {
|
|
||||||
out.write(payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
int code = conn.getResponseCode();
|
|
||||||
return new Response(code, readBody(conn, code));
|
|
||||||
} finally {
|
|
||||||
if (conn != null) conn.disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Form-encoded request body carrying the credentials and the telemetry sample. */
|
|
||||||
static String formBody(String apiKey, String token, String tlmJson) throws IOException {
|
|
||||||
return "api_key=" + URLEncoder.encode(apiKey, StandardCharsets.UTF_8.name())
|
|
||||||
+ "&token=" + URLEncoder.encode(token, StandardCharsets.UTF_8.name())
|
|
||||||
+ "&tlm=" + URLEncoder.encode(tlmJson, StandardCharsets.UTF_8.name());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String readBody(HttpURLConnection conn, int code) {
|
|
||||||
// getErrorStream() returns null on some non-200 responses; passing null to
|
|
||||||
// InputStreamReader throws. Checked rather than left to the catch-all below.
|
|
||||||
java.io.InputStream stream;
|
|
||||||
try {
|
|
||||||
stream = code == 200 ? conn.getInputStream() : conn.getErrorStream();
|
|
||||||
} catch (Exception e) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
if (stream == null) return "";
|
|
||||||
|
|
||||||
try (BufferedReader br = new BufferedReader(
|
|
||||||
new InputStreamReader(stream, StandardCharsets.UTF_8))) {
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
String line;
|
|
||||||
while ((line = br.readLine()) != null) sb.append(line);
|
|
||||||
return sb.toString();
|
|
||||||
} catch (Exception e) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,55 +5,33 @@ import android.app.NotificationChannel;
|
|||||||
import android.app.NotificationManager;
|
import android.app.NotificationManager;
|
||||||
import android.app.PendingIntent;
|
import android.app.PendingIntent;
|
||||||
import android.app.Service;
|
import android.app.Service;
|
||||||
import android.content.BroadcastReceiver;
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.IntentFilter;
|
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
import android.content.pm.PackageManager;
|
|
||||||
import android.content.pm.ServiceInfo;
|
import android.content.pm.ServiceInfo;
|
||||||
import android.location.Location;
|
|
||||||
import android.location.LocationListener;
|
|
||||||
import android.location.LocationManager;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.os.Handler;
|
|
||||||
import android.os.IBinder;
|
import android.os.IBinder;
|
||||||
import android.os.Looper;
|
|
||||||
import android.os.SystemClock;
|
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import android.Manifest;
|
|
||||||
|
|
||||||
import androidx.core.app.NotificationCompat;
|
import androidx.core.app.NotificationCompat;
|
||||||
import androidx.core.app.ServiceCompat;
|
import androidx.core.app.ServiceCompat;
|
||||||
import androidx.core.content.ContextCompat;
|
|
||||||
|
|
||||||
import com.evsuite.hardware.saic.SaicWeather;
|
|
||||||
import com.evsuite.hardware.telemetry.EnergySnapshot;
|
import com.evsuite.hardware.telemetry.EnergySnapshot;
|
||||||
import com.evsuite.hardware.telemetry.EnergyTelemetryReader;
|
import com.evsuite.hardware.telemetry.EnergyTelemetryReader;
|
||||||
import com.evsuite.hardware.telemetry.ChargingSessionMeter;
|
|
||||||
import com.evsuite.hardware.telemetry.TirePressureSnapshot;
|
|
||||||
|
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.ScheduledExecutorService;
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Foreground service that polls vehicle telemetry every 15 s and pushes it to ABRP.
|
* Foreground service that polls vehicle telemetry every 15 s, integrates consumption locally,
|
||||||
|
* and syncs it to the cloud (MongoDB) in 30-minute delta chunks. Never sends anything to ABRP.
|
||||||
*
|
*
|
||||||
* Reliability design:
|
* Always-on by design: started at boot (BootReceiver) and on app open, never gated on
|
||||||
* - Uses ScheduledExecutorService (not Handler chains) so exceptions inside one
|
* credentials or a toggle. The only thing it needs is a network for the cloud sync, and a
|
||||||
* upload cycle cannot break the schedule. Even on a thrown RuntimeException
|
* missing one is handled by caching chunks locally until the connection returns.
|
||||||
* the next tick still fires.
|
|
||||||
* - Uploads unconditionally: if vehicle telemetry is unavailable we still emit
|
|
||||||
* GPS + timestamp so ABRP at least sees the vehicle online.
|
|
||||||
* - EVHardware owns the vehicle connection, firmware routing and nullable reads.
|
|
||||||
* - All IPC + HTTP happens on the scheduler thread, never the main thread.
|
|
||||||
*/
|
*/
|
||||||
public class AbrpUploadService extends Service {
|
public class AbrpUploadService extends Service {
|
||||||
|
|
||||||
public static final String ACTION_STOP = "com.evsuite.abrp.STOP";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live in-process signal. The "service_running" preference cannot be trusted on its
|
* Live in-process signal. The "service_running" preference cannot be trusted on its
|
||||||
* own: a force-kill (adb install -r, low-memory kill) skips onDestroy and leaves it
|
* own: a force-kill (adb install -r, low-memory kill) skips onDestroy and leaves it
|
||||||
@@ -74,117 +52,33 @@ public class AbrpUploadService extends Service {
|
|||||||
|
|
||||||
public static UploadLog.State state() { return uploadLog.state(running); }
|
public static UploadLog.State state() { return uploadLog.state(running); }
|
||||||
|
|
||||||
/** Re-read after the user changes the cadence, without restarting the service. */
|
|
||||||
public static void reloadSettings() { settingsDirty = true; }
|
|
||||||
|
|
||||||
private static volatile boolean settingsDirty = false;
|
|
||||||
|
|
||||||
private static final String TAG = "AbrpUploadService";
|
private static final String TAG = "AbrpUploadService";
|
||||||
private static final String CHANNEL_ID = "abrp_uploader";
|
private static final String CHANNEL_ID = "abrp_uploader";
|
||||||
private static final int NOTIF_ID = 1;
|
private static final int NOTIF_ID = 1;
|
||||||
/** Scheduler tick. Whether a tick uploads is decided by {@link UploadCadence}. */
|
/** Scheduler tick — vehicle telemetry is sampled this often. */
|
||||||
private static final long UPLOAD_INTERVAL_SEC = 15;
|
private static final long UPLOAD_INTERVAL_SEC = 15;
|
||||||
/**
|
/** Warm-up before the first sample. */
|
||||||
* Warm-up before the first upload. Was 45 s with no stated reason; the car adapter
|
|
||||||
* connects asynchronously and telemetry now omits whatever it cannot read (T-913), so
|
|
||||||
* an early first sample is useful rather than misleading.
|
|
||||||
*/
|
|
||||||
private static final long FIRST_UPLOAD_DELAY_SEC = 20;
|
private static final long FIRST_UPLOAD_DELAY_SEC = 20;
|
||||||
private static final long LOCATION_RETRY_INTERVAL_SEC = 30;
|
|
||||||
/**
|
|
||||||
* Floor for how old a GPS fix may be and still describe where the car is now.
|
|
||||||
*
|
|
||||||
* A fix has no expiry of its own, so the previous behaviour was to keep reporting the
|
|
||||||
* last one forever: the position saved when the car was parked at home was uploaded,
|
|
||||||
* stamped with the current time, for the whole first part of the next drive. ABRP then
|
|
||||||
* plans from a place the car left minutes ago. The subscription asks for a fix every
|
|
||||||
* upload interval, so anything older than several intervals means the receiver has
|
|
||||||
* stopped producing (garage, tunnel) and position is genuinely unknown.
|
|
||||||
*/
|
|
||||||
private static final long MIN_LOCATION_MAX_AGE_MS = 5 * 60 * 1000L;
|
|
||||||
/**
|
|
||||||
* How long a weather reading stands in for the car's own thermometer.
|
|
||||||
*
|
|
||||||
* The query can reach the network from inside the head unit and blocks the upload thread
|
|
||||||
* while it does, so it is not something to do every tick. Outside air does not move fast
|
|
||||||
* enough for ten minutes to matter to a consumption model.
|
|
||||||
*/
|
|
||||||
private static final long WEATHER_TTL_MS = 10 * 60 * 1000L;
|
|
||||||
/**
|
|
||||||
* How long a broadcast temperature keeps standing in for the car's thermometer.
|
|
||||||
*
|
|
||||||
* The weather app broadcasts when it refreshes itself, not on a cadence this app can ask
|
|
||||||
* for, so a reading has to outlive the moment it arrived or it would be useless between
|
|
||||||
* refreshes. Two hours is long enough to bridge them and short enough that the value has
|
|
||||||
* not stopped describing the day.
|
|
||||||
*/
|
|
||||||
private static final long WEATHER_MAX_AGE_MS = 2 * 60 * 60 * 1000L;
|
|
||||||
private static final String KEY_WEATHER_TEMP = "weather_temp_c";
|
|
||||||
private static final String KEY_WEATHER_PLACE = "weather_place";
|
|
||||||
private static final String KEY_WEATHER_MS = "weather_received_ms";
|
|
||||||
|
|
||||||
// Set from the main thread (LocationListener uses Main looper), read from
|
|
||||||
// the scheduler thread inside doUpload. volatile is sufficient since Location
|
|
||||||
// is effectively immutable for our purposes.
|
|
||||||
private volatile Location lastLocation;
|
|
||||||
|
|
||||||
private EnergyTelemetryReader energyReader;
|
private EnergyTelemetryReader energyReader;
|
||||||
private ConsumptionTracker consumptionTracker;
|
private ConsumptionTracker consumptionTracker;
|
||||||
private ConsumptionCloudClient consumptionCloudClient;
|
private ConsumptionCloudClient consumptionCloudClient;
|
||||||
private ChargeSessionTracker chargeSessionTracker;
|
private ChargeSessionTracker chargeSessionTracker;
|
||||||
/**
|
private WifiAutoConnect wifi;
|
||||||
* Keeps the unit on the phone hotspot without a trip into Settings. Attempted on the
|
|
||||||
* upload tick because that is exactly when being offline costs something, and it is
|
|
||||||
* rate-limited internally so a tick every few seconds does not hammer the supplicant.
|
|
||||||
*/
|
|
||||||
private WifiAutoConnect wifi;
|
|
||||||
private LocationManager locationManager;
|
|
||||||
private ScheduledExecutorService scheduler;
|
private ScheduledExecutorService scheduler;
|
||||||
private SharedPreferences prefs;
|
private SharedPreferences prefs;
|
||||||
/** Credentials only — encrypted at rest, see {@link SecurePrefs}. */
|
|
||||||
private SharedPreferences securePrefs;
|
|
||||||
private Handler mainHandler;
|
|
||||||
|
|
||||||
private volatile long lastSuccessfulUploadMs = 0L;
|
|
||||||
/** Previous tick's state, to detect a transition worth reporting immediately. */
|
|
||||||
private volatile Boolean lastParked = null;
|
|
||||||
private volatile Boolean lastCharging = null;
|
|
||||||
private volatile long lastUploadAttemptMs = 0L;
|
|
||||||
/** Charge detection state. Touched only from the scheduler thread, inside doUpload. */
|
|
||||||
private final ChargingSignal chargingSignal = new ChargingSignal();
|
|
||||||
private final ChargingSessionMeter chargeMeter = new ChargingSessionMeter();
|
|
||||||
private volatile UploadSettings settings = UploadSettings.defaults();
|
|
||||||
/** False while no GPS subscription is delivering — drives the watchdog re-arm. */
|
|
||||||
/** What the foreground notification currently says, so re-asserting it does not blank it. */
|
/** What the foreground notification currently says, so re-asserting it does not blank it. */
|
||||||
private volatile String lastStatus = "Starting…";
|
private volatile String lastStatus = "Starting…";
|
||||||
private volatile boolean locationUpdatesActive = false;
|
|
||||||
private volatile long lastLocationRequestMs = 0L;
|
|
||||||
/** Last head-unit weather reading and when it was taken — see {@link #WEATHER_TTL_MS}. */
|
|
||||||
private volatile SaicWeather.Reading lastWeather = null;
|
|
||||||
private volatile long lastWeatherMs = 0L;
|
|
||||||
/** The head unit's own weather broadcast — the source its status bar uses. */
|
|
||||||
private final HeadUnitWeather headUnitWeather = new HeadUnitWeather();
|
|
||||||
private final BroadcastReceiver weatherReceiver = new BroadcastReceiver() {
|
|
||||||
@Override public void onReceive(Context context, Intent intent) {
|
|
||||||
headUnitWeather.accept(intent);
|
|
||||||
saveWeather();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
private volatile boolean weatherReceiverRegistered = false;
|
|
||||||
/** How long the last map-service query took, or -1 when it was never made. */
|
|
||||||
private volatile long lastQueryMs = -1L;
|
|
||||||
|
|
||||||
// ---------- Lifecycle ----------
|
// ---------- Lifecycle ----------
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCreate() {
|
public void onCreate() {
|
||||||
super.onCreate();
|
super.onCreate();
|
||||||
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
|
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
|
||||||
securePrefs = SecurePrefs.get(this);
|
|
||||||
settings = UploadSettings.from(prefs);
|
|
||||||
uploadLog.clear();
|
uploadLog.clear();
|
||||||
mainHandler = new Handler(Looper.getMainLooper());
|
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||||
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
|
||||||
Thread t = new Thread(r, "abrp-upload");
|
Thread t = new Thread(r, "abrp-upload");
|
||||||
t.setDaemon(false);
|
t.setDaemon(false);
|
||||||
return t;
|
return t;
|
||||||
@@ -195,73 +89,40 @@ public class AbrpUploadService extends Service {
|
|||||||
running = true;
|
running = true;
|
||||||
prefs.edit().putBoolean("service_running", true).apply();
|
prefs.edit().putBoolean("service_running", true).apply();
|
||||||
|
|
||||||
// One shared, firmware-aware EVHardware snapshot owns every vehicle signal.
|
|
||||||
energyReader = new EnergyTelemetryReader(getApplicationContext());
|
energyReader = new EnergyTelemetryReader(getApplicationContext());
|
||||||
consumptionTracker = ConsumptionTracker.get(this);
|
consumptionTracker = ConsumptionTracker.get(this);
|
||||||
consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext());
|
consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext());
|
||||||
chargeSessionTracker = ChargeSessionTracker.get(getApplicationContext());
|
chargeSessionTracker = ChargeSessionTracker.get(getApplicationContext());
|
||||||
wifi = new WifiAutoConnect(getApplicationContext());
|
wifi = new WifiAutoConnect(getApplicationContext());
|
||||||
// The head unit's own weather service, bound for one reason: ENV_OUTSIDE_TEMPERATURE
|
|
||||||
// is not implemented on this vehicle, so the car cannot say how warm it is outside
|
|
||||||
// and something else has to. Idempotent, asynchronous, and absent on a car that does
|
|
||||||
// not have the map stack — in which case ext_temp simply stays unreported.
|
|
||||||
|
|
||||||
if (settings.abrpUploadEnabled) requestLocationUpdates();
|
|
||||||
|
|
||||||
// Fire the first upload after a short warm-up, then every UPLOAD_INTERVAL_SEC.
|
|
||||||
// scheduleWithFixedDelay ensures we always get at least UPLOAD_INTERVAL_SEC
|
|
||||||
// between cycles even if a previous upload was slow.
|
|
||||||
scheduler.scheduleWithFixedDelay(
|
scheduler.scheduleWithFixedDelay(
|
||||||
this::safeUploadCycle,
|
this::safeUploadCycle,
|
||||||
FIRST_UPLOAD_DELAY_SEC, UPLOAD_INTERVAL_SEC, TimeUnit.SECONDS);
|
FIRST_UPLOAD_DELAY_SEC, UPLOAD_INTERVAL_SEC, TimeUnit.SECONDS);
|
||||||
|
|
||||||
Log.i(TAG, "Service started, upload scheduler armed");
|
uploadLog.record(new UploadLog.Entry(System.currentTimeMillis(), 0, true,
|
||||||
|
"Tüketim servisi başlatıldı"));
|
||||||
|
Log.i(TAG, "Service started, consumption tracking armed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||||
if (intent != null && ACTION_STOP.equals(intent.getAction())) {
|
|
||||||
prefs.edit().putBoolean("service_enabled", false).apply();
|
|
||||||
stopSelf();
|
|
||||||
return START_NOT_STICKY;
|
|
||||||
}
|
|
||||||
// Re-asserted on every start: a position grant that arrives after the service is up
|
|
||||||
// changes which foreground types it may hold, and the activity starts it again as
|
|
||||||
// soon as the user answers. Without this the grant only took effect at the next boot.
|
|
||||||
startInForeground();
|
startInForeground();
|
||||||
return START_STICKY;
|
return START_STICKY;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enters the foreground holding only the service types this app currently has the
|
* Enters the foreground. No location type — this service no longer reads GPS. Foreground
|
||||||
* permissions for.
|
* service types do not exist before API 29, so on this API-28 head unit a plain
|
||||||
*
|
* startForeground is the only correct call.
|
||||||
* The service declares {@code foregroundServiceType="location"}, and since API 34 the
|
|
||||||
* platform validates every declared type against what is held at the moment
|
|
||||||
* startForeground runs — the plain two-argument call claims them all. On a car where
|
|
||||||
* position had not been granted yet (a fresh install, or the boot receiver starting the
|
|
||||||
* service before the app was ever opened) that threw a SecurityException and the uploader
|
|
||||||
* died before its first tick. Without the permission the service still runs and still
|
|
||||||
* uploads; it simply has no position to send, exactly as before.
|
|
||||||
*/
|
*/
|
||||||
private void startInForeground() {
|
private void startInForeground() {
|
||||||
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.Q) {
|
Notification notification = buildNotification(lastStatus);
|
||||||
// Service types do not exist before API 29: nothing is declared and nothing is
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
||||||
// checked, so the plain call is the only correct one there.
|
ServiceCompat.startForeground(
|
||||||
startForeground(NOTIF_ID, buildNotification(lastStatus));
|
this, NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
|
||||||
return;
|
} else {
|
||||||
|
startForeground(NOTIF_ID, notification);
|
||||||
}
|
}
|
||||||
int types = hasLocationPermission()
|
|
||||||
? ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
|
|
||||||
: ServiceInfo.FOREGROUND_SERVICE_TYPE_NONE;
|
|
||||||
ServiceCompat.startForeground(this, NOTIF_ID, buildNotification(lastStatus), types);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean hasLocationPermission() {
|
|
||||||
return ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
|
|
||||||
== PackageManager.PERMISSION_GRANTED
|
|
||||||
|| ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION)
|
|
||||||
== PackageManager.PERMISSION_GRANTED;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -269,57 +130,18 @@ public class AbrpUploadService extends Service {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDestroy() {
|
public void onDestroy() {
|
||||||
Log.i(TAG, "Service stopping");
|
|
||||||
if (scheduler != null) scheduler.shutdownNow();
|
|
||||||
if (consumptionTracker != null) consumptionTracker.persist();
|
|
||||||
if (locationManager != null) {
|
|
||||||
try { locationManager.removeUpdates(locationListener); } catch (Exception ignored) {}
|
|
||||||
}
|
|
||||||
if (weatherReceiverRegistered) {
|
|
||||||
try { unregisterReceiver(weatherReceiver); } catch (Exception ignored) {}
|
|
||||||
weatherReceiverRegistered = false;
|
|
||||||
}
|
|
||||||
if (mainHandler != null) mainHandler.removeCallbacksAndMessages(null);
|
|
||||||
running = false;
|
running = false;
|
||||||
prefs.edit().putBoolean("service_running", false).apply();
|
prefs.edit().putBoolean("service_running", false).apply();
|
||||||
super.onDestroy();
|
if (scheduler != null) scheduler.shutdownNow();
|
||||||
|
Log.i(TAG, "Service stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Upload cycle (runs on scheduler thread) ----------
|
// ---------- Sampling cycle (runs on scheduler thread) ----------
|
||||||
|
|
||||||
private void safeUploadCycle() {
|
private void safeUploadCycle() {
|
||||||
try {
|
try {
|
||||||
if (settingsDirty) {
|
|
||||||
settingsDirty = false;
|
|
||||||
settings = UploadSettings.from(prefs);
|
|
||||||
// The GPS subscription is a power cost of its own; keep it in step with
|
|
||||||
// how often we actually send.
|
|
||||||
mainHandler.post(this::requestLocationUpdates);
|
|
||||||
Log.i(TAG, "Settings reloaded: interval=" + settings.intervalSec + "s"
|
|
||||||
+ " boostLowSoc=" + settings.boostLowSoc
|
|
||||||
+ " lowSoc=" + settings.lowSocPercent + "%"
|
|
||||||
+ " abrpUpload=" + settings.abrpUploadEnabled);
|
|
||||||
}
|
|
||||||
// Same watchdog for GPS: requestLocationUpdates used to run once in onCreate,
|
|
||||||
// so a provider that was off at start-up — or dropped later — meant
|
|
||||||
// position-less telemetry for the rest of the session.
|
|
||||||
if (settings.abrpUploadEnabled && !locationUpdatesActive
|
|
||||||
&& System.currentTimeMillis() - lastLocationRequestMs
|
|
||||||
> LOCATION_RETRY_INTERVAL_SEC * 1000) {
|
|
||||||
Log.w(TAG, "Location updates inactive, re-arming");
|
|
||||||
mainHandler.post(this::requestLocationUpdates);
|
|
||||||
}
|
|
||||||
// Before the upload, not after: an upload that fails for want of a network is
|
|
||||||
// the thing this is here to prevent. Cheap and rate-limited when already on.
|
|
||||||
if (settings.abrpUploadEnabled && wifi != null
|
|
||||||
&& prefs.getBoolean(MainActivity.WIFI_AUTO_KEY, true)) {
|
|
||||||
wifi.ensureConnected(false);
|
|
||||||
}
|
|
||||||
doUpload();
|
doUpload();
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
// Absolutely never let an exception escape — would not break the
|
|
||||||
// schedule with scheduleWithFixedDelay's executor, but we want a
|
|
||||||
// clean log line anyway.
|
|
||||||
Log.e(TAG, "upload cycle threw", t);
|
Log.e(TAG, "upload cycle threw", t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,388 +153,13 @@ public class AbrpUploadService extends Service {
|
|||||||
chargeSessionTracker.sample(vehicle);
|
chargeSessionTracker.sample(vehicle);
|
||||||
consumptionCloudClient.syncIfDue(consumptionTracker, chargeSessionTracker, sampleMs);
|
consumptionCloudClient.syncIfDue(consumptionTracker, chargeSessionTracker, sampleMs);
|
||||||
|
|
||||||
// Keep local consumption history alive while another app owns the ABRP feed.
|
// Keep the head unit on the phone hotspot — the sync needs a network, and this is
|
||||||
if (!settings.abrpUploadEnabled) {
|
// exactly when being offline costs something. Rate-limited internally.
|
||||||
updateNotification(getString(R.string.notif_tracking_locally));
|
if (wifi != null && prefs.getBoolean(MainActivity.WIFI_AUTO_KEY, true)) {
|
||||||
return;
|
wifi.ensureConnected(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
String token = securePrefs.getString(SecurePrefs.KEY_TOKEN, "").trim();
|
updateNotification("Tüketim takibi aktif");
|
||||||
String apiKey = securePrefs.getString(SecurePrefs.KEY_API_KEY, "").trim();
|
|
||||||
|
|
||||||
if (token.isEmpty()) {
|
|
||||||
updateNotification("No ABRP token — open app to configure");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (apiKey.isEmpty()) {
|
|
||||||
updateNotification("No API key — open app to configure");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read whatever the car will give us. Every getter returns null when the read
|
|
||||||
// fails, and a null field is omitted from the payload — never sent as 0.
|
|
||||||
boolean carUp = vehicle.getHasVehicleData();
|
|
||||||
Float speedKmh = vehicle.getSpeedKmh();
|
|
||||||
Integer soc = vehicle.getSocPercent() == null
|
|
||||||
? null : Math.round(vehicle.getSocPercent());
|
|
||||||
Integer rangeKm = vehicle.getRangeKm() == null
|
|
||||||
? null : Math.round(vehicle.getRangeKm());
|
|
||||||
Float powerKw = vehicle.getBatteryPowerKw();
|
|
||||||
Float chargeRateKw = powerKw == null ? null : -powerKw;
|
|
||||||
Boolean portConnected = vehicle.getChargePortConnected();
|
|
||||||
Boolean parked = vehicle.getParked();
|
|
||||||
|
|
||||||
// The charge port property is standard AAOS and this VHAL need not implement it.
|
|
||||||
// Trusting it alone meant a plugged-in car that reads "unplugged" was treated as
|
|
||||||
// parked-and-idle and throttled to one sample every 15 minutes for the whole
|
|
||||||
// charge — see ChargingSignal for the fallbacks.
|
|
||||||
Boolean charging = chargingSignal.evaluate(
|
|
||||||
sampleMs, portConnected, chargeRateKw, parked, soc);
|
|
||||||
|
|
||||||
// DCFC heuristic: charging at AC speeds (3-22 kW) is type-2; above ~25 kW
|
|
||||||
// it can only be DC fast. Undecidable if either input is missing.
|
|
||||||
Boolean dcfc = (charging == null || chargeRateKw == null)
|
|
||||||
? null : (charging && chargeRateKw > 25f);
|
|
||||||
|
|
||||||
Float cabinTemp = vehicle.getCabinTempCelsius();
|
|
||||||
Float battTemp = vehicle.getBatteryTempCelsius();
|
|
||||||
Float capacityKwh = vehicle.getBatteryCapacityKwh();
|
|
||||||
Float soeKwh = vehicle.getBatteryEnergyKwh();
|
|
||||||
Float odometerKm = vehicle.getOdometerKm();
|
|
||||||
Float hvacSetpoint = vehicle.getClimate().getDriverTargetCelsius();
|
|
||||||
TirePressureSnapshot tires = vehicle.getTirePressures();
|
|
||||||
|
|
||||||
// Sampled before the cadence check below: the meter integrates the charge rate
|
|
||||||
// over time, so it has to see every tick, not only the ones that get uploaded.
|
|
||||||
Double kwhCharged = chargeMeter.sample(
|
|
||||||
sampleMs, charging, chargeRateKw);
|
|
||||||
|
|
||||||
// Thin out uploads while the car is parked and not charging. Evaluated here, once
|
|
||||||
// parked/charging are known, so a transition is never delayed.
|
|
||||||
boolean stateChanged = !java.util.Objects.equals(parked, lastParked)
|
|
||||||
|| !java.util.Objects.equals(charging, lastCharging);
|
|
||||||
lastParked = parked;
|
|
||||||
lastCharging = charging;
|
|
||||||
|
|
||||||
// Cadence is measured on ATTEMPTS, not successes: keying it on the last success
|
|
||||||
// would retry every tick while offline, which is exactly when saving power matters.
|
|
||||||
if (!UploadCadence.shouldUpload(System.currentTimeMillis(), lastUploadAttemptMs,
|
|
||||||
parked, charging, soc, stateChanged, settings)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastUploadAttemptMs = System.currentTimeMillis();
|
|
||||||
|
|
||||||
Location loc = freshLocation();
|
|
||||||
|
|
||||||
// ext_temp was reaching ABRP as 0 degC, and omitting the unreadable value only turned
|
|
||||||
// that into ABRP's own default 0. The car is the problem: this VHAL does not implement
|
|
||||||
// ENV_OUTSIDE_TEMPERATURE, so there is no vehicle reading to correct. The head unit's
|
|
||||||
// weather service answers for a position, and ambient air at the car is what ext_temp
|
|
||||||
// means — a better figure than a bumper sensor in the sun would give anyway.
|
|
||||||
TelemetryPayload tlm = new TelemetryPayload(System.currentTimeMillis() / 1000);
|
|
||||||
tlm.soc = soc;
|
|
||||||
tlm.speedKmh = speedKmh;
|
|
||||||
tlm.rangeKm = rangeKm;
|
|
||||||
tlm.powerKw = powerKw;
|
|
||||||
tlm.charging = charging;
|
|
||||||
tlm.dcfc = dcfc;
|
|
||||||
tlm.parked = parked;
|
|
||||||
tlm.cabinTemp = cabinTemp;
|
|
||||||
tlm.battTempC = battTemp;
|
|
||||||
tlm.capacityKwh = capacityKwh;
|
|
||||||
tlm.soeKwh = soeKwh;
|
|
||||||
tlm.kwhCharged = kwhCharged;
|
|
||||||
tlm.odometerKm = odometerKm;
|
|
||||||
tlm.hvacSetpointC = hvacSetpoint;
|
|
||||||
tlm.tirePressureFlKpa = tires.getFrontLeftKpa();
|
|
||||||
tlm.tirePressureFrKpa = tires.getFrontRightKpa();
|
|
||||||
tlm.tirePressureRlKpa = tires.getRearLeftKpa();
|
|
||||||
tlm.tirePressureRrKpa = tires.getRearRightKpa();
|
|
||||||
if (loc != null) {
|
|
||||||
tlm.lat = loc.getLatitude();
|
|
||||||
tlm.lon = loc.getLongitude();
|
|
||||||
tlm.elevation = loc.hasAltitude() ? loc.getAltitude() : null;
|
|
||||||
tlm.heading = loc.hasBearing() ? loc.getBearing() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
String tlmJson = tlm.build();
|
|
||||||
String summary = TelemetryPayload.summarize(tlmJson);
|
|
||||||
if (loc != null) {
|
|
||||||
summary += " (fix " + locationAgeMs(loc) / 1000 + "s old"
|
|
||||||
+ " +/-" + (loc.hasAccuracy() ? Math.round(loc.getAccuracy()) + "m" : "?")
|
|
||||||
+ " via " + loc.getProvider();
|
|
||||||
// The weather service names the place it answered for. ABRP shows an address it
|
|
||||||
// reverse-geocodes from the same coordinates, so the two disagreeing says the
|
|
||||||
// coordinates are wrong, and the two agreeing says ABRP is showing something old.
|
|
||||||
summary += ")";
|
|
||||||
}
|
|
||||||
// What actually went on the wire. Kept on the log entry as well as in logcat: the
|
|
||||||
// head unit this runs on has no adb attached, so the in-app log is the only place
|
|
||||||
// the driver can see it.
|
|
||||||
Log.i(TAG, "TLM " + summary);
|
|
||||||
|
|
||||||
sendToAbrp(apiKey, token, tlmJson, summary, soc, speedKmh, carUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Listens for the head unit's weather updates.
|
|
||||||
*
|
|
||||||
* Exported on purpose: the broadcast comes from another application, and a receiver
|
|
||||||
* declared not-exported would never hear it. The flag is required from API 33 and does
|
|
||||||
* not exist before it — the car this runs on is API 28 — so the registration goes
|
|
||||||
* through ContextCompat, which picks the right overload for the platform underneath.
|
|
||||||
*/
|
|
||||||
private void registerWeatherReceiver() {
|
|
||||||
restoreWeather();
|
|
||||||
try {
|
|
||||||
androidx.core.content.ContextCompat.registerReceiver(
|
|
||||||
this, weatherReceiver, new IntentFilter(HeadUnitWeather.ACTION),
|
|
||||||
androidx.core.content.ContextCompat.RECEIVER_EXPORTED);
|
|
||||||
weatherReceiverRegistered = true;
|
|
||||||
// A null receiver asks the platform for the last broadcast it is holding without
|
|
||||||
// subscribing to anything. If the weather app sends its update sticky, this is the
|
|
||||||
// reading straight away rather than after however long until its next refresh —
|
|
||||||
// and the app is started to look at the log, which is exactly the wrong moment to
|
|
||||||
// begin waiting.
|
|
||||||
probeStickyWeather();
|
|
||||||
} catch (Exception e) {
|
|
||||||
// A car whose weather app broadcasts nothing is a car without ext_temp, not a
|
|
||||||
// reason to lose the upload service.
|
|
||||||
Log.w(TAG, "Weather broadcast unavailable: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Asks for the last held broadcast, if the platform is holding one. Harmless when not. */
|
|
||||||
private void probeStickyWeather() {
|
|
||||||
try {
|
|
||||||
headUnitWeather.accept(
|
|
||||||
registerReceiver(null, new IntentFilter(HeadUnitWeather.ACTION)));
|
|
||||||
saveWeather();
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.w(TAG, "Sticky weather probe failed: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keeps the last reading across a restart.
|
|
||||||
*
|
|
||||||
* The broadcast arrives when the weather app refreshes, so a service that forgets on every
|
|
||||||
* start would spend most of its life with no temperature at all — and the app is restarted
|
|
||||||
* every time the driver opens it.
|
|
||||||
*/
|
|
||||||
private void saveWeather() {
|
|
||||||
Float celsius = headUnitWeather.temperatureC();
|
|
||||||
if (celsius == null) return;
|
|
||||||
prefs.edit()
|
|
||||||
.putFloat(KEY_WEATHER_TEMP, celsius)
|
|
||||||
.putString(KEY_WEATHER_PLACE, headUnitWeather.place())
|
|
||||||
.putLong(KEY_WEATHER_MS, headUnitWeather.receivedMs())
|
|
||||||
.apply();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void restoreWeather() {
|
|
||||||
long whenMs = prefs.getLong(KEY_WEATHER_MS, 0L);
|
|
||||||
if (whenMs <= 0L || System.currentTimeMillis() - whenMs > WEATHER_MAX_AGE_MS) return;
|
|
||||||
headUnitWeather.restore(prefs.getFloat(KEY_WEATHER_TEMP, 0f),
|
|
||||||
prefs.getString(KEY_WEATHER_PLACE, ""), whenMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True while the stored reading still describes the day — see {@link #WEATHER_MAX_AGE_MS}. */
|
|
||||||
private boolean weatherIsFresh() {
|
|
||||||
long whenMs = headUnitWeather.receivedMs();
|
|
||||||
return whenMs > 0L && System.currentTimeMillis() - whenMs <= WEATHER_MAX_AGE_MS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Current conditions where the car is, or null when nothing can answer.
|
|
||||||
*
|
|
||||||
* Cached for {@link #WEATHER_TTL_MS}: the query blocks the upload thread on what may be a
|
|
||||||
* network round-trip inside the head unit, and it is asked on every upload the car's own
|
|
||||||
* thermometer cannot answer — which, on this vehicle, is all of them. A stale reading is
|
|
||||||
* kept when a refresh fails, since ten-minute-old air is a better answer than none.
|
|
||||||
*/
|
|
||||||
private SaicWeather.Reading weatherAt(Location loc) {
|
|
||||||
if (loc == null) return null;
|
|
||||||
long now = System.currentTimeMillis();
|
|
||||||
SaicWeather.Reading cached = lastWeather;
|
|
||||||
if (cached != null && now - lastWeatherMs < WEATHER_TTL_MS) return cached;
|
|
||||||
if (!SaicWeather.INSTANCE.isAvailable()) { lastQueryMs = -1L; return cached; }
|
|
||||||
// How long the call took is the only thing that separates its failure modes from out
|
|
||||||
// here: the query is bounded by a two-second wait inside the library, so a null that
|
|
||||||
// comes back at once was refused or answered undecodably, and a null that comes back
|
|
||||||
// at the bound is a service that did not answer in time. The library reports neither.
|
|
||||||
long startedMs = System.currentTimeMillis();
|
|
||||||
SaicWeather.Reading reading = SaicWeather.INSTANCE.currentAt(
|
|
||||||
loc.getLatitude(), loc.getLongitude(), weatherLanguage());
|
|
||||||
lastQueryMs = System.currentTimeMillis() - startedMs;
|
|
||||||
if (reading == null) return cached;
|
|
||||||
lastWeather = reading;
|
|
||||||
lastWeatherMs = now;
|
|
||||||
return reading;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The provider answers in the language it is asked in; the driver's is the right one. */
|
|
||||||
private static String weatherLanguage() {
|
|
||||||
java.util.Locale locale = java.util.Locale.getDefault();
|
|
||||||
String language = locale.getLanguage().toLowerCase(java.util.Locale.US);
|
|
||||||
String country = locale.getCountry().toLowerCase(java.util.Locale.US);
|
|
||||||
return country.isEmpty() ? language : language + "-" + country;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void sendToAbrp(String apiKey, String token, String tlmJson, String summary,
|
|
||||||
Integer soc, Float speedKmh, boolean carUp) {
|
|
||||||
try {
|
|
||||||
AbrpApi.Response response = AbrpApi.send(apiKey, token, tlmJson);
|
|
||||||
int code = response.code;
|
|
||||||
|
|
||||||
// The response body can echo request details — debug builds only.
|
|
||||||
if (BuildConfig.DEBUG) Log.d(TAG, "ABRP [" + code + "]: " + response.body);
|
|
||||||
|
|
||||||
uploadLog.record(new UploadLog.Entry(System.currentTimeMillis(), code,
|
|
||||||
code == 200, code == 200 ? "OK" : ("HTTP " + code), summary));
|
|
||||||
|
|
||||||
if (code == 200) {
|
|
||||||
lastSuccessfulUploadMs = System.currentTimeMillis();
|
|
||||||
String time = android.text.format.DateFormat
|
|
||||||
.format("HH:mm:ss", lastSuccessfulUploadMs).toString();
|
|
||||||
prefs.edit()
|
|
||||||
.putString("last_upload_time", time)
|
|
||||||
.putString("last_upload_status", "OK")
|
|
||||||
.apply();
|
|
||||||
// Only claim a value in the notification if we actually read one.
|
|
||||||
String detail = (soc != null && speedKmh != null)
|
|
||||||
? ("SOC " + soc + "% · " + Math.round(speedKmh) + " km/h · " + time)
|
|
||||||
: (carUp ? ("Car data partial · " + time) : ("GPS only · " + time));
|
|
||||||
updateNotification(detail);
|
|
||||||
} else {
|
|
||||||
prefs.edit().putString("last_upload_status", "HTTP " + code).apply();
|
|
||||||
int failures = uploadLog.consecutiveFailures();
|
|
||||||
updateNotification(failures >= UploadLog.FAILURES_FOR_ERROR
|
|
||||||
? ("Upload failing (" + failures + "x) — HTTP " + code)
|
|
||||||
: ("Upload error: HTTP " + code));
|
|
||||||
}
|
|
||||||
} catch (java.net.UnknownHostException e) {
|
|
||||||
// httpStatus 0: the request never reached a server.
|
|
||||||
uploadLog.record(new UploadLog.Entry(
|
|
||||||
System.currentTimeMillis(), 0, false, "No internet", summary));
|
|
||||||
prefs.edit().putString("last_upload_status", "No internet").apply();
|
|
||||||
updateNotification("Offline — will retry");
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Upload failed", e);
|
|
||||||
uploadLog.record(new UploadLog.Entry(System.currentTimeMillis(), 0, false,
|
|
||||||
e.getClass().getSimpleName(), summary));
|
|
||||||
prefs.edit().putString("last_upload_status",
|
|
||||||
e.getClass().getSimpleName() + ": " + e.getMessage()).apply();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Location ----------
|
|
||||||
|
|
||||||
private final LocationListener locationListener = new LocationListener() {
|
|
||||||
@Override public void onLocationChanged(Location location) {
|
|
||||||
lastLocation = location;
|
|
||||||
locationUpdatesActive = true;
|
|
||||||
}
|
|
||||||
@Override public void onStatusChanged(String p, int s, Bundle e) {}
|
|
||||||
@Override public void onProviderEnabled(String p) {
|
|
||||||
Log.i(TAG, "GPS provider enabled — re-arming location updates");
|
|
||||||
// The subscription made while the provider was off delivers nothing; ask again.
|
|
||||||
requestLocationUpdates();
|
|
||||||
}
|
|
||||||
@Override public void onProviderDisabled(String p) {
|
|
||||||
Log.w(TAG, "GPS provider disabled — telemetry will omit position");
|
|
||||||
locationUpdatesActive = false;
|
|
||||||
// A stale fix is worse than none: ABRP would think the car is parked there.
|
|
||||||
lastLocation = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The last GPS fix if it still describes where the car is, otherwise null so the
|
|
||||||
* payload omits position entirely. Omitting it leaves ABRP on its own last known
|
|
||||||
* point; sending a stale one actively moves the car back to where it used to be.
|
|
||||||
*/
|
|
||||||
private Location freshLocation() {
|
|
||||||
Location loc = lastLocation;
|
|
||||||
if (loc == null) return null;
|
|
||||||
long ageMs = locationAgeMs(loc);
|
|
||||||
long maxAgeMs = maxLocationAgeMs();
|
|
||||||
if (ageMs > maxAgeMs) {
|
|
||||||
Log.w(TAG, "Dropping stale GPS fix: " + ageMs / 1000 + "s old (max "
|
|
||||||
+ maxAgeMs / 1000 + "s) — position omitted");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return loc;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Age of a fix, measured on the monotonic clock. Location.getTime() is wall-clock and
|
|
||||||
* the head unit adjusts its own clock from GPS, which can make a fresh fix look like
|
|
||||||
* it came from the future — or from hours ago — the moment that correction lands.
|
|
||||||
*/
|
|
||||||
private static long locationAgeMs(Location loc) {
|
|
||||||
long fixNanos = loc.getElapsedRealtimeNanos();
|
|
||||||
// A provider that does not stamp the monotonic clock leaves this at zero, which would
|
|
||||||
// date every fix to the last boot and drop all of them — turning a staleness guard
|
|
||||||
// into a position blackout. An unstamped fix is one whose age cannot be known, and
|
|
||||||
// guessing "ancient" there is the worse of the two guesses.
|
|
||||||
if (fixNanos <= 0L) return 0L;
|
|
||||||
long ageMs = (SystemClock.elapsedRealtimeNanos() - fixNanos) / 1_000_000L;
|
|
||||||
// A fix from the future is a clock that moved, not a fix worth dropping.
|
|
||||||
return Math.max(ageMs, 0L);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Several subscription intervals, so a single missed fix never blanks the position. */
|
|
||||||
private long maxLocationAgeMs() {
|
|
||||||
return Math.max(MIN_LOCATION_MAX_AGE_MS, 3 * settings.intervalMs());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribes to GPS updates. Safe to call repeatedly: the previous subscription is
|
|
||||||
* removed first, so the watchdog can re-arm without stacking listeners.
|
|
||||||
*/
|
|
||||||
private void requestLocationUpdates() {
|
|
||||||
try {
|
|
||||||
locationManager = (LocationManager) getSystemService(LOCATION_SERVICE);
|
|
||||||
try { locationManager.removeUpdates(locationListener); } catch (Exception ignored) {}
|
|
||||||
locationUpdatesActive = false;
|
|
||||||
|
|
||||||
if (!settings.abrpUploadEnabled) {
|
|
||||||
lastLocation = null;
|
|
||||||
Log.i(TAG, "GPS updates stopped while ABRP upload is disabled");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
|
|
||||||
// Match the upload cadence: a 10 s GPS subscription burned power for
|
|
||||||
// fixes that were thrown away between uploads.
|
|
||||||
locationManager.requestLocationUpdates(
|
|
||||||
LocationManager.GPS_PROVIDER, settings.intervalMs(), 0f,
|
|
||||||
locationListener, Looper.getMainLooper());
|
|
||||||
locationUpdatesActive = true;
|
|
||||||
lastLocationRequestMs = System.currentTimeMillis();
|
|
||||||
// Seed from the last known fix so the first upload is not position-less —
|
|
||||||
// but only if it is recent. This is where the car's overnight parking spot
|
|
||||||
// used to come from: cold GPS at start-up delivers nothing for the first
|
|
||||||
// minutes, and the fix from the previous drive filled the gap.
|
|
||||||
Location last = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER);
|
|
||||||
if (last != null && locationAgeMs(last) <= maxLocationAgeMs()) {
|
|
||||||
lastLocation = last;
|
|
||||||
} else if (last != null) {
|
|
||||||
Log.i(TAG, "Ignoring last known fix: " + locationAgeMs(last) / 1000
|
|
||||||
+ "s old, waiting for a live one");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.w(TAG, "GPS provider disabled — will retry");
|
|
||||||
lastLocationRequestMs = System.currentTimeMillis();
|
|
||||||
}
|
|
||||||
} catch (SecurityException e) {
|
|
||||||
Log.w(TAG, "Location permission not granted: " + e.getMessage());
|
|
||||||
lastLocationRequestMs = System.currentTimeMillis();
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.w(TAG, "Location unavailable: " + e.getMessage());
|
|
||||||
lastLocationRequestMs = System.currentTimeMillis();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Notification ----------
|
// ---------- Notification ----------
|
||||||
@@ -731,22 +178,17 @@ public class AbrpUploadService extends Service {
|
|||||||
new Intent(this, MainActivity.class),
|
new Intent(this, MainActivity.class),
|
||||||
PendingIntent.FLAG_IMMUTABLE);
|
PendingIntent.FLAG_IMMUTABLE);
|
||||||
|
|
||||||
Intent stopIntent = new Intent(this, AbrpUploadService.class);
|
|
||||||
stopIntent.setAction(ACTION_STOP);
|
|
||||||
PendingIntent stopPi = PendingIntent.getService(this, 1,
|
|
||||||
stopIntent, PendingIntent.FLAG_IMMUTABLE);
|
|
||||||
|
|
||||||
return new NotificationCompat.Builder(this, CHANNEL_ID)
|
return new NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
.setContentTitle(getString(R.string.notif_title))
|
.setContentTitle(getString(R.string.notif_title))
|
||||||
.setContentText(status)
|
.setContentText(status)
|
||||||
.setContentIntent(openApp)
|
.setContentIntent(openApp)
|
||||||
.addAction(android.R.drawable.ic_delete, getString(R.string.notif_action_stop), stopPi)
|
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateNotification(String status) {
|
private void updateNotification(String status) {
|
||||||
|
if (status.equals(lastStatus)) return;
|
||||||
lastStatus = status;
|
lastStatus = status;
|
||||||
getSystemService(NotificationManager.class).notify(NOTIF_ID, buildNotification(status));
|
getSystemService(NotificationManager.class).notify(NOTIF_ID, buildNotification(status));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package com.evsuite.abrp;
|
|||||||
import android.content.BroadcastReceiver;
|
import android.content.BroadcastReceiver;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.SharedPreferences;
|
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
@@ -11,16 +10,14 @@ import java.util.HashSet;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restarts uploading when the head unit powers up.
|
* Starts the consumption tracking service when the head unit powers up.
|
||||||
*
|
*
|
||||||
* BOOT_COMPLETED alone was not enough. An MG4's head unit rarely cold-boots: switching the
|
* BOOT_COMPLETED alone was not enough. An MG4's head unit rarely cold-boots: switching the
|
||||||
* car on usually resumes it, and the resume is announced with QUICKBOOT_POWERON instead.
|
* car on usually resumed it, and the resume is announced with QUICKBOOT_POWERON instead.
|
||||||
* Users reported uploading never starting with the car, and this is why — the app was
|
* MY_PACKAGE_REPLACED brings the service back after an update stops it.
|
||||||
* waiting for a broadcast their car does not always send. The other EVSuite apps already
|
|
||||||
* listen to the whole family; this one now does too.
|
|
||||||
*
|
*
|
||||||
* MY_PACKAGE_REPLACED is in the list for a different reason: an update stops the service,
|
* Always-on: no credential or toggle gate. The service tracks consumption and syncs to the
|
||||||
* and nothing else would bring it back until the next power cycle.
|
* cloud unconditionally — that is its entire purpose.
|
||||||
*/
|
*/
|
||||||
public class BootReceiver extends BroadcastReceiver {
|
public class BootReceiver extends BroadcastReceiver {
|
||||||
|
|
||||||
@@ -39,32 +36,10 @@ public class BootReceiver extends BroadcastReceiver {
|
|||||||
String action = intent.getAction();
|
String action = intent.getAction();
|
||||||
if (action == null || !START_ACTIONS.contains(action)) return;
|
if (action == null || !START_ACTIONS.contains(action)) return;
|
||||||
|
|
||||||
SharedPreferences prefs = context.getSharedPreferences("abrp_prefs", Context.MODE_PRIVATE);
|
Log.i(TAG, action + " — starting consumption service");
|
||||||
boolean enabled = prefs.getBoolean("service_enabled", false);
|
|
||||||
boolean autostart = prefs.getBoolean(UploadSettings.KEY_AUTOSTART, UploadSettings.DEFAULT_AUTOSTART);
|
|
||||||
|
|
||||||
// Credentials come from SecurePrefs, not from the plaintext file. This receiver used
|
|
||||||
// to read "token" out of abrp_prefs — where it has not lived since the credentials
|
|
||||||
// were encrypted, because the migration deletes the plaintext copy. The check
|
|
||||||
// therefore always failed, and uploading never started by itself on any car.
|
|
||||||
SharedPreferences secure = SecurePrefs.get(context);
|
|
||||||
boolean haveCreds = !secure.getString(SecurePrefs.KEY_TOKEN, "").trim().isEmpty()
|
|
||||||
&& !secure.getString(SecurePrefs.KEY_API_KEY, "").trim().isEmpty();
|
|
||||||
boolean uploadEnabled = prefs.getBoolean(UploadSettings.KEY_ABRP_UPLOAD_ENABLED,
|
|
||||||
UploadSettings.DEFAULT_ABRP_UPLOAD_ENABLED);
|
|
||||||
|
|
||||||
if (!enabled || !autostart || (uploadEnabled && !haveCreds)) {
|
|
||||||
Log.i(TAG, action + " ignored (enabled=" + enabled
|
|
||||||
+ " autostart=" + autostart + " credentials=" + haveCreds + ")");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.i(TAG, action + " — starting upload service");
|
|
||||||
try {
|
try {
|
||||||
context.startForegroundService(new Intent(context, AbrpUploadService.class));
|
context.startForegroundService(new Intent(context, AbrpUploadService.class));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// A start refused this early is not worth crashing the boot broadcast over: the
|
|
||||||
// next power cycle, or opening the app, starts it just the same.
|
|
||||||
Log.w(TAG, "startForegroundService failed: " + e.getMessage());
|
Log.w(TAG, "startForegroundService failed: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
package com.evsuite.abrp;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses an ABRP configuration text file so a user can set the app up without typing on the
|
|
||||||
* car's on-screen keyboard — the api_key and token in particular are long and error-prone to
|
|
||||||
* enter by hand. The file is a plain {@code key = value} list, one entry per line:
|
|
||||||
*
|
|
||||||
* <pre>
|
|
||||||
* # ABRP Uploader config
|
|
||||||
* api_key = 1234abcd-...
|
|
||||||
* token = 5678efgh-...
|
|
||||||
* interval_sec = 60
|
|
||||||
* boost_low_soc = true
|
|
||||||
* low_soc_percent = 20
|
|
||||||
* </pre>
|
|
||||||
*
|
|
||||||
* Only api_key and token are commonly needed; the cadence keys are optional. Blank lines and
|
|
||||||
* lines starting with {@code #} are ignored, keys are case-insensitive, and surrounding
|
|
||||||
* whitespace is trimmed. Every field is nullable: the caller applies only what the file set,
|
|
||||||
* leaving anything absent untouched.
|
|
||||||
*/
|
|
||||||
final class ConfigImport {
|
|
||||||
|
|
||||||
final String apiKey;
|
|
||||||
final String token;
|
|
||||||
final Integer intervalSec;
|
|
||||||
final Boolean boostLowSoc;
|
|
||||||
final Integer lowSocPercent;
|
|
||||||
|
|
||||||
private ConfigImport(String apiKey, String token, Integer intervalSec,
|
|
||||||
Boolean boostLowSoc, Integer lowSocPercent) {
|
|
||||||
this.apiKey = apiKey;
|
|
||||||
this.token = token;
|
|
||||||
this.intervalSec = intervalSec;
|
|
||||||
this.boostLowSoc = boostLowSoc;
|
|
||||||
this.lowSocPercent = lowSocPercent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when the file yielded nothing usable — an empty or malformed file, not a config. */
|
|
||||||
boolean isEmpty() {
|
|
||||||
return apiKey == null && token == null && intervalSec == null
|
|
||||||
&& boostLowSoc == null && lowSocPercent == null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static ConfigImport parse(String text) {
|
|
||||||
String apiKey = null, token = null;
|
|
||||||
Integer intervalSec = null, lowSocPercent = null;
|
|
||||||
Boolean boostLowSoc = null;
|
|
||||||
|
|
||||||
for (String line : text.split("\n", -1)) {
|
|
||||||
line = line.trim();
|
|
||||||
if (line.isEmpty() || line.charAt(0) == '#') continue;
|
|
||||||
|
|
||||||
int sep = line.indexOf('=');
|
|
||||||
if (sep <= 0) continue;
|
|
||||||
|
|
||||||
String key = line.substring(0, sep).trim().toLowerCase(java.util.Locale.US);
|
|
||||||
String value = line.substring(sep + 1).trim();
|
|
||||||
if (value.isEmpty()) continue;
|
|
||||||
|
|
||||||
switch (key) {
|
|
||||||
case "api_key":
|
|
||||||
case "apikey":
|
|
||||||
apiKey = value;
|
|
||||||
break;
|
|
||||||
case "token":
|
|
||||||
case "user_token":
|
|
||||||
token = value;
|
|
||||||
break;
|
|
||||||
case "interval_sec":
|
|
||||||
case "upload_interval_sec":
|
|
||||||
intervalSec = nearestInterval(value);
|
|
||||||
break;
|
|
||||||
case "boost_low_soc":
|
|
||||||
boostLowSoc = parseBool(value);
|
|
||||||
break;
|
|
||||||
case "low_soc_percent":
|
|
||||||
lowSocPercent = clampPercent(value);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// Unknown keys are ignored rather than rejected, so a file can carry
|
|
||||||
// comments or future keys without breaking an older build.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new ConfigImport(apiKey, token, intervalSec, boostLowSoc, lowSocPercent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Snaps to the nearest offered choice: the UI spinner has no free-form interval. */
|
|
||||||
private static Integer nearestInterval(String value) {
|
|
||||||
int requested;
|
|
||||||
try {
|
|
||||||
requested = Integer.parseInt(value);
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
int best = UploadSettings.INTERVAL_CHOICES_SEC[0];
|
|
||||||
for (int choice : UploadSettings.INTERVAL_CHOICES_SEC) {
|
|
||||||
if (Math.abs(choice - requested) < Math.abs(best - requested)) best = choice;
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Same 1–99 clamp the manual field enforces, so an import cannot set an unusable value. */
|
|
||||||
private static Integer clampPercent(String value) {
|
|
||||||
try {
|
|
||||||
return Math.max(1, Math.min(99, Integer.parseInt(value)));
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Boolean parseBool(String value) {
|
|
||||||
value = value.toLowerCase(java.util.Locale.US);
|
|
||||||
if (value.equals("true") || value.equals("on") || value.equals("1")) return Boolean.TRUE;
|
|
||||||
if (value.equals("false") || value.equals("off") || value.equals("0")) return Boolean.FALSE;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,27 +4,37 @@ import android.content.Context;
|
|||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
|
import org.json.JSONArray;
|
||||||
import org.json.JSONObject;
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.net.HttpURLConnection;
|
import java.net.HttpURLConnection;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/** Best-effort MongoDB sync through the web API. Never blocks local persistence. */
|
/**
|
||||||
|
* Best-effort MongoDB sync through the web API. Never blocks local persistence.
|
||||||
|
*
|
||||||
|
* The installationId is a fixed value baked into BuildConfig — never a random UUID — so an
|
||||||
|
* APK wipe cannot orphan the cloud history under a new identity. The consumption UI reads
|
||||||
|
* back its own uploaded totals via {@link #fetchHistoryIfDue}, which means an APK reinstall
|
||||||
|
* picks up right where the old one left off instead of showing zeroes.
|
||||||
|
*/
|
||||||
final class ConsumptionCloudClient {
|
final class ConsumptionCloudClient {
|
||||||
private static final String TAG = "FatihsMG4.Mongo";
|
private static final String TAG = "FatihsMG4.Mongo";
|
||||||
private static final long CONSUMPTION_SYNC_INTERVAL_MS = 30 * 60_000L;
|
private static final long CONSUMPTION_SYNC_INTERVAL_MS = 30 * 60_000L;
|
||||||
private static final long CHARGE_RETRY_INTERVAL_MS = 5 * 60_000L;
|
private static final long CHARGE_RETRY_INTERVAL_MS = 5 * 60_000L;
|
||||||
|
private static final long HISTORY_FETCH_INTERVAL_MS = 5 * 60_000L;
|
||||||
private static final int TIMEOUT_MS = 8_000;
|
private static final int TIMEOUT_MS = 8_000;
|
||||||
private static final String PREFS = "consumption_cloud";
|
private static final String PREFS = "consumption_cloud";
|
||||||
private static final String INSTALLATION_ID = "installation_id";
|
private static final String CHUNK_CACHE = "chunk_cache"; // JSON array of pending chunks
|
||||||
private static final String LAST_CONSUMPTION_DATE = "last_consumption_date";
|
|
||||||
private static final int MAX_DAYS_PER_SYNC = 10;
|
|
||||||
private static final int MAX_CHARGES_PER_SYNC = 10;
|
private static final int MAX_CHARGES_PER_SYNC = 10;
|
||||||
|
|
||||||
private final String endpoint;
|
private final String endpoint;
|
||||||
@@ -35,57 +45,156 @@ final class ConsumptionCloudClient {
|
|||||||
private long lastChargeAttemptMs;
|
private long lastChargeAttemptMs;
|
||||||
private String lastAttemptedChargeId = "";
|
private String lastAttemptedChargeId = "";
|
||||||
|
|
||||||
|
/** Cached history read back from the cloud, refreshed on a 5-minute throttle. */
|
||||||
|
private volatile List<DayRecord> cachedHistory = Collections.emptyList();
|
||||||
|
private volatile long lastHistoryFetchMs;
|
||||||
|
|
||||||
|
/** One day's uploaded totals, as read back from the cloud. */
|
||||||
|
static final class DayRecord {
|
||||||
|
final LocalDate date;
|
||||||
|
final ConsumptionTracker.Totals day;
|
||||||
|
final ConsumptionTracker.Totals lifetime;
|
||||||
|
final Float sohPercent;
|
||||||
|
|
||||||
|
DayRecord(LocalDate date, ConsumptionTracker.Totals day, ConsumptionTracker.Totals lifetime, Float sohPercent) {
|
||||||
|
this.date = date;
|
||||||
|
this.day = day;
|
||||||
|
this.lifetime = lifetime;
|
||||||
|
this.sohPercent = sohPercent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ConsumptionCloudClient(Context context) {
|
ConsumptionCloudClient(Context context) {
|
||||||
endpoint = BuildConfig.CONSUMPTION_API_URL.trim();
|
endpoint = BuildConfig.CONSUMPTION_API_URL.trim();
|
||||||
token = BuildConfig.CONSUMPTION_API_TOKEN.trim();
|
token = BuildConfig.CONSUMPTION_API_TOKEN.trim();
|
||||||
prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||||
String existing = prefs.getString(INSTALLATION_ID, "");
|
installationId = BuildConfig.CONSUMPTION_INSTALLATION_ID.trim();
|
||||||
if (existing == null || existing.isEmpty()) {
|
if (endpoint.isEmpty() || token.isEmpty()) {
|
||||||
existing = UUID.randomUUID().toString();
|
Log.w(TAG, "Consumption API not configured: endpoint=" + (endpoint.isEmpty() ? "empty" : "ok")
|
||||||
prefs.edit().putString(INSTALLATION_ID, existing).commit();
|
+ ", token=" + (token.isEmpty() ? "empty" : "ok"));
|
||||||
}
|
}
|
||||||
installationId = existing;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Every 30 minutes, takes a delta chunk from the tracker and sends it. Chunks that fail
|
||||||
|
* to send (offline, server error) accumulate in a local cache and are retried on the
|
||||||
|
* next cycle — so a drive through a dead-signal area still reaches the cloud once the
|
||||||
|
* head unit is back on the hotspot. Every attempt is logged to the in-app "Kayıtlar" tab.
|
||||||
|
*
|
||||||
* @param nowMs wall clock, recorded with the payload — the throttle below runs on a
|
* @param nowMs wall clock, recorded with the payload — the throttle below runs on a
|
||||||
* monotonic clock instead, so a backwards NTP correction cannot stall
|
* monotonic clock instead, so a backwards NTP correction cannot stall
|
||||||
* syncing for however long the jump was.
|
* syncing for however long the jump was.
|
||||||
*/
|
*/
|
||||||
void syncIfDue(ConsumptionTracker tracker, ChargeSessionTracker charges, long nowMs) {
|
void syncIfDue(ConsumptionTracker tracker, ChargeSessionTracker charges, long nowMs) {
|
||||||
long elapsed = android.os.SystemClock.elapsedRealtime();
|
long elapsed = android.os.SystemClock.elapsedRealtime();
|
||||||
if (endpoint.isEmpty() || token.isEmpty()) return;
|
if (endpoint.isEmpty() || token.isEmpty()) {
|
||||||
|
Log.d(TAG, "Sync skipped: API not configured");
|
||||||
|
return;
|
||||||
|
}
|
||||||
syncPendingChargesIfDue(charges, elapsed);
|
syncPendingChargesIfDue(charges, elapsed);
|
||||||
if (lastConsumptionAttemptMs != 0L
|
|
||||||
&& elapsed - lastConsumptionAttemptMs < CONSUMPTION_SYNC_INTERVAL_MS) return;
|
// Drain any cached chunks first — a backlog from an offline period has priority.
|
||||||
|
int cached = drainChunkCache();
|
||||||
|
if (cached > 0) {
|
||||||
|
logEvent(true, 200, "Cache boşaltıldı: " + cached + " chunk gönderildi");
|
||||||
|
invalidateHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean due = lastConsumptionAttemptMs == 0L
|
||||||
|
|| elapsed - lastConsumptionAttemptMs >= CONSUMPTION_SYNC_INTERVAL_MS;
|
||||||
|
if (!due) return;
|
||||||
lastConsumptionAttemptMs = elapsed;
|
lastConsumptionAttemptMs = elapsed;
|
||||||
|
|
||||||
|
ConsumptionTracker.CloudSnapshot chunk = tracker.takeChunkDelta();
|
||||||
|
if (chunk == null) {
|
||||||
|
Log.d(TAG, "Sync due but no new consumption since last chunk");
|
||||||
|
return; // no driving since the last chunk
|
||||||
|
}
|
||||||
|
Log.d(TAG, "New chunk for " + chunk.date + ": +" + String.format("%.1f", chunk.day.km)
|
||||||
|
+ "km, +" + String.format("%.2f", chunk.day.kwh) + "kWh");
|
||||||
try {
|
try {
|
||||||
syncDailyConsumption(tracker, nowMs);
|
JSONObject body = consumptionBody(chunk, nowMs);
|
||||||
|
if (send("/v1/consumption/daily", "PUT", body)) {
|
||||||
|
logEvent(true, 200, "Chunk " + chunk.date + " gönderildi (+"
|
||||||
|
+ String.format("%.1f", chunk.day.km) + "km, +"
|
||||||
|
+ String.format("%.2f", chunk.day.kwh) + "kWh)");
|
||||||
|
invalidateHistory();
|
||||||
|
} else {
|
||||||
|
cacheChunk(body);
|
||||||
|
int pending = pendingChunkCount();
|
||||||
|
logEvent(false, 0, "Chunk gönderilemedi — cache'de bekliyor (" + pending + " chunk)");
|
||||||
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.w(TAG, "Consumption sync unavailable");
|
// Network failure: cache the chunk for the next cycle.
|
||||||
|
try {
|
||||||
|
JSONObject body = consumptionBody(chunk, nowMs);
|
||||||
|
cacheChunk(body);
|
||||||
|
} catch (Exception ignored) { }
|
||||||
|
int pending = pendingChunkCount();
|
||||||
|
logEvent(false, 0, "Çevrimdışı — cache'de bekliyor (" + pending + " chunk): " + e.getMessage());
|
||||||
|
Log.w(TAG, "Chunk sync failed, cached: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drains the backlog a few days per cycle; one a cycle took an hour per fortnight. */
|
/**
|
||||||
private void syncDailyConsumption(ConsumptionTracker tracker, long nowMs) throws Exception {
|
* Sends every cached chunk; removes each one that succeeds. Returns how many were sent.
|
||||||
String saved = prefs.getString(LAST_CONSUMPTION_DATE, "");
|
* A chunk that fails stops the drain — the rest stay in order for the next cycle.
|
||||||
LocalDate lastUploaded = null;
|
*/
|
||||||
try { if (saved != null && !saved.isEmpty()) lastUploaded = LocalDate.parse(saved); }
|
private int drainChunkCache() {
|
||||||
catch (RuntimeException ignored) { }
|
JSONArray cache = loadChunkCache();
|
||||||
for (int sent = 0; sent < MAX_DAYS_PER_SYNC; sent++) {
|
if (cache.length() == 0) return 0;
|
||||||
LocalDate target = tracker.nextStoredDayAfter(lastUploaded);
|
JSONArray remaining = new JSONArray();
|
||||||
if (target == null) break;
|
int sent = 0;
|
||||||
ConsumptionTracker.CloudSnapshot snapshot = tracker.cloudSnapshot(target);
|
for (int i = 0; i < cache.length(); i++) {
|
||||||
JSONObject body = consumptionBody(snapshot, nowMs);
|
try {
|
||||||
if (!send("/v1/consumption/daily", "PUT", body)) return;
|
JSONObject body = cache.getJSONObject(i);
|
||||||
prefs.edit().putString(LAST_CONSUMPTION_DATE, target.toString()).commit();
|
String date = body.optString("date", "?");
|
||||||
lastUploaded = target;
|
if (send("/v1/consumption/daily", "PUT", body)) {
|
||||||
|
sent++;
|
||||||
|
Log.d(TAG, "Cached chunk for " + date + " sent");
|
||||||
|
} else {
|
||||||
|
// Server rejected — keep this and everything after it.
|
||||||
|
for (int j = i; j < cache.length(); j++) remaining.put(cache.get(j));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Network error — keep this and everything after it, stop draining.
|
||||||
|
for (int j = i; j < cache.length(); j++) {
|
||||||
|
try { remaining.put(cache.get(j)); } catch (Exception ignored) { }
|
||||||
|
}
|
||||||
|
Log.w(TAG, "Cache drain stopped: " + e.getMessage());
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Today's cumulative counters overwrite the same installation/date document every
|
saveChunkCache(remaining);
|
||||||
// 30 minutes. Sending totals rather than increments makes retries idempotent.
|
return sent;
|
||||||
ConsumptionTracker.CloudSnapshot today = tracker.cloudSnapshot(LocalDate.now());
|
}
|
||||||
JSONObject current = consumptionBody(today, nowMs);
|
|
||||||
send("/v1/consumption/daily", "PUT", current);
|
private JSONArray loadChunkCache() {
|
||||||
|
String raw = prefs.getString(CHUNK_CACHE, "");
|
||||||
|
if (raw == null || raw.isEmpty()) return new JSONArray();
|
||||||
|
try { return new JSONArray(raw); } catch (Exception e) { return new JSONArray(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveChunkCache(JSONArray cache) {
|
||||||
|
prefs.edit().putString(CHUNK_CACHE, cache.toString()).commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cacheChunk(JSONObject body) {
|
||||||
|
JSONArray cache = loadChunkCache();
|
||||||
|
cache.put(body);
|
||||||
|
saveChunkCache(cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int pendingChunkCount() {
|
||||||
|
return loadChunkCache().length();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records a cloud-sync attempt to the in-app log (the "Kayıtlar" tab). */
|
||||||
|
private void logEvent(boolean success, int httpStatus, String detail) {
|
||||||
|
UploadLog log = AbrpUploadService.log();
|
||||||
|
if (log == null) return;
|
||||||
|
log.record(new UploadLog.Entry(System.currentTimeMillis(), httpStatus, success, detail));
|
||||||
}
|
}
|
||||||
|
|
||||||
private JSONObject consumptionBody(ConsumptionTracker.CloudSnapshot snapshot, long nowMs)
|
private JSONObject consumptionBody(ConsumptionTracker.CloudSnapshot snapshot, long nowMs)
|
||||||
@@ -112,18 +221,28 @@ final class ConsumptionCloudClient {
|
|||||||
try {
|
try {
|
||||||
syncPendingCharges(charges, pending);
|
syncPendingCharges(charges, pending);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.w(TAG, "Charging-session sync unavailable");
|
logEvent(false, 0, "Şarj session sync hatası: " + e.getMessage());
|
||||||
|
Log.w(TAG, "Charging-session sync unavailable: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void syncPendingCharges(ChargeSessionTracker charges, List<JSONObject> pending)
|
private void syncPendingCharges(ChargeSessionTracker charges, List<JSONObject> pending)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
|
Log.d(TAG, "Found " + pending.size() + " pending charge sessions");
|
||||||
int sent = 0;
|
int sent = 0;
|
||||||
for (JSONObject session : pending) {
|
for (JSONObject session : pending) {
|
||||||
JSONObject body = new JSONObject(session.toString()).put("installationId", installationId);
|
JSONObject body = new JSONObject(session.toString()).put("installationId", installationId);
|
||||||
String id = session.getString("sessionId");
|
String id = session.getString("sessionId");
|
||||||
if (!send("/v1/charging-sessions/" + id, "PUT", body)) return;
|
Log.d(TAG, "Uploading charge session: " + id);
|
||||||
charges.markUploaded(id);
|
if (send("/v1/charging-sessions/" + id, "PUT", body)) {
|
||||||
|
charges.markUploaded(id);
|
||||||
|
logEvent(true, 200, "Şarj session gönderildi: " + id);
|
||||||
|
Log.d(TAG, "Successfully uploaded charge session: " + id);
|
||||||
|
} else {
|
||||||
|
logEvent(false, 0, "Şarj session gönderilemedi: " + id);
|
||||||
|
Log.w(TAG, "Failed to upload charge session: " + id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (++sent >= MAX_CHARGES_PER_SYNC) return;
|
if (++sent >= MAX_CHARGES_PER_SYNC) return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,7 +251,9 @@ final class ConsumptionCloudClient {
|
|||||||
HttpURLConnection connection = null;
|
HttpURLConnection connection = null;
|
||||||
try {
|
try {
|
||||||
byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8);
|
byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8);
|
||||||
connection = (HttpURLConnection) new URL(trimSlash(endpoint) + path).openConnection();
|
String url = trimSlash(endpoint) + path;
|
||||||
|
Log.d(TAG, "Sending " + method + " request to " + url + " (" + bytes.length + " bytes)");
|
||||||
|
connection = (HttpURLConnection) new URL(url).openConnection();
|
||||||
connection.setRequestMethod(method);
|
connection.setRequestMethod(method);
|
||||||
connection.setConnectTimeout(TIMEOUT_MS);
|
connection.setConnectTimeout(TIMEOUT_MS);
|
||||||
connection.setReadTimeout(TIMEOUT_MS);
|
connection.setReadTimeout(TIMEOUT_MS);
|
||||||
@@ -142,13 +263,18 @@ final class ConsumptionCloudClient {
|
|||||||
connection.setRequestProperty("Authorization", "Bearer " + token);
|
connection.setRequestProperty("Authorization", "Bearer " + token);
|
||||||
try (OutputStream out = connection.getOutputStream()) { out.write(bytes); }
|
try (OutputStream out = connection.getOutputStream()) { out.write(bytes); }
|
||||||
int status = connection.getResponseCode();
|
int status = connection.getResponseCode();
|
||||||
InputStream response = status < 400 ? connection.getInputStream() : connection.getErrorStream();
|
Log.d(TAG, "Response: HTTP " + status);
|
||||||
if (response != null) try (InputStream ignored = response) { }
|
|
||||||
if (status < 200 || status >= 300) {
|
if (status < 200 || status >= 300) {
|
||||||
Log.w(TAG, "Sync rejected with HTTP " + status);
|
String errBody = readErrorBody(connection);
|
||||||
|
Log.w(TAG, "Sync rejected with HTTP " + status + (errBody.isEmpty() ? "" : ": " + errBody));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
InputStream response = connection.getInputStream();
|
||||||
|
if (response != null) try (InputStream ignored = response) { }
|
||||||
return true;
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "Network error: " + e.getMessage());
|
||||||
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
if (connection != null) connection.disconnect();
|
if (connection != null) connection.disconnect();
|
||||||
}
|
}
|
||||||
@@ -159,6 +285,107 @@ final class ConsumptionCloudClient {
|
|||||||
.put("hours", totals.hours).put("socDrop", totals.soc);
|
.put("hours", totals.hours).put("socDrop", totals.soc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Read-back for the consumption UI ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the cached cloud history, fetching a fresh copy at most every 5 minutes.
|
||||||
|
* Called from the UI poll loop (every 2 s), so the throttle is what keeps the head unit
|
||||||
|
* off the network. On a fetch failure the previous cache is kept — a transient outage
|
||||||
|
* must not blank the display.
|
||||||
|
*/
|
||||||
|
List<DayRecord> fetchHistoryIfDue() {
|
||||||
|
if (endpoint.isEmpty() || token.isEmpty()) return cachedHistory;
|
||||||
|
long elapsed = android.os.SystemClock.elapsedRealtime();
|
||||||
|
if (lastHistoryFetchMs != 0L && elapsed - lastHistoryFetchMs < HISTORY_FETCH_INTERVAL_MS) {
|
||||||
|
return cachedHistory;
|
||||||
|
}
|
||||||
|
lastHistoryFetchMs = elapsed;
|
||||||
|
try {
|
||||||
|
List<DayRecord> fresh = fetchHistory();
|
||||||
|
if (!fresh.isEmpty()) cachedHistory = fresh;
|
||||||
|
Log.d(TAG, "Fetched " + fresh.size() + " history records from cloud");
|
||||||
|
} catch (Exception e) {
|
||||||
|
String msg = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
|
||||||
|
Log.w(TAG, "History fetch unavailable: " + msg);
|
||||||
|
logEvent(false, 0, "History fetch hatası: " + msg);
|
||||||
|
}
|
||||||
|
return cachedHistory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forces a refresh on the next {@link #fetchHistoryIfDue} — call after an upload. */
|
||||||
|
void invalidateHistory() {
|
||||||
|
lastHistoryFetchMs = 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<DayRecord> fetchHistory() throws Exception {
|
||||||
|
HttpURLConnection connection = null;
|
||||||
|
try {
|
||||||
|
String url = trimSlash(endpoint) + "/v1/consumption/history/" + installationId;
|
||||||
|
connection = (HttpURLConnection) new URL(url).openConnection();
|
||||||
|
connection.setRequestMethod("GET");
|
||||||
|
connection.setConnectTimeout(TIMEOUT_MS);
|
||||||
|
connection.setReadTimeout(TIMEOUT_MS);
|
||||||
|
connection.setRequestProperty("Authorization", "Bearer " + token);
|
||||||
|
int status = connection.getResponseCode();
|
||||||
|
if (status < 200 || status >= 300) {
|
||||||
|
Log.w(TAG, "History fetch rejected with HTTP " + status);
|
||||||
|
return cachedHistory;
|
||||||
|
}
|
||||||
|
String body = readBody(connection);
|
||||||
|
JSONObject json = new JSONObject(body);
|
||||||
|
JSONArray records = json.optJSONArray("records");
|
||||||
|
if (records == null) return Collections.emptyList();
|
||||||
|
List<DayRecord> result = new ArrayList<>(records.length());
|
||||||
|
for (int i = 0; i < records.length(); i++) {
|
||||||
|
JSONObject r = records.getJSONObject(i);
|
||||||
|
LocalDate date = LocalDate.parse(r.getString("date"));
|
||||||
|
ConsumptionTracker.Totals day = parseTotals(r.getJSONObject("day"));
|
||||||
|
ConsumptionTracker.Totals life = parseTotals(r.getJSONObject("lifetime"));
|
||||||
|
Float soh = r.has("sohPercent") && !r.isNull("sohPercent")
|
||||||
|
? (float) r.getDouble("sohPercent") : null;
|
||||||
|
result.add(new DayRecord(date, day, life, soh));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
if (connection != null) connection.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ConsumptionTracker.Totals parseTotals(JSONObject o) {
|
||||||
|
return new ConsumptionTracker.Totals(
|
||||||
|
o.optDouble("km", 0d), o.optDouble("kwh", 0d),
|
||||||
|
o.optDouble("hours", 0d), o.optDouble("socDrop", 0d));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readBody(HttpURLConnection connection) throws Exception {
|
||||||
|
InputStream stream = connection.getInputStream();
|
||||||
|
try (BufferedReader br = new BufferedReader(
|
||||||
|
new InputStreamReader(stream, StandardCharsets.UTF_8))) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
String line;
|
||||||
|
while ((line = br.readLine()) != null) sb.append(line);
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the error response body (truncated) for logging — never throws. */
|
||||||
|
private static String readErrorBody(HttpURLConnection connection) {
|
||||||
|
try {
|
||||||
|
InputStream stream = connection.getErrorStream();
|
||||||
|
if (stream == null) return "";
|
||||||
|
try (BufferedReader br = new BufferedReader(
|
||||||
|
new InputStreamReader(stream, StandardCharsets.UTF_8))) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
String line;
|
||||||
|
while ((line = br.readLine()) != null) sb.append(line);
|
||||||
|
String body = sb.toString();
|
||||||
|
return body.length() > 300 ? body.substring(0, 300) + "…" : body;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static String trimSlash(String value) {
|
private static String trimSlash(String value) {
|
||||||
int end = value.length();
|
int end = value.length();
|
||||||
while (end > 0 && value.charAt(end - 1) == '/') end--;
|
while (end > 0 && value.charAt(end - 1) == '/') end--;
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ final class ConsumptionTracker {
|
|||||||
private static final String PREFS = "consumption_counters_v2";
|
private static final String PREFS = "consumption_counters_v2";
|
||||||
private static final String LEGACY_PREFS = "consumption_counters_v1";
|
private static final String LEGACY_PREFS = "consumption_counters_v1";
|
||||||
private static final String SOH_PERCENT = "manual_soh_percent";
|
private static final String SOH_PERCENT = "manual_soh_percent";
|
||||||
|
/** Snapshot of today's counter at the last chunk send — the baseline for the next delta. */
|
||||||
|
private static final String CHUNK_CHECKPOINT = "chunk_checkpoint_";
|
||||||
|
|
||||||
/** How often the in-memory accumulators reach disk. Matches DriveHub_Dort. */
|
/** How often the in-memory accumulators reach disk. Matches DriveHub_Dort. */
|
||||||
private static final long PERSIST_INTERVAL_MS = 30_000L;
|
private static final long PERSIST_INTERVAL_MS = 30_000L;
|
||||||
@@ -51,6 +53,8 @@ final class ConsumptionTracker {
|
|||||||
private final Counter tripB = new Counter();
|
private final Counter tripB = new Counter();
|
||||||
private final Counter today = new Counter();
|
private final Counter today = new Counter();
|
||||||
private LocalDate todayDate;
|
private LocalDate todayDate;
|
||||||
|
/** Today's totals as of the last chunk send, per field. Delta = today - this. */
|
||||||
|
private double chunkCpKm, chunkCpKwh, chunkCpHours, chunkCpSoc;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Monotonic. {@code EnergySnapshot.timestampMs} is wall clock, and the head unit's
|
* Monotonic. {@code EnergySnapshot.timestampMs} is wall clock, and the head unit's
|
||||||
@@ -70,6 +74,7 @@ final class ConsumptionTracker {
|
|||||||
tripB.load(prefs, "b");
|
tripB.load(prefs, "b");
|
||||||
todayDate = LocalDate.now();
|
todayDate = LocalDate.now();
|
||||||
today.load(prefs, dayKey(todayDate));
|
today.load(prefs, dayKey(todayDate));
|
||||||
|
loadChunkCheckpoint();
|
||||||
pruneOldDays();
|
pruneOldDays();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +153,49 @@ final class ConsumptionTracker {
|
|||||||
return new CloudSnapshot(date.toString(), totalsForDay(date), lifetimeAtEndOf(date), sohPercent());
|
return new CloudSnapshot(date.toString(), totalsForDay(date), lifetimeAtEndOf(date), sohPercent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The delta accumulated since the last chunk send — what a 30-minute consumption chunk
|
||||||
|
* should carry. Returns null when nothing has accumulated (no driving in this window).
|
||||||
|
* Calling this advances the checkpoint, so the same delta is never sent twice.
|
||||||
|
*/
|
||||||
|
synchronized CloudSnapshot takeChunkDelta() {
|
||||||
|
rollDayIfNeeded();
|
||||||
|
Totals current = today.snapshot();
|
||||||
|
double dKm = current.km - chunkCpKm;
|
||||||
|
double dKwh = current.kwh - chunkCpKwh;
|
||||||
|
double dHours = current.hours - chunkCpHours;
|
||||||
|
double dSoc = current.soc - chunkCpSoc;
|
||||||
|
if (Math.abs(dKm) < 0.001 && Math.abs(dKwh) < 0.001
|
||||||
|
&& Math.abs(dHours) < 0.001 && Math.abs(dSoc) < 0.001) {
|
||||||
|
return null; // nothing new since the last chunk
|
||||||
|
}
|
||||||
|
saveChunkCheckpoint(current);
|
||||||
|
Totals delta = new Totals(dKm, dKwh, dHours, dSoc);
|
||||||
|
return new CloudSnapshot(todayDate.toString(), delta, lifetime.snapshot(), sohPercent());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadChunkCheckpoint() {
|
||||||
|
String p = CHUNK_CHECKPOINT + todayDate;
|
||||||
|
chunkCpKm = bits(p, "km");
|
||||||
|
chunkCpKwh = bits(p, "kwh");
|
||||||
|
chunkCpHours = bits(p, "h");
|
||||||
|
chunkCpSoc = bits(p, "soc");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveChunkCheckpoint(Totals current) {
|
||||||
|
chunkCpKm = current.km;
|
||||||
|
chunkCpKwh = current.kwh;
|
||||||
|
chunkCpHours = current.hours;
|
||||||
|
chunkCpSoc = current.soc;
|
||||||
|
String p = CHUNK_CHECKPOINT + todayDate;
|
||||||
|
SharedPreferences.Editor e = prefs.edit();
|
||||||
|
e.putLong(p + "_km", Double.doubleToRawLongBits(current.km));
|
||||||
|
e.putLong(p + "_kwh", Double.doubleToRawLongBits(current.kwh));
|
||||||
|
e.putLong(p + "_h", Double.doubleToRawLongBits(current.hours));
|
||||||
|
e.putLong(p + "_soc", Double.doubleToRawLongBits(current.soc));
|
||||||
|
e.commit();
|
||||||
|
}
|
||||||
|
|
||||||
/** Lifetime as of the close of {@code date}, falling back to the live counter. */
|
/** Lifetime as of the close of {@code date}, falling back to the live counter. */
|
||||||
private Totals lifetimeAtEndOf(LocalDate date) {
|
private Totals lifetimeAtEndOf(LocalDate date) {
|
||||||
if (date.equals(todayDate)) return lifetime.snapshot();
|
if (date.equals(todayDate)) return lifetime.snapshot();
|
||||||
@@ -201,6 +249,7 @@ final class ConsumptionTracker {
|
|||||||
persist();
|
persist();
|
||||||
todayDate = date;
|
todayDate = date;
|
||||||
today.load(prefs, dayKey(date));
|
today.load(prefs, dayKey(date));
|
||||||
|
loadChunkCheckpoint(); // new day → fresh checkpoint baseline
|
||||||
pruneOldDays();
|
pruneOldDays();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,239 +0,0 @@
|
|||||||
package com.evsuite.abrp;
|
|
||||||
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.os.Bundle;
|
|
||||||
|
|
||||||
import org.json.JSONArray;
|
|
||||||
import org.json.JSONObject;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Outside temperature from the head unit's own weather app.
|
|
||||||
*
|
|
||||||
* The car does not implement ENV_OUTSIDE_TEMPERATURE, so there is no vehicle sensor to read,
|
|
||||||
* and the vendor map service that answers a weather query by position never bound on this
|
|
||||||
* firmware — {@code ext_temp} was simply absent from every upload. What demonstrably works is
|
|
||||||
* the head unit's status bar, which shows a temperature all the time. It gets it from a
|
|
||||||
* broadcast, and so does this.
|
|
||||||
*
|
|
||||||
* **Everything about the payload here is unverified.** The action and the extra name come from
|
|
||||||
* reading the head unit, not from a published interface, and the shape inside is read by
|
|
||||||
* searching rather than by field offsets: any key whose name mentions a temperature, at any
|
|
||||||
* depth. That is deliberate. A parser written against a guessed layout fails silently and
|
|
||||||
* looks exactly like the bug it was meant to fix, whereas a search either finds a number or
|
|
||||||
* reports what it was given — see {@link #diagnostic()}, which reaches the app's own log.
|
|
||||||
*
|
|
||||||
* Stays in this app rather than in EVHardware for the same reason: the shared library carries
|
|
||||||
* vendor knowledge that has been confirmed on a car. Once a driver's log shows this reading a
|
|
||||||
* real broadcast, it belongs there instead, next to SaicWeather.
|
|
||||||
*/
|
|
||||||
final class HeadUnitWeather {
|
|
||||||
|
|
||||||
/** The head unit's weather app announces each update with this. */
|
|
||||||
static final String ACTION = "com.saicmotor.weather";
|
|
||||||
|
|
||||||
/** Enough of an unreadable payload to recognise its shape, short enough for a log line. */
|
|
||||||
private static final int RAW_SAMPLE_CHARS = 200;
|
|
||||||
|
|
||||||
/** How deep the search descends. A weather payload is nested, not deeply nested. */
|
|
||||||
private static final int MAX_DEPTH = 6;
|
|
||||||
|
|
||||||
private volatile Float temperatureC;
|
|
||||||
/** The place the head unit named — what ABRP's address is checked against. */
|
|
||||||
private volatile String place = "";
|
|
||||||
/** Why the last broadcast produced nothing, for the log. Null once one produced something. */
|
|
||||||
private volatile String problem = "no broadcast yet";
|
|
||||||
private volatile long receivedMs = 0L;
|
|
||||||
|
|
||||||
/** Outside temperature in °C, or null while no broadcast has carried one. */
|
|
||||||
Float temperatureC() {
|
|
||||||
return temperatureC;
|
|
||||||
}
|
|
||||||
|
|
||||||
String place() {
|
|
||||||
return place;
|
|
||||||
}
|
|
||||||
|
|
||||||
long receivedMs() {
|
|
||||||
return receivedMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One short token for the upload log: the place a reading came from, or the reason there
|
|
||||||
* is none. "no broadcast yet" and "no temperature in {...}" call for different fixes and
|
|
||||||
* are indistinguishable from the ABRP side.
|
|
||||||
*/
|
|
||||||
String diagnostic() {
|
|
||||||
if (temperatureC == null) return problem;
|
|
||||||
String age = (System.currentTimeMillis() - receivedMs) / 60000 + "min";
|
|
||||||
return (place.isEmpty() ? "ok" : place) + "/" + age;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reads whatever the head unit just broadcast. Never throws: this runs in a receiver. */
|
|
||||||
void accept(Intent intent) {
|
|
||||||
if (intent == null) {
|
|
||||||
// Only reached from the sticky probe, which answers null when the platform is
|
|
||||||
// holding no such broadcast. Says nothing about whether one will ever be sent.
|
|
||||||
if (temperatureC == null) problem = "none held, none sent yet";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
acceptPayload(payloadOf(intent));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Restores the last reading across a restart — see the service's prefs. */
|
|
||||||
void restore(float celsius, String named, long whenMs) {
|
|
||||||
temperatureC = celsius;
|
|
||||||
place = named == null ? "" : named;
|
|
||||||
receivedMs = whenMs;
|
|
||||||
problem = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The half of {@link #accept} that has no Android in it, so the search below is testable
|
|
||||||
* against the payloads it might be handed rather than only against the one it expects.
|
|
||||||
*/
|
|
||||||
void acceptPayload(String payload) {
|
|
||||||
try {
|
|
||||||
if (payload == null) {
|
|
||||||
problem = "broadcast carried no JSON";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
JSONObject json = new JSONObject(payload);
|
|
||||||
Float celsius = toCelsius(findNumber(json, "temp", 0), findUnit(json, 0));
|
|
||||||
if (celsius == null) {
|
|
||||||
problem = "no temperature in " + truncate(payload);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
temperatureC = celsius;
|
|
||||||
String named = findText(json, 0);
|
|
||||||
place = named == null ? "" : named;
|
|
||||||
receivedMs = System.currentTimeMillis();
|
|
||||||
problem = null;
|
|
||||||
} catch (Exception e) {
|
|
||||||
// A payload that is not the JSON this expects is a finding, not a crash.
|
|
||||||
problem = e.getClass().getSimpleName() + " reading broadcast";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The JSON the broadcast carries. Named extras first, then any string extra that looks
|
|
||||||
* like an object — the extra's name is as unverified as the rest of the payload, and a
|
|
||||||
* renamed key is not a reason to give up on a broadcast that is otherwise readable.
|
|
||||||
*/
|
|
||||||
private static String payloadOf(Intent intent) {
|
|
||||||
Bundle extras = intent.getExtras();
|
|
||||||
if (extras == null) return null;
|
|
||||||
String named = asJson(extras.get("weather"));
|
|
||||||
if (named != null) return named;
|
|
||||||
for (String key : extras.keySet()) {
|
|
||||||
String candidate = asJson(extras.get(key));
|
|
||||||
if (candidate != null) return candidate;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String asJson(Object value) {
|
|
||||||
if (!(value instanceof String)) return null;
|
|
||||||
String text = ((String) value).trim();
|
|
||||||
return text.startsWith("{") ? text : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The first number under a key whose name mentions [needle], at any depth.
|
|
||||||
*
|
|
||||||
* A search rather than a path: the payload's own field names are the only thing here that
|
|
||||||
* is self-describing, and they survive a layout change that a field order would not.
|
|
||||||
*/
|
|
||||||
private static Double findNumber(Object node, String needle, int depth) {
|
|
||||||
if (depth > MAX_DEPTH) return null;
|
|
||||||
if (node instanceof JSONObject) {
|
|
||||||
JSONObject object = (JSONObject) node;
|
|
||||||
for (java.util.Iterator<String> keys = object.keys(); keys.hasNext(); ) {
|
|
||||||
String key = keys.next();
|
|
||||||
Object value = object.opt(key);
|
|
||||||
if (key.toLowerCase(java.util.Locale.US).contains(needle)) {
|
|
||||||
Double number = asNumber(value);
|
|
||||||
if (number != null) return number;
|
|
||||||
}
|
|
||||||
Double nested = findNumber(value, needle, depth + 1);
|
|
||||||
if (nested != null) return nested;
|
|
||||||
}
|
|
||||||
} else if (node instanceof JSONArray) {
|
|
||||||
JSONArray array = (JSONArray) node;
|
|
||||||
for (int i = 0; i < array.length(); i++) {
|
|
||||||
Double nested = findNumber(array.opt(i), needle, depth + 1);
|
|
||||||
if (nested != null) return nested;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Numbers arrive as numbers or as strings depending on the producer; both count. */
|
|
||||||
private static Double asNumber(Object value) {
|
|
||||||
if (value instanceof Number) return ((Number) value).doubleValue();
|
|
||||||
if (value instanceof String) {
|
|
||||||
try {
|
|
||||||
return Double.valueOf(((String) value).trim());
|
|
||||||
} catch (NumberFormatException ignored) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The unit the payload names, if it names one at all. */
|
|
||||||
private static String findUnit(Object node, int depth) {
|
|
||||||
if (depth > MAX_DEPTH) return null;
|
|
||||||
if (node instanceof JSONObject) {
|
|
||||||
JSONObject object = (JSONObject) node;
|
|
||||||
for (java.util.Iterator<String> keys = object.keys(); keys.hasNext(); ) {
|
|
||||||
String key = keys.next();
|
|
||||||
Object value = object.opt(key);
|
|
||||||
if (key.toLowerCase(java.util.Locale.US).contains("unit")
|
|
||||||
&& value instanceof String) {
|
|
||||||
return (String) value;
|
|
||||||
}
|
|
||||||
String nested = findUnit(value, depth + 1);
|
|
||||||
if (nested != null) return nested;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The place the reading is for — whatever the payload calls it. */
|
|
||||||
private static String findText(Object node, int depth) {
|
|
||||||
if (depth > MAX_DEPTH || !(node instanceof JSONObject)) return null;
|
|
||||||
JSONObject object = (JSONObject) node;
|
|
||||||
for (java.util.Iterator<String> keys = object.keys(); keys.hasNext(); ) {
|
|
||||||
String key = keys.next();
|
|
||||||
String lower = key.toLowerCase(java.util.Locale.US);
|
|
||||||
Object value = object.opt(key);
|
|
||||||
if ((lower.contains("address") || lower.contains("city") || lower.contains("poi"))
|
|
||||||
&& value instanceof String && !((String) value).trim().isEmpty()) {
|
|
||||||
return ((String) value).trim();
|
|
||||||
}
|
|
||||||
String nested = findText(value, depth + 1);
|
|
||||||
if (nested != null) return nested;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Celsius, or null when the payload named a unit this does not know.
|
|
||||||
*
|
|
||||||
* An unnamed unit is taken as Celsius: the head unit displays °C on this car, and the
|
|
||||||
* value is sanity-checked by the caller anyway. A named Fahrenheit is converted, because
|
|
||||||
* passing 81 °F through as 81 °C is worse than reporting nothing.
|
|
||||||
*/
|
|
||||||
private static Float toCelsius(Double value, String unit) {
|
|
||||||
if (value == null) return null;
|
|
||||||
String name = unit == null ? "" : unit.trim().toUpperCase(java.util.Locale.US);
|
|
||||||
if (name.contains("F")) return (float) ((value - 32.0) * 5.0 / 9.0);
|
|
||||||
return value.floatValue();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String truncate(String payload) {
|
|
||||||
return payload.length() <= RAW_SAMPLE_CHARS
|
|
||||||
? payload
|
|
||||||
: payload.substring(0, RAW_SAMPLE_CHARS) + "…";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.evsuite.abrp;
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
import android.Manifest;
|
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
import android.content.pm.PackageManager;
|
import android.content.pm.PackageManager;
|
||||||
@@ -9,21 +8,17 @@ import android.graphics.drawable.ColorDrawable;
|
|||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.widget.AdapterView;
|
|
||||||
import android.widget.ArrayAdapter;
|
|
||||||
import android.widget.Button;
|
import android.widget.Button;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
import android.widget.ImageView;
|
import android.widget.ImageView;
|
||||||
import android.widget.Spinner;
|
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
|
|
||||||
import androidx.activity.result.ActivityResultLauncher;
|
import java.time.LocalDate;
|
||||||
import androidx.activity.result.contract.ActivityResultContracts;
|
import java.util.List;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
import androidx.appcompat.app.AppCompatDelegate;
|
import androidx.appcompat.app.AppCompatDelegate;
|
||||||
import androidx.core.app.ActivityCompat;
|
|
||||||
import androidx.core.content.ContextCompat;
|
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
import androidx.viewpager2.widget.ViewPager2;
|
import androidx.viewpager2.widget.ViewPager2;
|
||||||
|
|
||||||
@@ -32,30 +27,27 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
|||||||
import com.google.android.material.button.MaterialButton;
|
import com.google.android.material.button.MaterialButton;
|
||||||
import com.google.android.material.textfield.TextInputEditText;
|
import com.google.android.material.textfield.TextInputEditText;
|
||||||
import com.google.android.material.textfield.TextInputLayout;
|
import com.google.android.material.textfield.TextInputLayout;
|
||||||
import com.evsuite.hardware.EVHardware;
|
|
||||||
import com.evsuite.hardware.FirmwareInfo;
|
|
||||||
import com.evsuite.hardware.telemetry.EnergySnapshot;
|
import com.evsuite.hardware.telemetry.EnergySnapshot;
|
||||||
import com.evsuite.hardware.telemetry.EnergyTelemetryReader;
|
import com.evsuite.hardware.telemetry.EnergyTelemetryReader;
|
||||||
|
|
||||||
|
|
||||||
public class MainActivity extends AppCompatActivity {
|
public class MainActivity extends AppCompatActivity {
|
||||||
|
|
||||||
private static final int LOCATION_PERMISSION_REQUEST = 100;
|
|
||||||
/** Shared with {@link AbrpUploadService}, which reads it on every tick. */
|
/** Shared with {@link AbrpUploadService}, which reads it on every tick. */
|
||||||
static final String WIFI_AUTO_KEY = "wifi_auto_connect";
|
static final String WIFI_AUTO_KEY = "wifi_auto_connect";
|
||||||
private static final String REPOSITORY_URL = "https://github.com/fatihdonmezdev/FatihsMG4";
|
private static final String REPOSITORY_URL = "https://github.com/fatihdonmezdev/FatihsMG4";
|
||||||
|
|
||||||
/** Navigation destinations in page order. Parallel to {@link #panes}. */
|
/** Navigation destinations in page order. Parallel to {@link #panes}. */
|
||||||
private static final int[] TAB_IDS =
|
private static final int[] TAB_IDS =
|
||||||
{ R.id.tabVehicle, R.id.tabConsumption, R.id.tabCharging, R.id.tabAbrp, R.id.tabService, R.id.tabWifi,
|
{ R.id.tabVehicle, R.id.tabConsumption, R.id.tabCharging, R.id.tabService, R.id.tabWifi,
|
||||||
R.id.tabLog };
|
R.id.tabLog };
|
||||||
|
|
||||||
private static final int[] PAGE_TITLES = {
|
private static final int[] PAGE_TITLES = {
|
||||||
R.string.design_overview_title, R.string.consumption_title, R.string.charging_title, R.string.design_abrp_title,
|
R.string.design_overview_title, R.string.consumption_title, R.string.charging_title,
|
||||||
R.string.design_service_title, R.string.design_wifi_title, R.string.design_log_title
|
R.string.design_service_title, R.string.design_wifi_title, R.string.design_log_title
|
||||||
};
|
};
|
||||||
private static final int[] PAGE_SUBTITLES = {
|
private static final int[] PAGE_SUBTITLES = {
|
||||||
R.string.design_overview_subtitle, R.string.consumption_subtitle, R.string.charging_subtitle, R.string.design_abrp_subtitle,
|
R.string.design_overview_subtitle, R.string.consumption_subtitle, R.string.charging_subtitle,
|
||||||
R.string.design_service_subtitle, R.string.design_wifi_subtitle, R.string.design_log_subtitle
|
R.string.design_service_subtitle, R.string.design_wifi_subtitle, R.string.design_log_subtitle
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,24 +58,8 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
private static final int COLOR_ERROR = 0xFFFFB4AB;
|
private static final int COLOR_ERROR = 0xFFFFB4AB;
|
||||||
private static final int COLOR_PENDING = 0xFFBDC4C6;
|
private static final int COLOR_PENDING = 0xFFBDC4C6;
|
||||||
|
|
||||||
private TextInputLayout apiKeyLayout;
|
|
||||||
private TextInputEditText apiKeyInput;
|
|
||||||
private TextInputLayout tokenLayout;
|
|
||||||
private TextInputEditText tokenInput;
|
|
||||||
private SwitchMaterial serviceSwitch;
|
|
||||||
private SwitchMaterial abrpUploadSwitch;
|
|
||||||
private SwitchMaterial autostartSwitch;
|
|
||||||
private TextView statusText;
|
private TextView statusText;
|
||||||
private Button testButton;
|
|
||||||
private View connectionStatusRow;
|
|
||||||
private View connectionIndicator;
|
|
||||||
private TextView connectionStatusText;
|
|
||||||
private Spinner intervalSpinner;
|
|
||||||
private SwitchMaterial boostSwitch;
|
|
||||||
private TextInputEditText lowSocInput;
|
|
||||||
private TextInputLayout lowSocLayout;
|
|
||||||
private TextView callLogText;
|
private TextView callLogText;
|
||||||
private View abrpPane;
|
|
||||||
private View servicePane;
|
private View servicePane;
|
||||||
private View logPane;
|
private View logPane;
|
||||||
private View vehiclePane;
|
private View vehiclePane;
|
||||||
@@ -91,7 +67,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
private View chargingPane;
|
private View chargingPane;
|
||||||
private View wifiPane;
|
private View wifiPane;
|
||||||
|
|
||||||
/** The five pages in navigation order. Parallel to {@link #TAB_IDS}. */
|
/** The six pages in navigation order. Parallel to {@link #TAB_IDS}. */
|
||||||
private View[] panes;
|
private View[] panes;
|
||||||
|
|
||||||
/** Wi-Fi page. The helper is the same one the upload service uses on its own tick. */
|
/** Wi-Fi page. The helper is the same one the upload service uses on its own tick. */
|
||||||
@@ -110,6 +86,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
vehCharging, vehHvac,
|
vehCharging, vehHvac,
|
||||||
vehTireFl, vehTireFr, vehTireRl, vehTireRr;
|
vehTireFl, vehTireFr, vehTireRl, vehTireRr;
|
||||||
private ConsumptionTracker consumptionTracker;
|
private ConsumptionTracker consumptionTracker;
|
||||||
|
private ConsumptionCloudClient consumptionCloudClient;
|
||||||
private ConsumptionTracker.Period consumptionPeriod = ConsumptionTracker.Period.LIFETIME;
|
private ConsumptionTracker.Period consumptionPeriod = ConsumptionTracker.Period.LIFETIME;
|
||||||
private TextView consumptionDistance, consumptionEnergy, consumptionAverage,
|
private TextView consumptionDistance, consumptionEnergy, consumptionAverage,
|
||||||
consumptionSpeed, consumptionTime, consumptionSoc, consumptionDailyHistory;
|
consumptionSpeed, consumptionTime, consumptionSoc, consumptionDailyHistory;
|
||||||
@@ -162,7 +139,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
// state, exactly as they did when they were siblings in the layout.
|
// state, exactly as they did when they were siblings in the layout.
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Keep all pages alive: the service switch, log and vehicle readings are refreshed
|
// Keep all pages alive: the log and vehicle readings are refreshed
|
||||||
// by the tick even when their page is off screen.
|
// by the tick even when their page is off screen.
|
||||||
pager.setOffscreenPageLimit(panes.length - 1);
|
pager.setOffscreenPageLimit(panes.length - 1);
|
||||||
pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
|
pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
|
||||||
@@ -207,18 +184,6 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private SharedPreferences prefs;
|
private SharedPreferences prefs;
|
||||||
/** Credentials only — encrypted at rest, see {@link SecurePrefs}. */
|
|
||||||
private SharedPreferences securePrefs;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Picks an ABRP config text file. Only reachable on devices that ship a document picker —
|
|
||||||
* the MG4 head unit does not, which is why {@link #startConfigImport()} scans the
|
|
||||||
* app-specific folders first and only falls back to this.
|
|
||||||
*/
|
|
||||||
private final ActivityResultLauncher<String[]> configPicker =
|
|
||||||
registerForActivityResult(new ActivityResultContracts.OpenDocument(), uri -> {
|
|
||||||
if (uri != null) importConfig(uri);
|
|
||||||
});
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
@@ -229,129 +194,40 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
((TextView) findViewById(R.id.version_badge)).setText("v" + BuildConfig.VERSION_NAME);
|
((TextView) findViewById(R.id.version_badge)).setText("v" + BuildConfig.VERSION_NAME);
|
||||||
|
|
||||||
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
|
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
|
||||||
securePrefs = SecurePrefs.get(this);
|
|
||||||
|
|
||||||
// The pages are inflated here, once, and handed to the pager as fixed,
|
// The pages are inflated here, once, and handed to the pager as fixed,
|
||||||
// non-recycled items. That is what lets every widget below be looked up now and
|
// non-recycled items. That is what lets every widget below be looked up now and
|
||||||
// held for the life of the activity, the way it was when the panes were siblings
|
// held for the life of the activity, the way it was when the panes were siblings
|
||||||
// in activity_main.xml — a recycled page would invalidate these references as
|
// in activity_main.xml — a recycled page would invalidate these references as
|
||||||
// soon as the user swiped away from it.
|
// soon as the user swiped away from it.
|
||||||
abrpPane = inflatePane(R.layout.pane_abrp);
|
|
||||||
servicePane = inflatePane(R.layout.pane_service);
|
servicePane = inflatePane(R.layout.pane_service);
|
||||||
vehiclePane = inflatePane(R.layout.pane_vehicle);
|
vehiclePane = inflatePane(R.layout.pane_vehicle);
|
||||||
consumptionPane = inflatePane(R.layout.pane_consumption);
|
consumptionPane = inflatePane(R.layout.pane_consumption);
|
||||||
chargingPane = inflatePane(R.layout.pane_charging);
|
chargingPane = inflatePane(R.layout.pane_charging);
|
||||||
wifiPane = inflatePane(R.layout.pane_wifi);
|
wifiPane = inflatePane(R.layout.pane_wifi);
|
||||||
logPane = inflatePane(R.layout.pane_log);
|
logPane = inflatePane(R.layout.pane_log);
|
||||||
panes = new View[] { vehiclePane, consumptionPane, chargingPane, abrpPane, servicePane, wifiPane, logPane };
|
panes = new View[] { vehiclePane, consumptionPane, chargingPane, servicePane, wifiPane, logPane };
|
||||||
|
|
||||||
bindVehiclePane();
|
bindVehiclePane();
|
||||||
bindConsumptionPane();
|
bindConsumptionPane();
|
||||||
bindChargingPane();
|
bindChargingPane();
|
||||||
bindWifiPane();
|
bindWifiPane();
|
||||||
|
|
||||||
apiKeyLayout = abrpPane.findViewById(R.id.api_key_layout);
|
|
||||||
apiKeyInput = abrpPane.findViewById(R.id.api_key_input);
|
|
||||||
tokenLayout = abrpPane.findViewById(R.id.token_layout);
|
|
||||||
tokenInput = abrpPane.findViewById(R.id.token_input);
|
|
||||||
serviceSwitch = servicePane.findViewById(R.id.service_switch);
|
|
||||||
abrpUploadSwitch = servicePane.findViewById(R.id.abrp_upload_switch);
|
|
||||||
autostartSwitch = servicePane.findViewById(R.id.autostart_switch);
|
|
||||||
statusText = servicePane.findViewById(R.id.status_text);
|
statusText = servicePane.findViewById(R.id.status_text);
|
||||||
testButton = abrpPane.findViewById(R.id.test_button);
|
|
||||||
connectionStatusRow = abrpPane.findViewById(R.id.connection_status_row);
|
|
||||||
connectionIndicator = abrpPane.findViewById(R.id.connection_indicator);
|
|
||||||
connectionStatusText = abrpPane.findViewById(R.id.connection_status_text);
|
|
||||||
intervalSpinner = servicePane.findViewById(R.id.interval_spinner);
|
|
||||||
boostSwitch = servicePane.findViewById(R.id.boost_switch);
|
|
||||||
lowSocInput = servicePane.findViewById(R.id.low_soc_input);
|
|
||||||
lowSocLayout = servicePane.findViewById(R.id.low_soc_layout);
|
|
||||||
callLogText = logPane.findViewById(R.id.call_log_text);
|
callLogText = logPane.findViewById(R.id.call_log_text);
|
||||||
|
|
||||||
setUpPager();
|
setUpPager();
|
||||||
findViewById(R.id.about_button).setOnClickListener(v -> showAbout());
|
findViewById(R.id.about_button).setOnClickListener(v -> showAbout());
|
||||||
findViewById(R.id.update_button).setOnClickListener(v -> checkForUpdate(v));
|
findViewById(R.id.update_button).setOnClickListener(v -> checkForUpdate(v));
|
||||||
|
|
||||||
bindCadenceControls();
|
|
||||||
|
|
||||||
// Unstable builds check for a newer pre-release; the stable flavor's UpdateHook is
|
// Unstable builds check for a newer pre-release; the stable flavor's UpdateHook is
|
||||||
// a no-op and does not even contain the updater.
|
// a no-op and does not even contain the updater.
|
||||||
UpdateHook.checkInBackground(this);
|
UpdateHook.checkInBackground(this);
|
||||||
|
|
||||||
apiKeyInput.setText(savedApiKeyOrDefault());
|
// The service is always-on: it tracks consumption and syncs to MongoDB cloud.
|
||||||
tokenInput.setText(securePrefs.getString(SecurePrefs.KEY_TOKEN, ""));
|
|
||||||
serviceSwitch.setChecked(prefs.getBoolean("service_enabled", false));
|
|
||||||
abrpUploadSwitch.setChecked(prefs.getBoolean(
|
|
||||||
UploadSettings.KEY_ABRP_UPLOAD_ENABLED,
|
|
||||||
UploadSettings.DEFAULT_ABRP_UPLOAD_ENABLED));
|
|
||||||
|
|
||||||
autostartSwitch.setChecked(
|
|
||||||
prefs.getBoolean(UploadSettings.KEY_AUTOSTART, UploadSettings.DEFAULT_AUTOSTART));
|
|
||||||
autostartSwitch.setOnCheckedChangeListener((btn, checked) ->
|
|
||||||
prefs.edit().putBoolean(UploadSettings.KEY_AUTOSTART, checked).apply());
|
|
||||||
|
|
||||||
// If the user wants the service running, kick it on every activity launch.
|
|
||||||
// startForegroundService is idempotent — if the service is already up this
|
// startForegroundService is idempotent — if the service is already up this
|
||||||
// is a no-op aside from delivering a new intent. We don't trust
|
// is a no-op aside from delivering a new intent.
|
||||||
// service_running as a sole indicator because it can be stale-true if a
|
startForegroundService(new Intent(this, AbrpUploadService.class));
|
||||||
// previous process was force-killed (e.g. by `adb install -r`) without
|
|
||||||
// onDestroy running.
|
|
||||||
boolean enabled = prefs.getBoolean("service_enabled", false);
|
|
||||||
boolean haveCreds = !securePrefs.getString(SecurePrefs.KEY_TOKEN, "").trim().isEmpty()
|
|
||||||
&& !securePrefs.getString(SecurePrefs.KEY_API_KEY, "").trim().isEmpty();
|
|
||||||
boolean uploadEnabled = prefs.getBoolean(UploadSettings.KEY_ABRP_UPLOAD_ENABLED,
|
|
||||||
UploadSettings.DEFAULT_ABRP_UPLOAD_ENABLED);
|
|
||||||
if (enabled && (!uploadEnabled || haveCreds)) {
|
|
||||||
// Asked for first, and deliberately: the service declares the `location`
|
|
||||||
// foreground type, and since API 34 starting it without the permission behind
|
|
||||||
// that type is a SecurityException in its own onCreate. Started this way round,
|
|
||||||
// the very first launch after an install could never bring the uploader up.
|
|
||||||
requestLocationPermissionIfNeeded();
|
|
||||||
startForegroundService(new Intent(this, AbrpUploadService.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
abrpPane.findViewById(R.id.save_button).setOnClickListener(v -> saveCredentials());
|
|
||||||
testButton.setOnClickListener(v -> testConnection());
|
|
||||||
abrpPane.findViewById(R.id.import_button).setOnClickListener(v -> startConfigImport());
|
|
||||||
wireProbeIfDebug();
|
|
||||||
|
|
||||||
serviceSwitch.setOnCheckedChangeListener((btn, checked) -> {
|
|
||||||
if (checked) {
|
|
||||||
String apiKey = textOf(apiKeyInput);
|
|
||||||
String token = textOf(tokenInput);
|
|
||||||
if (abrpUploadSwitch.isChecked() && (apiKey.isEmpty() || token.isEmpty())) {
|
|
||||||
serviceSwitch.setChecked(false);
|
|
||||||
if (apiKey.isEmpty()) apiKeyLayout.setError(getString(R.string.api_key_required));
|
|
||||||
if (token.isEmpty()) tokenLayout.setError(getString(R.string.token_required));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
apiKeyLayout.setError(null);
|
|
||||||
tokenLayout.setError(null);
|
|
||||||
prefs.edit().putBoolean("service_enabled", true).apply();
|
|
||||||
startForegroundService(new Intent(this, AbrpUploadService.class));
|
|
||||||
requestLocationPermissionIfNeeded();
|
|
||||||
} else {
|
|
||||||
prefs.edit().putBoolean("service_enabled", false).apply();
|
|
||||||
stopService(new Intent(this, AbrpUploadService.class));
|
|
||||||
}
|
|
||||||
refreshStatus();
|
|
||||||
});
|
|
||||||
|
|
||||||
abrpUploadSwitch.setOnCheckedChangeListener((btn, checked) -> {
|
|
||||||
if (checked) {
|
|
||||||
String apiKey = textOf(apiKeyInput);
|
|
||||||
String token = textOf(tokenInput);
|
|
||||||
if (apiKey.isEmpty() || token.isEmpty()) {
|
|
||||||
abrpUploadSwitch.setChecked(false);
|
|
||||||
if (apiKey.isEmpty()) apiKeyLayout.setError(getString(R.string.api_key_required));
|
|
||||||
if (token.isEmpty()) tokenLayout.setError(getString(R.string.token_required));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prefs.edit().putBoolean(UploadSettings.KEY_ABRP_UPLOAD_ENABLED, checked).apply();
|
|
||||||
AbrpUploadService.reloadSettings();
|
|
||||||
refreshStatus();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void showAbout() {
|
private void showAbout() {
|
||||||
@@ -386,250 +262,10 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
@Override
|
@Override
|
||||||
protected void onResume() {
|
protected void onResume() {
|
||||||
super.onResume();
|
super.onResume();
|
||||||
apiKeyInput.setText(savedApiKeyOrDefault());
|
|
||||||
tokenInput.setText(securePrefs.getString(SecurePrefs.KEY_TOKEN, ""));
|
|
||||||
serviceSwitch.setChecked(prefs.getBoolean("service_enabled", false));
|
|
||||||
abrpUploadSwitch.setChecked(prefs.getBoolean(
|
|
||||||
UploadSettings.KEY_ABRP_UPLOAD_ENABLED,
|
|
||||||
UploadSettings.DEFAULT_ABRP_UPLOAD_ENABLED));
|
|
||||||
autostartSwitch.setChecked(
|
|
||||||
prefs.getBoolean(UploadSettings.KEY_AUTOSTART, UploadSettings.DEFAULT_AUTOSTART));
|
|
||||||
// Poll state and log only while the screen is up; onPause cancels it.
|
// Poll state and log only while the screen is up; onPause cancels it.
|
||||||
uiHandler.post(uiRefresh);
|
uiHandler.post(uiRefresh);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Credentials ----------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The SAIC open-source gateway publishes this shared ABRP key for compatible clients.
|
|
||||||
* A user-supplied key saved in encrypted preferences always takes precedence.
|
|
||||||
*/
|
|
||||||
private String savedApiKeyOrDefault() {
|
|
||||||
String savedApiKey = securePrefs.getString(SecurePrefs.KEY_API_KEY, "");
|
|
||||||
return savedApiKey == null || savedApiKey.trim().isEmpty()
|
|
||||||
? getString(R.string.default_abrp_api_key)
|
|
||||||
: savedApiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void saveCredentials() {
|
|
||||||
String apiKey = textOf(apiKeyInput);
|
|
||||||
String token = textOf(tokenInput);
|
|
||||||
boolean valid = true;
|
|
||||||
|
|
||||||
if (apiKey.isEmpty()) {
|
|
||||||
apiKeyLayout.setError(getString(R.string.api_key_required));
|
|
||||||
valid = false;
|
|
||||||
} else {
|
|
||||||
apiKeyLayout.setError(null);
|
|
||||||
}
|
|
||||||
if (token.isEmpty()) {
|
|
||||||
tokenLayout.setError(getString(R.string.token_required));
|
|
||||||
valid = false;
|
|
||||||
} else {
|
|
||||||
tokenLayout.setError(null);
|
|
||||||
}
|
|
||||||
if (!valid) return;
|
|
||||||
|
|
||||||
securePrefs.edit()
|
|
||||||
.putString(SecurePrefs.KEY_API_KEY, apiKey)
|
|
||||||
.putString(SecurePrefs.KEY_TOKEN, token)
|
|
||||||
.apply();
|
|
||||||
|
|
||||||
if (serviceSwitch.isChecked()) {
|
|
||||||
startForegroundService(new Intent(this, AbrpUploadService.class));
|
|
||||||
}
|
|
||||||
refreshStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Config import ----------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The MG4 head unit ships no document picker — SAF answers "FileManagement is no supported
|
|
||||||
* on this device" — so the file is found by scanning instead. getExternalFilesDirs() returns
|
|
||||||
* this app's own folder on internal storage and on every mounted volume including a USB
|
|
||||||
* stick, and those need no storage permission at any API level. The picker stays as the
|
|
||||||
* fallback for phones and tablets, where the user may keep the file anywhere.
|
|
||||||
*/
|
|
||||||
private void startConfigImport() {
|
|
||||||
java.io.File found = findConfigFile();
|
|
||||||
if (found != null) {
|
|
||||||
importConfig(found);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// Any MIME: the MG4 Files app tags .txt inconsistently, so filtering by type hides
|
|
||||||
// the very file the user is trying to pick. They select it by name instead.
|
|
||||||
configPicker.launch(new String[]{"*/*"});
|
|
||||||
} catch (android.content.ActivityNotFoundException e) {
|
|
||||||
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_not_found, configDirHint()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* First readable file in an app-specific folder that parses as a config. Names are not
|
|
||||||
* filtered: the user copies one file to the folder, whatever they called it.
|
|
||||||
*/
|
|
||||||
private java.io.File findConfigFile() {
|
|
||||||
for (java.io.File dir : getExternalFilesDirs(null)) {
|
|
||||||
if (dir == null) continue;
|
|
||||||
java.io.File[] files = dir.listFiles();
|
|
||||||
if (files == null) continue;
|
|
||||||
for (java.io.File f : files) {
|
|
||||||
if (f.isFile() && f.canRead() && f.length() > 0 && f.length() <= 64 * 1024
|
|
||||||
&& !ConfigImport.parse(readText(f)).isEmpty()) {
|
|
||||||
return f;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** File text, or "" when unreadable — callers treat that as "not a config". */
|
|
||||||
private String readText(java.io.File file) {
|
|
||||||
try (java.io.InputStream in = new java.io.FileInputStream(file)) {
|
|
||||||
return readCapped(in);
|
|
||||||
} catch (Exception e) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cap the read so a wrong file (a huge binary picked by mistake) can't be slurped whole
|
|
||||||
* into memory before we find out it isn't a config.
|
|
||||||
*/
|
|
||||||
private String readCapped(java.io.InputStream in) throws java.io.IOException {
|
|
||||||
java.io.ByteArrayOutputStream buf = new java.io.ByteArrayOutputStream();
|
|
||||||
byte[] chunk = new byte[4096];
|
|
||||||
int n;
|
|
||||||
while ((n = in.read(chunk)) != -1 && buf.size() < 64 * 1024) buf.write(chunk, 0, n);
|
|
||||||
return buf.toString(java.nio.charset.StandardCharsets.UTF_8.name());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Where the user should drop the file, shown when nothing was found. */
|
|
||||||
private String configDirHint() {
|
|
||||||
java.io.File[] dirs = getExternalFilesDirs(null);
|
|
||||||
return dirs.length > 0 && dirs[0] != null ? dirs[0].getAbsolutePath() : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
private void importConfig(java.io.File file) {
|
|
||||||
String text = readText(file);
|
|
||||||
if (text.isEmpty()) {
|
|
||||||
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_read));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
applyConfig(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void importConfig(android.net.Uri uri) {
|
|
||||||
String text;
|
|
||||||
try (java.io.InputStream in = getContentResolver().openInputStream(uri)) {
|
|
||||||
if (in == null) throw new java.io.IOException("no stream");
|
|
||||||
text = readCapped(in);
|
|
||||||
} catch (Exception e) {
|
|
||||||
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_read));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
applyConfig(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies whatever the config text sets. Credentials go straight to the encrypted store;
|
|
||||||
* cadence keys go to the plain prefs the same way the manual controls write them. Absent
|
|
||||||
* keys are left untouched, so a file carrying only the credentials does not wipe a cadence
|
|
||||||
* the user already tuned.
|
|
||||||
*/
|
|
||||||
private void applyConfig(String text) {
|
|
||||||
ConfigImport config = ConfigImport.parse(text);
|
|
||||||
if (config.isEmpty()) {
|
|
||||||
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_empty));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.apiKey != null) {
|
|
||||||
securePrefs.edit().putString(SecurePrefs.KEY_API_KEY, config.apiKey).apply();
|
|
||||||
}
|
|
||||||
if (config.token != null) {
|
|
||||||
securePrefs.edit().putString(SecurePrefs.KEY_TOKEN, config.token).apply();
|
|
||||||
}
|
|
||||||
SharedPreferences.Editor edit = prefs.edit();
|
|
||||||
if (config.intervalSec != null) {
|
|
||||||
edit.putInt(UploadSettings.KEY_INTERVAL_SEC, config.intervalSec);
|
|
||||||
}
|
|
||||||
if (config.boostLowSoc != null) {
|
|
||||||
edit.putBoolean(UploadSettings.KEY_BOOST_LOW_SOC, config.boostLowSoc);
|
|
||||||
}
|
|
||||||
if (config.lowSocPercent != null) {
|
|
||||||
edit.putInt(UploadSettings.KEY_LOW_SOC_PERCENT, config.lowSocPercent);
|
|
||||||
}
|
|
||||||
edit.apply();
|
|
||||||
|
|
||||||
// Re-read every control from prefs so the screen shows what was imported.
|
|
||||||
apiKeyInput.setText(savedApiKeyOrDefault());
|
|
||||||
tokenInput.setText(securePrefs.getString(SecurePrefs.KEY_TOKEN, ""));
|
|
||||||
apiKeyLayout.setError(null);
|
|
||||||
tokenLayout.setError(null);
|
|
||||||
bindCadenceControls();
|
|
||||||
AbrpUploadService.reloadSettings();
|
|
||||||
|
|
||||||
if (serviceSwitch.isChecked()) {
|
|
||||||
startForegroundService(new Intent(this, AbrpUploadService.class));
|
|
||||||
}
|
|
||||||
setConnectionStatus(COLOR_OK, getString(R.string.import_ok));
|
|
||||||
refreshStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Connection test ----------
|
|
||||||
|
|
||||||
private void testConnection() {
|
|
||||||
String apiKey = textOf(apiKeyInput);
|
|
||||||
String token = textOf(tokenInput);
|
|
||||||
|
|
||||||
if (apiKey.isEmpty() || token.isEmpty()) {
|
|
||||||
if (apiKey.isEmpty()) apiKeyLayout.setError(getString(R.string.api_key_required));
|
|
||||||
if (token.isEmpty()) tokenLayout.setError(getString(R.string.token_required));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setConnectionStatus(COLOR_PENDING, getString(R.string.conn_testing));
|
|
||||||
testButton.setEnabled(false);
|
|
||||||
|
|
||||||
new Thread(() -> {
|
|
||||||
String result = pingAbrp(apiKey, token);
|
|
||||||
runOnUiThread(() -> {
|
|
||||||
testButton.setEnabled(true);
|
|
||||||
if (result == null) {
|
|
||||||
setConnectionStatus(COLOR_OK, getString(R.string.conn_ok));
|
|
||||||
} else {
|
|
||||||
setConnectionStatus(COLOR_ERROR, result);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}).start();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends a minimal test request to the ABRP API.
|
|
||||||
* Returns null on success, or a short error string on failure.
|
|
||||||
*/
|
|
||||||
private String pingAbrp(String apiKey, String token) {
|
|
||||||
try {
|
|
||||||
// Read-only check. The previous version POSTed {"utc":...,"soc":0} to the LIVE
|
|
||||||
// /tlm/send endpoint, i.e. told ABRP the car was at 0% every time the user
|
|
||||||
// pressed Test, wrecking the route plan it had computed.
|
|
||||||
AbrpApi.Response response = AbrpApi.verifyCredentials(apiKey, token);
|
|
||||||
|
|
||||||
if (response.code == 200) return null;
|
|
||||||
if (response.code == 401) return getString(R.string.conn_err_auth);
|
|
||||||
return getString(R.string.conn_err_http, response.code);
|
|
||||||
|
|
||||||
} catch (java.net.UnknownHostException e) {
|
|
||||||
return getString(R.string.conn_err_no_internet);
|
|
||||||
} catch (java.net.SocketTimeoutException e) {
|
|
||||||
return getString(R.string.conn_err_timeout);
|
|
||||||
} catch (Exception e) {
|
|
||||||
return e.getMessage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onPause() {
|
protected void onPause() {
|
||||||
super.onPause();
|
super.onPause();
|
||||||
@@ -637,67 +273,6 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
uiHandler.removeCallbacks(uiRefresh);
|
uiHandler.removeCallbacks(uiRefresh);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Cadence configuration ----------
|
|
||||||
|
|
||||||
private void bindCadenceControls() {
|
|
||||||
UploadSettings current = UploadSettings.from(prefs);
|
|
||||||
|
|
||||||
String[] labels = new String[UploadSettings.INTERVAL_CHOICES_SEC.length];
|
|
||||||
int selected = 0;
|
|
||||||
for (int i = 0; i < UploadSettings.INTERVAL_CHOICES_SEC.length; i++) {
|
|
||||||
int sec = UploadSettings.INTERVAL_CHOICES_SEC[i];
|
|
||||||
labels[i] = sec >= 60
|
|
||||||
? getResources().getQuantityString(R.plurals.interval_minutes, sec / 60, sec / 60)
|
|
||||||
: getResources().getQuantityString(R.plurals.interval_seconds, sec, sec);
|
|
||||||
if (sec == current.intervalSec) selected = i;
|
|
||||||
}
|
|
||||||
ArrayAdapter<String> adapter = new ArrayAdapter<>(
|
|
||||||
this, R.layout.spinner_value, labels);
|
|
||||||
adapter.setDropDownViewResource(R.layout.spinner_dropdown);
|
|
||||||
intervalSpinner.setAdapter(adapter);
|
|
||||||
intervalSpinner.setSelection(selected);
|
|
||||||
intervalSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
|
|
||||||
@Override
|
|
||||||
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
|
|
||||||
prefs.edit().putInt(UploadSettings.KEY_INTERVAL_SEC,
|
|
||||||
UploadSettings.INTERVAL_CHOICES_SEC[position]).apply();
|
|
||||||
// Takes effect on the next cycle — no service restart needed.
|
|
||||||
AbrpUploadService.reloadSettings();
|
|
||||||
}
|
|
||||||
@Override public void onNothingSelected(AdapterView<?> parent) { }
|
|
||||||
});
|
|
||||||
|
|
||||||
boostSwitch.setChecked(current.boostLowSoc);
|
|
||||||
lowSocLayout.setEnabled(current.boostLowSoc);
|
|
||||||
lowSocInput.setEnabled(current.boostLowSoc);
|
|
||||||
boostSwitch.setOnCheckedChangeListener((button, checked) -> {
|
|
||||||
prefs.edit().putBoolean(UploadSettings.KEY_BOOST_LOW_SOC, checked).apply();
|
|
||||||
lowSocLayout.setEnabled(checked);
|
|
||||||
lowSocInput.setEnabled(checked);
|
|
||||||
AbrpUploadService.reloadSettings();
|
|
||||||
});
|
|
||||||
|
|
||||||
lowSocInput.setText(String.valueOf(current.lowSocPercent));
|
|
||||||
lowSocInput.setOnFocusChangeListener((view, hasFocus) -> {
|
|
||||||
if (!hasFocus) saveLowSocThreshold();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clamped to 1-99: 0 would never trigger and 100 would boost permanently. */
|
|
||||||
private void saveLowSocThreshold() {
|
|
||||||
CharSequence raw = lowSocInput.getText();
|
|
||||||
int value;
|
|
||||||
try {
|
|
||||||
value = Integer.parseInt(raw == null ? "" : raw.toString().trim());
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
value = UploadSettings.DEFAULT_LOW_SOC_PERCENT;
|
|
||||||
}
|
|
||||||
value = Math.max(1, Math.min(99, value));
|
|
||||||
lowSocInput.setText(String.valueOf(value));
|
|
||||||
prefs.edit().putInt(UploadSettings.KEY_LOW_SOC_PERCENT, value).apply();
|
|
||||||
AbrpUploadService.reloadSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Update check ----------
|
// ---------- Update check ----------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -854,6 +429,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
|
|
||||||
private void bindConsumptionPane() {
|
private void bindConsumptionPane() {
|
||||||
consumptionTracker = ConsumptionTracker.get(this);
|
consumptionTracker = ConsumptionTracker.get(this);
|
||||||
|
consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext());
|
||||||
consumptionDistance = consumptionPane.findViewById(R.id.consumption_distance);
|
consumptionDistance = consumptionPane.findViewById(R.id.consumption_distance);
|
||||||
consumptionEnergy = consumptionPane.findViewById(R.id.consumption_energy);
|
consumptionEnergy = consumptionPane.findViewById(R.id.consumption_energy);
|
||||||
consumptionAverage = consumptionPane.findViewById(R.id.consumption_average);
|
consumptionAverage = consumptionPane.findViewById(R.id.consumption_average);
|
||||||
@@ -992,7 +568,15 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
|
|
||||||
private void refreshConsumption() {
|
private void refreshConsumption() {
|
||||||
if (consumptionTracker == null) return;
|
if (consumptionTracker == null) return;
|
||||||
ConsumptionTracker.Totals t = consumptionTracker.totals(consumptionPeriod);
|
// Trip counters are local-only (cloud has no trip concept); everything else reads
|
||||||
|
// back from the cloud so an APK wipe does not blank the display.
|
||||||
|
ConsumptionTracker.Totals t;
|
||||||
|
if (consumptionPeriod == ConsumptionTracker.Period.TRIP_A
|
||||||
|
|| consumptionPeriod == ConsumptionTracker.Period.TRIP_B) {
|
||||||
|
t = consumptionTracker.totals(consumptionPeriod);
|
||||||
|
} else {
|
||||||
|
t = cloudTotals(consumptionPeriod);
|
||||||
|
}
|
||||||
consumptionDistance.setText(String.format(java.util.Locale.getDefault(), "%.1f km", t.km));
|
consumptionDistance.setText(String.format(java.util.Locale.getDefault(), "%.1f km", t.km));
|
||||||
consumptionEnergy.setText(String.format(java.util.Locale.getDefault(), "%.2f kWh", t.kwh));
|
consumptionEnergy.setText(String.format(java.util.Locale.getDefault(), "%.2f kWh", t.kwh));
|
||||||
consumptionAverage.setText(t.km < 0.1 ? "—" : String.format(java.util.Locale.getDefault(),
|
consumptionAverage.setText(t.km < 0.1 ? "—" : String.format(java.util.Locale.getDefault(),
|
||||||
@@ -1003,7 +587,9 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
consumptionTime.setText(String.format(java.util.Locale.getDefault(), "%d sa %02d dk",
|
consumptionTime.setText(String.format(java.util.Locale.getDefault(), "%d sa %02d dk",
|
||||||
minutes / 60, minutes % 60));
|
minutes / 60, minutes % 60));
|
||||||
consumptionSoc.setText(String.format(java.util.Locale.getDefault(), "%.1f %%", t.soc));
|
consumptionSoc.setText(String.format(java.util.Locale.getDefault(), "%.1f %%", t.soc));
|
||||||
ConsumptionTracker.Totals day = consumptionTracker.totalsForDay(selectedConsumptionDate);
|
// Daily history: cloud first, local fallback for today (not yet uploaded).
|
||||||
|
ConsumptionTracker.Totals day = cloudDay(selectedConsumptionDate);
|
||||||
|
if (day == null) day = consumptionTracker.totalsForDay(selectedConsumptionDate);
|
||||||
if (day.km == 0 && day.kwh == 0 && day.hours == 0 && day.soc == 0) {
|
if (day.km == 0 && day.kwh == 0 && day.hours == 0 && day.soc == 0) {
|
||||||
consumptionDailyHistory.setText("Bu tarih için kayıt yok");
|
consumptionDailyHistory.setText("Bu tarih için kayıt yok");
|
||||||
} else {
|
} else {
|
||||||
@@ -1016,6 +602,43 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Period totals from the cloud history. LIFETIME = the latest record's lifetime field;
|
||||||
|
* WEEK/MONTH = sum of the last 7/30 days' day-totals. Falls back to the local tracker
|
||||||
|
* when the cloud is empty or not yet fetched, so the UI is never blank on a cold start.
|
||||||
|
*/
|
||||||
|
private ConsumptionTracker.Totals cloudTotals(ConsumptionTracker.Period period) {
|
||||||
|
List<ConsumptionCloudClient.DayRecord> history = consumptionCloudClient != null
|
||||||
|
? consumptionCloudClient.fetchHistoryIfDue() : java.util.Collections.emptyList();
|
||||||
|
if (history.isEmpty()) return consumptionTracker.totals(period);
|
||||||
|
if (period == ConsumptionTracker.Period.LIFETIME) {
|
||||||
|
// history is sorted date-desc, so index 0 is the latest day carrying lifetime.
|
||||||
|
return history.get(0).lifetime;
|
||||||
|
}
|
||||||
|
int days = period == ConsumptionTracker.Period.WEEK ? 7 : 30;
|
||||||
|
double km = 0, kwh = 0, hours = 0, soc = 0;
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
for (int i = 0; i < days; i++) {
|
||||||
|
ConsumptionTracker.Totals d = cloudDay(today.minusDays(i), history);
|
||||||
|
if (d != null) { km += d.km; kwh += d.kwh; hours += d.hours; soc += d.soc; }
|
||||||
|
}
|
||||||
|
return new ConsumptionTracker.Totals(km, kwh, hours, soc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Looks up one day in the cloud cache; null when that date is not present. */
|
||||||
|
private ConsumptionTracker.Totals cloudDay(LocalDate date) {
|
||||||
|
return cloudDay(date, consumptionCloudClient != null
|
||||||
|
? consumptionCloudClient.fetchHistoryIfDue() : java.util.Collections.emptyList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ConsumptionTracker.Totals cloudDay(LocalDate date,
|
||||||
|
List<ConsumptionCloudClient.DayRecord> history) {
|
||||||
|
for (ConsumptionCloudClient.DayRecord r : history) {
|
||||||
|
if (r.date.equals(date)) return r.day;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private void showConsumptionDatePicker() {
|
private void showConsumptionDatePicker() {
|
||||||
java.time.LocalDate selected = selectedConsumptionDate;
|
java.time.LocalDate selected = selectedConsumptionDate;
|
||||||
android.app.DatePickerDialog dialog = new android.app.DatePickerDialog(this,
|
android.app.DatePickerDialog dialog = new android.app.DatePickerDialog(this,
|
||||||
@@ -1091,86 +714,22 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
callLogText.setText(sb.toString());
|
callLogText.setText(sb.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- VHAL probe ----------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wires the "VHAL Probe" button that dumps the vehicle's own telemetry property
|
|
||||||
* surface into the in-app log. The head unit runs no adb, so the screen's existing
|
|
||||||
* log view is the only channel a diagnostic can reach the driver through.
|
|
||||||
*
|
|
||||||
* Present in every build, not just debug: identifying the SWI69 property ids needs to
|
|
||||||
* happen on a production APK on the car, and EVHardware's probe is read-only, so it is
|
|
||||||
* safe to ship. It deliberately holds no device credentials. Keep it until the
|
|
||||||
* per-generation property map for SWI69 is confirmed, then gate it on debug.
|
|
||||||
*/
|
|
||||||
private void wireProbeIfDebug() {
|
|
||||||
View probeButton = abrpPane.findViewById(R.id.probe_button);
|
|
||||||
if (probeButton == null) return;
|
|
||||||
probeButton.setVisibility(View.VISIBLE);
|
|
||||||
probeButton.setOnClickListener(v -> runProbe());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void runProbe() {
|
|
||||||
// Binder reads are slow enough to be worth keeping off the UI thread; the result
|
|
||||||
// is recorded through the upload log, which the screen already renders.
|
|
||||||
new Thread(() -> {
|
|
||||||
try {
|
|
||||||
EVHardware.INSTANCE.init(getApplicationContext());
|
|
||||||
java.util.List<EVHardware.PropertyReport> reports = EVHardware.INSTANCE.probeTelemetryProperties();
|
|
||||||
String firmware = String.valueOf(FirmwareInfo.INSTANCE.getGeneration());
|
|
||||||
StringBuilder sb = new StringBuilder("VHAL probe on ").append(firmware).append('\n');
|
|
||||||
for (EVHardware.PropertyReport r : reports) sb.append(" ").append(r).append('\n');
|
|
||||||
sb.append("TOTAL ").append(reports.size()).append(" properties probed");
|
|
||||||
runOnUiThread(() -> AbrpUploadService.log().record(
|
|
||||||
new UploadLog.Entry(System.currentTimeMillis(), 200, true, "VHAL Probe", sb.toString())));
|
|
||||||
} catch (Throwable t) {
|
|
||||||
String msg = "Probe failed: " + (t.getMessage() != null ? t.getMessage() : t.getClass().getSimpleName());
|
|
||||||
runOnUiThread(() -> AbrpUploadService.log().record(
|
|
||||||
new UploadLog.Entry(System.currentTimeMillis(), 0, false, "VHAL Probe", msg)));
|
|
||||||
}
|
|
||||||
}).start();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setConnectionStatus(int color, String message) {
|
|
||||||
connectionStatusRow.setVisibility(View.VISIBLE);
|
|
||||||
connectionIndicator.getBackground().setTint(color);
|
|
||||||
connectionStatusText.setText(message);
|
|
||||||
connectionStatusText.setTextColor(color);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Service status ----------
|
// ---------- Service status ----------
|
||||||
|
|
||||||
private void refreshStatus() {
|
private void refreshStatus() {
|
||||||
// Live signal, not the preference: "service_running" stays stale-true after a
|
// The service is always running. Show the cloud sync state derived from the log.
|
||||||
// force-kill because onDestroy never ran.
|
|
||||||
UploadLog.State state = AbrpUploadService.state();
|
UploadLog.State state = AbrpUploadService.state();
|
||||||
|
|
||||||
if (AbrpUploadService.isRunning() && !prefs.getBoolean(
|
|
||||||
UploadSettings.KEY_ABRP_UPLOAD_ENABLED,
|
|
||||||
UploadSettings.DEFAULT_ABRP_UPLOAD_ENABLED)) {
|
|
||||||
statusText.setText(R.string.state_tracking_locally);
|
|
||||||
statusText.setTextColor(COLOR_OK);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case ERROR:
|
case ERROR:
|
||||||
// Derived from the log, not from the last status alone: a single failed
|
// Derived from the log: three consecutive cloud sync failures is a problem.
|
||||||
// upload is normal (tunnel, dead spot), three in a row is a problem.
|
|
||||||
statusText.setText(getString(R.string.state_error,
|
statusText.setText(getString(R.string.state_error,
|
||||||
AbrpUploadService.log().consecutiveFailures()));
|
AbrpUploadService.log().consecutiveFailures()));
|
||||||
statusText.setTextColor(COLOR_ERROR);
|
statusText.setTextColor(COLOR_ERROR);
|
||||||
break;
|
break;
|
||||||
case RUNNING:
|
case RUNNING:
|
||||||
String lastTime = prefs.getString("last_upload_time", null);
|
|
||||||
statusText.setText(lastTime != null
|
|
||||||
? getString(R.string.status_last_upload, lastTime,
|
|
||||||
getString(R.string.status_ok))
|
|
||||||
: getString(R.string.state_running));
|
|
||||||
statusText.setTextColor(COLOR_OK);
|
|
||||||
break;
|
|
||||||
case STARTING:
|
case STARTING:
|
||||||
statusText.setText(R.string.state_starting);
|
statusText.setText("Tüketim takibi aktif");
|
||||||
statusText.setTextColor(COLOR_OK);
|
statusText.setTextColor(COLOR_OK);
|
||||||
break;
|
break;
|
||||||
case STOPPED:
|
case STOPPED:
|
||||||
@@ -1187,90 +746,4 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
CharSequence text = field.getText();
|
CharSequence text = field.getText();
|
||||||
return text != null ? text.toString().trim() : "";
|
return text != null ? text.toString().trim() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Permissions that need a runtime grant (vs being granted at install time
|
|
||||||
* via the platform signature). CAR_SPEED and CAR_ENERGY are dangerous-level
|
|
||||||
* AAOS permissions — without these, the corresponding property reads throw
|
|
||||||
* SecurityException and we have no SOC / speed data.
|
|
||||||
*/
|
|
||||||
private static final String[] RUNTIME_PERMISSIONS = {
|
|
||||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
|
||||||
"android.car.permission.CAR_SPEED",
|
|
||||||
"android.car.permission.CAR_ENERGY",
|
|
||||||
"android.car.permission.CAR_ENERGY_PORTS",
|
|
||||||
// Outside temperature and cabin temperature: declared and read since 2.1.0, but
|
|
||||||
// never asked for, so both came back unreadable and were dropped from every
|
|
||||||
// payload on a car that would have answered them.
|
|
||||||
"android.car.permission.CAR_EXTERIOR_ENVIRONMENT",
|
|
||||||
"android.car.permission.CONTROL_CAR_CLIMATE",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Asked for alongside the telemetry permissions from API 33, and separately because a
|
|
||||||
* denial costs something different: the foreground notification is the only status this
|
|
||||||
* service has, and without this it is dropped silently while the service runs on.
|
|
||||||
*/
|
|
||||||
private static String[] runtimePermissions() {
|
|
||||||
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
return RUNTIME_PERMISSIONS;
|
|
||||||
}
|
|
||||||
String[] all = java.util.Arrays.copyOf(RUNTIME_PERMISSIONS, RUNTIME_PERMISSIONS.length + 1);
|
|
||||||
all[RUNTIME_PERMISSIONS.length] = Manifest.permission.POST_NOTIFICATIONS;
|
|
||||||
return all;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void requestLocationPermissionIfNeeded() {
|
|
||||||
java.util.List<String> missing = new java.util.ArrayList<>();
|
|
||||||
for (String p : runtimePermissions()) {
|
|
||||||
if (ContextCompat.checkSelfPermission(this, p)
|
|
||||||
!= PackageManager.PERMISSION_GRANTED) {
|
|
||||||
missing.add(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!missing.isEmpty()) {
|
|
||||||
ActivityCompat.requestPermissions(this,
|
|
||||||
missing.toArray(new String[0]),
|
|
||||||
LOCATION_PERMISSION_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A denied permission used to be silent: the service kept running and simply uploaded
|
|
||||||
* nothing useful, with the UI claiming everything was fine. Say what was denied and
|
|
||||||
* what it costs.
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
|
|
||||||
@NonNull int[] grantResults) {
|
|
||||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
|
||||||
if (requestCode != LOCATION_PERMISSION_REQUEST) return;
|
|
||||||
|
|
||||||
boolean locationDenied = false;
|
|
||||||
boolean carDataDenied = false;
|
|
||||||
for (int i = 0; i < permissions.length && i < grantResults.length; i++) {
|
|
||||||
if (grantResults[i] == PackageManager.PERMISSION_GRANTED) continue;
|
|
||||||
if (Manifest.permission.ACCESS_FINE_LOCATION.equals(permissions[i])) {
|
|
||||||
locationDenied = true;
|
|
||||||
} else {
|
|
||||||
carDataDenied = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A grant that arrives after the service is already up changes which foreground
|
|
||||||
// types it may hold, and which properties it may read. Starting it again is what
|
|
||||||
// applies both without waiting for the next boot.
|
|
||||||
if (prefs.getBoolean("service_enabled", false)) {
|
|
||||||
startForegroundService(new Intent(this, AbrpUploadService.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (locationDenied && carDataDenied) {
|
|
||||||
setConnectionStatus(COLOR_ERROR, getString(R.string.perm_denied_all));
|
|
||||||
} else if (locationDenied) {
|
|
||||||
setConnectionStatus(COLOR_ERROR, getString(R.string.perm_denied_location));
|
|
||||||
} else if (carDataDenied) {
|
|
||||||
setConnectionStatus(COLOR_ERROR, getString(R.string.perm_denied_car));
|
|
||||||
}
|
|
||||||
refreshStatus();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
package com.evsuite.abrp;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.SharedPreferences;
|
|
||||||
import android.util.Log;
|
|
||||||
|
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences;
|
|
||||||
import androidx.security.crypto.MasterKey;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Storage for the ABRP credentials (api_key, token).
|
|
||||||
*
|
|
||||||
* They used to sit in plaintext in the regular SharedPreferences file, which
|
|
||||||
* `adb backup` hands out. They now live in an AndroidKeyStore-backed encrypted
|
|
||||||
* file, and the plaintext copy is wiped on first access.
|
|
||||||
*/
|
|
||||||
final class SecurePrefs {
|
|
||||||
|
|
||||||
private static final String TAG = "SecurePrefs";
|
|
||||||
private static final String FILE_NAME = "abrp_secure_prefs";
|
|
||||||
private static final String PLAIN_NAME = "abrp_prefs";
|
|
||||||
|
|
||||||
static final String KEY_API_KEY = "api_key";
|
|
||||||
static final String KEY_TOKEN = "token";
|
|
||||||
|
|
||||||
private static SharedPreferences instance;
|
|
||||||
|
|
||||||
private SecurePrefs() { }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypted preferences, or the plaintext file if the crypto stack is
|
|
||||||
* unavailable. Falling back keeps the app usable on a device with a broken
|
|
||||||
* keystore instead of crashing it in a vehicle.
|
|
||||||
*/
|
|
||||||
static synchronized SharedPreferences get(Context context) {
|
|
||||||
if (instance != null) return instance;
|
|
||||||
Context app = context.getApplicationContext();
|
|
||||||
try {
|
|
||||||
MasterKey masterKey = new MasterKey.Builder(app)
|
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
|
||||||
.build();
|
|
||||||
instance = EncryptedSharedPreferences.create(
|
|
||||||
app,
|
|
||||||
FILE_NAME,
|
|
||||||
masterKey,
|
|
||||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
|
||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM);
|
|
||||||
migratePlaintext(app, instance);
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Encrypted preferences unavailable, falling back to plaintext", e);
|
|
||||||
instance = app.getSharedPreferences(PLAIN_NAME, Context.MODE_PRIVATE);
|
|
||||||
}
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Moves credentials written by an older version, then deletes the plaintext copy. */
|
|
||||||
private static void migratePlaintext(Context context, SharedPreferences secure) {
|
|
||||||
SharedPreferences plain = context.getSharedPreferences(PLAIN_NAME, Context.MODE_PRIVATE);
|
|
||||||
String apiKey = plain.getString(KEY_API_KEY, null);
|
|
||||||
String token = plain.getString(KEY_TOKEN, null);
|
|
||||||
if (apiKey == null && token == null) return;
|
|
||||||
|
|
||||||
SharedPreferences.Editor secureEdit = secure.edit();
|
|
||||||
if (apiKey != null) secureEdit.putString(KEY_API_KEY, apiKey);
|
|
||||||
if (token != null) secureEdit.putString(KEY_TOKEN, token);
|
|
||||||
secureEdit.commit();
|
|
||||||
|
|
||||||
plain.edit().remove(KEY_API_KEY).remove(KEY_TOKEN).commit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
package com.evsuite.abrp;
|
|
||||||
|
|
||||||
import org.json.JSONException;
|
|
||||||
import org.json.JSONObject;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the ABRP telemetry JSON payload.
|
|
||||||
*
|
|
||||||
* Kept free of Android types so it can be unit-tested on the JVM: the caller
|
|
||||||
* pulls the values out of the car adapter and the Location, this class only
|
|
||||||
* decides what ends up in the payload.
|
|
||||||
*
|
|
||||||
* Every vehicle field is nullable and a null field is OMITTED. A property that
|
|
||||||
* cannot be read is not a zero: sending soc:0 because a getter threw tells ABRP
|
|
||||||
* the battery is empty and wrecks the user's route plan.
|
|
||||||
*
|
|
||||||
* Fields are set by name rather than passed positionally. The payload now carries
|
|
||||||
* 24 optional values and a positional build(...) call had become a wall of nulls
|
|
||||||
* that no reader could check against the ABRP field list.
|
|
||||||
*/
|
|
||||||
final class TelemetryPayload {
|
|
||||||
|
|
||||||
/** Only value that is always present. Epoch SECONDS, not milliseconds. */
|
|
||||||
private final long utc;
|
|
||||||
|
|
||||||
// --- High priority (ABRP: "many features will only be usable with enough data") ---
|
|
||||||
Integer soc; // %
|
|
||||||
Float speedKmh; // km/h
|
|
||||||
Float powerKw; // kW, ABRP sign: positive leaving the battery
|
|
||||||
Boolean charging;
|
|
||||||
Boolean dcfc;
|
|
||||||
Boolean parked;
|
|
||||||
Double lat; // °
|
|
||||||
Double lon; // °
|
|
||||||
|
|
||||||
// --- Lower priority ---
|
|
||||||
Integer rangeKm; // km, vehicle's own estimate
|
|
||||||
Float cabinTemp; // °C
|
|
||||||
Float battTempC; // °C, traction battery
|
|
||||||
Float capacityKwh; // kWh, usable pack capacity
|
|
||||||
Float soeKwh; // kWh, energy currently in the pack
|
|
||||||
Double kwhCharged; // kWh, taken by the pack during the current charge session
|
|
||||||
Float odometerKm; // km
|
|
||||||
Float hvacSetpointC; // °C, climate setpoint
|
|
||||||
Float tirePressureFlKpa;
|
|
||||||
Float tirePressureFrKpa;
|
|
||||||
Float tirePressureRlKpa;
|
|
||||||
Float tirePressureRrKpa;
|
|
||||||
Double elevation; // m
|
|
||||||
Float heading; // °
|
|
||||||
|
|
||||||
TelemetryPayload(long utc) {
|
|
||||||
this.utc = utc;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build the {@code tlm} JSON object. Only utc is always present. */
|
|
||||||
String build() {
|
|
||||||
JSONObject tlm = new JSONObject();
|
|
||||||
try {
|
|
||||||
tlm.put("utc", utc);
|
|
||||||
|
|
||||||
putIfPresent(tlm, "soc", soc);
|
|
||||||
putIfPresent(tlm, "speed", speedKmh == null ? null : Math.round(speedKmh));
|
|
||||||
putIfPresent(tlm, "est_battery_range", rangeKm);
|
|
||||||
if (powerKw != null) tlm.put("power", round2(powerKw));
|
|
||||||
putIfPresent(tlm, "is_charging", boolToInt(charging));
|
|
||||||
putIfPresent(tlm, "is_dcfc", boolToInt(dcfc));
|
|
||||||
putIfPresent(tlm, "is_parked", boolToInt(parked));
|
|
||||||
|
|
||||||
// Only send cabin_temp if we got a plausible reading — 0.0 likely
|
|
||||||
// means the property isn't supported on this VHAL.
|
|
||||||
if (isPlausibleTemp(cabinTemp)) {
|
|
||||||
tlm.put("cabin_temp", Math.round(cabinTemp));
|
|
||||||
}
|
|
||||||
// Same guard for the pack: a VHAL that does not implement the property
|
|
||||||
// answers 0.0, and "battery at 0 °C" is a reading ABRP acts on.
|
|
||||||
if (isPlausibleTemp(battTempC)) {
|
|
||||||
tlm.put("batt_temp", Math.round(battTempC));
|
|
||||||
}
|
|
||||||
// hvac_setpoint is a user setting, so 0 °C is not a real value either —
|
|
||||||
// no cabin climate control is ever set to freezing.
|
|
||||||
if (isPlausibleTemp(hvacSetpointC)) {
|
|
||||||
tlm.put("hvac_setpoint", Math.round(hvacSetpointC));
|
|
||||||
}
|
|
||||||
|
|
||||||
// A pack cannot hold 0 kWh and a car cannot have driven 0 km by the time
|
|
||||||
// it runs this app: both zeros mean "property not implemented".
|
|
||||||
if (capacityKwh != null && capacityKwh > 0f) {
|
|
||||||
tlm.put("capacity", round2(capacityKwh));
|
|
||||||
}
|
|
||||||
if (odometerKm != null && odometerKm > 0f) {
|
|
||||||
tlm.put("odometer", Math.round(odometerKm));
|
|
||||||
}
|
|
||||||
// Same reasoning as capacity: a car that is running this app has some energy
|
|
||||||
// in its pack, so 0 kWh is the VHAL saying it does not implement the property.
|
|
||||||
if (soeKwh != null && soeKwh > 0f) {
|
|
||||||
tlm.put("soe", round2(soeKwh));
|
|
||||||
}
|
|
||||||
// 0.0 IS meaningful here — it is a charge session that has just begun — so
|
|
||||||
// This one is guarded on null only. EVHardware's charging-session meter sends
|
|
||||||
// null between sessions.
|
|
||||||
if (kwhCharged != null) {
|
|
||||||
tlm.put("kwh_charged", kwhCharged.doubleValue());
|
|
||||||
}
|
|
||||||
|
|
||||||
putIfPresent(tlm, "tire_pressure_fl", kpa(tirePressureFlKpa));
|
|
||||||
putIfPresent(tlm, "tire_pressure_fr", kpa(tirePressureFrKpa));
|
|
||||||
putIfPresent(tlm, "tire_pressure_rl", kpa(tirePressureRlKpa));
|
|
||||||
putIfPresent(tlm, "tire_pressure_rr", kpa(tirePressureRrKpa));
|
|
||||||
|
|
||||||
if (lat != null && lon != null) {
|
|
||||||
tlm.put("lat", lat.doubleValue());
|
|
||||||
tlm.put("lon", lon.doubleValue());
|
|
||||||
putIfPresent(tlm, "elevation", elevation == null ? null : Math.round(elevation));
|
|
||||||
putIfPresent(tlm, "heading", heading == null ? null : Math.round(heading));
|
|
||||||
}
|
|
||||||
} catch (JSONException e) {
|
|
||||||
// JSONObject.put only throws on NaN/Infinity values, which the guards above
|
|
||||||
// already exclude. Returning just the timestamp keeps the upload alive.
|
|
||||||
return "{\"utc\":" + utc + "}";
|
|
||||||
}
|
|
||||||
return tlm.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True for a temperature that a sensor could actually have measured. Excludes exactly
|
|
||||||
* 0.0, which is what an unimplemented VHAL property returns — see cabin_temp above.
|
|
||||||
*
|
|
||||||
* Package-private because the service asks the same question before deciding whether to
|
|
||||||
* look for an outside temperature somewhere other than the car.
|
|
||||||
*/
|
|
||||||
static boolean isPlausibleTemp(Float celsius) {
|
|
||||||
return celsius != null && celsius > -50f && celsius < 80f && celsius != 0f;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rounded kPa, or null when the reading is outside anything a road tyre reports.
|
|
||||||
* A flat tyre still reads well above zero, so 0 means the wheel has no sensor.
|
|
||||||
*/
|
|
||||||
private static Integer kpa(Float pressure) {
|
|
||||||
if (pressure == null || pressure < 50f || pressure > 500f) return null;
|
|
||||||
return Math.round(pressure);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void putIfPresent(JSONObject json, String key, Object value)
|
|
||||||
throws JSONException {
|
|
||||||
if (value != null) json.put(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Integer boolToInt(Boolean value) {
|
|
||||||
return value == null ? null : (value ? 1 : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ABRP expects kW with 2 decimals; avoids a locale-dependent String.format. */
|
|
||||||
private static double round2(float value) {
|
|
||||||
return Math.round(value * 100.0) / 100.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every field {@link #build()} can emit, in the order the summary lists them.
|
|
||||||
* Used only to work out what was left out — see {@link #summarize(String)}.
|
|
||||||
*/
|
|
||||||
private static final String[] ALL_FIELDS = {
|
|
||||||
"utc", "soc", "speed", "power", "est_battery_range", "cabin_temp",
|
|
||||||
"batt_temp", "hvac_setpoint", "capacity", "soe", "kwh_charged", "odometer",
|
|
||||||
"is_charging", "is_dcfc", "is_parked", "lat", "lon", "elevation", "heading",
|
|
||||||
"tire_pressure_fl", "tire_pressure_fr", "tire_pressure_rl", "tire_pressure_rr",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One-line, human-readable digest of an upload: what was sent, and what was left out.
|
|
||||||
*
|
|
||||||
* Derived from the JSON that actually goes on the wire rather than from the fields
|
|
||||||
* above, so it cannot drift away from the payload it claims to describe — which is
|
|
||||||
* the whole point of having it when a value on the ABRP side looks wrong.
|
|
||||||
*/
|
|
||||||
static String summarize(String tlmJson) {
|
|
||||||
JSONObject tlm;
|
|
||||||
try {
|
|
||||||
tlm = new JSONObject(tlmJson);
|
|
||||||
} catch (JSONException e) {
|
|
||||||
return "unparseable payload: " + tlmJson;
|
|
||||||
}
|
|
||||||
StringBuilder sent = new StringBuilder();
|
|
||||||
StringBuilder omitted = new StringBuilder();
|
|
||||||
for (String field : ALL_FIELDS) {
|
|
||||||
if (tlm.has(field)) {
|
|
||||||
if (sent.length() > 0) sent.append(' ');
|
|
||||||
sent.append(field).append('=').append(tlm.opt(field));
|
|
||||||
} else {
|
|
||||||
if (omitted.length() > 0) omitted.append(',');
|
|
||||||
omitted.append(field);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sent + (omitted.length() == 0 ? "" : " | omitted: " + omitted);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package com.evsuite.abrp;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decides whether a given scheduler tick should actually upload.
|
|
||||||
*
|
|
||||||
* The service ticks at a fixed short rate; this class thins the uploads out. A car parked
|
|
||||||
* overnight and not charging does not need a sample every minute — that is wasted data and
|
|
||||||
* power — but nothing may be lost while driving or charging, where ABRP needs the
|
|
||||||
* resolution to keep a route plan accurate.
|
|
||||||
*
|
|
||||||
* Free of Android types so the policy can be unit-tested directly.
|
|
||||||
*/
|
|
||||||
final class UploadCadence {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parked and not charging: one keep-alive sample every 15 minutes. The car is not
|
|
||||||
* moving and its SOC is not changing, so anything more is spent for nothing.
|
|
||||||
*/
|
|
||||||
static final long PARKED_INTERVAL_MS = 900_000L;
|
|
||||||
|
|
||||||
private UploadCadence() { }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param nowMs current time
|
|
||||||
* @param lastUploadMs when the last upload was attempted, 0 if never
|
|
||||||
* @param parked null when the gear could not be read
|
|
||||||
* @param charging null when the charge port state could not be read
|
|
||||||
* @param socPercent null when the battery level could not be read
|
|
||||||
* @param stateChanged true if parked/charging differ from the previous tick
|
|
||||||
* @param settings user-configured cadence
|
|
||||||
*/
|
|
||||||
static boolean shouldUpload(long nowMs, long lastUploadMs,
|
|
||||||
Boolean parked, Boolean charging, Integer socPercent,
|
|
||||||
boolean stateChanged, UploadSettings settings) {
|
|
||||||
// Never uploaded yet: send immediately so ABRP sees the car come online.
|
|
||||||
if (lastUploadMs <= 0L) return true;
|
|
||||||
|
|
||||||
// A transition (just parked, plugged in, unplugged) is exactly what ABRP must
|
|
||||||
// learn straight away — never make it wait for the slow interval.
|
|
||||||
if (stateChanged) return true;
|
|
||||||
|
|
||||||
return nowMs - lastUploadMs >= intervalFor(parked, charging, socPercent, settings);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interval that applies in the given state.
|
|
||||||
*
|
|
||||||
* Unknown state counts as active: if the gear or charge port could not be read we do
|
|
||||||
* not know the car is idle, and under-reporting a moving car is worse than a few
|
|
||||||
* extra requests from a stationary one.
|
|
||||||
*/
|
|
||||||
static long intervalFor(Boolean parked, Boolean charging, Integer socPercent,
|
|
||||||
UploadSettings settings) {
|
|
||||||
boolean idle = Boolean.TRUE.equals(parked) && Boolean.FALSE.equals(charging);
|
|
||||||
if (idle) return PARKED_INTERVAL_MS;
|
|
||||||
|
|
||||||
// Low battery: the driver is picking a charge stop and ABRP needs a tighter SOC
|
|
||||||
// curve to get the arrival estimate right. Only while actually in use — a car
|
|
||||||
// parked at 5 % is still parked and gains nothing from fast polling.
|
|
||||||
if (settings.boostLowSoc && socPercent != null
|
|
||||||
&& socPercent <= settings.lowSocPercent) {
|
|
||||||
return Math.min(settings.intervalMs(), UploadSettings.BOOSTED_INTERVAL_SEC * 1000L);
|
|
||||||
}
|
|
||||||
return settings.intervalMs();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +1,14 @@
|
|||||||
package com.evsuite.abrp;
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
import android.content.SharedPreferences;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User-configurable upload policy.
|
* Placeholder — the service is always-on and samples on a fixed 15-second scheduler, so
|
||||||
*
|
* there is no user-configurable upload policy left. Kept as an empty shell so any stray
|
||||||
* Defaults are deliberately conservative. The head unit is awake whenever the car is on,
|
* references compile during the ABRP removal; will be deleted once all callers are gone.
|
||||||
* and both the upload tick and the GPS subscription cost power the whole time, so the
|
|
||||||
* default cadence is the slowest one that still keeps an ABRP route plan usable.
|
|
||||||
*/
|
*/
|
||||||
final class UploadSettings {
|
final class UploadSettings {
|
||||||
|
|
||||||
static final String KEY_INTERVAL_SEC = "upload_interval_sec";
|
static final String KEY_AUTOSTART = "autostart";
|
||||||
static final String KEY_BOOST_LOW_SOC = "boost_low_soc";
|
|
||||||
static final String KEY_LOW_SOC_PERCENT = "low_soc_percent";
|
|
||||||
static final String KEY_AUTOSTART = "autostart";
|
|
||||||
static final String KEY_ABRP_UPLOAD_ENABLED = "abrp_upload_enabled";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* On by default: an ABRP plan is only useful if it is fed from the moment the drive
|
|
||||||
* starts, and nobody opens this app before setting off. Kept as a switch because a
|
|
||||||
* service that starts itself must also be switchable off.
|
|
||||||
*/
|
|
||||||
static final boolean DEFAULT_AUTOSTART = true;
|
static final boolean DEFAULT_AUTOSTART = true;
|
||||||
static final boolean DEFAULT_ABRP_UPLOAD_ENABLED = true;
|
|
||||||
|
|
||||||
/**
|
private UploadSettings() { }
|
||||||
* Default cadence while driving. 60 s is roughly 1.5 km between points at motorway
|
|
||||||
* speed, which ABRP can still track; going much slower makes the live plan lag
|
|
||||||
* reality. Parked and idle backs off far further on its own — see UploadCadence.
|
|
||||||
*/
|
|
||||||
static final int DEFAULT_INTERVAL_SEC = 60;
|
|
||||||
|
|
||||||
/** Offered in the UI. Kept short so the list stays a single glance on a car screen. */
|
|
||||||
static final int[] INTERVAL_CHOICES_SEC = { 15, 30, 60, 120, 300 };
|
|
||||||
|
|
||||||
static final boolean DEFAULT_BOOST_LOW_SOC = true;
|
|
||||||
|
|
||||||
/** Below this, range planning gets tight and ABRP needs a finer SOC curve. */
|
|
||||||
static final int DEFAULT_LOW_SOC_PERCENT = 20;
|
|
||||||
|
|
||||||
/** Cadence used while SOC is low and the boost is enabled. */
|
|
||||||
static final int BOOSTED_INTERVAL_SEC = 15;
|
|
||||||
|
|
||||||
final int intervalSec;
|
|
||||||
final boolean boostLowSoc;
|
|
||||||
final int lowSocPercent;
|
|
||||||
final boolean abrpUploadEnabled;
|
|
||||||
|
|
||||||
UploadSettings(int intervalSec, boolean boostLowSoc, int lowSocPercent) {
|
|
||||||
this(intervalSec, boostLowSoc, lowSocPercent, DEFAULT_ABRP_UPLOAD_ENABLED);
|
|
||||||
}
|
|
||||||
|
|
||||||
UploadSettings(int intervalSec, boolean boostLowSoc, int lowSocPercent,
|
|
||||||
boolean abrpUploadEnabled) {
|
|
||||||
this.intervalSec = intervalSec;
|
|
||||||
this.boostLowSoc = boostLowSoc;
|
|
||||||
this.lowSocPercent = lowSocPercent;
|
|
||||||
this.abrpUploadEnabled = abrpUploadEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
static UploadSettings from(SharedPreferences prefs) {
|
|
||||||
return new UploadSettings(
|
|
||||||
prefs.getInt(KEY_INTERVAL_SEC, DEFAULT_INTERVAL_SEC),
|
|
||||||
prefs.getBoolean(KEY_BOOST_LOW_SOC, DEFAULT_BOOST_LOW_SOC),
|
|
||||||
prefs.getInt(KEY_LOW_SOC_PERCENT, DEFAULT_LOW_SOC_PERCENT),
|
|
||||||
prefs.getBoolean(KEY_ABRP_UPLOAD_ENABLED, DEFAULT_ABRP_UPLOAD_ENABLED));
|
|
||||||
}
|
|
||||||
|
|
||||||
static UploadSettings defaults() {
|
|
||||||
return new UploadSettings(
|
|
||||||
DEFAULT_INTERVAL_SEC, DEFAULT_BOOST_LOW_SOC, DEFAULT_LOW_SOC_PERCENT);
|
|
||||||
}
|
|
||||||
|
|
||||||
long intervalMs() {
|
|
||||||
return intervalSec * 1000L;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Flèche vers un plateau : onglet ABRP (envoi de télémétrie). Teinté par
|
|
||||||
@color/nav_tab_foreground. -->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:strokeColor="#FFFFFFFF"
|
|
||||||
android:strokeWidth="2"
|
|
||||||
android:strokeLineCap="round"
|
|
||||||
android:strokeLineJoin="round"
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M4,14V19H20V14" />
|
|
||||||
<path
|
|
||||||
android:strokeColor="#FFFFFFFF"
|
|
||||||
android:strokeWidth="2"
|
|
||||||
android:strokeLineCap="round"
|
|
||||||
android:strokeLineJoin="round"
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M12,3V13M8,7L12,3L16,7" />
|
|
||||||
</vector>
|
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabVehicle" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_vehicle" app:icon="@drawable/ic_tab_vehicle" />
|
<com.google.android.material.button.MaterialButton android:id="@+id/tabVehicle" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_vehicle" app:icon="@drawable/ic_tab_vehicle" />
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabConsumption" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_consumption" app:icon="@drawable/ic_tab_vehicle" />
|
<com.google.android.material.button.MaterialButton android:id="@+id/tabConsumption" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_consumption" app:icon="@drawable/ic_tab_vehicle" />
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabCharging" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_charging" app:icon="@drawable/ic_tab_service" />
|
<com.google.android.material.button.MaterialButton android:id="@+id/tabCharging" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_charging" app:icon="@drawable/ic_tab_service" />
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabAbrp" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_abrp" app:icon="@drawable/ic_tab_abrp" />
|
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabService" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_service" app:icon="@drawable/ic_tab_service" />
|
<com.google.android.material.button.MaterialButton android:id="@+id/tabService" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_service" app:icon="@drawable/ic_tab_service" />
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabWifi" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_wifi" app:icon="@drawable/ic_tab_wifi" />
|
<com.google.android.material.button.MaterialButton android:id="@+id/tabWifi" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_wifi" app:icon="@drawable/ic_tab_wifi" />
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabLog" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_log" app:icon="@drawable/ic_tab_log" />
|
<com.google.android.material.button.MaterialButton android:id="@+id/tabLog" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_log" app:icon="@drawable/ic_tab_log" />
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false">
|
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:padding="24dp" android:baselineAligned="false">
|
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1.6" android:orientation="vertical" android:layout_marginEnd="16dp" android:padding="24dp" android:background="@drawable/bg_glass">
|
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_account" android:textAppearance="@style/Text.EV.Eyebrow" />
|
|
||||||
<com.google.android.material.textfield.TextInputLayout android:id="@+id/api_key_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_api_key" app:endIconMode="password_toggle" app:endIconMinSize="72dp" app:helperText="@string/helper_api_key">
|
|
||||||
<com.google.android.material.textfield.TextInputEditText android:id="@+id/api_key_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="textPassword" android:singleLine="true" android:textSize="20sp" />
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
|
||||||
<com.google.android.material.textfield.TextInputLayout android:id="@+id/token_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_token" app:endIconMode="password_toggle" app:endIconMinSize="72dp" app:helperText="@string/helper_token">
|
|
||||||
<com.google.android.material.textfield.TextInputEditText android:id="@+id/token_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="textPassword" android:singleLine="true" android:textSize="20sp" />
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/save_button" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_save" android:layout_marginTop="20dp" />
|
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/test_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_test" android:layout_marginTop="8dp" />
|
|
||||||
<LinearLayout android:id="@+id/connection_status_row" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:gravity="center_vertical" android:orientation="horizontal" android:visibility="gone">
|
|
||||||
<View android:id="@+id/connection_indicator" android:layout_width="10dp" android:layout_height="10dp" android:layout_marginEnd="12dp" android:background="@drawable/circle_indicator" />
|
|
||||||
<TextView android:id="@+id/connection_status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:textAppearance="@style/Text.EV.Body" />
|
|
||||||
</LinearLayout>
|
|
||||||
</LinearLayout>
|
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
|
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_private_title" android:textAppearance="@style/Text.EV.Title" />
|
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_private_body" android:textAppearance="@style/Text.EV.Secondary" android:layout_marginTop="16dp" android:lineSpacingMultiplier="1.3" />
|
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/import_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_import" android:layout_marginTop="24dp" />
|
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/probe_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/design_probe" />
|
|
||||||
</LinearLayout>
|
|
||||||
</LinearLayout>
|
|
||||||
</ScrollView>
|
|
||||||
@@ -16,7 +16,6 @@
|
|||||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabVehicle" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_vehicle" app:icon="@drawable/ic_tab_vehicle"/>
|
<com.google.android.material.button.MaterialButton android:id="@+id/tabVehicle" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_vehicle" app:icon="@drawable/ic_tab_vehicle"/>
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabConsumption" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_consumption" app:icon="@drawable/ic_tab_vehicle"/>
|
<com.google.android.material.button.MaterialButton android:id="@+id/tabConsumption" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_consumption" app:icon="@drawable/ic_tab_vehicle"/>
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabCharging" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_charging" app:icon="@drawable/ic_tab_service"/>
|
<com.google.android.material.button.MaterialButton android:id="@+id/tabCharging" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_charging" app:icon="@drawable/ic_tab_service"/>
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabAbrp" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_abrp" app:icon="@drawable/ic_tab_abrp"/>
|
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabService" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_service" app:icon="@drawable/ic_tab_service"/>
|
<com.google.android.material.button.MaterialButton android:id="@+id/tabService" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_service" app:icon="@drawable/ic_tab_service"/>
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabWifi" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_wifi" app:icon="@drawable/ic_tab_wifi"/>
|
<com.google.android.material.button.MaterialButton android:id="@+id/tabWifi" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_wifi" app:icon="@drawable/ic_tab_wifi"/>
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabLog" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_log" app:icon="@drawable/ic_tab_log"/>
|
<com.google.android.material.button.MaterialButton android:id="@+id/tabLog" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_log" app:icon="@drawable/ic_tab_log"/>
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false">
|
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp">
|
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
|
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_account" android:textAppearance="@style/Text.EV.Eyebrow" />
|
|
||||||
<com.google.android.material.textfield.TextInputLayout android:id="@+id/api_key_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_api_key" app:endIconMode="password_toggle" app:endIconMinSize="72dp" app:helperText="@string/helper_api_key">
|
|
||||||
<com.google.android.material.textfield.TextInputEditText android:id="@+id/api_key_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="textPassword" android:singleLine="true" android:textSize="20sp" />
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
|
||||||
<com.google.android.material.textfield.TextInputLayout android:id="@+id/token_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_token" app:endIconMode="password_toggle" app:endIconMinSize="72dp" app:helperText="@string/helper_token">
|
|
||||||
<com.google.android.material.textfield.TextInputEditText android:id="@+id/token_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="textPassword" android:singleLine="true" android:textSize="20sp" />
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/save_button" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_save" android:layout_marginTop="20dp" />
|
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/test_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_test" android:layout_marginTop="8dp" />
|
|
||||||
<LinearLayout android:id="@+id/connection_status_row" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:gravity="center_vertical" android:orientation="horizontal" android:visibility="gone">
|
|
||||||
<View android:id="@+id/connection_indicator" android:layout_width="10dp" android:layout_height="10dp" android:layout_marginEnd="12dp" android:background="@drawable/circle_indicator" />
|
|
||||||
<TextView android:id="@+id/connection_status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:textAppearance="@style/Text.EV.Body" />
|
|
||||||
</LinearLayout>
|
|
||||||
</LinearLayout>
|
|
||||||
<Space android:layout_width="1dp" android:layout_height="20dp" />
|
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
|
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_private_title" android:textAppearance="@style/Text.EV.Title" />
|
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_private_body" android:textAppearance="@style/Text.EV.Secondary" android:layout_marginTop="16dp" android:lineSpacingMultiplier="1.3" />
|
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/import_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_import" android:layout_marginTop="24dp" />
|
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/probe_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/design_probe" />
|
|
||||||
</LinearLayout>
|
|
||||||
</LinearLayout>
|
|
||||||
</ScrollView>
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Installing a Platform-Signed APK on the MG4 Head Unit
|
||||||
|
|
||||||
|
This documents the reliable way to install a (platform-signed) APK onto the
|
||||||
|
MG4 SWI69 head unit (Android 9 / SAIC MT2712) over ADB.
|
||||||
|
|
||||||
|
The head unit rejects the standard `adb install` / `pm install` flow with:
|
||||||
|
|
||||||
|
```
|
||||||
|
java.io.IOException: Not allowed to install non-system apps on internal storage
|
||||||
|
at com.android.internal.content.PackageHelper.resolveInstallVolume(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
This is not a signature problem — the same `platform.pk8` / `platform.x509.pem`
|
||||||
|
that signs the already-installed `com.mg4.*` apps signs our APK too (verified by
|
||||||
|
matching SHA-256 cert digests). The PackageManager simply will not let the
|
||||||
|
shell UID (2000) stage a non-system package onto internal storage through its
|
||||||
|
default install path, and `--install-location`, `--force-uuid internal`, `-l`
|
||||||
|
(forward lock) and session-based `install-create`/`install-write`/`install-commit`
|
||||||
|
all hit the same wall or fail to open the file (SELinux denies `system_server`
|
||||||
|
read access to shell-owned files in some flag combinations).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- The MG4 head unit connected over ADB (`adb devices` lists it).
|
||||||
|
- A built APK. For a platform-signed build see
|
||||||
|
[`MG4_PLATFORM_BUILD.md`](./MG4_PLATFORM_BUILD.md) (DiPlay) — the same
|
||||||
|
`zipalign` + `apksigner sign --key tools/platform.pk8 --cert
|
||||||
|
tools/platform.x509.pem` step applies here.
|
||||||
|
- The platform keys in `tools/` (`platform.pk8`, `platform.x509.pem`).
|
||||||
|
|
||||||
|
## The Install Command
|
||||||
|
|
||||||
|
The only flag combination that works on this unit is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb push <your-app>.apk /data/local/tmp/update.apk
|
||||||
|
adb shell "pm install -r -f -d -t /data/local/tmp/update.apk"
|
||||||
|
```
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
|
||||||
|
- `-r` — replace the existing package if one is installed.
|
||||||
|
- `-f` — force install on internal flash (`/data/app/`).
|
||||||
|
- `-d` — allow a version-code downgrade (debuggable packages only). The
|
||||||
|
unstable channel bumps `versionCode` per CI build, so a locally built APK
|
||||||
|
is almost always lower than whatever the OTA last pushed; without `-d` the
|
||||||
|
install silently fails on a version mismatch.
|
||||||
|
- `-t` — allow test packages. Unstable/debug builds are marked debuggable,
|
||||||
|
which the PackageManager treats as a test package.
|
||||||
|
|
||||||
|
The file **must** be pushed as (or renamed to) something under
|
||||||
|
`/data/local/tmp/`. Other locations (`/sdcard/`, a custom cache dir) fail with
|
||||||
|
`Can't open file` because `system_server` has no SELinux read access to those
|
||||||
|
contexts. A failed `pm install` attempt can delete the staged file, so if a
|
||||||
|
later install says `No such file or directory`, re-push.
|
||||||
|
|
||||||
|
## Verifying the Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb shell pm path com.evsuite.abrp.unstable
|
||||||
|
# -> package:/data/app/com.evsuite.abrp.unstable-.../base.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
To confirm the platform signature matches the system:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BUILD_TOOLS="$ANDROID_HOME/build-tools/36.0.0"
|
||||||
|
adb pull "$(adb shell pm path com.evsuite.abrp.unstable | sed 's/package://')" /tmp/installed.apk
|
||||||
|
"$BUILD_TOOLS/apksigner" verify --print-certs /tmp/installed.apk
|
||||||
|
# SHA-256 cert digest should match tools/platform.x509.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uninstalling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb uninstall com.evsuite.abrp.unstable
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `-k` to keep data/cache around (useful when re-installing to test without
|
||||||
|
wiping the local consumption store).
|
||||||
|
|
||||||
|
## End-to-End: Build → Sign → Install
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Build
|
||||||
|
./gradlew :app:assembleUnstableDebug
|
||||||
|
|
||||||
|
# 2. Align + platform-sign
|
||||||
|
SDK="$HOME/Library/Android/sdk"
|
||||||
|
BT="$SDK/build-tools/36.0.0"
|
||||||
|
IN=app/build/outputs/apk/unstable/debug/app-unstable-debug.apk
|
||||||
|
ALIGNED=app/build/outputs/apk/unstable/debug/app-unstable-debug-aligned.apk
|
||||||
|
OUT=FatihsMG4-unstable-platform.apk
|
||||||
|
"$BT/zipalign" -f 4 "$IN" "$ALIGNED"
|
||||||
|
"$BT/apksigner" sign --key tools/platform.pk8 --cert tools/platform.x509.pem --out "$OUT" "$ALIGNED"
|
||||||
|
"$BT/apksigner" verify --verbose "$OUT"
|
||||||
|
|
||||||
|
# 3. Install on the head unit
|
||||||
|
adb push "$OUT" /data/local/tmp/update.apk
|
||||||
|
adb shell "pm install -r -f -d -t /data/local/tmp/update.apk"
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user