diff --git a/AGENTS.md b/AGENTS.md
index 9d26677..642d400 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -15,9 +15,11 @@ Fork of Leon Kernan's `ABRP_Uploader`. See `LICENSE.md` — the licence status i
## Non-negotiables
-1. **Read-only towards the car.** This app must never write a vehicle property. The
- sibling EVProfile project writes settings and gates every write on 0 km/h; this one has
- no business writing at all. A write path is a bug.
+1. **Telemetry is read-only.** The explicitly requested SWI69 WinClose integration is
+ the only vehicle-write exception: an opt-in service closes the four windows on the
+ 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`
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.
@@ -41,6 +43,9 @@ UploadLog last 20 attempts + derived service state — pure, unit-test
SecurePrefs credentials in EncryptedSharedPreferences, migrates plaintext
AbrpApi HTTP; credentials in the POST body, never the query string
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;
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 05709da..89949b1 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -4,8 +4,9 @@ This app runs on a car. That single fact drives everything below.
## Ground rules
-1. **The app never writes to the vehicle.** It is read-only towards the car. A patch that
- introduces a write will be rejected regardless of how useful the feature is.
+1. **Telemetry never writes to the vehicle.** The opt-in SWI69 WinClose integration 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
unreadable battery level tells ABRP the car is empty and corrupts the user's route
plan. Nullable in, absent from the payload.
diff --git a/DISCLAIMER.md b/DISCLAIMER.md
index e7b5a21..808e557 100644
--- a/DISCLAIMER.md
+++ b/DISCLAIMER.md
@@ -24,6 +24,9 @@ otherwise, arising from, out of or in connection with the software or its use.
data once sent.
- Reading vehicle properties uses **undocumented runtime interfaces**. They can change or
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
diff --git a/README.md b/README.md
index 847deae..b63c1b8 100644
--- a/README.md
+++ b/README.md
@@ -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
place it set off from.
-**The app never writes to the car.** It only reads. Any write path would be a bug; see
-[`SECURITY.md`](SECURITY.md).
+Telemetry only reads the car. An optional SWI69 WinClose integration can close the four
+windows while parked; this is the only vehicle-write feature. See configuration below
+and [`SECURITY.md`](SECURITY.md).
## Install
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.
## 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 |
|---|---|---|
| Upload frequency | 60 s | 15 / 30 / 60 / 120 / 300 s. Applies while driving |
diff --git a/SECURITY.md b/SECURITY.md
index e9e788c..9b4478a 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -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 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
- app is read-only towards the car — so any write path is a bug by definition.
+- Any vehicle write outside the explicitly enabled SWI69 WinClose feature, or a window
+ 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.
## 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.
- The VHAL probe helpers, which enumerate the whole vehicle abstraction layer, exist only
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.
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4155835..3d2bce4 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,12 @@
+
+
+
+
@@ -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. -->
+
@@ -65,6 +72,15 @@
android:exported="false"
android:foregroundServiceType="location" />
+
+
+
+
+ Kapı açılınca camları kapat
+ 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.
+ Cam kapatma kapalı
+ WinClose kapı sinyali bekleniyor
+ Dört cama kapatma komutu gönderiliyor…
+ 5 saniyelik komut tamamlandı. Cam konumu sensörsüz olduğu için doğrulanamıyor.
+ Kapatma durdu veya komut gönderilemedi
+ Cam bağlantısı henüz hazır değil
+ Cam servisi başlatılamadı
+ Bu cam entegrasyonu yalnızca SWI69 için etkin
+ Bu sürüm araca yazamıyor (sistem imzası gerekli)
+ P ve 0 km/sa: kapı açılınca dört camı kapat
+ Kapat
Fatih\'sMG4
diff --git a/app/src/test/java/com/evsuite/abrp/WinCloseDoorSignalTest.java b/app/src/test/java/com/evsuite/abrp/WinCloseDoorSignalTest.java
new file mode 100644
index 0000000..b5850dc
--- /dev/null
+++ b/app/src/test/java/com/evsuite/abrp/WinCloseDoorSignalTest.java
@@ -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));
+ }
+}
diff --git a/app/src/test/java/com/evsuite/abrp/WindowClosePolicyTest.java b/app/src/test/java/com/evsuite/abrp/WindowClosePolicyTest.java
new file mode 100644
index 0000000..95e0c48
--- /dev/null
+++ b/app/src/test/java/com/evsuite/abrp/WindowClosePolicyTest.java
@@ -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));
+ }
+}
diff --git a/app/src/test/java/com/evsuite/abrp/WindowClosePulseTest.java b/app/src/test/java/com/evsuite/abrp/WindowClosePulseTest.java
new file mode 100644
index 0000000..805df03
--- /dev/null
+++ b/app/src/test/java/com/evsuite/abrp/WindowClosePulseTest.java
@@ -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 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();
+ }
+}