Skip to content

Commit 0dcbb74

Browse files
malysclaude
andcommitted
chore: bump MG4Hardware submodule, land expert-mode UX and BT fixes
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 8640d30 commit 0dcbb74

24 files changed

Lines changed: 630 additions & 103 deletions

‎app/src/main/AndroidManifest.xml‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,14 @@
6262
<action android:name="android.intent.action.MAIN" />
6363
<category android:name="android.intent.category.LAUNCHER" />
6464
</intent>
65+
<!-- Every MG4Control channel: the offline and unstable builds carry a suffixed
66+
application id and are the same app. Declaring only the stable id made
67+
MG4Control invisible on a car that had it, and the profile actions vanished
68+
from the rule editor with it. -->
6569
<package android:name="com.mg4.control" />
70+
<package android:name="com.mg4.control.offline" />
71+
<package android:name="com.mg4.control.unstable" />
72+
<package android:name="com.mg4.control.offline.unstable" />
6673
<!-- API 30+: without this the TTS engine is invisible and the "speak" action
6774
would report "no speech engine" on a car that has one installed. -->
6875
<intent>
@@ -76,6 +83,13 @@
7683
<action android:name="android.intent.action.VIEW" />
7784
<data android:scheme="google.navigation" />
7885
</intent>
86+
<!-- The head unit's own map apps. They publish no geo: filter, so the intent
87+
declarations above never see them and "Open map" resolved to nothing. Names
88+
taken from the vendor launcher and its navigation widget. -->
89+
<package android:name="com.saicmotor.navigation" />
90+
<package android:name="com.telenav.app.arp" />
91+
<package android:name="com.nng.igo.primong" />
92+
<package android:name="com.mmi.navimaps_auto" />
7993
</queries>
8094

8195
<application

‎app/src/main/java/com/mg4/tasker/bridge/BridgeContract.kt‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,23 @@ package com.mg4.tasker.bridge
1212
*/
1313
object BridgeContract {
1414

15-
const val MG4CONTROL_PACKAGE = "com.mg4.control"
15+
/**
16+
* Every application id MG4Control ships under, most "official" first.
17+
*
18+
* The suffixes come from MG4Control's own flavors — `.offline` for the build with no
19+
* network code, `.unstable` for pre-releases — and they install side by side. Looking
20+
* only for the stable id reported "MG4Control not installed" on a car that had it, which
21+
* removed the profile actions and the profile picker from the rule editor entirely.
22+
*
23+
* The service class name does not carry the suffix: only the application id does.
24+
*/
25+
val MG4CONTROL_PACKAGES = listOf(
26+
"com.mg4.control",
27+
"com.mg4.control.offline",
28+
"com.mg4.control.unstable",
29+
"com.mg4.control.offline.unstable"
30+
)
31+
1632
const val BRIDGE_SERVICE = "com.mg4.control.api.ProfileControlService"
1733
const val PERMISSION_BRIDGE = "com.mg4.control.permission.CONTROL_PROFILES"
1834

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

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import com.mg4.tasker.util.BtDevices
1818
import com.mg4.tasker.util.CarLocation
1919
import com.mg4.tasker.util.Notifier
2020
import com.mg4.tasker.util.SpeechEngines
21+
import com.mg4.tasker.vehicle.BtOnboard
2122
import com.mg4.tasker.vehicle.BtTracker
2223
import com.mg4.tasker.vehicle.DeferredWrites
2324
import com.mg4.tasker.vehicle.VendorServices
@@ -115,9 +116,15 @@ object DiagnosticProbe {
115116
// right after boot is also what gets them connected for the next rule cycle.
116117
VendorServices.connect(appContext)
117118
val fix = CarLocation.lastKnown(appContext)
119+
// The two Bluetooth sets are read here for the same reason as the rest of the
120+
// snapshot: left out, they arrived null and the diagnostic reported the "on board"
121+
// and "hands-free" conditions as blocked on every car, including the ones where a
122+
// rule using them evaluates perfectly well.
118123
val snapshot = VehicleReader.read(
119124
btMacs = BtTracker.snapshot(appContext),
120125
btAvailable = BtDevices.isAvailable(appContext),
126+
btOnboardMacs = BtOnboard.onboard(appContext),
127+
btHandsFreeMacs = BtDevices.activeHandsFree(appContext),
121128
fix = fix
122129
)
123130

@@ -141,17 +148,33 @@ object DiagnosticProbe {
141148
navigationApp = hasNavigationApp(appContext),
142149
)
143150

151+
val conditions = Diagnostics.conditions(snapshot, gen)
152+
val actions = Diagnostics.actions(caps, gen)
153+
// The editor reads this back, so what the Diagnostic tab calls blocked is no longer
154+
// offered in the pickers. Only the verdicts that describe the car are kept — see
155+
// Diagnostics.Reason.describesTheCar.
156+
SupportStore.saveDiagnostic(
157+
appContext,
158+
conditions = structurallyBlocked(conditions),
159+
actions = structurallyBlocked(actions),
160+
)
161+
144162
return Report(
145163
at = System.currentTimeMillis(),
146164
firmwareGen = genName,
147165
appVersion = "${com.mg4.tasker.BuildConfig.VERSION_NAME} (${com.mg4.tasker.BuildConfig.VERSION_CODE})",
148166
capabilities = caps,
149167
environment = environment(appContext, caps, gen?.name, engines, snapshot, fix),
150-
conditions = Diagnostics.conditions(snapshot, gen),
151-
actions = Diagnostics.actions(caps, gen),
168+
conditions = conditions,
169+
actions = actions,
152170
)
153171
}
154172

173+
private fun structurallyBlocked(entries: List<Diagnostics.Entry>): Set<String> =
174+
entries.filter { it.status == Diagnostics.Status.BLOCKED && it.reason.describesTheCar }
175+
.map { it.name }
176+
.toSet()
177+
155178
private fun environment(
156179
context: Context,
157180
caps: Diagnostics.Capabilities,
@@ -237,13 +260,13 @@ object DiagnosticProbe {
237260
}
238261

239262
/** Whether any activity answers a `geo:` intent — what NAVIGATE_TO needs to exist. */
240-
private fun hasNavigationApp(context: Context): Boolean {
241-
val intent = android.content.Intent(
242-
android.content.Intent.ACTION_VIEW,
243-
android.net.Uri.parse("geo:0,0?q=test")
244-
)
245-
return intent.resolveActivity(context.packageManager) != null
246-
}
263+
/**
264+
* A `geo:` resolver is not the question — the MG4's map app has no such filter, and this
265+
* reported "no navigation app" on a car whose home screen shows one. Asks [MapApps],
266+
* which is also what actually opens it.
267+
*/
268+
private fun hasNavigationApp(context: Context): Boolean =
269+
com.mg4.tasker.util.MapApps.isAvailable(context)
247270

248271
private fun gateVerdict(): String =
249272
when (VehicleWriteGate.decideNow()) {

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

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,38 @@ object Diagnostics {
8484
NOT_DRIVEN_YET,
8585
/** The platform would not say which phone the head unit made hands-free. */
8686
NO_HANDSFREE_INFO,
87+
;
88+
89+
/**
90+
* True when this verdict describes **the car**, not the moment it was taken.
91+
*
92+
* The rule editor hides entries the last diagnostic blocked for one of these
93+
* ([com.mg4.tasker.store.SupportStore]) — a diagnostic that says "this head unit has
94+
* no radio service" and an action picker that keeps offering "tune radio" cannot both
95+
* be right, and the picker was the one lying.
96+
*
97+
* Everything else is deliberately absent. A gate refusal, a Bluetooth radio switched
98+
* off, a missing location fix, a vehicle layer still starting: all of those change
99+
* within one drive, and a rule the user could not even write because the car happened
100+
* to be moving during a diagnostic would be a far worse bug than the one being fixed.
101+
*
102+
* [NOTIFICATIONS_OFF] is left out for the same reason from the other side: it is a
103+
* permission the user flips in Settings, and the entry must be there when they come
104+
* back from flipping it.
105+
*/
106+
val describesTheCar: Boolean
107+
get() = this in STRUCTURAL
87108
}
88109

110+
private val STRUCTURAL = setOf(
111+
Reason.UNSUPPORTED_FIRMWARE,
112+
Reason.NO_VENDOR_SERVICE,
113+
Reason.NO_NAVIGATION_APP,
114+
Reason.NO_TTS_ENGINE,
115+
Reason.NO_MG4CONTROL,
116+
Reason.NOT_READABLE
117+
)
118+
89119
/**
90120
* One catalogue entry's verdict.
91121
*
@@ -140,6 +170,14 @@ object Diagnostics {
140170
ConditionType.entries.map { type ->
141171
val outcome = ConditionEvaluator.evaluate(probeOf(type), snapshot)
142172
val hidden = !FirmwareSupport.isSupported(type, gen)
173+
// A steering-wheel button has no resting value: the snapshot carries one only
174+
// while the press is being dispatched. Probing it at rest therefore always found
175+
// nothing, and the report accused the car of not reporting a signal it reports
176+
// perfectly well — a false verdict the rule editor now acts on by hiding the
177+
// entry. There is nothing to read here, and nothing wrong.
178+
if (type == ConditionType.PHYSICAL_BUTTON) {
179+
return@map Entry(type.name, Status.OK, Reason.NONE, hidden = hidden)
180+
}
143181
if (outcome == ConditionOutcome.UNAVAILABLE) {
144182
Entry(
145183
name = type.name,
@@ -229,7 +267,7 @@ object Diagnostics {
229267

230268
// These run entirely inside MG4Tasker. Their configured value can still be invalid,
231269
// but availability of the vehicle layer is irrelevant to whether the action exists.
232-
ActionType.WEBHOOK_GET, ActionType.WEBHOOK_POST, ActionType.DELAY -> Reason.NONE
270+
ActionType.WEBHOOK, ActionType.DELAY -> Reason.NONE
233271

234272
// Everything else is a direct MG4Hardware write.
235273
else -> if (!caps.vehicleLayerReady) Reason.LAYER_NOT_READY else gateReason(type, caps)

‎app/src/main/java/com/mg4/tasker/service/TaskerVehicleService.kt‎

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import com.mg4.hardware.PhysicalButtonEventDecoder
1616
import com.mg4.hardware.VehicleWriteGate
1717
import com.mg4.tasker.model.RuleTrigger
1818
import com.mg4.tasker.store.AppState
19+
import com.mg4.tasker.util.BtDevices
1920
import com.mg4.tasker.util.Notifier
2021
import com.mg4.tasker.vehicle.BtOnboard
2122
import com.mg4.tasker.vehicle.BtTracker
@@ -123,16 +124,29 @@ class TaskerVehicleService : Service() {
123124
AppState.applyWriteThreshold(this)
124125
onboardThread.start()
125126
onboardHandler = Handler(onboardThread.looper)
127+
// The Bluetooth profile proxies answer on a callback, so the first question asked of
128+
// them cannot be answered. Asked for here, they are ready long before the first rule
129+
// cycle — which is the cycle that decides whose phone is on board.
130+
BtDevices.warmUp(applicationContext)
126131
registerBtReceiver()
127132
registerHardkeyReceiver()
128133
registerIgnitionListener()
129134
// START_STICKY means this service can be recreated mid-drive. Waiting for the next
130135
// IGNITION_ON would then leave the whole drive unsampled, and every "phone on board"
131136
// rule unevaluable until the car had been switched off and on again.
132-
if (MG4Hardware.getCurrentIgnitionState() == MG4Hardware.CarIgnitionItem.RUN) {
137+
// IgnitionState, not CarIgnitionItem: getCurrentIgnitionState() answers on the AAOS
138+
// scale, where 2 means OFF — the very value CarIgnitionItem calls RUN. The mistake
139+
// was invisible while the property was unreadable on SWI68 and the comparison could
140+
// only ever be false; now that the reader falls back to Katman5, it would have
141+
// started sampling on a car that had just been switched off.
142+
if (MG4Hardware.getCurrentIgnitionState() == MG4Hardware.IgnitionState.ON) {
133143
startOnboardSampling()
134144
}
135-
warnIfMG4ControlPresent()
145+
// No "MG4Control is also installed" notification here any more. It fired on every
146+
// service start on the cars where the two apps are meant to run together, and it said
147+
// less than the rule editor already does: the profile actions carry the coexistence
148+
// warning at the moment it matters, when one is picked.
149+
logMG4ControlPresence()
136150
isRunning = true
137151
}
138152

@@ -259,10 +273,9 @@ class TaskerVehicleService : Service() {
259273
)
260274
}
261275

262-
private fun warnIfMG4ControlPresent() {
263-
if (ProfileBridge.isMG4ControlInstalled(this)) {
264-
AppLogger.w(TAG, "MG4Control is installed — both apps can write the vehicle")
265-
Notifier.showRuleMessage(this, getString(com.mg4.tasker.R.string.warn_mg4control_present))
266-
}
276+
/** Kept in the log — the exported debug report still has to say which apps share the car. */
277+
private fun logMG4ControlPresence() {
278+
val pkg = ProfileBridge.installedPackage(this) ?: return
279+
AppLogger.i(TAG, "MG4Control present ($pkg) — both apps can write the vehicle")
267280
}
268281
}

‎app/src/main/java/com/mg4/tasker/store/SupportStore.kt‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ object SupportStore {
2323
private const val KEY_TIME = "checked_at"
2424
private const val KEY_CONDITIONS = "supported_conditions"
2525
private const val KEY_ACTIONS = "supported_actions"
26+
private const val KEY_DIAG_CONDITIONS = "diagnostic_blocked_conditions"
27+
private const val KEY_DIAG_ACTIONS = "diagnostic_blocked_actions"
2628

2729
/** No record yet, or the record predates the current app version (annotations may have moved). */
2830
fun needsCheck(context: Context): Boolean {
@@ -48,6 +50,33 @@ object SupportStore {
4850
fun supportedActions(context: Context): Set<String>? =
4951
stored(context, KEY_ACTIONS)?.let { known(it, ActionType.entries.map { e -> e.name }) }
5052

53+
/**
54+
* Records what the last diagnostic run found permanently blocked on this car.
55+
*
56+
* The firmware matrix above is a static table; this is the car answering for itself —
57+
* which vendor services actually bound, whether a map app exists, whether the head unit
58+
* reports a signal at all. The rule editor subtracts both, so an entry the Diagnostic tab
59+
* reports as blocked is no longer offered two screens away.
60+
*
61+
* Overwritten on every run, so re-running the diagnostic after installing a map app or
62+
* granting a permission is what brings the entries back.
63+
*/
64+
fun saveDiagnostic(context: Context, conditions: Set<String>, actions: Set<String>) {
65+
prefs(context).edit()
66+
.putStringSet(KEY_DIAG_CONDITIONS, conditions)
67+
.putStringSet(KEY_DIAG_ACTIONS, actions)
68+
.apply()
69+
}
70+
71+
/** ConditionType names the last diagnostic found blocked for a reason that will not change. */
72+
fun diagnosticBlockedConditions(context: Context): Set<String> =
73+
stored(context, KEY_DIAG_CONDITIONS)?.let { known(it, ConditionType.entries.map { e -> e.name }) }
74+
?: emptySet()
75+
76+
fun diagnosticBlockedActions(context: Context): Set<String> =
77+
stored(context, KEY_DIAG_ACTIONS)?.let { known(it, ActionType.entries.map { e -> e.name }) }
78+
?: emptySet()
79+
5180
private fun stored(context: Context, key: String): Set<String>? =
5281
prefs(context).takeIf { it.contains(key) }?.getStringSet(key, emptySet())?.toSet()
5382

‎app/src/main/java/com/mg4/tasker/ui/CatalogPicker.kt‎

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import com.mg4.hardware.FirmwareGen
1919
import com.mg4.hardware.FirmwareSupport
2020
import com.mg4.tasker.store.SupportStore
2121
import com.mg4.tasker.util.BtDevices
22+
import com.mg4.tasker.util.EmulatorDetector
23+
import com.mg4.tasker.util.MapApps
2224
import com.mg4.tasker.util.SpeechEngines
2325
import com.mg4.tasker.vehicle.ProfileBridge
2426
import java.util.Locale
@@ -46,12 +48,19 @@ object CatalogPicker {
4648

4749
fun pickCondition(context: Context, firmware: FirmwareGen?, onPick: (ConditionType) -> Unit) {
4850
val allowed = SupportStore.supportedConditions(context)
51+
val diagnosticBlocked = SupportStore.diagnosticBlockedConditions(context)
52+
// No emulator image has a paired device, a diagnostic history, or vendor services —
53+
// on the head unit those gaps mean "would never work", on an emulator they just mean
54+
// "nobody ran the prerequisite step yet". Skip them so every entry stays pickable to
55+
// debug, whichever hardware read behind it ends up failing on its own.
56+
val debug = EmulatorDetector.isEmulator()
4957
// Nothing paired means the device picker would be empty, and the rule would carry a
5058
// blank MAC that can never match. Same reasoning as the profile action below.
5159
val hasPairedDevice = BtDevices.bonded(context).isNotEmpty()
5260
val groups = ConditionType.byGroup().mapNotNull { (group, types) ->
5361
val supported = types
54-
.filter { hasPairedDevice || it.spec.kind != ValueKind.BT_DEVICE }
62+
.filter { debug || hasPairedDevice || it.spec.kind != ValueKind.BT_DEVICE }
63+
.filter { debug || it.name !in diagnosticBlocked }
5564
.filter { type ->
5665
allow(allowed, type.name) { FirmwareSupport.isSupported(type, firmware) }
5766
}
@@ -68,21 +77,25 @@ object CatalogPicker {
6877

6978
fun pickAction(context: Context, firmware: FirmwareGen?, onPick: (ActionType) -> Unit) {
7079
val allowed = SupportStore.supportedActions(context)
80+
val diagnosticBlocked = SupportStore.diagnosticBlockedActions(context)
81+
// See pickCondition: an emulator fails every local prerequisite by construction, so
82+
// none of them should hide an entry there.
83+
val debug = EmulatorDetector.isEmulator()
7184
// The profile actions are the ones that cannot work without MG4Control. Offering them
7285
// with an empty profile list only produces a rule that fails at ignition.
7386
val hasProfiles = ProfileBridge.isMG4ControlInstalled(context)
7487
val visible = ActionType.entries.filterNot { it in CatalogVisibility.hiddenActions }
7588
val grouped = visible.groupBy { type ->
7689
when (type) {
7790
ActionType.APPLY_PROFILE, ActionType.SHOW_PROFILE_PICKER,
78-
ActionType.WEBHOOK_GET, ActionType.WEBHOOK_POST -> IntegrationGroup
91+
ActionType.WEBHOOK -> IntegrationGroup
7992
else -> type.group
8093
}
8194
}
8295
val groups = grouped.mapNotNull { (group, types) ->
8396
val supported = types
84-
.filter { hasProfiles || it.group != ActionGroup.PROFILE }
85-
.filter { runnableHere(context, it) }
97+
.filter { debug || hasProfiles || it.group != ActionGroup.PROFILE }
98+
.filter { type -> debug || runnableHere(context, type) ?: (type.name !in diagnosticBlocked) }
8699
.filter { type -> allow(allowed, type.name) { FirmwareSupport.isSupported(type, firmware) } }
87100
if (supported.isEmpty()) return@mapNotNull null
88101
Group(
@@ -117,20 +130,27 @@ object CatalogPicker {
117130
allowed?.contains(name) ?: live()
118131

119132
/**
120-
* The local actions depend on the head unit, not on the firmware matrix, so the matrix
121-
* cannot filter them.
133+
* Answers "can this head unit do it" for the actions that can be checked **now**, or null
134+
* when only the last diagnostic run can say.
135+
*
136+
* These depend on the head unit rather than on the firmware matrix, so the matrix cannot
137+
* filter them; and they are cheap local queries — a package lookup, no vehicle read,
138+
* nothing that blocks — so they are asked live rather than read back from a stored
139+
* diagnostic. That matters when the answer changes: installing a speech engine or a map
140+
* app brings its action back to the picker immediately, without a second diagnostic.
122141
*
123142
* Speech was the visible case: the Diagnostic tab reported "no speech engine" while the
124143
* editor kept offering the action, and picking it produced a rule that could only ever
125-
* fail. Both now ask [SpeechEngines] the same question — a package query, no vehicle
126-
* read, nothing that blocks.
144+
* fail. Both now ask [SpeechEngines] the same question.
127145
*
128-
* The message action is not filtered: it shows on screen whatever the notification
146+
* The message action is deliberately absent: it shows on screen whatever the notification
129147
* channel is doing, so it always has a way to reach the driver.
130148
*/
131-
private fun runnableHere(context: Context, type: ActionType): Boolean = when (type) {
149+
private fun runnableHere(context: Context, type: ActionType): Boolean? = when (type) {
132150
ActionType.SPEAK_TEXT -> SpeechEngines.any(context)
133-
else -> true
151+
ActionType.NAVIGATE_TO -> MapApps.isAvailable(context)
152+
ActionType.SHOW_NOTIFICATION -> true
153+
else -> null
134154
}
135155

136156
private class Group(val label: String, val entries: List<Entry>)

0 commit comments

Comments
 (0)