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
@@ -15,9 +15,11 @@ Fork of Leon Kernan's `ABRP_Uploader`. See `LICENSE.md` — the licence status i
|
|||||||
|
|
||||||
## Non-negotiables
|
## Non-negotiables
|
||||||
|
|
||||||
1. **Read-only towards the car.** This app must never write a vehicle property. The
|
1. **Telemetry is read-only.** The explicitly requested SWI69 WinClose integration is
|
||||||
sibling EVProfile project writes settings and gates every write on 0 km/h; this one has
|
the only vehicle-write exception: an opt-in service closes the four windows on the
|
||||||
no business writing at all. A write path is a bug.
|
WinClose door callback, requiring P and readable 0 km/h before and during the pulse.
|
||||||
|
It uses WinClose's CarState/VSM interfaces and the shared VehicleWriteGate; no other
|
||||||
|
vehicle settings may be written. Always send STOP when an active pulse exits.
|
||||||
2. **A failed read is not a zero.** Getters return `Integer`/`Float`/`Boolean` and `null`
|
2. **A failed read is not a zero.** Getters return `Integer`/`Float`/`Boolean` and `null`
|
||||||
means "could not read". Null fields are omitted from the payload. Sending 0 for SOC
|
means "could not read". Null fields are omitted from the payload. Sending 0 for SOC
|
||||||
tells ABRP the battery is empty and wrecks a live route plan.
|
tells ABRP the battery is empty and wrecks a live route plan.
|
||||||
@@ -41,6 +43,9 @@ UploadLog last 20 attempts + derived service state — pure, unit-test
|
|||||||
SecurePrefs credentials in EncryptedSharedPreferences, migrates plaintext
|
SecurePrefs credentials in EncryptedSharedPreferences, migrates plaintext
|
||||||
AbrpApi HTTP; credentials in the POST body, never the query string
|
AbrpApi HTTP; credentials in the POST body, never the query string
|
||||||
MainActivity configuration, service state, upload log
|
MainActivity configuration, service state, upload log
|
||||||
|
WindowCloseService independent, opt-in foreground service; no ABRP/GPS dependency
|
||||||
|
WinCloseHardware SWI69 adapter derived from the sibling winclose/WindowHardware.kt
|
||||||
|
WindowClosePulse bounded UP/STOP sequence; pure, unit-tested
|
||||||
```
|
```
|
||||||
|
|
||||||
**Put logic in the pure classes.** Anything Android-free can be unit-tested on the JVM;
|
**Put logic in the pure classes.** Anything Android-free can be unit-tested on the JVM;
|
||||||
|
|||||||
+3
-2
@@ -4,8 +4,9 @@ This app runs on a car. That single fact drives everything below.
|
|||||||
|
|
||||||
## Ground rules
|
## Ground rules
|
||||||
|
|
||||||
1. **The app never writes to the vehicle.** It is read-only towards the car. A patch that
|
1. **Telemetry never writes to the vehicle.** The opt-in SWI69 WinClose integration is
|
||||||
introduces a write will be rejected regardless of how useful the feature is.
|
the only write exception: four-window closing, gated on P and readable 0 km/h,
|
||||||
|
with a bounded pulse and STOP on completion, interruption or failure.
|
||||||
2. **A property that cannot be read is omitted, never defaulted.** Sending 0 for an
|
2. **A property that cannot be read is omitted, never defaulted.** Sending 0 for an
|
||||||
unreadable battery level tells ABRP the car is empty and corrupts the user's route
|
unreadable battery level tells ABRP the car is empty and corrupts the user's route
|
||||||
plan. Nullable in, absent from the payload.
|
plan. Nullable in, absent from the payload.
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ otherwise, arising from, out of or in connection with the software or its use.
|
|||||||
data once sent.
|
data once sent.
|
||||||
- Reading vehicle properties uses **undocumented runtime interfaces**. They can change or
|
- Reading vehicle properties uses **undocumented runtime interfaces**. They can change or
|
||||||
disappear with any firmware update.
|
disappear with any firmware update.
|
||||||
|
- Optional WinClose automation sends window-closing commands. Sensorless Comfort windows
|
||||||
|
cannot confirm completion; command timing and motor/anti-pinch behavior have not been
|
||||||
|
verified on this vehicle. Check physical operation while parked before relying on it.
|
||||||
|
|
||||||
## Not affiliated
|
## Not affiliated
|
||||||
|
|
||||||
|
|||||||
@@ -78,8 +78,9 @@ same rule in time as well as in value: a GPS fix older than `max(5 min, 3 x your
|
|||||||
interval)` is dropped rather than resent, so a stale fix cannot report the car back at the
|
interval)` is dropped rather than resent, so a stale fix cannot report the car back at the
|
||||||
place it set off from.
|
place it set off from.
|
||||||
|
|
||||||
**The app never writes to the car.** It only reads. Any write path would be a bug; see
|
Telemetry only reads the car. An optional SWI69 WinClose integration can close the four
|
||||||
[`SECURITY.md`](SECURITY.md).
|
windows while parked; this is the only vehicle-write feature. See configuration below
|
||||||
|
and [`SECURITY.md`](SECURITY.md).
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
The MG4 head unit hides Settings and APK install. The known route in:
|
The MG4 head unit hides Settings and APK install. The known route in:
|
||||||
@@ -134,6 +135,39 @@ Typing a long API key and token on the car's on-screen keyboard is painful. Inst
|
|||||||
put them in a text file and tap **Import file** — see [Config file](#config-file) below.
|
put them in a text file and tap **Import file** — see [Config file](#config-file) below.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
On the **Upload Service** page, **Kapı açılınca camları kapat** enables the WinClose
|
||||||
|
integration independently of ABRP. It is off by default and restarts with the head unit
|
||||||
|
once enabled; no ABRP token, GPS permission, speed threshold or driving timer is needed.
|
||||||
|
|
||||||
|
The integration is adapted from the sibling WinClose `WindowHardware.kt`: SWI69
|
||||||
|
`CarStateClient` door callback (transaction 6, value 0) triggers the VSM's
|
||||||
|
`setVehicleWindowStatus` on all four areas. WinClose describes this as the driver-door
|
||||||
|
signal; passenger-door coverage has not been established. No alternative door-property
|
||||||
|
source is used. The car must be in P with readable speed exactly 0 km/h. Comfort windows
|
||||||
|
receive UP (1) every 120 ms for five seconds, then STOP (0). P and speed are checked
|
||||||
|
during the pulse too. Cancellation, a failed command, or leaving P/stationary state ends
|
||||||
|
the pulse and attempts STOP on all windows. Duplicate door events and openings during
|
||||||
|
an active pulse do not extend it, and WinClose's 60-second cooldown means reaching back
|
||||||
|
into the car does not run a second pulse. A partial wake lock is held across the pulse,
|
||||||
|
because the close fires exactly as the head unit is heading for suspend.
|
||||||
|
|
||||||
|
The feature is SWI69-only and needs the platform signature: the service stops itself on
|
||||||
|
any other known generation, and the switch is disabled when the process does not hold
|
||||||
|
system uid (only the unstable flavor declares `android.uid.system`).
|
||||||
|
|
||||||
|
**No cancellation is possible on a Comfort trim.** Its window motors carry no position
|
||||||
|
sensor, so nothing can observe you working the switch, and there is no way to abort a
|
||||||
|
pulse once it starts. Verify the motors' pinch behaviour on a parked car before relying
|
||||||
|
on this — the windows travel while the driver's door is open.
|
||||||
|
|
||||||
|
No position sensor is assumed: a completed command is not confirmation that the windows
|
||||||
|
closed. The existing WinClose pulse duration is the starting value, not a manufacturer
|
||||||
|
guarantee. Vehicle testing of timing, permissions and motor behavior is still required.
|
||||||
|
The APK keeps its existing application IDs and signing configuration (the unstable flavor
|
||||||
|
already uses `android.uid.system`). WinClose's source license is unspecified; see its
|
||||||
|
README before redistribution.
|
||||||
|
|
||||||
| Setting | Default | Notes |
|
| Setting | Default | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Upload frequency | 60 s | 15 / 30 / 60 / 120 / 300 s. Applies while driving |
|
| Upload frequency | 60 s | 15 / 30 / 60 / 120 / 300 s. Applies while driving |
|
||||||
|
|||||||
+6
-2
@@ -15,8 +15,9 @@ was moving. A proof of concept helps; a working exploit is not required.
|
|||||||
|
|
||||||
- Anything that lets another app on the head unit read the stored ABRP credentials.
|
- Anything that lets another app on the head unit read the stored ABRP credentials.
|
||||||
- Anything that gets attacker-controlled code installed through the unstable updater.
|
- Anything that gets attacker-controlled code installed through the unstable updater.
|
||||||
- Anything that makes the app write to the vehicle. **It should never write at all** — this
|
- Any vehicle write outside the explicitly enabled SWI69 WinClose feature, or a window
|
||||||
app is read-only towards the car — so any write path is a bug by definition.
|
close that bypasses P/readable-zero-speed checks, exceeds its five-second pulse, or
|
||||||
|
fails to attempt STOP on every window after interruption or failure.
|
||||||
- Telemetry that leaks credentials into logs, URLs or crash reports.
|
- Telemetry that leaks credentials into logs, URLs or crash reports.
|
||||||
|
|
||||||
## What is not in scope
|
## What is not in scope
|
||||||
@@ -35,3 +36,6 @@ was moving. A proof of concept helps; a working exploit is not required.
|
|||||||
checks fail closed. The stable channel does not contain the updater at all.
|
checks fail closed. The stable channel does not contain the updater at all.
|
||||||
- The VHAL probe helpers, which enumerate the whole vehicle abstraction layer, exist only
|
- The VHAL probe helpers, which enumerate the whole vehicle abstraction layer, exist only
|
||||||
in debug builds.
|
in debug builds.
|
||||||
|
- Window closing uses WinClose's SWI69 CarState callback and VSM commands. Its service is
|
||||||
|
not exported, has its own opt-in switch, and holds no wake lock or alarm. It does not
|
||||||
|
assert that a sensorless window has physically closed or that anti-pinch is available.
|
||||||
|
|||||||
@@ -5,6 +5,12 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<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
|
<!-- Required from API 33 to post the foreground-service notification, which is the
|
||||||
only status UI the service has. Without it the notification is silently dropped. -->
|
only status UI the service has. Without it the notification is silently dropped. -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
@@ -38,6 +44,7 @@
|
|||||||
outside temperature. From API 30 an explicit bind to a package this app does not
|
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. -->
|
declare here simply fails, and ext_temp would go back to being unreportable. -->
|
||||||
<queries>
|
<queries>
|
||||||
|
<package android:name="com.saicmotor.launcher" />
|
||||||
<package android:name="com.saicmotor.mapservice" />
|
<package android:name="com.saicmotor.mapservice" />
|
||||||
</queries>
|
</queries>
|
||||||
|
|
||||||
@@ -65,6 +72,15 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="location" />
|
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
|
<!-- The head unit resumes far more often than it cold-boots, and announces the
|
||||||
resume with QUICKBOOT_POWERON. Listening only for BOOT_COMPLETED is why
|
resume with QUICKBOOT_POWERON. Listening only for BOOT_COMPLETED is why
|
||||||
uploading did not start with the car. MY_PACKAGE_REPLACED covers the other
|
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();
|
String action = intent.getAction();
|
||||||
if (action == null || !START_ACTIONS.contains(action)) return;
|
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);
|
SharedPreferences prefs = context.getSharedPreferences("abrp_prefs", Context.MODE_PRIVATE);
|
||||||
boolean enabled = prefs.getBoolean("service_enabled", false);
|
boolean enabled = prefs.getBoolean("service_enabled", false);
|
||||||
boolean autostart = prefs.getBoolean(UploadSettings.KEY_AUTOSTART, UploadSettings.DEFAULT_AUTOSTART);
|
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.AdapterView;
|
||||||
import android.widget.ArrayAdapter;
|
import android.widget.ArrayAdapter;
|
||||||
import android.widget.Button;
|
import android.widget.Button;
|
||||||
|
import android.widget.CompoundButton;
|
||||||
import android.widget.ImageView;
|
import android.widget.ImageView;
|
||||||
import android.widget.Spinner;
|
import android.widget.Spinner;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
@@ -60,6 +61,10 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
private TextInputEditText tokenInput;
|
private TextInputEditText tokenInput;
|
||||||
private SwitchMaterial serviceSwitch;
|
private SwitchMaterial serviceSwitch;
|
||||||
private SwitchMaterial autostartSwitch;
|
private SwitchMaterial autostartSwitch;
|
||||||
|
private SwitchMaterial windowCloseSwitch;
|
||||||
|
private CompoundButton.OnCheckedChangeListener windowCloseListener;
|
||||||
|
private TextView windowCloseStatus;
|
||||||
|
private TextView windowCloseLog;
|
||||||
private TextView statusText;
|
private TextView statusText;
|
||||||
private Button testButton;
|
private Button testButton;
|
||||||
private View connectionStatusRow;
|
private View connectionStatusRow;
|
||||||
@@ -170,6 +175,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
refreshCallLog();
|
refreshCallLog();
|
||||||
refreshVehicle();
|
refreshVehicle();
|
||||||
refreshWifi();
|
refreshWifi();
|
||||||
|
refreshWindowClose();
|
||||||
uiHandler.postDelayed(this, 2_000L);
|
uiHandler.postDelayed(this, 2_000L);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -210,6 +216,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
|
|
||||||
bindVehiclePane();
|
bindVehiclePane();
|
||||||
bindWifiPane();
|
bindWifiPane();
|
||||||
|
bindWindowClose();
|
||||||
|
|
||||||
apiKeyLayout = abrpPane.findViewById(R.id.api_key_layout);
|
apiKeyLayout = abrpPane.findViewById(R.id.api_key_layout);
|
||||||
apiKeyInput = abrpPane.findViewById(R.id.api_key_input);
|
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
|
@Override
|
||||||
protected void onResume() {
|
protected void onResume() {
|
||||||
super.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:orientation="vertical"
|
||||||
android:padding="@dimen/spacing_lg">
|
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 -->
|
<!-- Service control card -->
|
||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
@@ -1,4 +1,17 @@
|
|||||||
<resources>
|
<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>
|
<string name="app_name">Fatih\'sMG4</string>
|
||||||
|
|
||||||
<!-- Main screen -->
|
<!-- 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