From 9670cf7907761b2183676e355ab8f24634e50f6c Mon Sep 17 00:00:00 2001
From: Nshpiter <176191868+Nshpiter@users.noreply.github.com>
Date: Mon, 26 Oct 2026 22:45:34 +0800
Subject: [PATCH 1/5] release: HuaweiPods 1.9.2
(cherry picked from commit dc5b9d14e9030be95b178699d674d1a3217e1e6d)
---
app/src/debug/AndroidManifest.xml | 4 +
.../ui/FreeClip2SettingsPreviewActivity.kt | 77 +++
.../huaweipods/config/DeviceRoutePrefs.kt | 2 +
.../huaweipods/hook/HeadsetRefreshPolicy.kt | 14 +
.../huaweipods/hook/HeadsetStateDispatcher.kt | 34 +-
.../huaweipods/pods/DeviceCapabilities.kt | 16 +
.../huaweipods/pods/HuaweiAncLevelProfile.kt | 1 +
.../pods/HuaweiDeviceInfoRoutePolicy.kt | 4 +-
.../huaweipods/pods/HuaweiEqualizerCodec.kt | 2 +
.../pods/HuaweiFreeBuds7iController.kt | 10 +-
.../pods/HuaweiFreeClip2Controller.kt | 73 ++-
.../pods/HuaweiGestureController.kt | 7 +-
.../pods/HuaweiWearDetectionController.kt | 3 +-
.../ui/components/FreeBuds4eControls.kt | 12 +-
.../ui/components/FreeBuds7iControls.kt | 46 +-
.../ui/components/FreeClip2Controls.kt | 493 ++++++++----------
.../ui/components/HuaweiGestureControls.kt | 9 +-
.../huaweipods/ui/pages/PodDetailPage.kt | 11 +-
.../res/values-zh-rCN/freeclip2_strings.xml | 7 +-
app/src/main/res/values/freeclip2_strings.xml | 7 +-
.../hook/HeadsetRefreshPolicyTest.kt | 32 ++
.../huaweipods/pods/DeviceCapabilitiesTest.kt | 1 +
.../pods/FreeBuds4CompatibilityTest.kt | 54 ++
.../pods/FreeClip2BooleanReadbackTest.kt | 78 +++
.../pods/HuaweiFreeBuds7iControllerTest.kt | 10 +-
.../OfficialImageCatalogPolicyTest.kt | 1 +
26 files changed, 676 insertions(+), 332 deletions(-)
create mode 100644 app/src/debug/java/moe/chenxy/huaweipods/ui/FreeClip2SettingsPreviewActivity.kt
create mode 100644 app/src/main/java/moe/chenxy/huaweipods/hook/HeadsetRefreshPolicy.kt
create mode 100644 app/src/test/java/moe/chenxy/huaweipods/hook/HeadsetRefreshPolicyTest.kt
create mode 100644 app/src/test/java/moe/chenxy/huaweipods/pods/FreeBuds4CompatibilityTest.kt
create mode 100644 app/src/test/java/moe/chenxy/huaweipods/pods/FreeClip2BooleanReadbackTest.kt
diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
index 7adfd6e..e3480bd 100644
--- a/app/src/debug/AndroidManifest.xml
+++ b/app/src/debug/AndroidManifest.xml
@@ -10,6 +10,10 @@
android:label="@string/debug_capture_app_name"
tools:replace="android:label">
+
+
+ values = values + (feature to enabled)
+ if (feature == FreeClip2BooleanFeature.HEAD_MOTION_CONTROL) {
+ updateHead(FreeClip2HeadMotionState(enabled, FreeClip2HeadMotionAction.ANSWER_CALL, FreeClip2HeadMotionAction.REJECT_CALL))
+ }
+ done(true)
+ }
+ changeHead = { gesture, action, done ->
+ head?.let { updateHead(if (gesture == moe.chenxy.huaweipods.pods.FreeClip2HeadMotionGesture.NOD) it.copy(nod = action) else it.copy(shake = action)) }
+ done(true)
+ }
+ }
+ }
+ Scaffold(topBar = { TopAppBar(title = "HUAWEI FreeClip 2") }) { padding ->
+ Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState())) {
+ SmallTitle(stringResource(R.string.freeclip2_sound_effect))
+ Card(Modifier.padding(horizontal = 12.dp)) {
+ ArrowPreference(
+ title = stringResource(R.string.freeclip2_sound_effect_preset),
+ summary = "默认",
+ )
+ HuaweiEqualizerPreference(
+ address = "",
+ route = HuaweiDeviceRoute.HUAWEI_FREECLIP2,
+ requestOnMount = false,
+ )
+ }
+ FreeClip2SettingsGroups(state)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/moe/chenxy/huaweipods/config/DeviceRoutePrefs.kt b/app/src/main/java/moe/chenxy/huaweipods/config/DeviceRoutePrefs.kt
index c6bf42f..19ed25c 100644
--- a/app/src/main/java/moe/chenxy/huaweipods/config/DeviceRoutePrefs.kt
+++ b/app/src/main/java/moe/chenxy/huaweipods/config/DeviceRoutePrefs.kt
@@ -152,6 +152,7 @@ object DeviceRoutePrefs {
private fun HuaweiDeviceRoute.storageId(): String = when (this) {
HuaweiDeviceRoute.HUAWEI_FREEBUDS3 -> "freebuds3"
+ HuaweiDeviceRoute.HUAWEI_FREEBUDS4 -> "freebuds4"
HuaweiDeviceRoute.HUAWEI_FREEBUDS4E -> "freebuds4e"
HuaweiDeviceRoute.HUAWEI_FREEBUDS5 -> "freebuds5"
HuaweiDeviceRoute.HUAWEI_FREEBUDS5I -> "freebuds5i"
@@ -173,6 +174,7 @@ object DeviceRoutePrefs {
private fun routeFromStorage(value: String): HuaweiDeviceRoute? = when (value) {
"freebuds3" -> HuaweiDeviceRoute.HUAWEI_FREEBUDS3
+ "freebuds4" -> HuaweiDeviceRoute.HUAWEI_FREEBUDS4
"freebuds4e" -> HuaweiDeviceRoute.HUAWEI_FREEBUDS4E
"freebuds5" -> HuaweiDeviceRoute.HUAWEI_FREEBUDS5
"freebuds5i" -> HuaweiDeviceRoute.HUAWEI_FREEBUDS5I
diff --git a/app/src/main/java/moe/chenxy/huaweipods/hook/HeadsetRefreshPolicy.kt b/app/src/main/java/moe/chenxy/huaweipods/hook/HeadsetRefreshPolicy.kt
new file mode 100644
index 0000000..750a1a8
--- /dev/null
+++ b/app/src/main/java/moe/chenxy/huaweipods/hook/HeadsetRefreshPolicy.kt
@@ -0,0 +1,14 @@
+package moe.chenxy.huaweipods.hook
+
+/** 连接初期保留快速图标修复,稳定后只做低频兜底;熄屏由宿主取消任务。 */
+internal object HeadsetRefreshPolicy {
+ fun iconDelayMs(completedTicks: Int): Long = if (completedTicks < 4) 2_500L else 30_000L
+
+ fun shouldQueryDetails(
+ resumed: Boolean,
+ attached: Boolean,
+ shown: Boolean,
+ windowVisible: Boolean,
+ interactive: Boolean,
+ ): Boolean = resumed && attached && shown && windowVisible && interactive
+}
diff --git a/app/src/main/java/moe/chenxy/huaweipods/hook/HeadsetStateDispatcher.kt b/app/src/main/java/moe/chenxy/huaweipods/hook/HeadsetStateDispatcher.kt
index 22eb98a..036e7ef 100644
--- a/app/src/main/java/moe/chenxy/huaweipods/hook/HeadsetStateDispatcher.kt
+++ b/app/src/main/java/moe/chenxy/huaweipods/hook/HeadsetStateDispatcher.kt
@@ -16,6 +16,7 @@ import android.content.IntentFilter
import android.os.Handler
import android.os.Bundle
import android.os.Looper
+import android.os.PowerManager
import android.os.SystemClock
import moe.chenxy.huaweipods.BuildConfig
import moe.chenxy.huaweipods.pods.HuaweiHfpController
@@ -39,7 +40,6 @@ import moe.chenxy.huaweipods.coloros.queryColorOsLiveAlertAudioProfiles
object HeadsetStateDispatcher : HookContext() {
private const val ROUTE_PROBE_WATCHDOG_MS = 5_500L
- private const val HEADSET_ICON_WATCHDOG_MS = 2_500L
private var appRequestReceiverRegistered = false
@Volatile
@@ -62,13 +62,19 @@ object HeadsetStateDispatcher : HookContext() {
private var iconWatchdogContext: Context? = null
@Volatile
private var iconWatchdogRunning = false
+ private var iconWatchdogTicks = 0
private val iconWatchdogTick = object : Runnable {
override fun run() {
val context = iconWatchdogContext
if (!iconWatchdogRunning || context == null || connectedHuaweiA2dpAddresses.isEmpty()) return
+ if (!isScreenInteractive(context)) {
+ stopIconWatchdog()
+ return
+ }
setHeadsetIcon(context, visible = true, reason = "watchdog")
+ iconWatchdogTicks = (iconWatchdogTicks + 1).coerceAtMost(4)
if (iconWatchdogRunning) {
- mainHandler.postDelayed(this, HEADSET_ICON_WATCHDOG_MS)
+ mainHandler.postDelayed(this, HeadsetRefreshPolicy.iconDelayMs(iconWatchdogTicks))
}
}
}
@@ -223,13 +229,17 @@ object HeadsetStateDispatcher : HookContext() {
}
private fun startIconWatchdog(context: Context) {
- if (statusBarIconUnavailable) return
+ if (statusBarIconUnavailable || !isScreenInteractive(context) || connectedHuaweiA2dpAddresses.isEmpty()) return
iconWatchdogContext = context
iconWatchdogRunning = true
+ iconWatchdogTicks = 0
mainHandler.removeCallbacks(iconWatchdogTick)
- mainHandler.postDelayed(iconWatchdogTick, HEADSET_ICON_WATCHDOG_MS)
+ mainHandler.postDelayed(iconWatchdogTick, HeadsetRefreshPolicy.iconDelayMs(iconWatchdogTicks))
}
+ private fun isScreenInteractive(context: Context): Boolean =
+ context.getSystemService(PowerManager::class.java)?.isInteractive == true
+
private fun stopIconWatchdog() {
iconWatchdogRunning = false
iconWatchdogContext = null
@@ -393,6 +403,20 @@ object HeadsetStateDispatcher : HookContext() {
return
}
runCatching {
+ // Only exact protected system actions bypass app-control sender validation.
+ when (receivedIntent.action) {
+ Intent.ACTION_SCREEN_OFF -> {
+ stopIconWatchdog()
+ return@runCatching
+ }
+ Intent.ACTION_SCREEN_ON -> {
+ if (connectedHuaweiA2dpAddresses.isNotEmpty()) {
+ setHeadsetIcon(context, visible = true, reason = "screen-on")
+ startIconWatchdog(context)
+ }
+ return@runCatching
+ }
+ }
val action = HuaweiPodsAction.canonical(receivedIntent.action)
if (!HuaweiPodsBroadcastTrustPolicy.isTrustedAppControlSender(sentFromPackage)) {
Log.w(
@@ -445,6 +469,8 @@ object HeadsetStateDispatcher : HookContext() {
addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
addAction(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED)
addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)
+ addAction(Intent.ACTION_SCREEN_ON)
+ addAction(Intent.ACTION_SCREEN_OFF)
addHuaweiPodsAction(HuaweiPodsAction.ACTION_PODS_UI_INIT)
addHuaweiPodsAction(HuaweiPodsAction.ACTION_REFRESH_STATUS)
addHuaweiPodsAction(HuaweiPodsAction.ACTION_CONNECT_POD_REQUEST)
diff --git a/app/src/main/java/moe/chenxy/huaweipods/pods/DeviceCapabilities.kt b/app/src/main/java/moe/chenxy/huaweipods/pods/DeviceCapabilities.kt
index 1eb21ba..91f16ca 100644
--- a/app/src/main/java/moe/chenxy/huaweipods/pods/DeviceCapabilities.kt
+++ b/app/src/main/java/moe/chenxy/huaweipods/pods/DeviceCapabilities.kt
@@ -2,6 +2,7 @@ package moe.chenxy.huaweipods.pods
enum class HuaweiDeviceRoute {
HUAWEI_FREEBUDS3,
+ HUAWEI_FREEBUDS4,
HUAWEI_FREEBUDS4E,
HUAWEI_FREEBUDS5,
HUAWEI_FREEBUDS5I,
@@ -47,6 +48,17 @@ private val routeCapabilities = linkedMapOf(
supportsGestureConfiguration = true,
hasChargingCase = true,
),
+ // FreeBuds 4 / 4E 官方 ZA09 / ZAA4 配置共用基础控制;保留独立身份与图片。
+ HuaweiDeviceRoute.HUAWEI_FREEBUDS4 to HuaweiDeviceCapabilities(
+ displayName = "HUAWEI FreeBuds 4",
+ aliases = setOf("huaweifreebuds4", "freebuds4"),
+ supportsAnc = true,
+ supportsAncStateReadback = true,
+ supportsDiscreteAncLevels = true,
+ supportsRfcommBattery = true,
+ supportsGestureConfiguration = true,
+ hasChargingCase = true,
+ ),
HuaweiDeviceRoute.HUAWEI_FREEBUDS4E to HuaweiDeviceCapabilities(
displayName = "HUAWEI FreeBuds 4E",
aliases = setOf("huaweifreebuds4e", "freebuds4e"),
@@ -216,6 +228,7 @@ private val normalizedAliasRoutes: Map = buildMap {
private val broadcastValueByRoute = mapOf(
HuaweiDeviceRoute.HUAWEI_FREEBUDS3 to "HUAWEI_FREEBUDS3",
+ HuaweiDeviceRoute.HUAWEI_FREEBUDS4 to "HUAWEI_FREEBUDS4",
HuaweiDeviceRoute.HUAWEI_FREEBUDS4E to "HUAWEI_FREEBUDS4E",
HuaweiDeviceRoute.HUAWEI_FREEBUDS5 to "HUAWEI_FREEBUDS5",
HuaweiDeviceRoute.HUAWEI_FREEBUDS5I to "HUAWEI_FREEBUDS5I",
@@ -241,6 +254,9 @@ private val routeByBroadcastValue = broadcastValueByRoute.entries.associate { (r
val HuaweiDeviceRoute.capabilities: HuaweiDeviceCapabilities?
get() = routeCapabilities[this]
+internal val HuaweiDeviceRoute.isFreeBuds4Series: Boolean
+ get() = this == HuaweiDeviceRoute.HUAWEI_FREEBUDS4 || this == HuaweiDeviceRoute.HUAWEI_FREEBUDS4E
+
val HuaweiDeviceRoute.displayName: String
get() = capabilities?.displayName ?: "Unsupported"
diff --git a/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiAncLevelProfile.kt b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiAncLevelProfile.kt
index 60bad31..4435aac 100644
--- a/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiAncLevelProfile.kt
+++ b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiAncLevelProfile.kt
@@ -55,6 +55,7 @@ internal val HuaweiDeviceRoute.ancLevelOptions: List
HuaweiDeviceRoute.HUAWEI_FREEBUDS6,
HuaweiDeviceRoute.HUAWEI_FREEBUDS7,
-> freeBuds5AncOptions
+ HuaweiDeviceRoute.HUAWEI_FREEBUDS4,
HuaweiDeviceRoute.HUAWEI_FREEBUDS4E -> freeBuds4eAncOptions
HuaweiDeviceRoute.HUAWEI_FREEBUDS_SE4_ANC -> freeBudsSe4AncOptions
HuaweiDeviceRoute.HUAWEI_FREEBUDS5I,
diff --git a/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiDeviceInfoRoutePolicy.kt b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiDeviceInfoRoutePolicy.kt
index 8e2845d..1b91415 100644
--- a/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiDeviceInfoRoutePolicy.kt
+++ b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiDeviceInfoRoutePolicy.kt
@@ -1,8 +1,10 @@
package moe.chenxy.huaweipods.pods
-/** 仅收录已有实机 DeviceInfo 帧证明的 modelId;未知机型绝不靠名称或默认配色猜测。 */
+/** 收录实机 DeviceInfo 或官方产品配置确认的 modelId;未知机型不猜测配色或身份。 */
internal object HuaweiDeviceInfoRoutePolicy {
private val routeByModelId = mapOf(
+ // 官方 000129.json:Hero / ZA09 / HUAWEI FreeBuds 4,尚待本机型抓包复测。
+ "000129" to HuaweiDeviceRoute.HUAWEI_FREEBUDS4,
"000141" to HuaweiDeviceRoute.HUAWEI_FREEBUDS5,
"000145" to HuaweiDeviceRoute.HUAWEI_FREEBUDS5I,
"000169" to HuaweiDeviceRoute.HUAWEI_FREEBUDS_SE4_ANC,
diff --git a/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiEqualizerCodec.kt b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiEqualizerCodec.kt
index d1b876d..0205aef 100644
--- a/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiEqualizerCodec.kt
+++ b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiEqualizerCodec.kt
@@ -93,6 +93,7 @@ object HuaweiEqualizerCodec {
*/
fun buildBuiltInPresetPacket(route: HuaweiDeviceRoute, presetId: Int): ByteArray? {
val allowedIds = when (route) {
+ HuaweiDeviceRoute.HUAWEI_FREEBUDS4,
HuaweiDeviceRoute.HUAWEI_FREEBUDS4E -> setOf(1, 2, 3)
HuaweiDeviceRoute.HUAWEI_FREEBUDS_SE4_ANC -> setOf(0x01, 0x02, 0x03, 0x09)
HuaweiDeviceRoute.HUAWEI_FREEBUDS6I -> setOf(0x01, 0x02, 0x03, 0x09)
@@ -135,6 +136,7 @@ object HuaweiEqualizerCodec {
}
fun supportsStateRead(route: HuaweiDeviceRoute): Boolean = route in setOf(
+ HuaweiDeviceRoute.HUAWEI_FREEBUDS4,
HuaweiDeviceRoute.HUAWEI_FREEBUDS5,
HuaweiDeviceRoute.HUAWEI_FREEBUDS4E,
HuaweiDeviceRoute.HUAWEI_FREEBUDS_SE4_ANC,
diff --git a/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiFreeBuds7iController.kt b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiFreeBuds7iController.kt
index 206cef3..1e202aa 100644
--- a/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiFreeBuds7iController.kt
+++ b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiFreeBuds7iController.kt
@@ -25,8 +25,8 @@ object HuaweiFreeBuds7iController {
private val DUAL_DEVICE_QUERY = hex("5A0008002B3101000D0100AEEE")
private val SPATIAL_AUDIO_MODE_PACKETS = mapOf(
FreeClip2SpatialAudioMode.OFF to hex("5A0009002BB401011802010060ED"),
- FreeClip2SpatialAudioMode.FIXED to hex("5A0009002BB401011802010170CC"),
- FreeClip2SpatialAudioMode.HEAD_TRACKING to hex("5A0009002BB401011802010240AF"),
+ FreeClip2SpatialAudioMode.FIXED to hex("5A0009002BB401011802010240AF"),
+ FreeClip2SpatialAudioMode.HEAD_TRACKING to hex("5A0009002BB401011802010170CC"),
)
fun setBooleanFeature(
@@ -158,7 +158,7 @@ object HuaweiFreeBuds7iController {
fun dualDeviceQueryPacket(): ByteArray = DUAL_DEVICE_QUERY.copyOf()
- /** FreeBuds 7i 抓包确认使用 0=关闭、1=固定、2=头部跟踪。 */
+ /** 依据 7i 用户实际切换反馈校正:0=关闭、1=头部跟踪、2=固定。 */
internal fun spatialAudioModePacket(mode: FreeClip2SpatialAudioMode): ByteArray =
requireNotNull(SPATIAL_AUDIO_MODE_PACKETS[mode]).copyOf()
@@ -166,8 +166,8 @@ object HuaweiFreeBuds7iController {
HuaweiFreeClip2Controller.parseSpatialAudioState(stream) { value ->
when (value) {
0 -> FreeClip2SpatialAudioMode.OFF
- 1 -> FreeClip2SpatialAudioMode.FIXED
- 2 -> FreeClip2SpatialAudioMode.HEAD_TRACKING
+ 1 -> FreeClip2SpatialAudioMode.HEAD_TRACKING
+ 2 -> FreeClip2SpatialAudioMode.FIXED
else -> null
}
}
diff --git a/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiFreeClip2Controller.kt b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiFreeClip2Controller.kt
index a72eb95..6b7ff9e 100644
--- a/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiFreeClip2Controller.kt
+++ b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiFreeClip2Controller.kt
@@ -15,6 +15,76 @@ object HuaweiFreeClip2Controller {
private val HEAD_MOTION_QUERY = hex("5A0006002BB401010B289B")
private val NOISY_VOLUME_QUERY = hex("5A0005002B8801009182")
+ // 2026-07-29 FreeClip 2 抓包同时包含这些查询与状态返回;只复用相同字段的编解码。
+ private val booleanReadbackCodecs = linkedMapOf(
+ FreeClip2BooleanFeature.WEAR_DETECTION to FreeBuds7Setting.WEAR_DETECTION,
+ FreeClip2BooleanFeature.DROP_REMINDER to FreeBuds7Setting.DROP_REMINDER,
+ FreeClip2BooleanFeature.ADAPTIVE_VOLUME to FreeBuds7Setting.ADAPTIVE_VOLUME,
+ FreeClip2BooleanFeature.LOW_LATENCY to FreeBuds7Setting.LOW_LATENCY,
+ FreeClip2BooleanFeature.DUAL_DEVICE to FreeBuds7Setting.AUTO_SWITCH,
+ FreeClip2BooleanFeature.CASE_PROMPT_SOUND to FreeBuds7Setting.CASE_SOUND,
+ )
+
+ internal fun booleanSettingsQueryPacket(): ByteArray =
+ booleanReadbackCodecs.values.fold(byteArrayOf()) { bytes, codec -> bytes + codec.query() } +
+ HEAD_MOTION_QUERY + NOISY_VOLUME_QUERY
+
+ internal fun parseBooleanSettings(stream: ByteArray): Map =
+ booleanReadbackCodecs.mapValues { (_, codec) -> codec.parse(stream) } + mapOf(
+ FreeClip2BooleanFeature.HEAD_MOTION_CONTROL to parseHeadMotionState(stream)?.enabled,
+ FreeClip2BooleanFeature.NOISY_VOLUME_BOOST to parseNoisyVolumeState(stream),
+ )
+
+ fun requestBooleanSettings(
+ context: Context, device: BluetoothDevice,
+ onState: (Map, FreeClip2HeadMotionState?) -> Unit,
+ ) {
+ var delivered = false
+ fun deliver(bytes: ByteArray) {
+ if (delivered) return
+ delivered = true
+ onState(parseBooleanSettings(bytes), parseHeadMotionState(bytes))
+ }
+ HuaweiL2capAncController.requestRawPacketOnce(
+ context, device, HuaweiDeviceRoute.HUAWEI_FREECLIP2, booleanSettingsQueryPacket(),
+ description = "freeclip2 boolean-settings", responseWindowMs = 1_500L,
+ responseComplete = { parseBooleanSettings(it).values.all { value -> value != null } },
+ onComplete = { if (!it) deliver(byteArrayOf()) }, onResponse = ::deliver,
+ )
+ }
+
+ /** UI 以耳机回读为准;通用 ACK 和本地期望值均不能证明当前开关状态。 */
+ fun setBooleanFeatureVerified(
+ context: Context, device: BluetoothDevice, feature: FreeClip2BooleanFeature, enabled: Boolean,
+ onState: (Boolean?) -> Unit, onComplete: (Boolean) -> Unit,
+ ) {
+ if (feature == FreeClip2BooleanFeature.NOISY_VOLUME_BOOST) {
+ setNoisyVolumeEnabled(context, device, enabled, onState, onComplete)
+ return
+ }
+ val codec = booleanReadbackCodecs[feature] ?: run { onComplete(false); return }
+ if (feature == FreeClip2BooleanFeature.LOW_LATENCY) {
+ // 保留蓝牙宿主写入,避免恢复 App 与宿主争抢连接的旧问题。
+ HuaweiLowLatencyController.setEnabled(context, device, HuaweiDeviceRoute.HUAWEI_FREECLIP2, enabled) { sent ->
+ if (!sent) {
+ onComplete(false)
+ } else {
+ requestSetting(context, device, codec.query(), codec::parse) { state ->
+ fun finish(actual: Boolean?) {
+ onState(actual)
+ onComplete(actual == enabled)
+ }
+ if (state == enabled) finish(state)
+ else requestSetting(context, device, codec.query(), codec::parse, ::finish)
+ }
+ }
+ }
+ } else {
+ writeSetting(context, device, feature.packet(enabled), codec.query(), codec::parse,
+ { it == enabled }, onState, onComplete)
+ }
+ }
+
fun requestHeadMotionState(context: Context, device: BluetoothDevice, onState: (FreeClip2HeadMotionState?) -> Unit) =
requestSetting(context, device, HEAD_MOTION_QUERY, ::parseHeadMotionState, onState)
@@ -415,8 +485,7 @@ data class FreeClip2HeadMotionState(
/**
* FreeClip 2 耳机 AAM 空间音频协议:0=关闭、1=头部跟踪、2=固定。
*
- * 该顺序同时由官方 API 和真机实际效果确认。FreeBuds 7i 的同类命令顺序不同,
- * 必须由它自己的控制器转换,不能再共用这里的 wire value。
+ * 该顺序同时由官方 API 和真机实际效果确认。各型号控制器负责对应的协议转换。
*/
enum class FreeClip2SpatialAudioMode(
val extraValue: String,
diff --git a/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiGestureController.kt b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiGestureController.kt
index b05a467..23d2ee3 100644
--- a/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiGestureController.kt
+++ b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiGestureController.kt
@@ -23,6 +23,7 @@ object HuaweiGestureController {
private val freeClip2SwipeQuery = hex("5A0007002B1F01000200328A")
private val freeBuds4eLongPressQuery = hex("5A0007002B170100020030A7")
private val modernLongPressRoutes = setOf(
+ HuaweiDeviceRoute.HUAWEI_FREEBUDS4,
HuaweiDeviceRoute.HUAWEI_FREEBUDS4E,
HuaweiDeviceRoute.HUAWEI_FREEBUDS6I,
HuaweiDeviceRoute.HUAWEI_FREEBUDS_PRO3,
@@ -301,7 +302,7 @@ object HuaweiGestureController {
}
internal fun buildGestureStateQuery(route: HuaweiDeviceRoute): ByteArray? =
- if (route == HuaweiDeviceRoute.HUAWEI_FREEBUDS4E) {
+ if (route.isFreeBuds4Series) {
freeClip2DoubleTapQuery + freeBuds4eLongPressQuery
} else if (route == HuaweiDeviceRoute.HUAWEI_FREEBUDS5I) {
freeClip2DoubleTapQuery
@@ -631,6 +632,7 @@ enum class HuaweiTapAction(val extraValue: String) {
HuaweiDeviceRoute.HUAWEI_FREEBUDS3 to HuaweiGestureKind.DOUBLE_TAP -> freeBuds3DoubleTapActions
HuaweiDeviceRoute.HUAWEI_FREECLIP to HuaweiGestureKind.DOUBLE_TAP -> freeBuds7iDoubleTapActions
HuaweiDeviceRoute.HUAWEI_FREECLIP to HuaweiGestureKind.TRIPLE_TAP -> freeBuds7iTripleTapActions
+ HuaweiDeviceRoute.HUAWEI_FREEBUDS4 to HuaweiGestureKind.DOUBLE_TAP,
HuaweiDeviceRoute.HUAWEI_FREEBUDS4E to HuaweiGestureKind.DOUBLE_TAP -> freeBuds4eDoubleTapActions
HuaweiDeviceRoute.HUAWEI_FREEBUDS5I to HuaweiGestureKind.DOUBLE_TAP -> freeBuds7iDoubleTapActions
HuaweiDeviceRoute.HUAWEI_FREEBUDS6I to HuaweiGestureKind.DOUBLE_TAP -> freeBuds6iDoubleTapActions
@@ -689,6 +691,7 @@ enum class HuaweiTapAction(val extraValue: String) {
}
HuaweiDeviceRoute.HUAWEI_FREECLIP,
+ HuaweiDeviceRoute.HUAWEI_FREEBUDS4,
HuaweiDeviceRoute.HUAWEI_FREEBUDS4E,
HuaweiDeviceRoute.HUAWEI_FREEBUDS5I,
HuaweiDeviceRoute.HUAWEI_FREEBUDS_PRO5,
@@ -827,6 +830,7 @@ enum class FreeBudsPro3LongPressAction(
entries.firstOrNull { it.protocolValue == value }
fun availableFor(route: HuaweiDeviceRoute): List = when (route) {
+ HuaweiDeviceRoute.HUAWEI_FREEBUDS4,
HuaweiDeviceRoute.HUAWEI_FREEBUDS4E -> listOf(NOISE_CONTROL, SONG_RECOGNITION, NONE)
HuaweiDeviceRoute.HUAWEI_FREEBUDS6I,
HuaweiDeviceRoute.HUAWEI_FREEBUDS_PRO3,
@@ -849,6 +853,7 @@ enum class FreeBudsPro3LongPressAction(
fun protocolValue(route: HuaweiDeviceRoute): Int? {
if (this !in availableFor(route)) return null
return when (route) {
+ HuaweiDeviceRoute.HUAWEI_FREEBUDS4,
HuaweiDeviceRoute.HUAWEI_FREEBUDS4E -> when (this) {
NOISE_CONTROL -> 0x03
SONG_RECOGNITION -> 0x0E
diff --git a/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiWearDetectionController.kt b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiWearDetectionController.kt
index 6164eb4..8916b45 100644
--- a/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiWearDetectionController.kt
+++ b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiWearDetectionController.kt
@@ -16,7 +16,7 @@ import moe.chenxy.huaweipods.config.DeviceRoutePrefs
* Shared wear-detection protocol used by the models for which a real capture proves 2B10/2B11.
*
* Keeping the protocol here avoids copying the same packets into each model-specific screen. A
- * route is added only after its capture confirms both the setter and the matching state query.
+ * Routes require matching setter/query evidence; FreeBuds 4 also uses the official ZA09 service.
*/
object HuaweiWearDetectionController {
private val mainHandler by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
@@ -25,6 +25,7 @@ object HuaweiWearDetectionController {
private val generation = AtomicLong(0L)
private val pendingCallbacks = ConcurrentHashMap.newKeySet()
private val supportedRoutes = setOf(
+ HuaweiDeviceRoute.HUAWEI_FREEBUDS4,
HuaweiDeviceRoute.HUAWEI_FREEBUDS5,
HuaweiDeviceRoute.HUAWEI_FREEBUDS5I,
HuaweiDeviceRoute.HUAWEI_FREEBUDS4E,
diff --git a/app/src/main/java/moe/chenxy/huaweipods/ui/components/FreeBuds4eControls.kt b/app/src/main/java/moe/chenxy/huaweipods/ui/components/FreeBuds4eControls.kt
index 5a69313..690bc68 100644
--- a/app/src/main/java/moe/chenxy/huaweipods/ui/components/FreeBuds4eControls.kt
+++ b/app/src/main/java/moe/chenxy/huaweipods/ui/components/FreeBuds4eControls.kt
@@ -27,6 +27,7 @@ import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.unit.dp
import moe.chenxy.huaweipods.R
import moe.chenxy.huaweipods.pods.HuaweiDeviceRoute
+import moe.chenxy.huaweipods.pods.isFreeBuds4Series
import moe.chenxy.huaweipods.pods.HuaweiEqualizerController
import top.yukonga.miuix.kmp.basic.Checkbox
import top.yukonga.miuix.kmp.basic.Text
@@ -41,22 +42,23 @@ private val freeBuds4ePresets = listOf(
FreeBuds4ePreset(3, R.string.freebuds5_sound_effect_treble),
)
-/** FreeBuds 4E controls backed only by packets present in the 2026-08-15 guided capture. */
+/** Shared FreeBuds 4 / 4E controls: official ZA09/ZAA4 services and the 4E capture. */
@Composable
-fun FreeBuds4eControls(address: String) {
+fun FreeBuds4eControls(address: String, route: HuaweiDeviceRoute = HuaweiDeviceRoute.HUAWEI_FREEBUDS4E) {
+ require(route.isFreeBuds4Series)
val context = LocalContext.current
var selectedId by remember(address) { mutableStateOf(null) }
var showDialog by remember(address) { mutableStateOf(false) }
var pending by remember(address) { mutableStateOf(false) }
- DisposableEffect(address, context) {
+ DisposableEffect(address, context, route) {
val device = context.bluetoothDevice(address)
?: return@DisposableEffect onDispose { }
var disposed = false
HuaweiEqualizerController.requestState(
context,
device,
- HuaweiDeviceRoute.HUAWEI_FREEBUDS4E,
+ route,
) { state ->
if (!disposed) selectedId = state?.selectedId?.takeIf { id ->
freeBuds4ePresets.any { it.id == id }
@@ -111,7 +113,7 @@ fun FreeBuds4eControls(address: String) {
HuaweiEqualizerController.setBuiltInPreset(
context,
device,
- HuaweiDeviceRoute.HUAWEI_FREEBUDS4E,
+ route,
preset.id,
) { success ->
pending = false
diff --git a/app/src/main/java/moe/chenxy/huaweipods/ui/components/FreeBuds7iControls.kt b/app/src/main/java/moe/chenxy/huaweipods/ui/components/FreeBuds7iControls.kt
index cfb1988..bd54864 100644
--- a/app/src/main/java/moe/chenxy/huaweipods/ui/components/FreeBuds7iControls.kt
+++ b/app/src/main/java/moe/chenxy/huaweipods/ui/components/FreeBuds7iControls.kt
@@ -54,6 +54,7 @@ import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.overlay.OverlayDialog
+import top.yukonga.miuix.kmp.preference.ArrowPreference
import top.yukonga.miuix.kmp.theme.MiuixTheme
/** FreeBuds 7i controls backed exclusively by the verified 2026-08-08 capture. */
@@ -397,39 +398,26 @@ internal fun HuaweiEqualizerPreference(
}
onDispose { disposed = true }
}
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(12.dp))
- .clickable(enabled = editable && !pending, role = Role.Button) {
- openPresetEditor(null)
- }
- .padding(horizontal = 24.dp, vertical = 14.dp),
- ) {
- Text(
- stringResource(
+ ArrowPreference(
+ title = stringResource(
+ if (route == HuaweiDeviceRoute.HUAWEI_FREECLIP2) {
+ R.string.freeclip2_custom_effect_title
+ } else {
+ R.string.huawei_equalizer_title
+ },
+ ),
+ summary = currentState?.selectedName?.takeIf(String::isNotBlank)
+ ?: stringResource(
if (route == HuaweiDeviceRoute.HUAWEI_FREECLIP2) {
- R.string.freeclip2_custom_effect_title
+ R.string.freeclip2_custom_effect_summary
} else {
- R.string.huawei_equalizer_title
+ R.string.huawei_equalizer_summary
},
),
- color = MiuixTheme.colorScheme.onSurface,
- style = MiuixTheme.textStyles.headline1,
- )
- Text(
- currentState?.selectedName?.takeIf(String::isNotBlank)
- ?: stringResource(
- if (route == HuaweiDeviceRoute.HUAWEI_FREECLIP2) {
- R.string.freeclip2_custom_effect_summary
- } else {
- R.string.huawei_equalizer_summary
- },
- ),
- color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
- style = MiuixTheme.textStyles.body2,
- )
- }
+ enabled = editable && !pending,
+ holdDownState = showDialog,
+ onClick = { openPresetEditor(null) },
+ )
OverlayDialog(
title = stringResource(
if (route == HuaweiDeviceRoute.HUAWEI_FREECLIP2) {
diff --git a/app/src/main/java/moe/chenxy/huaweipods/ui/components/FreeClip2Controls.kt b/app/src/main/java/moe/chenxy/huaweipods/ui/components/FreeClip2Controls.kt
index 2e68384..63c5e97 100644
--- a/app/src/main/java/moe/chenxy/huaweipods/ui/components/FreeClip2Controls.kt
+++ b/app/src/main/java/moe/chenxy/huaweipods/ui/components/FreeClip2Controls.kt
@@ -8,14 +8,9 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.widget.Toast
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
@@ -23,14 +18,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.semantics.Role
-import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@@ -53,8 +44,12 @@ import moe.chenxy.huaweipods.pods.readHuaweiEqualizerCustomPresets
import moe.chenxy.huaweipods.pods.encodeHuaweiDeviceRouteForBroadcast
import moe.chenxy.huaweipods.utils.miuiStrongToast.data.sendIdentitySharingBroadcast
import moe.chenxy.huaweipods.utils.miuiStrongToast.data.HuaweiPodsAction
-import top.yukonga.miuix.kmp.basic.Checkbox
+import top.yukonga.miuix.kmp.basic.Card
+import top.yukonga.miuix.kmp.basic.SmallTitle
import top.yukonga.miuix.kmp.basic.Text
+import top.yukonga.miuix.kmp.preference.ArrowPreference
+import top.yukonga.miuix.kmp.preference.RadioButtonPreference
+import top.yukonga.miuix.kmp.preference.SwitchPreference
import top.yukonga.miuix.kmp.overlay.OverlayDialog
import top.yukonga.miuix.kmp.theme.MiuixTheme
@@ -89,6 +84,7 @@ fun FreeClip2Controls(address: String) {
)
}
var equalizer by remember(address) { mutableStateOf(null) }
+ val settings = rememberFreeClip2BooleanSettings(address)
FreeClip2AudioReadbackEffect(address) { mode, scene, effect, equalizerState ->
mode?.let {
@@ -107,114 +103,86 @@ fun FreeClip2Controls(address: String) {
}
Column(modifier = Modifier.fillMaxWidth()) {
- SectionTitle(R.string.freeclip2_spatial_audio)
- EnumPreference(
- title = stringResource(R.string.freeclip2_spatial_mode),
- selected = spatialMode,
- values = FreeClip2SpatialAudioMode.entries,
- label = { mode -> stringResource(mode.labelRes()) },
- onSelected = { mode, complete ->
- context.setFreeClip2SpatialMode(address, mode) { success ->
- complete(success)
- }
- },
- )
-
- if (spatialMode != FreeClip2SpatialAudioMode.OFF) {
+ FreeClip2Section(R.string.freeclip2_spatial_audio) {
EnumPreference(
- title = stringResource(R.string.freeclip2_spatial_scene),
- selected = spatialScene,
- values = FreeClip2SpatialScene.entries,
- label = { scene -> stringResource(scene.labelRes()) },
- onSelected = { scene, complete ->
- context.setFreeClip2SpatialScene(address, scene) { success ->
+ title = stringResource(R.string.freeclip2_spatial_mode),
+ selected = spatialMode,
+ values = FreeClip2SpatialAudioMode.entries,
+ label = { mode -> stringResource(mode.labelRes()) },
+ onSelected = { mode, complete ->
+ context.setFreeClip2SpatialMode(address, mode) { success ->
complete(success)
}
},
)
+ if (spatialMode != FreeClip2SpatialAudioMode.OFF) {
+ EnumPreference(
+ title = stringResource(R.string.freeclip2_spatial_scene),
+ selected = spatialScene,
+ values = FreeClip2SpatialScene.entries,
+ label = { scene -> stringResource(scene.labelRes()) },
+ onSelected = { scene, complete ->
+ context.setFreeClip2SpatialScene(address, scene) { success ->
+ complete(success)
+ }
+ },
+ )
+ }
}
- SectionTitle(R.string.freeclip2_sound_effect)
- EnumPreference(
- title = stringResource(R.string.freeclip2_sound_effect_preset),
- selected = soundEffect,
- values = FreeClip2SoundEffect.selectableEntries,
- label = { effect -> stringResource(effect.labelRes()) },
- onSelected = { effect, complete ->
- context.setFreeClip2SoundEffect(address, effect) { success ->
- complete(success)
- }
- },
- )
- HuaweiEqualizerPreference(
- address = address,
- route = HuaweiDeviceRoute.HUAWEI_FREECLIP2,
- readback = equalizer,
- requestOnMount = false,
- editable = true,
- )
-
- SectionTitle(R.string.freeclip2_smart_features)
- listOf(
- FreeClip2BooleanFeature.WEAR_DETECTION to R.string.freeclip2_wear_detection,
- FreeClip2BooleanFeature.DROP_REMINDER to R.string.freeclip2_drop_reminder,
- FreeClip2BooleanFeature.ADAPTIVE_VOLUME to R.string.freeclip2_adaptive_volume,
- ).forEach { (feature, title) ->
- FeatureToggle(
- titleRes = title,
- initialValue = prefs.getBoolean(keyPrefix + feature.extraValue, false),
- onChange = { enabled, complete ->
- context.setFreeClip2BooleanFeature(address, feature, enabled) { success ->
- if (success) prefs.edit().putBoolean(keyPrefix + feature.extraValue, enabled).apply()
+ FreeClip2Section(R.string.freeclip2_sound_effect) {
+ EnumPreference(
+ title = stringResource(R.string.freeclip2_sound_effect_preset),
+ selected = soundEffect,
+ values = FreeClip2SoundEffect.selectableEntries,
+ label = { effect -> stringResource(effect.labelRes()) },
+ onSelected = { effect, complete ->
+ context.setFreeClip2SoundEffect(address, effect) { success ->
complete(success)
}
},
)
+ HuaweiEqualizerPreference(
+ address = address,
+ route = HuaweiDeviceRoute.HUAWEI_FREECLIP2,
+ readback = equalizer,
+ requestOnMount = false,
+ editable = true,
+ )
}
- FreeClip2DeviceSettingsControls(address)
+ FreeClip2SettingsGroups(settings)
+ }
+}
- SectionTitle(R.string.freeclip2_sound_and_connection)
- listOf(
- FreeClip2BooleanFeature.LOW_LATENCY to R.string.freeclip2_low_latency,
- FreeClip2BooleanFeature.DUAL_DEVICE to R.string.freeclip2_dual_device,
- FreeClip2BooleanFeature.CASE_PROMPT_SOUND to R.string.freeclip2_case_prompt_sound,
- ).forEach { (feature, title) ->
- val initialValue = if (feature == FreeClip2BooleanFeature.LOW_LATENCY) {
- LowLatencyPrefs.desiredOrNull(
- prefs,
- address,
- HuaweiDeviceRoute.HUAWEI_FREECLIP2,
- ) ?: false
- } else {
- prefs.getBoolean(keyPrefix + feature.extraValue, false)
+@Composable
+internal fun FreeClip2SettingsGroups(settings: FreeClip2BooleanUiState) {
+ Column(Modifier.fillMaxWidth()) {
+ FreeClip2Section(R.string.freeclip2_smart_features) {
+ listOf(
+ FreeClip2BooleanFeature.WEAR_DETECTION to R.string.freeclip2_wear_detection,
+ FreeClip2BooleanFeature.DROP_REMINDER to R.string.freeclip2_drop_reminder,
+ FreeClip2BooleanFeature.ADAPTIVE_VOLUME to R.string.freeclip2_adaptive_volume,
+ ).forEach { (feature, title) ->
+ BooleanFeaturePreference(title, feature, settings)
}
- FeatureToggle(
- titleRes = title,
- initialValue = initialValue,
- onChange = { enabled, complete ->
- context.setFreeClip2BooleanFeature(address, feature, enabled) { success ->
- if (success) {
- val stored = if (feature == FreeClip2BooleanFeature.LOW_LATENCY) {
- LowLatencyPrefs.setDesired(
- prefs = prefs,
- service = HuaweiPodsApp.xposedService,
- address = address,
- route = HuaweiDeviceRoute.HUAWEI_FREECLIP2,
- enabled = enabled,
- )
- } else {
- prefs.edit().putBoolean(keyPrefix + feature.extraValue, enabled).apply()
- true
- }
- complete(stored)
- } else {
- complete(false)
- }
- }
- },
- )
+ FreeClip2DeviceSettingsControls(settings)
}
+ FreeClip2Section(R.string.freeclip2_sound_and_connection) {
+ listOf(
+ FreeClip2BooleanFeature.LOW_LATENCY to R.string.freeclip2_low_latency,
+ FreeClip2BooleanFeature.DUAL_DEVICE to R.string.freeclip2_dual_device,
+ FreeClip2BooleanFeature.CASE_PROMPT_SOUND to R.string.freeclip2_case_prompt_sound,
+ ).forEach { (feature, title) ->
+ BooleanFeaturePreference(title, feature, settings)
+ }
+ ArrowPreference(
+ title = stringResource(R.string.freeclip2_refresh_settings),
+ summary = stringResource(if (settings.reading) R.string.freeclip2_settings_loading else R.string.freeclip2_refresh_summary),
+ enabled = !settings.busy,
+ onClick = settings.refresh,
+ )
+ }
Text(
text = stringResource(R.string.freeclip2_state_hint),
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
@@ -224,171 +192,186 @@ fun FreeClip2Controls(address: String) {
}
}
+internal class FreeClip2BooleanUiState {
+ var values by mutableStateOf