feat: add ota and ui changes

This commit is contained in:
TCFDONMEZ
2026-09-29 14:32:25 +03:00
parent c102625306
commit 511c166436
61 changed files with 895 additions and 2178 deletions
@@ -39,9 +39,6 @@ public class BootReceiver extends BroadcastReceiver {
String action = intent.getAction();
if (action == null || !START_ACTIONS.contains(action)) return;
// Window closing has its own switch and never requires ABRP credentials.
WindowCloseService.startIfEnabled(context);
SharedPreferences prefs = context.getSharedPreferences("abrp_prefs", Context.MODE_PRIVATE);
boolean enabled = prefs.getBoolean("service_enabled", false);
boolean autostart = prefs.getBoolean(UploadSettings.KEY_AUTOSTART, UploadSettings.DEFAULT_AUTOSTART);
@@ -12,7 +12,6 @@ import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.CompoundButton;
import android.widget.ImageView;
import android.widget.Spinner;
import android.widget.TextView;
@@ -21,6 +20,7 @@ import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.RecyclerView;
@@ -44,17 +44,26 @@ public class MainActivity extends AppCompatActivity {
static final String WIFI_AUTO_KEY = "wifi_auto_connect";
private static final String REPOSITORY_URL = "https://github.com/fatihdonmezdev/MG4ABRP";
/** Top-bar tabs, in page order. Parallel to {@link #panes}. */
/** Navigation destinations in page order. Parallel to {@link #panes}. */
private static final int[] TAB_IDS =
{ R.id.tabAbrp, R.id.tabService, R.id.tabVehicle, R.id.tabWindows, R.id.tabWifi,
{ R.id.tabVehicle, R.id.tabAbrp, R.id.tabService, R.id.tabWifi,
R.id.tabLog };
private static final int[] PAGE_TITLES = {
R.string.design_overview_title, R.string.design_abrp_title,
R.string.design_service_title, R.string.design_wifi_title, R.string.design_log_title
};
private static final int[] PAGE_SUBTITLES = {
R.string.design_overview_subtitle, R.string.design_abrp_subtitle,
R.string.design_service_subtitle, R.string.design_wifi_subtitle, R.string.design_log_subtitle
};
// Status colours come from the palette, not from the Material swatches: #4CAF50 and
// #F44336 sit around 4:1 on this background, which disappears behind a sunlit
// reflection. The palette entries are the lightened variants (7:1 and above).
private static final int COLOR_OK = 0xFF8FE6A6;
private static final int COLOR_ERROR = 0xFFFF9A9A;
private static final int COLOR_PENDING = 0xFFC6CFD8;
private static final int COLOR_OK = 0xFFA4DCC1;
private static final int COLOR_ERROR = 0xFFFFB4AB;
private static final int COLOR_PENDING = 0xFFBDC4C6;
private TextInputLayout apiKeyLayout;
private TextInputEditText apiKeyInput;
@@ -62,10 +71,6 @@ public class MainActivity extends AppCompatActivity {
private TextInputEditText tokenInput;
private SwitchMaterial serviceSwitch;
private SwitchMaterial autostartSwitch;
private SwitchMaterial windowCloseSwitch;
private CompoundButton.OnCheckedChangeListener windowCloseListener;
private TextView windowCloseStatus;
private TextView windowCloseLog;
private TextView statusText;
private Button testButton;
private View connectionStatusRow;
@@ -80,10 +85,9 @@ public class MainActivity extends AppCompatActivity {
private View servicePane;
private View logPane;
private View vehiclePane;
private View windowsPane;
private View wifiPane;
/** The six pages, in the order the top bar lists them. Parallel to {@link #TAB_IDS}. */
/** The five pages in navigation order. Parallel to {@link #TAB_IDS}. */
private View[] panes;
/** Wi-Fi page. The helper is the same one the upload service uses on its own tick. */
@@ -117,7 +121,7 @@ public class MainActivity extends AppCompatActivity {
}
/**
* Wires the top bar to the pager: every tab is a page, and every page is a tab.
* Wires the navigation to the pager: every tab is a page, and every page is a tab.
*
* Each page has its own view type, so the pager asks for it once and keeps it: these
* views are the activity's own, held in {@link #panes}, not rows to be recycled.
@@ -140,10 +144,8 @@ public class MainActivity extends AppCompatActivity {
// state, exactly as they did when they were siblings in the layout.
}
});
// All of them stay alive. They are cheap, and the service switch, the call log, the
// window-close status and the vehicle readings are refreshed by the tick whether or
// not their page is on screen — refreshWindowClose in particular dereferences views
// the pager would otherwise be free to detach.
// Keep all pages alive: the service switch, log and vehicle readings are refreshed
// by the tick even when their page is off screen.
pager.setOffscreenPageLimit(panes.length - 1);
pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
@Override public void onPageSelected(int position) { markCurrentPage(position); }
@@ -159,7 +161,7 @@ public class MainActivity extends AppCompatActivity {
}
/**
* Marks the top-bar tab of the page on screen.
* Marks the destination and labels the page on screen.
*
* Only isSelected is set: fill, text, icon and stroke come from the
* res/color/nav_tab_*.xml selectors applied by the Widget.EV.NavTab style. isSelected
@@ -169,6 +171,8 @@ public class MainActivity extends AppCompatActivity {
for (int i = 0; i < TAB_IDS.length; i++) {
findViewById(TAB_IDS[i]).setSelected(i == position);
}
((TextView) findViewById(R.id.header_title)).setText(PAGE_TITLES[position]);
((TextView) findViewById(R.id.header_subtitle)).setText(PAGE_SUBTITLES[position]);
}
/** Refreshes state + call log while the screen is visible. */
@@ -179,7 +183,6 @@ public class MainActivity extends AppCompatActivity {
refreshCallLog();
refreshVehicle();
refreshWifi();
refreshWindowClose();
uiHandler.postDelayed(this, 2_000L);
}
};
@@ -200,13 +203,14 @@ public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES);
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
securePrefs = SecurePrefs.get(this);
// The three pages are inflated here, once, and handed to the pager as fixed,
// 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
// 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
@@ -214,14 +218,12 @@ public class MainActivity extends AppCompatActivity {
abrpPane = inflatePane(R.layout.pane_abrp);
servicePane = inflatePane(R.layout.pane_service);
vehiclePane = inflatePane(R.layout.pane_vehicle);
windowsPane = inflatePane(R.layout.pane_windows);
wifiPane = inflatePane(R.layout.pane_wifi);
logPane = inflatePane(R.layout.pane_log);
panes = new View[] { abrpPane, servicePane, vehiclePane, windowsPane, wifiPane, logPane };
panes = new View[] { vehiclePane, abrpPane, servicePane, wifiPane, logPane };
bindVehiclePane();
bindWifiPane();
bindWindowClose();
apiKeyLayout = abrpPane.findViewById(R.id.api_key_layout);
apiKeyInput = abrpPane.findViewById(R.id.api_key_input);
@@ -327,51 +329,13 @@ public class MainActivity extends AppCompatActivity {
dialog.show();
if (dialog.getWindow() != null) {
dialog.getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
android.util.DisplayMetrics metrics = getResources().getDisplayMetrics();
int width = Math.min((int) (760 * metrics.density),
metrics.widthPixels - (int) (32 * metrics.density));
dialog.getWindow().setLayout(width, android.view.ViewGroup.LayoutParams.WRAP_CONTENT);
}
}
private void bindWindowClose() {
windowCloseSwitch = windowsPane.findViewById(R.id.window_close_switch);
windowCloseStatus = windowsPane.findViewById(R.id.window_close_status);
windowCloseLog = windowsPane.findViewById(R.id.window_close_log);
windowCloseListener = (button, enabled) -> {
prefs.edit().putBoolean(WindowCloseService.KEY_ENABLED, enabled).apply();
if (enabled) WindowCloseService.startIfEnabled(this);
else stopService(new Intent(this, WindowCloseService.class));
refreshWindowClose();
};
windowCloseSwitch.setChecked(prefs.getBoolean(WindowCloseService.KEY_ENABLED, false));
windowCloseSwitch.setOnCheckedChangeListener(windowCloseListener);
// A build that cannot reach the vehicle service would accept the toggle and then fail
// every command, which reads as a broken feature rather than an inapplicable one.
if (!WindowCloseService.canWriteToVehicle()) windowCloseSwitch.setEnabled(false);
WindowCloseService.startIfEnabled(this);
refreshWindowClose();
}
private void refreshWindowClose() {
if (windowCloseSwitch == null) return;
boolean enabled = prefs.getBoolean(WindowCloseService.KEY_ENABLED, false);
if (windowCloseSwitch.isChecked() != enabled) {
// Detached first: setChecked re-enters the listener, which would write the pref
// and restart the service on every one of these two-second refreshes.
windowCloseSwitch.setOnCheckedChangeListener(null);
windowCloseSwitch.setChecked(enabled);
windowCloseSwitch.setOnCheckedChangeListener(windowCloseListener);
}
FirmwareInfo.Gen generation = FirmwareInfo.INSTANCE.getGeneration();
if (!WindowCloseService.canWriteToVehicle()) {
windowCloseStatus.setText(R.string.window_close_no_system_uid);
} else if (!enabled) {
windowCloseStatus.setText(R.string.window_close_stopped);
} else if (generation != FirmwareInfo.Gen.UNKNOWN && generation != FirmwareInfo.Gen.SWI69) {
windowCloseStatus.setText(R.string.window_close_unsupported);
} else {
windowCloseStatus.setText(WindowCloseService.statusResource());
}
windowCloseLog.setText(WindowCloseService.eventLog());
}
@Override
protected void onResume() {
super.onResume();
@@ -638,8 +602,8 @@ public class MainActivity extends AppCompatActivity {
if (sec == current.intervalSec) selected = i;
}
ArrayAdapter<String> adapter = new ArrayAdapter<>(
this, android.R.layout.simple_spinner_item, labels);
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
this, R.layout.spinner_value, labels);
adapter.setDropDownViewResource(R.layout.spinner_dropdown);
intervalSpinner.setAdapter(adapter);
intervalSpinner.setSelection(selected);
intervalSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@@ -690,7 +654,7 @@ public class MainActivity extends AppCompatActivity {
* Checks for a newer build, and installs one if it is there.
*
* The button is disabled for the duration rather than guarded by a flag: the whole
* sequence — API call, download, {@code pm install} — runs on a background thread, and
* sequence — API call, download, installer staging — runs on a background thread, and
* a second press would start a second download of the same APK. It comes back on when
* the result lands, whatever that result is.
*
@@ -0,0 +1,33 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.RadialGradient;
import android.graphics.Shader;
import android.util.AttributeSet;
import android.view.View;
/** Static, low-opacity light behind the glass panels; no blur passes or animation loop. */
public final class PremiumBackdropView extends View {
private final Paint cyan = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint gold = new Paint(Paint.ANTI_ALIAS_FLAG);
public PremiumBackdropView(Context context, AttributeSet attrs) {
super(context, attrs);
setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
}
@Override protected void onSizeChanged(int w, int h, int oldw, int oldh) {
float radius = Math.max(1, Math.max(w, h) * 0.65f);
cyan.setShader(new RadialGradient(w * 0.70f, 0, radius,
new int[]{0x2419565B, 0x00000000}, null, Shader.TileMode.CLAMP));
gold.setShader(new RadialGradient(w, h, radius * 0.65f,
new int[]{0x1C8B693A, 0x00000000}, null, Shader.TileMode.CLAMP));
}
@Override protected void onDraw(Canvas canvas) {
canvas.drawRect(0, 0, getWidth(), getHeight(), cyan);
canvas.drawRect(0, 0, getWidth(), getHeight(), gold);
}
}
@@ -78,6 +78,8 @@ final class WifiAutoConnect {
String name = c.SSID.replace("\"", "").trim();
if (!name.isEmpty() && !names.contains(name)) names.add(name);
}
} catch (SecurityException e) {
Log.w(TAG, "Saved networks unavailable: permission denied");
} catch (Throwable t) {
Log.w(TAG, "getConfiguredNetworks refused: " + t.getMessage());
}
@@ -127,6 +129,9 @@ final class WifiAutoConnect {
}
}
return Result.REFUSED;
} catch (SecurityException e) {
Log.w(TAG, "Wi-Fi connection refused: permission denied");
return Result.REFUSED;
} catch (Throwable t) {
Log.w(TAG, "connect attempt failed: " + t.getMessage());
return Result.REFUSED;
@@ -1,26 +0,0 @@
package com.evsuite.abrp;
/** WinClose's TX_DOOR_SENSOR=6: zero is open; suppress repeated identical callbacks. */
final class WinCloseDoorSignal {
private int previous = -1;
boolean opening(int raw) {
if (raw < 0) return false;
boolean opening = raw == 0 && previous != 0;
previous = raw;
return opening;
}
/**
* Records a reading that can never count as an opening.
*
* A fresh listener registration replays the door's existing state. Treating that replay
* as an edge would close the windows on a driver who is halfway out when the binder
* reconnects — and the binder reconnects every five seconds while it is down.
*/
void seed(int raw) {
if (raw >= 0) previous = raw;
}
void reset() { previous = -1; }
}
@@ -1,221 +0,0 @@
package com.evsuite.abrp
import android.content.Context
import android.os.Binder
import android.os.Handler
import android.os.HandlerThread
import android.os.IBinder
import android.os.Parcel
import android.os.SystemClock
import com.evsuite.hardware.EVHardware
import com.evsuite.hardware.FirmwareInfo
import com.evsuite.hardware.VehicleWriteGate
import java.lang.reflect.Proxy
/**
* Adapted from ../winclose/app/src/main/java/com/mg4/winclose/WindowHardware.kt.
* Keeps WinClose's SWI69 CarAdapterClient clients, CarState transaction 6 (0=open),
* and setVehicleWindowStatus(area, command). No alternative door property is used.
* Speed/time arming, delay, beeps, AUTO and per-window settings are intentionally absent.
* WinClose identifies this event as the driver door; it carries no per-door area ID.
*/
internal class WinCloseHardware(
context: Context,
private val onOpening: () -> Unit,
private val report: (String) -> Unit,
) : AutoCloseable {
private val context = context.applicationContext
private val thread = HandlerThread("winclose-hardware").apply { start() }
private val handler = Handler(thread.looper)
@Volatile private var closed = false
@Volatile private var vsm: Any? = null
@Volatile private var state: Any? = null
private var vsmBinder: IBinder? = null
private var stateBinder: IBinder? = null
private var callbackProxy: Any? = null
private val doorSignal = WinCloseDoorSignal()
@Volatile private var registeredAt = Long.MIN_VALUE
private var initialized = false
private val retry = object : Runnable {
override fun run() {
if (closed) return
try {
if (!initialized) {
EVHardware.init(context)
initialized = true
}
if (FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI69) connect()
} catch (e: Exception) {
report("WinClose connection: ${e.javaClass.simpleName}")
}
if (!closed) handler.postDelayed(this, 5_000L)
}
}
fun start() { handler.post(retry) }
private fun connect() {
if (vsmBinder?.isBinderAlive == true && stateBinder?.isBinderAlive == true) return
val launcher = context.createPackageContext("com.saicmotor.launcher",
Context.CONTEXT_INCLUDE_CODE or Context.CONTEXT_IGNORE_SECURITY)
val loader = launcher.classLoader
val adapterClass = loader.loadClass("com.saicmotor.carapi.CarAdapterClient")
val adapter = adapterClass.getMethod("getInstance", Context::class.java)
.invoke(null, context) ?: return
// The same adapter startup and queryClient codes as WinClose.
adapterClass.getMethod("start").invoke(adapter)
val query = adapterClass.getMethod("queryClient", INT)
if (vsmBinder?.isBinderAlive != true) {
vsm = null
val binder = query.invoke(adapter, 0x8) as? IBinder
if (binder != null) {
val cls = loader.loadClass("com.saicmotor.carapi.client.CarVehicleSettingClient")
vsm = cls.getConstructor(IBinder::class.java).newInstance(binder)
vsmBinder = binder
report("WinClose VSM ready")
}
}
if (stateBinder?.isBinderAlive == true) return
detachListener()
val binder = query.invoke(adapter, 0xb) as? IBinder ?: return
val cls = loader.loadClass("com.saicmotor.carapi.client.CarStateClient")
val client = cls.getConstructor(IBinder::class.java).newInstance(binder)
val register = cls.methods.firstOrNull {
it.name in listOf("registerListener", "registListener") && it.parameterCount == 1
} ?: return
val iface = register.parameterTypes[0]
if (!iface.isInterface) return
val callback = object : Binder() {
override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {
if (code == IBinder.INTERFACE_TRANSACTION) {
reply?.writeString(DESCRIPTOR)
return true
}
if (code !in IBinder.FIRST_CALL_TRANSACTION..IBinder.LAST_CALL_TRANSACTION)
return super.onTransact(code, data, reply, flags)
try {
data.enforceInterface(DESCRIPTOR)
// Acknowledge the other CarState callbacks as WinClose does, but only
// TX_DOOR_SENSOR=6 is a trigger. It is not a generic property or bitmask.
if (code == 6) {
val raw = data.readInt()
val receivedAt = SystemClock.elapsedRealtime()
handler.post {
if (closed || stateBinder !== binder) return@post
val sinceRegistration = receivedAt - registeredAt
if (sinceRegistration < REGISTRATION_REPLAY_MS) {
// Whatever arrives this soon after registering describes the
// door as it already is, not a door someone just opened.
doorSignal.seed(raw)
report("WinClose door state seeded: raw=$raw")
return@post
}
val fresh = SystemClock.elapsedRealtime() - receivedAt < 1_500L
val opening = doorSignal.opening(raw)
if (opening) report("WinClose door opening: raw=$raw fresh=$fresh")
if (fresh && opening) onOpening()
}
}
reply?.writeNoException()
} catch (_: Exception) {
// An unreadable callback must not re-arm an already-open door.
reply?.writeNoException()
}
return true
}
}
val proxy = Proxy.newProxyInstance(loader, arrayOf(iface)) { self, method, args ->
when (method.name) {
"asBinder" -> callback
"hashCode" -> System.identityHashCode(self)
"equals" -> self === args?.firstOrNull()
"toString" -> "WinCloseCarStateListener"
else -> when (method.returnType) {
java.lang.Boolean.TYPE -> false
java.lang.Integer.TYPE -> 0
java.lang.Long.TYPE -> 0L
java.lang.Float.TYPE -> 0f
java.lang.Double.TYPE -> 0.0
else -> null
}
}
}
doorSignal.reset()
state = client
stateBinder = binder
callbackProxy = proxy
// Stamped before the call: the replay can arrive inside register.invoke itself.
registeredAt = SystemClock.elapsedRealtime()
try {
register.invoke(client, proxy)
} catch (e: Exception) {
detachListener()
throw e
}
report("WinClose CarState door listener ready")
}
fun isReady(): Boolean = vsm != null && state != null
fun isParked(): Boolean? {
val client = state ?: return null
return try {
val gear = client.javaClass.getMethod("getGearState").invoke(client) as? Int
gear?.takeIf { it >= 0 }?.let { it == 1 }
} catch (_: Exception) { null }
}
fun closeAllWindowsPulsed(keepRunning: () -> Boolean): Boolean {
val client = vsm ?: return false
val setter = try {
client.javaClass.getMethod("setVehicleWindowStatus", INT, INT)
} catch (_: Exception) { return false }
return WindowClosePulse.run({
!closed && keepRunning() && FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI69
&& isParked() == true && VehicleWriteGate.allow("WinClose setVehicleWindowStatus")
}, { area, command ->
try {
setter.invoke(client, area, command)
true
} catch (e: Exception) {
report("WinClose command failed: area=$area command=$command ${e.javaClass.simpleName}")
false
}
})
}
private fun detachListener() {
val client = state
val proxy = callbackProxy
if (client != null && proxy != null) runCatching {
client.javaClass.methods.firstOrNull {
it.name in listOf("unregisterListener", "unregistListener")
&& it.parameterCount == 1 && it.parameterTypes[0].isInstance(proxy)
}?.invoke(client, proxy)
}
state = null
stateBinder = null
callbackProxy = null
registeredAt = Long.MIN_VALUE
doorSignal.reset()
}
override fun close() {
closed = true
handler.removeCallbacksAndMessages(null)
handler.post {
detachListener()
vsm = null
vsmBinder = null
thread.quitSafely()
}
}
private companion object {
val INT = Int::class.javaPrimitiveType!!
const val DESCRIPTOR = "com.saicmotor.carapi.carstate.ICarStateCallback"
/** How long after registering a door callback still counts as the state replay. */
const val REGISTRATION_REPLAY_MS = 2_000L
}
}
@@ -1,42 +0,0 @@
package com.evsuite.abrp;
import java.util.function.LongSupplier;
/** One close per opening event; events during an existing close are consumed, not queued. */
final class WindowClosePolicy {
/**
* WinClose's TRIGGER_COOLDOWN_MS. Getting back in to fetch something reopens the door,
* and without this the glass would run a second full pulse each time.
*/
static final long COOLDOWN_MS = 60_000L;
private final LongSupplier clock;
private boolean closing;
private Long lastFinishedAt;
WindowClosePolicy() {
// Elapsed time, not wall clock: a head-unit time correction must not open the gate.
this(() -> System.nanoTime() / 1_000_000L);
}
WindowClosePolicy(LongSupplier clock) {
this.clock = clock;
}
synchronized boolean begin(Boolean parked, Float speedKmh) {
if (closing || !Boolean.TRUE.equals(parked)
|| speedKmh == null || !Float.isFinite(speedKmh) || speedKmh != 0f) {
return false;
}
if (lastFinishedAt != null && clock.getAsLong() - lastFinishedAt < COOLDOWN_MS) {
return false;
}
closing = true;
return true;
}
synchronized void finish() {
closing = false;
lastFinishedAt = clock.getAsLong();
}
}
@@ -1,47 +0,0 @@
package com.evsuite.abrp;
import java.util.function.BooleanSupplier;
import java.util.function.LongSupplier;
/** WinClose's Comfort sequence: areas 0..3, UP=1 every 120 ms, then STOP=0. */
final class WindowClosePulse {
static final long DURATION_MS = 5_000L;
static final long INTERVAL_MS = 120L;
interface Sender { boolean send(int area, int command); }
interface Sleeper { void sleep(long millis) throws InterruptedException; }
static boolean run(BooleanSupplier allowed, Sender sender) {
return run(allowed, sender, () -> System.nanoTime() / 1_000_000L, Thread::sleep);
}
static boolean run(BooleanSupplier allowed, Sender sender, LongSupplier clock, Sleeper sleeper) {
if (!allowed.getAsBoolean() || Thread.currentThread().isInterrupted()) return false;
long deadline = clock.getAsLong() + DURATION_MS;
boolean ok = true;
try {
while (clock.getAsLong() < deadline) {
if (Thread.currentThread().isInterrupted() || !allowed.getAsBoolean()) return false;
for (int area = 0; area < 4; area++) {
if (!sender.send(area, 1)) ok = false;
}
if (!ok) break;
long remaining = deadline - clock.getAsLong();
if (remaining > 0) sleeper.sleep(Math.min(INTERVAL_MS, remaining));
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
ok = false;
} finally {
// Release every motor even after cancellation, a lost P/speed reading, or failure.
for (int area = 0; area < 4; area++) {
try {
if (!sender.send(area, 0)) ok = false;
} catch (RuntimeException e) {
ok = false;
}
}
}
return ok;
}
}
@@ -1,227 +0,0 @@
package com.evsuite.abrp;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ServiceInfo;
import android.os.Build;
import android.os.IBinder;
import android.os.PowerManager;
import android.os.Process;
import android.util.Log;
import androidx.core.app.NotificationCompat;
import com.evsuite.hardware.EVHardware;
import com.evsuite.hardware.FirmwareInfo;
import java.util.ArrayDeque;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import kotlin.Unit;
/** Independent of ABRP credentials, GPS and upload cadence. No alarms or wake locks. */
public final class WindowCloseService extends Service {
static final String KEY_ENABLED = "window_close_enabled";
private static final String ACTION_STOP = "com.evsuite.abrp.WINDOW_CLOSE_STOP";
private static final String CHANNEL = "window_close";
private static final int NOTIFICATION = 2;
private static final ArrayDeque<String> events = new ArrayDeque<>();
private static volatile boolean running;
private static volatile int status = R.string.window_close_stopped;
private final WindowClosePolicy policy = new WindowClosePolicy();
private volatile boolean active;
private SharedPreferences prefs;
private ExecutorService worker;
private WinCloseHardware monitor;
static void startIfEnabled(Context context) {
if (!context.getSharedPreferences("abrp_prefs", MODE_PRIVATE)
.getBoolean(KEY_ENABLED, false)) return;
try {
context.startForegroundService(new Intent(context, WindowCloseService.class));
} catch (RuntimeException e) {
status = R.string.window_close_start_failed;
log("Service start failed: " + e.getClass().getSimpleName());
}
}
static int statusResource() { return status; }
static boolean isRunning() { return running; }
/**
* True when this process can actually reach the vehicle setting service.
*
* Only the unstable flavor declares android.uid.system, so a stable build can toggle the
* feature on and then fail every single command. Checking the uid rather than the flavor
* keeps the answer true for any build that is genuinely platform-signed.
*/
static boolean canWriteToVehicle() { return Process.myUid() == Process.SYSTEM_UID; }
static synchronized String eventLog() { return String.join("\n", events); }
private static synchronized void log(String message) {
Log.i("WindowClose", message);
String time = new java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.US)
.format(new java.util.Date());
events.addFirst(time + " " + message);
while (events.size() > 30) events.removeLast();
}
@Override public void onCreate() {
super.onCreate();
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
worker = Executors.newSingleThreadExecutor(r -> new Thread(r, "window-close"));
NotificationManager notifications = getSystemService(NotificationManager.class);
notifications.createNotificationChannel(new NotificationChannel(
CHANNEL, getString(R.string.window_close_title), NotificationManager.IMPORTANCE_LOW));
Notification notification = notification();
if (Build.VERSION.SDK_INT >= 34) {
startForeground(NOTIFICATION, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE);
} else {
startForeground(NOTIFICATION, notification);
}
running = true;
}
@Override public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null && ACTION_STOP.equals(intent.getAction())) {
prefs.edit().putBoolean(KEY_ENABLED, false).apply();
}
if (!prefs.getBoolean(KEY_ENABLED, false)) {
active = false;
stopSelf();
return START_NOT_STICKY;
}
// UNKNOWN is left running: at boot the generation is often not resolved yet, and the
// monitor re-checks before it connects. A different known generation never will be
// SWI69, so idling a foreground notification and a 5 s poll on it buys nothing.
FirmwareInfo.Gen generation = FirmwareInfo.INSTANCE.getGeneration();
if (generation != FirmwareInfo.Gen.SWI69 && generation != FirmwareInfo.Gen.UNKNOWN) {
status = R.string.window_close_unsupported;
log("Stopping: window closing is SWI69 only, this is " + generation);
active = false;
stopSelf();
return START_NOT_STICKY;
}
if (!canWriteToVehicle()) {
status = R.string.window_close_no_system_uid;
log("Stopping: process uid " + Process.myUid() + " cannot write vehicle settings");
active = false;
stopSelf();
return START_NOT_STICKY;
}
if (monitor == null) {
active = true;
status = R.string.window_close_waiting;
log("Enabled: SWI69, P + 0 km/h, all four windows, 5 seconds");
monitor = new WinCloseHardware(getApplicationContext(), () -> {
onDoorOpening();
return Unit.INSTANCE;
}, message -> {
log(message);
return Unit.INSTANCE;
});
monitor.start();
}
return START_STICKY;
}
/** Runs on the monitor's callback thread, so it must not block on the car. */
private void onDoorOpening() {
if (!active || !prefs.getBoolean(KEY_ENABLED, false)) return;
if (FirmwareInfo.INSTANCE.getGeneration() != FirmwareInfo.Gen.SWI69) return;
if (!monitor.isReady()) {
status = R.string.window_close_unavailable;
log("Close skipped: window interface unavailable");
return;
}
try {
worker.execute(this::closeNow);
} catch (RejectedExecutionException e) {
log("Close skipped: worker is shutting down");
}
}
/**
* The gear and speed reads are binder round trips, so they happen here rather than on the
* callback thread — a slow CPM read there would delay the next door event behind it.
*/
private void closeNow() {
Boolean parked = monitor.isParked();
Float speed = EVHardware.INSTANCE.getVehicleSpeedKmh();
if (!policy.begin(parked, speed)) {
log("WinClose door: skipped (P=" + parked + ", speed=" + speed
+ "; or a close ran in the last minute)");
return;
}
// Held across the pulse because this fires as the driver leaves, which is exactly
// when the head unit suspends. Timed out well past the pulse so a stuck close can
// never pin the unit awake, and released in the finally regardless.
PowerManager power = getSystemService(PowerManager.class);
PowerManager.WakeLock wakeLock =
power.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "EVABRP:windowClose");
wakeLock.acquire(WindowClosePulse.DURATION_MS + 10_000L);
status = R.string.window_close_closing;
log("WinClose: closing all four windows");
try {
boolean ok = monitor.closeAllWindowsPulsed(() ->
active && prefs.getBoolean(KEY_ENABLED, false));
status = ok ? R.string.window_close_sent : R.string.window_close_interrupted;
log(ok ? "Close pulse finished; STOP sent. Position is unknown."
: "Close interrupted/refused or command failed; check P and speed.");
} catch (RuntimeException e) {
status = R.string.window_close_interrupted;
log("Close failed: " + e.getClass().getSimpleName());
} finally {
if (wakeLock.isHeld()) wakeLock.release();
policy.finish();
}
}
private Notification notification() {
PendingIntent open = PendingIntent.getActivity(this, 20,
new Intent(this, MainActivity.class), PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
PendingIntent stop = PendingIntent.getService(this, 21,
new Intent(this, WindowCloseService.class).setAction(ACTION_STOP),
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
return new NotificationCompat.Builder(this, CHANNEL)
.setSmallIcon(R.drawable.ic_launcher_monochrome)
.setContentTitle(getString(R.string.window_close_title))
.setContentText(getString(R.string.window_close_notification))
.setContentIntent(open)
.addAction(0, getString(R.string.window_close_disable), stop)
.setOngoing(true)
.build();
}
@Override public void onDestroy() {
active = false;
if (monitor != null) monitor.close();
// The pulse runner releases all windows in finally, including an interrupted sleep.
// Waited on so those STOP commands actually go out before the process moves on.
if (worker != null) {
worker.shutdownNow();
try {
if (!worker.awaitTermination(2, TimeUnit.SECONDS)) log("Worker did not stop in 2 s");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
running = false;
status = R.string.window_close_stopped;
log("Service stopped");
super.onDestroy();
}
@Override public IBinder onBind(Intent intent) { return null; }
}