feat: close all windows when parked and a door opens
Ports WinClose's SWI69 window control as an opt-in foreground service that is independent of ABRP: no token, no GPS, no upload cadence. The driver opening a door while the car is in P at a readable 0 km/h pulses UP at all four areas for five seconds, then STOP. Only the parts that serve that one behaviour are taken. WinClose's speed and time arming, configurable delay, warning beep, per-window modes and position reads are all absent — the target car is a Comfort trim whose motors carry no position sensor, so a close can be neither verified nor cancelled. The pulse, the door edge detector and the eligibility policy are pure classes with unit tests. STOP is attempted from a finally block on every exit, including an interrupted sleep, a refused write and a sender that throws, because a pulse that stops arriving leaves the motors wherever they were. Guards worth naming, several of them learned from reading WinClose rather than from the port itself: - A wake lock spans the pulse. This fires as the driver leaves, which is exactly when the head unit suspends; without it the glass can stop halfway with no STOP sent. - A fresh listener registration replays the door's current state. That replay seeds the edge detector instead of triggering it, or a binder reconnect while the door stands open would close the windows on someone halfway out. - WinClose's 60-second cooldown, so reaching back into the car does not run a second pulse. - The service stops itself on a non-SWI69 generation, and the switch is disabled unless the process holds system uid — only the unstable flavor declares android.uid.system, so a stable build could otherwise offer a toggle whose every command would fail. This is the project's first write to the vehicle. README, SECURITY, AGENTS, CONTRIBUTING and DISCLAIMER all claimed the app never writes; that claim is retracted and scoped rather than quietly left standing. Untested on a car. The feature is off by default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
07d76700e0
commit
444732438d
@@ -5,6 +5,12 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<!-- Optional local window automation; independent of the location/upload service. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<!-- The window close fires exactly as the driver leaves and the head unit heads for
|
||||
suspend. Without a wake lock held across the pulse, the unit can sleep partway
|
||||
through and leave the glass halfway up with no STOP ever sent. -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<!-- Required from API 33 to post the foreground-service notification, which is the
|
||||
only status UI the service has. Without it the notification is silently dropped. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
@@ -38,6 +44,7 @@
|
||||
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>
|
||||
|
||||
@@ -65,6 +72,15 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="location" />
|
||||
|
||||
<service
|
||||
android:name=".WindowCloseService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Local MG4 parked front-door monitoring and bounded window closing" />
|
||||
</service>
|
||||
|
||||
<!-- The head unit resumes far more often than it cold-boots, and announces the
|
||||
resume with QUICKBOOT_POWERON. Listening only for BOOT_COMPLETED is why
|
||||
uploading did not start with the car. MY_PACKAGE_REPLACED covers the other
|
||||
|
||||
@@ -39,6 +39,9 @@ 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,6 +12,7 @@ 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;
|
||||
@@ -60,6 +61,10 @@ 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;
|
||||
@@ -170,6 +175,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
refreshCallLog();
|
||||
refreshVehicle();
|
||||
refreshWifi();
|
||||
refreshWindowClose();
|
||||
uiHandler.postDelayed(this, 2_000L);
|
||||
}
|
||||
};
|
||||
@@ -210,6 +216,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
bindVehiclePane();
|
||||
bindWifiPane();
|
||||
bindWindowClose();
|
||||
|
||||
apiKeyLayout = abrpPane.findViewById(R.id.api_key_layout);
|
||||
apiKeyInput = abrpPane.findViewById(R.id.api_key_input);
|
||||
@@ -318,6 +325,48 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
}
|
||||
|
||||
private void bindWindowClose() {
|
||||
windowCloseSwitch = servicePane.findViewById(R.id.window_close_switch);
|
||||
windowCloseStatus = servicePane.findViewById(R.id.window_close_status);
|
||||
windowCloseLog = servicePane.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();
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
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; }
|
||||
}
|
||||
@@ -12,6 +12,54 @@
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/spacing_lg">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="@dimen/spacing_md"
|
||||
app:cardBackgroundColor="@color/ev_surface"
|
||||
app:cardCornerRadius="@dimen/ev_corner"
|
||||
app:strokeColor="@color/ev_outline"
|
||||
app:strokeWidth="1dp"
|
||||
app:cardElevation="0dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/spacing_lg">
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/window_close_switch"
|
||||
style="@style/Widget.EV.Switch"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="@dimen/touch_target"
|
||||
android:text="@string/window_close_title" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/window_close_hint"
|
||||
android:textAppearance="@style/Text.EV.Secondary" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/window_close_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/spacing_sm"
|
||||
android:text="@string/window_close_stopped"
|
||||
android:textAppearance="@style/Text.EV.Body" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/window_close_log"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/spacing_sm"
|
||||
android:maxLines="4"
|
||||
android:textAppearance="@style/Text.EV.Secondary" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Service control card -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
<resources>
|
||||
<string name="window_close_title">Kapı açılınca camları kapat</string>
|
||||
<string name="window_close_hint">P konumunda ve araç dururken WinClose kapı sinyaliyle dört cama 5 saniye kapatma komutu gönderilir. ABRP yüklemesinden bağımsız çalışır.</string>
|
||||
<string name="window_close_stopped">Cam kapatma kapalı</string>
|
||||
<string name="window_close_waiting">WinClose kapı sinyali bekleniyor</string>
|
||||
<string name="window_close_closing">Dört cama kapatma komutu gönderiliyor…</string>
|
||||
<string name="window_close_sent">5 saniyelik komut tamamlandı. Cam konumu sensörsüz olduğu için doğrulanamıyor.</string>
|
||||
<string name="window_close_interrupted">Kapatma durdu veya komut gönderilemedi</string>
|
||||
<string name="window_close_unavailable">Cam bağlantısı henüz hazır değil</string>
|
||||
<string name="window_close_start_failed">Cam servisi başlatılamadı</string>
|
||||
<string name="window_close_unsupported">Bu cam entegrasyonu yalnızca SWI69 için etkin</string>
|
||||
<string name="window_close_no_system_uid">Bu sürüm araca yazamıyor (sistem imzası gerekli)</string>
|
||||
<string name="window_close_notification">P ve 0 km/sa: kapı açılınca dört camı kapat</string>
|
||||
<string name="window_close_disable">Kapat</string>
|
||||
<string name="app_name">Fatih\'sMG4</string>
|
||||
|
||||
<!-- Main screen -->
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import org.junit.Test;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
public class WinCloseDoorSignalTest {
|
||||
@Test public void firstOpenEventWorksWithoutDrivingOrArming() {
|
||||
assertTrue(new WinCloseDoorSignal().opening(0));
|
||||
}
|
||||
|
||||
@Test public void repeatedOpenIsIgnoredButAReopeningTriggers() {
|
||||
WinCloseDoorSignal signal = new WinCloseDoorSignal();
|
||||
assertFalse(signal.opening(2));
|
||||
assertTrue(signal.opening(0));
|
||||
assertFalse(signal.opening(0));
|
||||
assertFalse(signal.opening(1));
|
||||
assertFalse(signal.opening(2));
|
||||
assertTrue(signal.opening(0));
|
||||
}
|
||||
|
||||
@Test public void aSeededOpenDoorIsNotTreatedAsSomeoneOpeningIt() {
|
||||
WinCloseDoorSignal signal = new WinCloseDoorSignal();
|
||||
// What a fresh listener registration replays: the door is already open.
|
||||
signal.seed(0);
|
||||
assertFalse(signal.opening(0));
|
||||
// Closing it and opening it again is a real edge.
|
||||
assertFalse(signal.opening(2));
|
||||
assertTrue(signal.opening(0));
|
||||
}
|
||||
|
||||
@Test public void seedingAClosedDoorStillArmsTheNextOpening() {
|
||||
WinCloseDoorSignal signal = new WinCloseDoorSignal();
|
||||
signal.seed(2);
|
||||
assertTrue(signal.opening(0));
|
||||
}
|
||||
|
||||
@Test public void seedIgnoresAnInvalidReadingRatherThanRecordingIt() {
|
||||
WinCloseDoorSignal signal = new WinCloseDoorSignal();
|
||||
signal.seed(0);
|
||||
signal.seed(-1);
|
||||
assertFalse(signal.opening(0));
|
||||
}
|
||||
|
||||
@Test public void invalidReadingDoesNotInventAnotherOpening() {
|
||||
WinCloseDoorSignal signal = new WinCloseDoorSignal();
|
||||
assertTrue(signal.opening(0));
|
||||
assertFalse(signal.opening(-1));
|
||||
assertFalse(signal.opening(0));
|
||||
signal.reset();
|
||||
assertTrue(signal.opening(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import org.junit.Test;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
public class WindowClosePolicyTest {
|
||||
@Test public void onlyKnownParkAndStandstillCanStart() {
|
||||
WindowClosePolicy policy = new WindowClosePolicy();
|
||||
assertFalse(policy.begin(null, 0f));
|
||||
assertFalse(policy.begin(false, 0f));
|
||||
assertFalse(policy.begin(true, null));
|
||||
assertFalse(policy.begin(true, 0.1f));
|
||||
assertFalse(policy.begin(true, -1f));
|
||||
assertFalse(policy.begin(true, Float.NaN));
|
||||
assertFalse(policy.begin(true, Float.POSITIVE_INFINITY));
|
||||
assertTrue(policy.begin(true, 0f));
|
||||
}
|
||||
|
||||
@Test public void openingsDuringACloseCannotExtendOrQueueIt() {
|
||||
long[] now = {0L};
|
||||
WindowClosePolicy policy = new WindowClosePolicy(() -> now[0]);
|
||||
assertTrue(policy.begin(true, 0f));
|
||||
assertFalse(policy.begin(true, 0f));
|
||||
assertFalse(policy.begin(true, 0f));
|
||||
policy.finish();
|
||||
// A later, distinct opening needs no previous drive or arming timer.
|
||||
now[0] += WindowClosePolicy.COOLDOWN_MS;
|
||||
assertTrue(policy.begin(true, 0f));
|
||||
}
|
||||
|
||||
@Test public void reopeningTheDoorWithinTheCooldownDoesNotCloseAgain() {
|
||||
long[] now = {0L};
|
||||
WindowClosePolicy policy = new WindowClosePolicy(() -> now[0]);
|
||||
assertTrue(policy.begin(true, 0f));
|
||||
now[0] += 5_000L;
|
||||
policy.finish();
|
||||
// Reaching back into the car reopens the door; the glass must not run a second pulse.
|
||||
now[0] += WindowClosePolicy.COOLDOWN_MS - 1;
|
||||
assertFalse(policy.begin(true, 0f));
|
||||
now[0] += 1;
|
||||
assertTrue(policy.begin(true, 0f));
|
||||
}
|
||||
|
||||
@Test public void theCooldownIsMeasuredFromTheEndOfTheCloseNotItsStart() {
|
||||
long[] now = {0L};
|
||||
WindowClosePolicy policy = new WindowClosePolicy(() -> now[0]);
|
||||
assertTrue(policy.begin(true, 0f));
|
||||
now[0] += WindowClosePulse.DURATION_MS;
|
||||
policy.finish();
|
||||
now[0] += WindowClosePolicy.COOLDOWN_MS - WindowClosePulse.DURATION_MS;
|
||||
assertFalse(policy.begin(true, 0f));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import org.junit.Test;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
public class WindowClosePulseTest {
|
||||
private final List<String> writes = new ArrayList<>();
|
||||
private long now;
|
||||
|
||||
private boolean send(int area, int command) {
|
||||
writes.add(area + ":" + command);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void assertStoppedAll() {
|
||||
assertEquals(Arrays.asList("0:0", "1:0", "2:0", "3:0"),
|
||||
writes.subList(writes.size() - 4, writes.size()));
|
||||
}
|
||||
|
||||
@Test public void comfortPulseTargetsAllFourAndEndsAtFiveSeconds() {
|
||||
assertTrue(WindowClosePulse.run(() -> true, this::send, () -> now, ms -> now += ms));
|
||||
assertEquals(5_000L, now);
|
||||
assertEquals(Arrays.asList("0:1", "1:1", "2:1", "3:1"), writes.subList(0, 4));
|
||||
// Forty-two UP batches at 120 ms intervals, followed by a single STOP batch.
|
||||
assertEquals(42 * 4 + 4, writes.size());
|
||||
assertStoppedAll();
|
||||
}
|
||||
|
||||
@Test public void refusedStartWritesNothing() {
|
||||
assertFalse(WindowClosePulse.run(() -> false, this::send, () -> now, ms -> now += ms));
|
||||
assertTrue(writes.isEmpty());
|
||||
}
|
||||
|
||||
@Test public void leavingParkMovingOrDisablingStopsAtNextPulse() {
|
||||
assertFalse(WindowClosePulse.run(() -> now < 120L, this::send, () -> now, ms -> now += ms));
|
||||
assertEquals(8, writes.size());
|
||||
assertStoppedAll();
|
||||
}
|
||||
|
||||
@Test public void interruptionReleasesEveryWindowAndPreservesInterrupt() {
|
||||
try {
|
||||
assertFalse(WindowClosePulse.run(() -> true, this::send, () -> now,
|
||||
ms -> { throw new InterruptedException(); }));
|
||||
assertTrue(Thread.currentThread().isInterrupted());
|
||||
assertStoppedAll();
|
||||
} finally {
|
||||
Thread.interrupted();
|
||||
}
|
||||
}
|
||||
|
||||
@Test public void oneFailedCommandEndsPulseAndReleasesEveryWindow() {
|
||||
assertFalse(WindowClosePulse.run(() -> true, (area, command) -> {
|
||||
send(area, command);
|
||||
return area != 2 || command == 0;
|
||||
}, () -> now, ms -> now += ms));
|
||||
assertEquals(0L, now);
|
||||
assertStoppedAll();
|
||||
}
|
||||
|
||||
@Test public void unexpectedFailureStillReleasesEveryWindow() {
|
||||
try {
|
||||
WindowClosePulse.run(() -> true, (area, command) -> {
|
||||
send(area, command);
|
||||
if (area == 1 && command == 1) throw new IllegalStateException();
|
||||
return true;
|
||||
}, () -> now, ms -> now += ms);
|
||||
fail("Expected sender failure");
|
||||
} catch (IllegalStateException expected) {
|
||||
assertStoppedAll();
|
||||
}
|
||||
}
|
||||
|
||||
@Test public void aFailedStopDoesNotPreventOtherStops() {
|
||||
assertFalse(WindowClosePulse.run(() -> true, (area, command) -> {
|
||||
send(area, command);
|
||||
if (area == 0 && command == 0) throw new IllegalStateException();
|
||||
return true;
|
||||
}, () -> now, ms -> now += ms));
|
||||
assertStoppedAll();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user