Skip to content

Commit 63780c1

Browse files
committed
merge: integrate physical button and contact actions
2 parents b37824e + 4615bf5 commit 63780c1

52 files changed

Lines changed: 1682 additions & 326 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/security/permission-allowlist.txt‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ android.permission.POST_NOTIFICATIONS
2929
android.permission.BLUETOOTH
3030
android.permission.BLUETOOTH_CONNECT
3131

32+
# --- Contacts ---
33+
# Read-only access, requested only while configuring "Call a contact". The OEM PBAP client
34+
# publishes the paired phone's explicitly shared phone book through ContactsContract.
35+
android.permission.READ_CONTACTS
36+
37+
# Signature permission defined by MG4Tasker to reject forged OEM hard-key broadcasts.
38+
com.mg4.tasker.permission.RECEIVE_HARDKEY
39+
3240
# --- Optional MG4Control bridge (profile action only) ---
3341
# Signature-level, obtained solely because both APKs share the platform key. Used only to
3442
# apply an MG4Control profile when MG4Control is installed.

‎AGENTS.md‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,18 @@ Adding a vehicle entry without `@SupportedOn` fails `FirmwareSupportTest`.
4545

4646
`TaskerVehicleService` gets every ignition transition from one MG4Hardware listener. The
4747
switch-off trigger reads the other end of that same stream — no second listener, no bind, no
48-
poll. Keep it that way: before adding a trigger, check whether something already delivers the
49-
event. Repeats are filtered in the service (`lastTrigger`), because the bus re-asserts states
50-
and a rule that locks the doors must not run four times.
48+
poll. Physical buttons are conditions (never a `RuleTrigger`): a rule containing one is
49+
addressed by that event and excluded from ignition cycles. MG4Hardware owns the OEM keycode
50+
catalogue and short/long-press state machine; the app only receives the broadcast and feeds
51+
its payload to that decoder. The receiver requires the
52+
signature sender permission because that action is otherwise forgeable. Long press fires on
53+
the OEM long event; its following release is suppressed instead of also firing short press.
54+
Before adding a trigger, check whether something already delivers the event. Ignition repeats
55+
are filtered in the service (`lastTrigger`), because the bus re-asserts states and a rule that
56+
locks the doors must not run four times.
57+
58+
The manual test addresses only the selected rule (by id). It ignores that rule's trigger,
59+
but must never evaluate the rest of the rule store.
5160

5261
`Rule.trigger` is **nullable on purpose**. Gson builds instances without calling the
5362
constructor, so a Kotlin default never applies to a key absent from stored JSON; a non-null
@@ -84,17 +93,16 @@ assume universal), expose it through `TaskerBridgeService`, then add the catalog
8493
here. Property ids and per-generation routing live in MG4Control; do not duplicate them.
8594

8695
Climate, charging, radio and hands-free calls do **not** go through property ids. They use
87-
the SAIC vendor binder services the car's own apps use (`com.mg4.hardware.saic`), with every
88-
descriptor and transaction code read off the decompiled head-unit APKs in `apks/` — cite the
89-
APK and class when you add one. That is what made climate writes honest: the earlier AOSP
90-
climate ids were standard ids the R69 sources name but no MG4 confirmed, so writing them
96+
the SAIC vendor binder services the car's own apps use (`com.mg4.hardware.saic`). Keep every
97+
descriptor and transaction code documented as a project implementation detail. The earlier AOSP
98+
climate ids were standard ids that no MG4 confirmed, so writing them
9199
would have been a guess.
92100

93101
Window writes remain absent — no vendor service exposes them. The AOSP climate reads stay as
94102
a fallback where the vendor service does not answer.
95103

96104
Whether a car has a given vendor service is a **bind, not a table**: the `@SupportedOn` set
97-
records the generations we have evidence for (SWI68/SWI165, the `apks/` platform), and the
105+
records the generations we have evidence for (SWI68/SWI165, the R69 platform), and the
98106
Diagnostic tab's *Vehicle services* row is what widens it.
99107

100108
## Reference patterns (inherited from MG4Control / MG4ABRPUploader)

‎CHANGELOG.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,22 @@ All notable changes to this project are documented here. Format follows
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- **Wait** action (1 to 60 seconds), and reordering of a rule's actions with the arrows
12+
on each row. Actions run one after the other in the order shown, so a wait placed
13+
between two of them lets the first write land before the second is asked for
14+
(climate on, then fan level). A rule made only of waits is refused at save time.
15+
- Configuration tab. The speed threshold, the language and the rules file
16+
(export / import) moved there from the rules pane; the Rules tab is now the rule
17+
list and nothing else.
18+
919
### Changed
1020

21+
- Dialog text raised to the suite's sizes (20sp body, 26sp title) instead of
22+
Material's phone defaults — the test result, the language list and every
23+
confirmation. The "Remove" button of a condition or an action and the weekday chips
24+
were also below the 16sp floor.
1125
- Adopted the MG4Suite design system. Colour, spacing, type and component styles now
1226
come from shared tokens (`values/colors.xml`, `values/dimens.xml`,
1327
`values/styles_mg4.xml`), generated by `tools/sync-tokens.mjs` and specified in

‎MG4Hardware‎

‎README.md‎

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
<p align="center"><img src="docs/logo.svg" width="440" alt="MG4Tasker"></p>
44

5-
[![Tests](../../actions/workflows/tests.yml/badge.svg)](../../actions/workflows/tests.yml)
6-
[![Security](../../actions/workflows/security.yml/badge.svg)](../../actions/workflows/security.yml)
7-
[![Release](../../actions/workflows/release.yml/badge.svg)](../../actions/workflows/release.yml)
5+
[![Tests](https://github.com/malys/MG4Tasker/actions/workflows/tests.yml/badge.svg)](https://github.com/malys/MG4Tasker/actions/workflows/tests.yml)
6+
[![Security](https://github.com/malys/MG4Tasker/actions/workflows/security.yml/badge.svg)](https://github.com/malys/MG4Tasker/actions/workflows/security.yml)
7+
[![Unstable](https://github.com/malys/MG4Tasker/actions/workflows/unstable.yml/badge.svg)](https://github.com/malys/MG4Tasker/actions/workflows/unstable.yml)
8+
[![Release](https://img.shields.io/github/v/release/malys/MG4Tasker?include_prereleases&amp;sort=semver)](https://github.com/malys/MG4Tasker/releases)
89
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
910

1011
> ⚠️ **This app changes vehicle settings automatically and runs on a car.** Read
@@ -79,15 +80,25 @@ MG4Tasker runs its **own** persistent service: it initialises MG4Hardware, liste
7980
ignition directly (no dependency on MG4Control), reads the vehicle and applies the rules'
8081
actions through MG4Hardware. Started at boot and on app open.
8182

82-
**Triggers: vehicle start and vehicle switch-off.** Each rule chooses one; a rule written
83-
before triggers existed runs at start, as it always did. Switching off costs nothing extra —
83+
**Triggers: vehicle start and vehicle switch-off.** Physical buttons are conditions, not a
84+
third “Runs at” choice: adding one makes that rule event-driven and hides the ignition choice.
85+
Button rules can choose short or long press on phone, center, directional/OK, source,
86+
volume up/down, next/previous, mute, left star, right star, or assistant. Decoding and press-state handling live in the
87+
shared MG4Hardware library.
88+
A long press fires once and suppresses the following short-release event. The known right-button
89+
codes (`286` and `18`) are both accepted for firmware compatibility; the assistant uses the
90+
OEM voice keycode `287`. The SAIC broadcast is
91+
accepted only from a signature-authorized sender because the action itself is not protected.
92+
93+
Each ignition rule chooses one trigger; a rule written before triggers existed runs at start,
94+
as it always did. Switching off costs nothing extra —
8495
the service already received every ignition transition and simply stopped reading at RUN, so
8596
there is no second listener, no extra bind and no polling. At switch-off the car is powering
8697
down: settings that persist (charge limit, locks, windows) land, and anything the vehicle
8798
drops with the ignition is reported by the history rather than assumed.
8899

89-
The "Test now" button replays the whole path on demand and addresses **every** rule whatever
90-
it is wired to — that is what testing means. A master switch on the Rules screen disables
100+
The "Test now" button replays the whole path on demand for the **selected rule only**, while
101+
ignoring its ignition trigger. A master switch on the Rules screen disables
91102
automation without deleting rules.
92103

93104
---
@@ -129,7 +140,7 @@ an action is refused, the history gives the exact reason instead of staying sile
129140

130141
### Raising the threshold
131142

132-
The Rules screen lets you move the limit from 0 to 50 km/h. **0 — apply only when stopped —
143+
The Configuration tab lets you move the limit from 0 to 50 km/h. **0 — apply only when stopped —
133144
is the default**, and the value that needs no justification: a car creeping in a car park
134145
reads as moving, and a rule refused there is the most common false negative.
135146

@@ -169,6 +180,18 @@ Gate refusals are now kept and re-attempted at the first standstill. Three limit
169180

170181
- **Only gate refusals.** "Moving" and "speed unreadable" mean *not now*. An unsupported
171182
action or a failed write is not retried — waiting adds no missing firmware feature.
183+
184+
Transient action failures are different: each failed action is retried independently up to
185+
three times with exponential backoff (250 ms, then 500 ms). Actions that already succeeded
186+
are never replayed.
187+
188+
A rule's actions run **one after the other, in the order shown in the editor**, and each one
189+
is finished before the next starts. That order is part of the rule: the arrows on every
190+
action row move it up or down. Where one write must land before the next is asked for —
191+
switching the climate on, then setting the fan level — the **Wait** action (1 to 60 seconds)
192+
holds the sequence in between. It touches nothing in the car, and appears in the history at
193+
its place in the sequence. A rule made only of waits is refused at save time: it would hold
194+
the cycle and change nothing.
172195
- **15 minutes, and never across an ignition cycle.** A rule that fired for this drive
173196
belongs to this drive. At switch-off the queue is dropped, not applied: the car is stopped
174197
then, which is exactly what makes it the wrong moment to change the setting the next
@@ -200,7 +223,7 @@ temperature, A/C, AUTO, recirculation, fan level, front and rear defrosters), **
200223
(charge limit, allow charging, scheduled charging and its window, battery pre-heating),
201224
**audio** (volume, the fine controls, play the radio), **driver assistance**, and **system**
202225
(launch an app, show a message, speak through the head unit's text-to-speech engine,
203-
navigate to a destination, call a number). ADAS state — AEB, ELK, ACC/TJA, TSR, overspeed and
226+
navigate to a destination, call a number, wait). ADAS state — AEB, ELK, ACC/TJA, TSR, overspeed and
204227
so on — is fully covered as gated actions.
205228

206229
**Near a place** compares the car's last known fix with a point stored in the rule and a
@@ -224,7 +247,7 @@ generations it works on, derived from MG4Hardware's `FirmwareInfo` and per-gener
224247
routing. That annotation is the single source of truth for three things:
225248

226249
1. **Self-documenting source** — the support set sits next to the entry.
227-
2. **The compatibility matrix** — [docs/firmware-matrix.md](docs/firmware-matrix.md) is
250+
2. **The compatibility matrix** — [MG4Hardware/docs/firmware-matrix.md](MG4Hardware/docs/firmware-matrix.md) is
228251
*generated* from the annotations by `FirmwareMatrix`, checked by a unit test. It is
229252
never edited by hand.
230253
3. **The runtime filter** — the editor offers only the entries supported on the connected
@@ -245,8 +268,7 @@ Highlights (see the generated matrix for the full grid):
245268
- **Lane-departure sound + vibration** — SWI132 only.
246269
- **Fine audio** (Bose, balance, fader, tone, 3D, speed volume) — SWI69, SWI131, SWI132: the
247270
SAIC `caradapter` audio helper these go through is bound on the A9 platform only.
248-
- **Climate, charging, radio and calls** — SWI68 and SWI165, the generations the decompiled
249-
head-unit APKs in `apks/` come from. They run on SAIC vendor services rather than on
271+
- **Climate, charging, radio and calls** — SWI68 and SWI165. They run on SAIC vendor services rather than on
250272
property ids, and whether a given car answers is a bind, not a table: the Diagnostic tab's
251273
*Vehicle services* row reports which of the four responded, and the matrix widens once a
252274
car outside that set reports one.
@@ -255,12 +277,12 @@ Highlights (see the generated matrix for the full grid):
255277

256278
## Vehicle services: climate, charging, radio, calls
257279
Climate and charging used to be read-only and unverified — standard AOSP property ids the
258-
R69 sources name, that no MG4 confirmed. Writing one of those would have been a guess, so
280+
that no MG4 confirmed. Writing one of those would have been a guess, so
259281
there were no write actions.
260282

261283
They now go through the **SAIC vendor services** instead: the same binder interfaces the
262-
car's own HVAC, charging, radio and hands-free apps use, read off the decompiled head-unit
263-
APKs in [`apks/`](../apks). One bound service (`com.saicmotor.service.vehicle`) exposes a hub
284+
car's own HVAC, charging, radio and hands-free apps use. One bound service
285+
(`com.saicmotor.service.vehicle`) exposes a hub
264286
that hands out `aircondition`, `vehiclecharging`, `vehiclecontrol` and `vehiclecondition`;
265287
radio and telephony are their own services. What those calls do is not in doubt — they are what the car does to itself.
266288

@@ -277,6 +299,9 @@ That buys four things the property ids could not:
277299
DAB is out: `tuneDab` takes a service and ensemble id, not a frequency.
278300
- **Calls** — placed by the car's hands-free stack on the paired phone. The head unit has no
279301
SIM and no dialer, so `ACTION_CALL` would find nothing to handle it.
302+
A rule can store a typed number or select a name/number from the phone book explicitly
303+
shared over Bluetooth PBAP. Contact access is requested only while configuring that action;
304+
execution uses the stored number and does not need the phone book to remain available.
280305

281306
Climate **conditions** now read from the same service where it answers, falling back to the
282307
AOSP ids elsewhere — so what a rule tests and what an action writes are the same signal.
@@ -285,6 +310,8 @@ Window **writes** stay absent: no vendor service exposes them.
285310

286311
Navigation is the exception with no vendor API — `NAVIGATE_TO` uses the standard `geo:`
287312
intent, then `google.navigation:`, and reports honestly when the head unit has neither.
313+
Its destination can be either `latitude,longitude` or a place name/address. HTTPS webhooks
314+
are available as GET and POST actions; POST accepts an optional JSON body.
288315

289316
---
290317

@@ -323,7 +350,7 @@ reach it.
323350
### Testing a rule
324351

325352
**Test now** on the Rules screen runs one full cycle — the exact ignition path, so a passing
326-
test proves the real thing — and then **shows what it decided**: one line per rule with its
353+
test proves the real thing — and then **shows what it decided** for that rule: its
327354
outcome (applied, conditions not met, cannot be evaluated, failed), the per-action verdict
328355
behind a failure, and the name of any signal that could not be read. It used to say "test
329356
running" and stop there, which answers nothing for a button whose whole purpose is to answer
@@ -397,7 +424,7 @@ unparsed. Import stays the deliberate way to replace the set.
397424
---
398425

399426
## Import and export (USB stick)
400-
**Export** and **Import** on the Rules screen move the whole rule set to and from a JSON
427+
**Export** and **Import**, on the Configuration tab, move the whole rule set to and from a JSON
401428
file — a backup before a reinstall, or the same rules on a second car, without retyping
402429
anything on the on-screen keyboard.
403430

@@ -521,7 +548,7 @@ app/src/main/java/com/mg4/tasker/
521548
model/ Rule, Condition, Action, catalogues, @SupportedOn, FirmwareMatrix
522549
engine/ condition and rule evaluation — no Android dependency
523550
store/ JSON persistence of rules and history, rules file format, storage browsing
524-
ui/ list, generic editor, history, diagnostic, console, file browser
551+
ui/ list, generic editor, configuration, history, diagnostic, console, file browser
525552
service/ run cycle (foreground service)
526553
receiver/ ignition wake-up
527554
debug/ AppLogger ring buffer, CrashLogger, diagnostic verdicts + report export

‎app/src/main/AndroidManifest.xml‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,18 @@
2424
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
2525
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
2626

27+
<!-- The SAIC hard-key report is otherwise forgeable by any app. Requiring this from
28+
the sender admits the platform-signed OEM hard-key service only. -->
29+
<permission
30+
android:name="com.mg4.tasker.permission.RECEIVE_HARDKEY"
31+
android:protectionLevel="signature" />
32+
2733
<!-- Bluetooth: paired list for the editor, and live connection tracking for rules. -->
2834
<uses-permission android:name="android.permission.BLUETOOTH" />
2935
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
36+
<!-- Requested only when the user chooses "Call a contact". The OEM PBAP client
37+
publishes the paired phone's downloaded phone book through ContactsContract. -->
38+
<uses-permission android:name="android.permission.READ_CONTACTS" />
3039

3140
<!-- Position, for the "near a place" condition only. Last known fix, never a live
3241
request, never sent anywhere: it is compared to a point stored in the rule and the
@@ -57,6 +66,14 @@
5766
<intent>
5867
<action android:name="android.intent.action.TTS_SERVICE" />
5968
</intent>
69+
<intent>
70+
<action android:name="android.intent.action.VIEW" />
71+
<data android:scheme="geo" />
72+
</intent>
73+
<intent>
74+
<action android:name="android.intent.action.VIEW" />
75+
<data android:scheme="google.navigation" />
76+
</intent>
6077
</queries>
6178

6279
<application

‎app/src/main/java/com/mg4/tasker/MG4TaskerApp.kt‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ class MG4TaskerApp : Application() {
2424
// filled from a stored list instead of reflecting the catalogue on every open. Off
2525
// the main thread: MG4Hardware init and system-property reads block.
2626
kotlin.concurrent.thread(name = "mg4-tasker-support-check") {
27+
com.mg4.hardware.FirmwareInfo.initWithContext(this)
28+
val firmware = com.mg4.hardware.FirmwareInfo.getGeneration()
29+
val exact = com.mg4.hardware.FirmwareInfo.getDetectedString()
30+
AppLogger.i(
31+
"App",
32+
"MG4Tasker ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) — " +
33+
"firmware=$firmware exact=$exact"
34+
)
2735
runCatching { com.mg4.tasker.store.SupportChecker.ensureChecked(this) }
2836
}
2937
}

‎app/src/main/java/com/mg4/tasker/debug/Diagnostics.kt‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ object Diagnostics {
211211
if (caps.chargingService) gateReason(type, caps) else Reason.NO_VENDOR_SERVICE
212212
ActionType.PLAY_RADIO ->
213213
if (caps.radioService) Reason.NONE else Reason.NO_VENDOR_SERVICE
214-
ActionType.CALL_NUMBER ->
214+
ActionType.CALL_NUMBER, ActionType.CALL_CONTACT ->
215215
if (caps.phoneService) Reason.NONE else Reason.NO_VENDOR_SERVICE
216216

217217
// Everything else is a direct MG4Hardware write.
@@ -250,6 +250,7 @@ object Diagnostics {
250250
text = when (type) {
251251
ConditionType.BT_DEVICE_CONNECTED -> PROBE_MAC
252252
ConditionType.LOCATION_WITHIN -> PROBE_POINT
253+
ConditionType.DATE -> java.time.LocalDate.now().toString()
253254
else -> ""
254255
},
255256
days = ALL_DAYS
@@ -265,6 +266,7 @@ object Diagnostics {
265266
private fun rawValue(type: ConditionType, snapshot: Snapshot): Any? = when (type) {
266267
ConditionType.TIME_OF_DAY -> snapshot.minutesOfDay
267268
ConditionType.DAY_OF_WEEK -> snapshot.dayOfWeek
269+
ConditionType.DATE -> snapshot.localDate
268270
ConditionType.ANY_BT_CONNECTED -> snapshot.btMacs.isNotEmpty()
269271
ConditionType.BT_DEVICE_CONNECTED -> snapshot.btMacs.sorted().joinToString(", ")
270272
// Where the car thinks it is, which is the only way to tell a radius that is too

0 commit comments

Comments
 (0)