Rename app and persist all-time consumption to MongoDB

This commit is contained in:
TCFDONMEZ
2026-10-08 13:23:00 +03:00
parent 7366ba0480
commit 9a6c121d09
22 changed files with 1449 additions and 30 deletions
@@ -130,6 +130,7 @@ public class AbrpUploadService extends Service {
private EnergyTelemetryReader energyReader;
private ConsumptionTracker consumptionTracker;
private ConsumptionCloudClient consumptionCloudClient;
/**
* 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
@@ -196,6 +197,7 @@ public class AbrpUploadService extends Service {
// One shared, firmware-aware EVHardware snapshot owns every vehicle signal.
energyReader = new EnergyTelemetryReader(getApplicationContext());
consumptionTracker = ConsumptionTracker.get(this);
consumptionCloudClient = new ConsumptionCloudClient(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
@@ -324,6 +326,7 @@ public class AbrpUploadService extends Service {
long sampleMs = System.currentTimeMillis();
EnergySnapshot vehicle = energyReader.read(sampleMs);
consumptionTracker.sample(vehicle);
consumptionCloudClient.syncIfDue(consumptionTracker.cloudSnapshot(), sampleMs);
// Keep local consumption history alive while another app owns the ABRP feed.
if (!settings.abrpUploadEnabled) {
@@ -0,0 +1,82 @@
package com.evsuite.abrp;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import org.json.JSONObject;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
/** Best-effort MongoDB sync through the API in server/. Never blocks local persistence. */
final class ConsumptionCloudClient {
private static final String TAG = "FatihsMG4.Mongo";
private static final long SYNC_INTERVAL_MS = 5 * 60_000L;
private static final int TIMEOUT_MS = 8_000;
private static final String PREFS = "consumption_cloud";
private static final String INSTALLATION_ID = "installation_id";
private final String endpoint;
private final String token;
private final String installationId;
private long lastAttemptMs;
ConsumptionCloudClient(Context context) {
endpoint = BuildConfig.CONSUMPTION_API_URL.trim();
token = BuildConfig.CONSUMPTION_API_TOKEN.trim();
SharedPreferences prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
String existing = prefs.getString(INSTALLATION_ID, "");
if (existing == null || existing.isEmpty()) {
existing = UUID.randomUUID().toString();
prefs.edit().putString(INSTALLATION_ID, existing).commit();
}
installationId = existing;
}
void syncIfDue(ConsumptionTracker.CloudSnapshot snapshot, long nowMs) {
if (endpoint.isEmpty() || token.isEmpty() || nowMs - lastAttemptMs < SYNC_INTERVAL_MS) return;
lastAttemptMs = nowMs;
HttpURLConnection connection = null;
try {
JSONObject body = new JSONObject()
.put("installationId", installationId)
.put("recordedAt", nowMs)
.put("lifetime", totals(snapshot.lifetime));
if (snapshot.sohPercent != null) body.put("sohPercent", snapshot.sohPercent);
byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8);
connection = (HttpURLConnection) new URL(trimSlash(endpoint) + "/v1/consumption").openConnection();
connection.setRequestMethod("PUT");
connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(TIMEOUT_MS);
connection.setDoOutput(true);
connection.setFixedLengthStreamingMode(bytes.length);
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Authorization", "Bearer " + token);
try (OutputStream out = connection.getOutputStream()) { out.write(bytes); }
int status = connection.getResponseCode();
try (InputStream ignored = status < 400
? connection.getInputStream() : connection.getErrorStream()) { }
if (status < 200 || status >= 300) Log.w(TAG, "Sync rejected with HTTP " + status);
} catch (Exception e) {
Log.w(TAG, "Consumption sync unavailable");
} finally {
if (connection != null) connection.disconnect();
}
}
private static JSONObject totals(ConsumptionTracker.Totals totals) throws Exception {
return new JSONObject().put("km", totals.km).put("kwh", totals.kwh)
.put("hours", totals.hours).put("socDrop", totals.soc);
}
private static String trimSlash(String value) {
int end = value.length();
while (end > 0 && value.charAt(end - 1) == '/') end--;
return value.substring(0, end);
}
}
@@ -34,6 +34,7 @@ final class ConsumptionTracker {
*/
private static final String PREFS = "consumption_counters_v2";
private static final String LEGACY_PREFS = "consumption_counters_v1";
private static final String SOH_PERCENT = "manual_soh_percent";
/** How often the in-memory accumulators reach disk. Matches DriveHub_Dort. */
private static final long PERSIST_INTERVAL_MS = 30_000L;
@@ -138,6 +139,29 @@ final class ConsumptionTracker {
return loadTotals(dayKey(date));
}
synchronized CloudSnapshot cloudSnapshot() {
return new CloudSnapshot(lifetime.snapshot(), sohPercent());
}
static final class CloudSnapshot {
final Totals lifetime;
final Float sohPercent;
CloudSnapshot(Totals lifetime, Float sohPercent) {
this.lifetime = lifetime;
this.sohPercent = sohPercent;
}
}
synchronized Float sohPercent() {
return prefs.contains(SOH_PERCENT) ? prefs.getFloat(SOH_PERCENT, 0f) : null;
}
synchronized boolean setSohPercent(float value) {
if (!Float.isFinite(value) || value <= 0f || value > 100f) return false;
return prefs.edit().putFloat(SOH_PERCENT, value).commit();
}
/** Flushes the day that just ended and opens the new one. */
private void rollDayIfNeeded() {
LocalDate date = LocalDate.now();
@@ -12,6 +12,7 @@ import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.Toast;
import android.widget.ImageView;
import android.widget.Spinner;
import android.widget.TextView;
@@ -42,7 +43,7 @@ public class MainActivity extends AppCompatActivity {
private static final int LOCATION_PERMISSION_REQUEST = 100;
/** Shared with {@link AbrpUploadService}, which reads it on every tick. */
static final String WIFI_AUTO_KEY = "wifi_auto_connect";
private static final String REPOSITORY_URL = "https://github.com/fatihdonmezdev/MG4ABRP";
private static final String REPOSITORY_URL = "https://github.com/fatihdonmezdev/FatihsMG4";
/** Navigation destinations in page order. Parallel to {@link #panes}. */
private static final int[] TAB_IDS =
@@ -114,6 +115,8 @@ public class MainActivity extends AppCompatActivity {
consumptionSpeed, consumptionTime, consumptionSoc, consumptionDailyHistory;
private Button consumptionReset;
private MaterialButton consumptionDateButton;
private TextInputLayout consumptionSohLayout;
private TextInputEditText consumptionSohInput;
private java.time.LocalDate selectedConsumptionDate = java.time.LocalDate.now();
private TextView chargeStatus, chargeSoc, chargeVoltage, chargeCurrent, chargePower,
chargeEnergy, chargeDuration, chargeCurveTable;
@@ -861,6 +864,12 @@ public class MainActivity extends AppCompatActivity {
consumptionDateButton.setText(selectedConsumptionDate.toString());
consumptionDateButton.setOnClickListener(v -> showConsumptionDatePicker());
consumptionReset = consumptionPane.findViewById(R.id.consumption_reset);
consumptionSohLayout = consumptionPane.findViewById(R.id.consumption_soh_layout);
consumptionSohInput = consumptionPane.findViewById(R.id.consumption_soh_input);
Float savedSoh = consumptionTracker.sohPercent();
if (savedSoh != null) consumptionSohInput.setText(String.format(
java.util.Locale.US, "%.1f", savedSoh));
consumptionPane.findViewById(R.id.consumption_soh_save).setOnClickListener(v -> saveSoh());
bindPeriodButton(R.id.consumption_lifetime, ConsumptionTracker.Period.LIFETIME);
bindPeriodButton(R.id.consumption_week, ConsumptionTracker.Period.WEEK);
bindPeriodButton(R.id.consumption_month, ConsumptionTracker.Period.MONTH);
@@ -880,6 +889,19 @@ public class MainActivity extends AppCompatActivity {
});
}
private void saveSoh() {
String raw = textOf(consumptionSohInput).replace(',', '.');
try {
float value = Float.parseFloat(raw);
if (!consumptionTracker.setSohPercent(value)) throw new NumberFormatException();
consumptionSohLayout.setError(null);
consumptionSohInput.setText(String.format(java.util.Locale.US, "%.1f", value));
Toast.makeText(this, "SoH kaydedildi", Toast.LENGTH_SHORT).show();
} catch (NumberFormatException e) {
consumptionSohLayout.setError("1 ile 100 arasında bir değer girin");
}
}
private void bindPeriodButton(int id, ConsumptionTracker.Period period) {
View button = consumptionPane.findViewById(id);
button.setOnClickListener(v -> {
+9 -1
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true">
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="TÜKETİM DÖNEMİ" android:textAppearance="@style/Text.EV.Eyebrow"/>
@@ -23,6 +23,14 @@
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_reset" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginTop="12dp" android:text="Seçili tripi sıfırla" android:visibility="gone"/>
</LinearLayout>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="Son 7/30 gün, tüm zamanlar ve Trip A/B cihazda kalıcı saklanır; uygulama ve OTA güncellemeleriyle silinmez." android:textAppearance="@style/Text.EV.Secondary"/>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" android:layout_marginTop="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="BATARYA SAĞLIĞI (SOH)" android:textAppearance="@style/Text.EV.Eyebrow"/>
<com.google.android.material.textfield.TextInputLayout android:id="@+id/consumption_soh_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:hint="Manuel SoH (%)" app:suffixText="%">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/consumption_soh_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="5" android:minHeight="72dp"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_soh_save" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="8dp" android:text="SoH değerini kaydet"/>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="Bu manuel değer cihazda saklanır ve MongoDB'deki tüm zamanlar kaydına eklenir." android:textAppearance="@style/Text.EV.Secondary"/>
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" android:layout_marginTop="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="GÜNLÜK KAYIT · SON 4 AY" android:textAppearance="@style/Text.EV.Eyebrow"/>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_date_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="56dp" android:layout_marginTop="12dp" android:text="Tarih seç"/>
+3 -3
View File
@@ -1,5 +1,5 @@
<resources>
<string name="app_name">Fatih\'sMG4</string>
<string name="app_name">FatihsMG4</string>
<!-- Main screen -->
<string name="tab_abrp">ABRP</string>
@@ -128,9 +128,9 @@
<string name="about_title">Fatih\'sMG4 Hakkında</string>
<string name="about_version">Version %1$s</string>
<string name="about_attribution">Fork of malys / EVABRPUploader\nand Leon Kernan / ABRP_Uploader</string>
<string name="about_repository">github.com/fatihdonmezdev/MG4ABRP</string>
<string name="about_repository">github.com/fatihdonmezdev/FatihsMG4</string>
<string name="about_made_with_love">MG4 EH32 · SWI69</string>
<string name="about_logo_description">EVABRPUploader logo</string>
<string name="about_logo_description">FatihsMG4 logo</string>
<string name="about_qr_description">QR code for the project repository</string>
<string name="about_version_unknown">unknown</string>
<string name="about_close">Close</string>
@@ -102,13 +102,13 @@ public class OtaUpdaterTest {
@Test
public void versionIsReadFromTheAssetName() {
// The release tag is the constant "unstable", so the asset name carries the build.
assertEquals("1.0.42", OtaUpdater.versionFromAssetName("EVABRPUploader-unstable-1.0.42.apk"));
assertEquals("1.0.100", OtaUpdater.versionFromAssetName("EVABRPUploader-unstable-1.0.100.APK"));
assertEquals("1.0.42", OtaUpdater.versionFromAssetName("FatihsMG4-unstable-1.0.42.apk"));
assertEquals("1.0.100", OtaUpdater.versionFromAssetName("FatihsMG4-unstable-1.0.100.APK"));
}
@Test
public void assetNameWithoutAVersionIsIgnored() {
assertNull(OtaUpdater.versionFromAssetName("EVABRPUploader-unstable.apk"));
assertNull(OtaUpdater.versionFromAssetName("FatihsMG4-unstable.apk"));
assertNull(OtaUpdater.versionFromAssetName("unstable"));
}
@@ -218,7 +218,7 @@ public class OtaUpdaterTest {
private static JSONObject asset(String version) throws Exception {
String name = "FatihsMG4-unstable-" + version + ".apk";
return new JSONObject().put("name", name).put("browser_download_url",
"https://github.com/fatihdonmezdev/MG4ABRP/releases/download/unstable/" + name);
"https://github.com/fatihdonmezdev/FatihsMG4/releases/download/unstable/" + name);
}
private static JSONObject release(boolean prerelease, JSONObject... assets) throws Exception {
@@ -249,7 +249,7 @@ public class OtaUpdaterTest {
boolean disconnected;
Response(int status, String body) throws Exception {
super(new URL("https://api.github.com/repos/fatihdonmezdev/MG4ABRP/releases"));
super(new URL("https://api.github.com/repos/fatihdonmezdev/FatihsMG4/releases"));
this.status = status;
this.body = body;
}
@@ -53,7 +53,7 @@ import java.security.MessageDigest;
final class OtaUpdater {
private static final String TAG = "OtaUpdater";
private static final String CACHE_PREFIX = "EVABRPUploader-ota-";
private static final String CACHE_PREFIX = "FatihsMG4-ota-";
/**
* Pre-releases live here; the unstable channel tracks them.
@@ -64,7 +64,7 @@ final class OtaUpdater {
* nothing until this repository publishes a release, which is the intended quiet.
*/
private static final String RELEASES_API =
"https://api.github.com/repos/fatihdonmezdev/MG4ABRP/releases";
"https://api.github.com/repos/fatihdonmezdev/FatihsMG4/releases";
/**
* Hosts an update may come from. The githubusercontent entries are the CDNs GitHub
@@ -162,7 +162,7 @@ final class OtaUpdater {
/**
* Version carried by an unstable asset name:
* "EVABRPUploader-unstable-1.0.42.apk" -> "1.0.42".
* "FatihsMG4-unstable-1.0.42.apk" -> "1.0.42".
*
* The release tag is the fixed string "unstable" (one rolling pre-release), so the asset
* name is what identifies a build. Returns null when the name carries no version.
@@ -198,7 +198,7 @@ final class OtaUpdater {
try {
conn.setInstanceFollowRedirects(false);
conn.setRequestProperty("Accept", "application/vnd.github.v3+json");
conn.setRequestProperty("User-Agent", "EVABRPUploader-Android");
conn.setRequestProperty("User-Agent", "FatihsMG4-Android");
conn.setConnectTimeout(TIMEOUT_MS);
conn.setReadTimeout(TIMEOUT_MS);
int status = conn.getResponseCode();
@@ -297,7 +297,7 @@ final class OtaUpdater {
connection.setInstanceFollowRedirects(false);
connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(TIMEOUT_MS);
connection.setRequestProperty("User-Agent", "EVABRPUploader-Android");
connection.setRequestProperty("User-Agent", "FatihsMG4-Android");
try {
int status = connection.getResponseCode();
if (status >= 300 && status <= 399) {
@@ -358,7 +358,7 @@ final class OtaUpdater {
long downloadId = -1L;
try {
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(update.apkUrl))
.setTitle("EVABRPUploader " + update.versionName)
.setTitle("FatihsMG4 " + update.versionName)
.setDescription("Güncelleme indiriliyor")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, publicName)
@@ -564,7 +564,7 @@ final class OtaUpdater {
Intent install = new Intent(Intent.ACTION_VIEW)
.setDataAndType(contentUri, "application/vnd.android.package-archive")
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_GRANT_READ_URI_PERMISSION);
install.setClipData(ClipData.newRawUri("EVABRPUploader update", contentUri));
install.setClipData(ClipData.newRawUri("FatihsMG4 update", contentUri));
grantToInstallers(context, contentUri);
if (install.resolveActivity(context.getPackageManager()) == null) {
throw new IllegalStateException("No package installer activity");