diff --git a/app/src/androidTest/java/moe/chenxy/huaweipods/ui/components/FreeBuds4eControlSessionTest.kt b/app/src/androidTest/java/moe/chenxy/huaweipods/ui/components/FreeBuds4eControlSessionTest.kt new file mode 100644 index 0000000..6379fcd --- /dev/null +++ b/app/src/androidTest/java/moe/chenxy/huaweipods/ui/components/FreeBuds4eControlSessionTest.kt @@ -0,0 +1,155 @@ +package moe.chenxy.huaweipods.ui.components + +import androidx.compose.runtime.AbstractApplier +import androidx.compose.runtime.Composition +import androidx.compose.runtime.Recomposer +import androidx.test.platform.app.InstrumentationRegistry +import kotlin.coroutines.EmptyCoroutineContext +import moe.chenxy.huaweipods.pods.HuaweiDeviceRoute +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class FreeBuds4eControlSessionTest { + @Test + fun sameAddressRouteSwitchRejectsDelayedSuccessAndFailure() = onMain { + val routes = listOf( + HuaweiDeviceRoute.HUAWEI_FREEBUDS4, + HuaweiDeviceRoute.HUAWEI_FREEBUDS4E, + ) + for (oldRoute in routes) { + val newRoute = routes.first { it != oldRoute } + for (oldSuccess in listOf(true, false)) { + val host = SessionHost() + try { + val oldSession = host.render(ADDRESS, oldRoute) + oldSession.selectedId.value = 1 + oldSession.showDialog.value = true + oldSession.pending.value = true + var acceptedOldCompletions = 0 + val delayedOldCompletion: (Boolean) -> Unit = { success -> + if (oldSession.completeWrite(2, success)) { + acceptedOldCompletions++ + } + } + + // Re-rendering the same identity must retain its in-flight UI state. + assertSame(oldSession, host.render(ADDRESS, oldRoute)) + assertEquals(1, oldSession.selectedId.value) + assertTrue(oldSession.showDialog.value) + assertTrue(oldSession.pending.value) + + val current = host.render(ADDRESS, newRoute) + assertNotSame(oldSession, current) + assertFresh(current) + current.selectedId.value = 3 + current.showDialog.value = true + current.pending.value = true + + // The old transport finishes only after the new route starts a write. + delayedOldCompletion(oldSuccess) + assertEquals(0, acceptedOldCompletions) + assertEquals(3, current.selectedId.value) + assertTrue(current.showDialog.value) + assertTrue(current.pending.value) + // Invalidation also prevents writes to the retired state object itself. + assertEquals(1, oldSession.selectedId.value) + assertTrue(oldSession.showDialog.value) + assertTrue(oldSession.pending.value) + + // The active route still accepts its own completion normally. + assertTrue(current.completeWrite(2, true)) + assertEquals(2, current.selectedId.value) + assertFalse(current.showDialog.value) + assertFalse(current.pending.value) + + // Returning to the original route must not revive its old callback. + val returned = host.render(ADDRESS, oldRoute) + assertNotSame(oldSession, returned) + assertFresh(returned) + delayedOldCompletion(oldSuccess) + assertEquals(0, acceptedOldCompletions) + assertFresh(returned) + } finally { + host.close() + } + } + } + } + + @Test + fun addressSwitchInvalidatesOldWriteAndActiveFailureStillCompletes() = onMain { + val host = SessionHost() + try { + val route = HuaweiDeviceRoute.HUAWEI_FREEBUDS4E + val oldSession = host.render(ADDRESS, route) + oldSession.selectedId.value = 1 + oldSession.showDialog.value = true + oldSession.pending.value = true + + val current = host.render("AA:BB:CC:DD:EE:02", route) + assertNotSame(oldSession, current) + assertFresh(current) + current.selectedId.value = 3 + current.showDialog.value = true + current.pending.value = true + + assertFalse(oldSession.completeWrite(2, true)) + assertEquals(3, current.selectedId.value) + assertTrue(current.showDialog.value) + assertTrue(current.pending.value) + + assertTrue(current.completeWrite(2, false)) + assertEquals(3, current.selectedId.value) + assertFalse(current.showDialog.value) + assertFalse(current.pending.value) + } finally { + host.close() + } + } + + private fun assertFresh(session: FreeBuds4eControlSession) { + assertNull(session.selectedId.value) + assertFalse(session.showDialog.value) + assertFalse(session.pending.value) + } + + private fun onMain(block: () -> Unit) { + InstrumentationRegistry.getInstrumentation().runOnMainSync { block() } + } + + /** Runtime-only composition: exercises the production remember keys and disposal effect. */ + private class SessionHost { + private val recomposer = Recomposer(EmptyCoroutineContext) + private val composition = Composition(NoOpApplier(), recomposer) + + fun render(address: String, route: HuaweiDeviceRoute): FreeBuds4eControlSession { + var result: FreeBuds4eControlSession? = null + composition.setContent { + result = rememberFreeBuds4eControlSession(address, route) + } + return checkNotNull(result) + } + + fun close() { + composition.dispose() + recomposer.cancel() + } + } + + private class NoOpApplier : AbstractApplier(Unit) { + override fun insertTopDown(index: Int, instance: Unit) = Unit + override fun insertBottomUp(index: Int, instance: Unit) = Unit + override fun remove(index: Int, count: Int) = Unit + override fun move(from: Int, to: Int, count: Int) = Unit + override fun onClear() = Unit + } + + private companion object { + const val ADDRESS = "AA:BB:CC:DD:EE:01" + } +} 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/FreeClip2LowLatencyTransaction.kt b/app/src/main/java/moe/chenxy/huaweipods/pods/FreeClip2LowLatencyTransaction.kt new file mode 100644 index 0000000..7d90dbe --- /dev/null +++ b/app/src/main/java/moe/chenxy/huaweipods/pods/FreeClip2LowLatencyTransaction.kt @@ -0,0 +1,60 @@ +package moe.chenxy.huaweipods.pods + +import java.util.concurrent.atomic.AtomicBoolean + +internal data class FreeClip2LowLatencyResult(val state: Boolean?, val success: Boolean) + +/** + * FreeClip 2 手动低时延事务。写出成功不是策略提交条件;只有当前请求的匹配回读才允许提交。 + * I/O 和最终提交由宿主注入,所有步骤共用同一个请求有效性检查。 + */ +internal object FreeClip2LowLatencyTransaction { + fun run( + enabled: Boolean, + stillCurrent: () -> Boolean, + write: (() -> Boolean, (Boolean) -> Unit) -> Unit, + read: ((Boolean?) -> Unit) -> Unit, + commit: () -> Boolean, + complete: (FreeClip2LowLatencyResult) -> Unit, + ) { + val finished = AtomicBoolean(false) + fun finish(state: Boolean?, matches: Boolean) { + if (!finished.compareAndSet(false, true)) return + if (!stillCurrent()) { + complete(FreeClip2LowLatencyResult(null, false)) + return + } + // commit 必须在宿主会话锁内再次检查有效性,再更新策略和 applied generation。 + val committed = matches && commit() + complete(FreeClip2LowLatencyResult(state, committed)) + } + fun readState(attempt: Int) { + if (!stillCurrent()) { + finish(null, false) + return + } + val delivered = AtomicBoolean(false) + read(response@{ state -> + if (!delivered.compareAndSet(false, true) || finished.get()) return@response + if (!stillCurrent()) { + finish(null, false) + } else if (state == enabled) { + finish(state, true) + } else if (attempt < 2) { + readState(attempt + 1) + } else { + finish(state, false) + } + }) + } + if (!stillCurrent()) { + finish(null, false) + return + } + val written = AtomicBoolean(false) + write(stillCurrent, response@{ success -> + if (!written.compareAndSet(false, true) || finished.get()) return@response + if (!success || !stillCurrent()) finish(null, false) else readState(1) + }) + } +} 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..d5a05c3 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,72 @@ 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) { + // 写出、回读和策略提交都由宿主完成;不能先经过写出即保存的旧入口。 + HuaweiLowLatencyController.setFreeClip2EnabledVerified( + context, device, enabled, onState, onComplete, + ) + } else { + writeSetting(context, device, feature.packet(enabled), codec.query(), codec::parse, + { it == enabled }, onState, onComplete) + } + } + + internal fun requestLowLatencyState( + context: Context, device: BluetoothDevice, onState: (Boolean?) -> Unit, + ) = requestSetting( + context, device, FreeBuds7Setting.LOW_LATENCY.query(), + FreeBuds7Setting.LOW_LATENCY::parse, onState, + ) + fun requestHeadMotionState(context: Context, device: BluetoothDevice, onState: (FreeClip2HeadMotionState?) -> Unit) = requestSetting(context, device, HEAD_MOTION_QUERY, ::parseHeadMotionState, onState) @@ -235,14 +301,15 @@ object HuaweiFreeClip2Controller { ): FreeClip2AudioState? { var latest: FreeClip2AudioState? = null frames(stream).forEach { frame -> - val marker = frame.indexOfSequence(SPATIAL_AUDIO_RESPONSE_PREFIX, startIndex = 4) - if (marker < 0 || marker + SPATIAL_AUDIO_RESPONSE_SIZE > frame.size - CHECKSUM_SIZE) { - return@forEach - } - val mode = modeFromStateReportValue(frame.u8(marker + 7)) - ?: return@forEach - val scene = FreeClip2SpatialScene.fromProtocolValue(frame.u8(marker + 10)) - ?: return@forEach + if (frame.u8OrNull(4) != 0x2B || frame.u8OrNull(5) != 0xB4) return@forEach + // Parse the complete TLV area before using any value. A matching byte + // sequence alone cannot establish field boundaries, lengths or uniqueness. + val fields = frame.settingFields() ?: return@forEach + if (fields[1] != 0x18) return@forEach + val modeValue = fields[2] ?: return@forEach + val sceneValue = fields[3] ?: return@forEach + val mode = modeFromStateReportValue(modeValue) ?: return@forEach + val scene = FreeClip2SpatialScene.fromProtocolValue(sceneValue) ?: return@forEach latest = FreeClip2AudioState(mode = mode, scene = scene) } return latest @@ -257,18 +324,10 @@ object HuaweiFreeClip2Controller { return FreeClip2AudioState(effect = effect, equalizer = equalizer) } } - var latest: FreeClip2AudioState? = null - frames(stream).forEach { frame -> - if (frame.u8OrNull(4) != 0x2B || frame.u8OrNull(5) != 0x4A) return@forEach - val marker = frame.indexOfSequence(SOUND_EFFECT_RESPONSE_FIELD, startIndex = 6) - if (marker < 0 || marker + SOUND_EFFECT_RESPONSE_SIZE > frame.size - CHECKSUM_SIZE) { - return@forEach - } - val effect = FreeClip2SoundEffect.fromProtocolValue(frame.u8(marker + 2)) - ?: return@forEach - latest = FreeClip2AudioState(effect = effect) - } - return latest + // The equalizer codec also accepts minimal built-in effect reports. Do + // not rescue rejected reports by scanning for 02 01 inside TLV values or + // across fields: only a unique, one-byte field 02 is a selected effect. + return null } /** Parses both response types when the transport returns them in the same byte stream. */ @@ -415,8 +474,7 @@ data class FreeClip2HeadMotionState( /** * FreeClip 2 耳机 AAM 空间音频协议:0=关闭、1=头部跟踪、2=固定。 * - * 该顺序同时由官方 API 和真机实际效果确认。FreeBuds 7i 的同类命令顺序不同, - * 必须由它自己的控制器转换,不能再共用这里的 wire value。 + * 该顺序同时由官方 API 和真机实际效果确认。各型号控制器负责对应的协议转换。 */ enum class FreeClip2SpatialAudioMode( val extraValue: String, @@ -499,10 +557,6 @@ enum class FreeClip2SoundEffect( private const val HEADER_SIZE = 5 private const val CHECKSUM_SIZE = 2 -private const val SPATIAL_AUDIO_RESPONSE_SIZE = 11 -private const val SOUND_EFFECT_RESPONSE_SIZE = 3 -private val SPATIAL_AUDIO_RESPONSE_PREFIX = hex("2BB40101180201") -private val SOUND_EFFECT_RESPONSE_FIELD = hex("0201") private fun frames(stream: ByteArray): Sequence = sequence { var offset = 0 @@ -572,15 +626,6 @@ private fun ByteArray.settingFields(): Map? { private fun Int.asBoolean(): Boolean? = when (this) { 0 -> false; 1 -> true; else -> null } -private fun ByteArray.indexOfSequence(needle: ByteArray, startIndex: Int): Int { - if (needle.isEmpty()) return startIndex.coerceIn(0, size) - val lastStart = size - needle.size - for (index in startIndex.coerceAtLeast(0)..lastStart) { - if (needle.indices.all { offset -> this[index + offset] == needle[offset] }) return index - } - return -1 -} - private fun ByteArray.u8(index: Int): Int = this[index].toInt() and 0xFF private fun ByteArray.u8OrNull(index: Int): Int? = getOrNull(index)?.toInt()?.and(0xFF) 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/HuaweiHfpController.kt b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiHfpController.kt index f1631d9..8aa68a8 100644 --- a/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiHfpController.kt +++ b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiHfpController.kt @@ -389,6 +389,7 @@ object HuaweiHfpController { HuaweiSharedControlHost.handle( currentContext, currentDevice, route, receivedIntent, writeLowLatency = { enabled, complete -> setLowLatency(enabled, complete) }, + writeLowLatencyVerified = ::setFreeClip2LowLatencyVerified, ) { isCurrentSession(generation, address, route) } } HuaweiPodsAction.ACTION_HUAWEI_WEAR_DETECTION_SET -> { @@ -1687,6 +1688,83 @@ object HuaweiHfpController { } } + private fun setFreeClip2LowLatencyVerified( + requestedEnabled: Boolean, + requestCurrent: () -> Boolean, + complete: (FreeClip2LowLatencyResult) -> Unit, + ) { + synchronized(sessionStateLock) { + val currentContext = context + val currentDevice = device + val requestedRoute = sessionRoute + if (currentContext == null || currentDevice == null || + requestedRoute != HuaweiDeviceRoute.HUAWEI_FREECLIP2 || !requestCurrent() + ) { + complete(FreeClip2LowLatencyResult(null, false)) + return + } + val requestedAddress = currentDevice.address + val requestedGeneration = sessionGeneration + val previousDesired = LowLatencyPrefs.desiredForHook(requestedAddress, requestedRoute) ?: false + val token = lowLatencyWritePolicy.beginManual() + cancelAutoLowLatency() + val stillCurrent = { + synchronized(sessionStateLock) { + lowLatencyWritePolicy.isCurrent(token) && requestCurrent() && + isCurrentSession(requestedGeneration, requestedAddress, requestedRoute) + } + } + FreeClip2LowLatencyTransaction.run( + enabled = requestedEnabled, + stillCurrent = stillCurrent, + write = { shouldWrite, written -> + HuaweiLowLatencyController.sendInHost( + context = currentContext, + device = currentDevice, + route = requestedRoute, + enabled = requestedEnabled, + shouldWrite = shouldWrite, + onComplete = written, + ) + }, + read = { onState -> + HuaweiFreeClip2Controller.requestLowLatencyState(currentContext, currentDevice, onState) + }, + commit = { + synchronized(sessionStateLock) { + // 核实和提交之间也可能发生换会话、新操作或客户端超时。 + if (!stillCurrent()) { + false + } else { + LowLatencyPrefs.setDesiredFromHook(requestedAddress, requestedRoute, requestedEnabled) + if (requestedEnabled) { + lowLatencyAppliedGeneration = requestedGeneration + } else { + cancelAutoLowLatency() + } + true + } + } + }, + complete = { result -> + synchronized(sessionStateLock) { + val current = stillCurrent() + val finished = lowLatencyWritePolicy.finishManual(token) + val effective = if (current && finished) result else FreeClip2LowLatencyResult(null, false) + if (current && finished) { + // 失败不能通过状态广播把未确认的新策略带回 App。 + sendLowLatencyState( + if (effective.success) requestedEnabled else previousDesired, + writeSuccess = effective.success, + ) + } + complete(effective) + } + }, + ) + } + } + private fun requestOfficialImageIdentity(force: Boolean = false) { val currentContext = context ?: return val currentDevice = device ?: return diff --git a/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiLowLatencyController.kt b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiLowLatencyController.kt index 9f9dcf5..87dec4a 100644 --- a/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiLowLatencyController.kt +++ b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiLowLatencyController.kt @@ -13,6 +13,41 @@ object HuaweiLowLatencyController { private val DISABLED_PACKET = decodeLowLatencyHex("5A0006002B6C010100B430") private val ENABLED_PACKET = decodeLowLatencyHex("5A0006002B6C010101A411") + /** 页面专用确认事务。没有宿主响应时失败,不回退到 App 进程争抢连接。 */ + internal fun setFreeClip2EnabledVerified( + context: Context, + device: BluetoothDevice, + enabled: Boolean, + onState: (Boolean?) -> Unit, + onComplete: (Boolean) -> Unit, + ) { + val route = HuaweiDeviceRoute.HUAWEI_FREECLIP2 + val address = runCatching { device.address }.getOrNull() + if (!usesSharedConnection(context.packageName, route) || + address == null || !BluetoothAdapter.checkBluetoothAddress(address) + ) { + onState(null) + onComplete(false) + return + } + HuaweiSharedControlClient.request( + context, device, route, HuaweiSharedControlPolicy.LOW_LATENCY_SET_VERIFIED, + enabled = enabled, + ) { response -> + val result = response?.takeIf { + it.size == 2 && (it[0] == 0.toByte() || it[0] == 1.toByte()) && + it[1].toInt() in 0..2 + } + val actual = when (result?.get(1)?.toInt()) { + 0 -> false + 1 -> true + else -> null + } + onState(actual) + onComplete(result?.get(0) == 1.toByte() && actual == enabled) + } + } + fun setEnabled( context: Context, device: BluetoothDevice, diff --git a/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiSharedControlClient.kt b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiSharedControlClient.kt index 0c2b57a..ccecb15 100644 --- a/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiSharedControlClient.kt +++ b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiSharedControlClient.kt @@ -7,6 +7,7 @@ import android.content.Intent import android.content.IntentFilter import android.os.Handler import android.os.Looper +import android.os.SystemClock import java.util.UUID import moe.chenxy.huaweipods.BuildConfig import moe.chenxy.huaweipods.utils.miuiStrongToast.data.HuaweiPodsAction @@ -38,6 +39,8 @@ internal object HuaweiSharedControlClient { return } val nonce = UUID.randomUUID().toString() + // 与宿主共享单调时钟期限,避免客户端已超时后宿主才提交 verified 策略。 + val deadlineElapsed = SystemClock.elapsedRealtime() + 8_000L val timeout = Runnable { finish(nonce, null) } if (!pending.add(nonce, Pending(device.address, route, complete, timeout))) { complete(null) @@ -51,6 +54,7 @@ internal object HuaweiSharedControlClient { putExtra("address", device.address) putExtra(HuaweiPodsAction.EXTRA_DEVICE_ROUTE, encodeHuaweiDeviceRouteForBroadcast(route)) putExtra("operation", operation) + putExtra("deadline_elapsed", deadlineElapsed) putExtra("preset_id", presetId) putExtra("name", name) putExtra("gains", gains.toIntArray()) diff --git a/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiSharedControlHost.kt b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiSharedControlHost.kt index 470b0a5..702182d 100644 --- a/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiSharedControlHost.kt +++ b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiSharedControlHost.kt @@ -5,19 +5,23 @@ import android.content.Context import android.content.Intent import android.os.Handler import android.os.Looper +import android.os.SystemClock import java.util.concurrent.atomic.AtomicBoolean import moe.chenxy.huaweipods.BuildConfig import moe.chenxy.huaweipods.utils.miuiStrongToast.data.HuaweiPodsAction import moe.chenxy.huaweipods.utils.miuiStrongToast.data.sendIdentitySharingBroadcast internal object HuaweiSharedControlHost { - private val handler = Handler(Looper.getMainLooper()) + private val handler by lazy { Handler(Looper.getMainLooper()) } + fun handle( context: Context, device: BluetoothDevice, route: HuaweiDeviceRoute, intent: Intent, writeLowLatency: (Boolean, (Boolean) -> Unit) -> Unit, + writeLowLatencyVerified: (Boolean, () -> Boolean, (FreeClip2LowLatencyResult) -> Unit) -> Unit = + { _, _, complete -> complete(FreeClip2LowLatencyResult(null, false)) }, stillCurrent: () -> Boolean, ) { val nonce = intent.getStringExtra("nonce") @@ -39,16 +43,23 @@ internal object HuaweiSharedControlHost { addFlags(Intent.FLAG_RECEIVER_FOREGROUND) }) } - if (operation == HuaweiSharedControlPolicy.LOW_LATENCY_SET) { - if (!route.supportsLowLatencyControl || !intent.hasExtra("enabled") || !stillCurrent()) { - reply(null) - } else { - writeLowLatency(enabled) { success -> - reply(if (success) byteArrayOf(1) else null) - } - } - return - } + val deadline = intent.getLongExtra("deadline_elapsed", -1L) + val now = SystemClock.elapsedRealtime() + val validDeadline = deadline > now && deadline - now <= 8_000L + if (dispatchLowLatency( + operation = operation, + route = route, + enabled = enabled.takeIf { intent.hasExtra("enabled") }, + writeLowLatency = writeLowLatency, + writeLowLatencyVerified = writeLowLatencyVerified, + stillCurrent = { + stillCurrent() && + (operation != HuaweiSharedControlPolicy.LOW_LATENCY_SET_VERIFIED || + (validDeadline && !replied.get() && SystemClock.elapsedRealtime() < deadline)) + }, + reply = ::reply, + ) + ) return val query = HuaweiSharedControlPolicy.query(route, operation) ?: return reply(null) if (!stillCurrent()) return reply(null) fun readState() { @@ -88,4 +99,52 @@ internal object HuaweiSharedControlHost { } } } + + /** 与广播入口共用的操作分发;verified 操作绝不能落入写出即保存的旧 setter。 */ + internal fun dispatchLowLatency( + operation: String, + route: HuaweiDeviceRoute, + enabled: Boolean?, + writeLowLatency: (Boolean, (Boolean) -> Unit) -> Unit, + writeLowLatencyVerified: (Boolean, () -> Boolean, (FreeClip2LowLatencyResult) -> Unit) -> Unit, + stillCurrent: () -> Boolean, + reply: (ByteArray?) -> Unit, + ): Boolean { + if (operation != HuaweiSharedControlPolicy.LOW_LATENCY_SET && + operation != HuaweiSharedControlPolicy.LOW_LATENCY_SET_VERIFIED + ) return false + if (enabled == null || !stillCurrent()) { + reply(null) + return true + } + if (operation == HuaweiSharedControlPolicy.LOW_LATENCY_SET_VERIFIED) { + if (route != HuaweiDeviceRoute.HUAWEI_FREECLIP2) { + reply(null) + } else { + writeLowLatencyVerified(enabled, stillCurrent) { result -> + if (!stillCurrent()) { + reply(null) + } else { + val state = when (result.state) { + false -> 0 + true -> 1 + null -> 2 + } + // 两字节分别表示事务提交成功和耳机实际状态;未知状态不能成为成功。 + reply(byteArrayOf( + (if (result.success && result.state == enabled) 1 else 0).toByte(), + state.toByte(), + )) + } + } + } + } else if (!route.supportsLowLatencyControl) { + reply(null) + } else { + writeLowLatency(enabled) { success -> + reply(if (success && stillCurrent()) byteArrayOf(1) else null) + } + } + return true + } } diff --git a/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiSharedControlPolicy.kt b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiSharedControlPolicy.kt index 2276c17..767ff65 100644 --- a/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiSharedControlPolicy.kt +++ b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiSharedControlPolicy.kt @@ -10,6 +10,7 @@ internal object HuaweiSharedControlPolicy { const val WIND_READ = "wind_read" const val WIND_SET = "wind_set" const val LOW_LATENCY_SET = "low_latency_set" + const val LOW_LATENCY_SET_VERIFIED = "low_latency_set_verified" fun supports(route: HuaweiDeviceRoute): Boolean = route == HuaweiDeviceRoute.HUAWEI_FREEBUDS6I || 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/FreeBuds4eControlSession.kt b/app/src/main/java/moe/chenxy/huaweipods/ui/components/FreeBuds4eControlSession.kt new file mode 100644 index 0000000..746b67b --- /dev/null +++ b/app/src/main/java/moe/chenxy/huaweipods/ui/components/FreeBuds4eControlSession.kt @@ -0,0 +1,41 @@ +package moe.chenxy.huaweipods.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import moe.chenxy.huaweipods.pods.HuaweiDeviceRoute + +/** UI state and write-completion ownership for one address/route composition session. */ +internal class FreeBuds4eControlSession { + val selectedId = mutableStateOf(null) + val showDialog = mutableStateOf(false) + val pending = mutableStateOf(false) + + private var active = true + + fun dispose() { + active = false + } + + /** False means the callback is stale, including any associated failure notification. */ + fun completeWrite(presetId: Int, success: Boolean): Boolean { + if (!active) return false + pending.value = false + if (success) selectedId.value = presetId + showDialog.value = false + return true + } +} + +@Composable +internal fun rememberFreeBuds4eControlSession( + address: String, + route: HuaweiDeviceRoute, +): FreeBuds4eControlSession { + val session = remember(address, route) { FreeBuds4eControlSession() } + DisposableEffect(session) { + onDispose { session.dispose() } + } + return session +} 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..fbef55f 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 @@ -14,8 +14,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -27,6 +25,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 +40,24 @@ 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) } + val session = rememberFreeBuds4eControlSession(address, route) + var selectedId by session.selectedId + var showDialog by session.showDialog + val pending by session.pending - 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 } @@ -107,17 +108,14 @@ fun FreeBuds4eControls(address: String) { Toast.makeText(context, R.string.connect_failed, Toast.LENGTH_SHORT).show() return@clickable } - pending = true + session.pending.value = true HuaweiEqualizerController.setBuiltInPreset( context, device, - HuaweiDeviceRoute.HUAWEI_FREEBUDS4E, + route, preset.id, ) { success -> - pending = false - if (success) selectedId = preset.id - showDialog = false - if (!success) { + if (session.completeWrite(preset.id, success) && !success) { Toast.makeText( context, R.string.connect_failed, 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..8baad54 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) - - 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) + FreeClip2SettingsGroups(settings) + } +} + +@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,237 @@ fun FreeClip2Controls(address: String) { } } +internal class FreeClip2BooleanUiState { + var values by mutableStateOf>(emptyMap()) + var head by mutableStateOf(null) + var reading by mutableStateOf(false) + private set + var writing by mutableStateOf(false) + private set + val busy get() = reading || writing + private var revision = 0L + var active = true + private set + private var writeCompletion: ((Boolean) -> Unit)? = null + var refresh: () -> Unit = {} + var change: (FreeClip2BooleanFeature, Boolean, (Boolean) -> Unit) -> Unit = { _, _, done -> done(false) } + var changeHead: (FreeClip2HeadMotionGesture, FreeClip2HeadMotionAction, (Boolean) -> Unit) -> Unit = { _, _, done -> done(false) } + + // These methods and the controller callbacks are confined to the main thread. + fun activate() { + active = true + refresh() + } + + fun deactivate() { + active = false + revision++ + reading = false + writing = false + values = emptyMap() + head = null + val complete = writeCompletion + writeCompletion = null + // Release a retained preference's pending UI exactly once. A later transport + // completion must not finish this callback again or affect a new session. + complete?.invoke(false) + } + + fun isCurrent(ticket: Long): Boolean = active && ticket == revision + + fun beginRead(): Long? { + if (!active || busy) return null + revision++ + reading = true + values = emptyMap() + head = null + return revision + } + + fun finishRead( + ticket: Long, + actual: Map, + actualHead: FreeClip2HeadMotionState?, + ) { + if (!isCurrent(ticket) || !reading) return + values = actual + head = actualHead + reading = false + } + + fun beginWrite(done: (Boolean) -> Unit): Long? { + if (!active || busy) { + done(false) + return null + } + revision++ + writing = true + writeCompletion = done + return revision + } + + fun finishWrite(ticket: Long, success: Boolean, persist: () -> Boolean = { true }) { + if (!isCurrent(ticket) || !writing) return + val complete = writeCompletion + writeCompletion = null + writing = false + // Only a current, confirmed user write may change reconnect policy. + // Invalidate duplicate callbacks before running persistence or UI code. + val stored = success && persist() + complete?.invoke(stored) + } + + fun updateHead(value: FreeClip2HeadMotionState?) { + head = value + values = values + (FreeClip2BooleanFeature.HEAD_MOTION_CONTROL to value?.enabled) + } +} + @Composable -private fun FreeClip2DeviceSettingsControls(address: String) { +private fun rememberFreeClip2BooleanSettings(address: String): FreeClip2BooleanUiState { val context = LocalContext.current val lifecycleOwner = LocalView.current.findViewTreeLifecycleOwner() val device = remember(context, address) { context.freeClip2Device(address) } - var headState by remember(address) { mutableStateOf(null) } - var noisyVolume by remember(address) { mutableStateOf(null) } - var headPending by remember(address) { mutableStateOf(false) } - var volumePending by remember(address) { mutableStateOf(false) } - var headRevision by remember(address) { mutableStateOf(0) } - var volumeRevision by remember(address) { mutableStateOf(0) } - val refresh by rememberUpdatedState { - device?.let { - if (!headPending) { - val revision = ++headRevision - HuaweiFreeClip2Controller.requestHeadMotionState(context, it) { state -> - if (revision == headRevision) headState = state + val state = remember(address) { FreeClip2BooleanUiState() } + state.refresh = { + if (device != null) { + state.beginRead()?.let { revision -> + HuaweiFreeClip2Controller.requestBooleanSettings(context, device) { values, head -> + state.finishRead(revision, values, head) } } - if (!volumePending) { - val revision = ++volumeRevision - HuaweiFreeClip2Controller.requestNoisyVolumeState(context, it) { state -> - if (revision == volumeRevision) noisyVolume = state + } + } + state.change = { feature, enabled, done -> + if (device == null) done(false) + else state.beginWrite(done)?.let { revision -> + val complete: (Boolean) -> Unit = { success -> + state.finishWrite(revision, success) { + // 自动应用策略只由当前已确认的用户写入更新,读取不会覆盖策略。 + if (feature == FreeClip2BooleanFeature.LOW_LATENCY) { + LowLatencyPrefs.setDesired( + context.getSharedPreferences(ConfigManager.PREFS_NAME, Context.MODE_PRIVATE), + HuaweiPodsApp.xposedService, address, HuaweiDeviceRoute.HUAWEI_FREECLIP2, enabled, + ) + } else true } } + if (feature == FreeClip2BooleanFeature.HEAD_MOTION_CONTROL) { + HuaweiFreeClip2Controller.setHeadMotionEnabled(context, device, enabled, + onState = { if (state.isCurrent(revision)) state.updateHead(it) }, + onComplete = complete, + ) + } else HuaweiFreeClip2Controller.setBooleanFeatureVerified( + context, device, feature, enabled, + onState = { actual -> + if (state.isCurrent(revision)) state.values = state.values + (feature to actual) + }, + onComplete = complete, + ) + } + } + state.changeHead = { gesture, action, done -> + if (device == null) done(false) + else state.beginWrite(done)?.let { revision -> + HuaweiFreeClip2Controller.setHeadMotionAction(context, device, gesture, action, + onState = { if (state.isCurrent(revision)) state.updateHead(it) }, + onComplete = { state.finishWrite(revision, it) }, + ) } } - DisposableEffect(address, lifecycleOwner) { + DisposableEffect(state, lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) refresh() + when (event) { + Lifecycle.Event.ON_RESUME -> state.activate() + Lifecycle.Event.ON_PAUSE, + Lifecycle.Event.ON_STOP, + Lifecycle.Event.ON_DESTROY -> state.deactivate() + else -> Unit + } } + state.deactivate() lifecycleOwner?.lifecycle?.addObserver(observer) - if (lifecycleOwner == null || !lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) refresh() + if (lifecycleOwner == null || lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + state.activate() + } onDispose { - headRevision++ - volumeRevision++ lifecycleOwner?.lifecycle?.removeObserver(observer) + state.deactivate() } } + return state +} +@Composable +private fun BooleanFeaturePreference( + title: Int, feature: FreeClip2BooleanFeature, state: FreeClip2BooleanUiState, summary: String? = null, +) { FeatureToggle( - titleRes = R.string.freeclip2_head_motion, - initialValue = headState?.enabled, - available = headState != null && !headPending, - onChange = { enabled, complete -> - if (device == null) complete(false) - else { - headRevision++ - headPending = true - HuaweiFreeClip2Controller.setHeadMotionEnabled( - context, device, enabled, onState = { headState = it }, - onComplete = { headPending = false; complete(it) }, - ) - } + titleRes = title, + value = state.values[feature], + available = !state.busy, + summary = when { + state.reading -> stringResource(R.string.freeclip2_settings_loading) + state.values[feature] == null -> stringResource(R.string.freeclip2_settings_unknown) + else -> summary }, + onChange = { enabled, done -> state.change(feature, enabled, done) }, ) - if (headState?.enabled == true) { +} + +@Composable +private fun FreeClip2DeviceSettingsControls(settings: FreeClip2BooleanUiState) { + BooleanFeaturePreference(R.string.freeclip2_head_motion, FreeClip2BooleanFeature.HEAD_MOTION_CONTROL, settings) + if (settings.head?.enabled == true) { FreeClip2HeadMotionGesture.entries.forEach { gesture -> EnumPreference( title = stringResource(if (gesture == FreeClip2HeadMotionGesture.NOD) R.string.freeclip2_head_nod else R.string.freeclip2_head_shake), - selected = headState?.action(gesture), + selected = settings.head?.action(gesture), values = FreeClip2HeadMotionAction.entries, label = { stringResource(it.labelRes()) }, - available = !headPending, + available = !settings.busy, onSelected = { action, complete -> - if (device == null) complete(false) - else { - headRevision++ - headPending = true - HuaweiFreeClip2Controller.setHeadMotionAction( - context, device, gesture, action, onState = { headState = it }, - onComplete = { headPending = false; complete(it) }, - ) - } + settings.changeHead(gesture, action, complete) }, ) } } - FeatureToggle( - titleRes = R.string.freeclip2_noisy_volume_boost, - initialValue = noisyVolume, - available = noisyVolume != null && !volumePending, - onChange = { enabled, complete -> - if (device == null) complete(false) - else { - volumeRevision++ - volumePending = true - HuaweiFreeClip2Controller.setNoisyVolumeEnabled( - context, device, enabled, onState = { noisyVolume = it }, - onComplete = { volumePending = false; complete(it) }, - ) - } - }, - ) - Text( - stringResource(R.string.freeclip2_noisy_volume_hint), - color = MiuixTheme.colorScheme.onSurfaceVariantSummary, - style = MiuixTheme.textStyles.body2, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp), + BooleanFeaturePreference( + title = R.string.freeclip2_noisy_volume_boost, + feature = FreeClip2BooleanFeature.NOISY_VOLUME_BOOST, + state = settings, + summary = stringResource(R.string.freeclip2_noisy_volume_hint), ) - if (headState == null || noisyVolume == null) { - Text( - stringResource(R.string.freeclip2_settings_retry), - color = MiuixTheme.colorScheme.primary, - style = MiuixTheme.textStyles.body2, - modifier = Modifier.clickable { refresh() }.padding(horizontal = 24.dp, vertical = 12.dp), - ) - } } @Composable -private fun SectionTitle(titleRes: Int) { - Text( - text = stringResource(titleRes), - color = MiuixTheme.colorScheme.primary, - style = MiuixTheme.textStyles.headline1, - modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp), - ) +private fun FreeClip2Section(titleRes: Int, content: @Composable () -> Unit) { + SmallTitle(text = stringResource(titleRes)) + Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 12.dp)) { + content() + } } @Composable private fun FeatureToggle( titleRes: Int, - initialValue: Boolean?, + value: Boolean?, available: Boolean = true, + summary: String? = null, onChange: (Boolean, (Boolean) -> Unit) -> Unit, ) { val context = LocalContext.current - var enabled by remember(initialValue) { mutableStateOf(initialValue ?: false) } - var pending by remember { mutableStateOf(false) } - val toggle = { - if (available && !pending) { - val target = !enabled - pending = true + SwitchPreference( + title = stringResource(titleRes), + summary = if (value == null) summary ?: stringResource(R.string.freeclip2_settings_unknown) else summary, + checked = value == true, + enabled = available && value != null, + onCheckedChange = { target -> onChange(target) { success -> - pending = false - if (success) { - enabled = target - } else { - Toast.makeText(context, R.string.connect_failed, Toast.LENGTH_SHORT).show() - } + if (!success) Toast.makeText(context, R.string.freeclip2_write_unconfirmed, Toast.LENGTH_SHORT).show() } - } - } - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .clickable(enabled = available && !pending, role = Role.Switch, onClick = toggle) - .padding(horizontal = 24.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(titleRes), - color = MiuixTheme.colorScheme.onSurface, - style = MiuixTheme.textStyles.headline1, - modifier = Modifier.weight(1f), - ) - Spacer(Modifier.width(12.dp)) - Checkbox( - state = if (initialValue == null) ToggleableState.Indeterminate else ToggleableState(enabled), - enabled = available && !pending, - onClick = toggle, - ) - } + }, + ) } @Composable @@ -403,20 +437,13 @@ private fun EnumPreference( val context = LocalContext.current var showDialog by remember { mutableStateOf(false) } var pending by remember { mutableStateOf(false) } - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .clickable(enabled = available && !pending, role = Role.Button) { showDialog = true } - .padding(horizontal = 24.dp, vertical = 14.dp), - ) { - Text(title, color = MiuixTheme.colorScheme.onSurface, style = MiuixTheme.textStyles.headline1) - Text( - selected?.let { label(it) }.orEmpty(), - color = MiuixTheme.colorScheme.onSurfaceVariantSummary, - style = MiuixTheme.textStyles.body2, - ) - } + ArrowPreference( + title = title, + summary = selected?.let { label(it) } ?: stringResource(R.string.freeclip2_settings_unknown), + enabled = available && !pending, + holdDownState = showDialog, + onClick = { showDialog = true }, + ) OverlayDialog( title = title, summary = selected?.let { label(it) }.orEmpty(), @@ -426,30 +453,25 @@ private fun EnumPreference( Column(modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp)) { values.forEach { value -> val isSelected = value == selected - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .clickable(enabled = available && !pending, role = Role.RadioButton) { - if (isSelected) { + RadioButtonPreference( + title = label(value), + selected = isSelected, + enabled = available && !pending, + onClick = { + if (isSelected) { + showDialog = false + } else { + pending = true + onSelected(value) { success -> + pending = false showDialog = false - } else { - pending = true - onSelected(value) { success -> - pending = false - showDialog = false - if (!success) { - Toast.makeText(context, R.string.connect_failed, Toast.LENGTH_SHORT).show() - } + if (!success) { + Toast.makeText(context, R.string.connect_failed, Toast.LENGTH_SHORT).show() } } } - .padding(horizontal = 12.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(label(value), modifier = Modifier.weight(1f), style = MiuixTheme.textStyles.headline1) - Checkbox(state = ToggleableState(isSelected), enabled = available && !pending, onClick = null) - } + }, + ) } } } @@ -462,16 +484,6 @@ private fun Context.freeClip2Device(address: String) = ?.adapter ?.getRemoteDevice(address) -private fun Context.setFreeClip2BooleanFeature( - address: String, - feature: FreeClip2BooleanFeature, - enabled: Boolean, - complete: (Boolean) -> Unit, -) { - val device = freeClip2Device(address) ?: return complete(false) - HuaweiFreeClip2Controller.setBooleanFeature(this, device, feature, enabled, complete) -} - private fun Context.setFreeClip2SpatialMode( address: String, value: FreeClip2SpatialAudioMode, diff --git a/app/src/main/java/moe/chenxy/huaweipods/ui/components/HuaweiGestureControls.kt b/app/src/main/java/moe/chenxy/huaweipods/ui/components/HuaweiGestureControls.kt index 0e7ddb8..04694a5 100644 --- a/app/src/main/java/moe/chenxy/huaweipods/ui/components/HuaweiGestureControls.kt +++ b/app/src/main/java/moe/chenxy/huaweipods/ui/components/HuaweiGestureControls.kt @@ -47,6 +47,7 @@ import moe.chenxy.huaweipods.config.ConfigManager import moe.chenxy.huaweipods.pods.FreeBudsPro3GestureToggle import moe.chenxy.huaweipods.pods.FreeBudsPro3LongPressAction import moe.chenxy.huaweipods.pods.HuaweiDeviceRoute +import moe.chenxy.huaweipods.pods.isFreeBuds4Series import moe.chenxy.huaweipods.pods.HuaweiGestureController import moe.chenxy.huaweipods.pods.HuaweiGestureKind import moe.chenxy.huaweipods.pods.HuaweiGestureSide @@ -918,8 +919,8 @@ internal fun huaweiGestureControlLayout(route: HuaweiDeviceRoute): HuaweiGesture return HuaweiGestureControlLayout( tapKinds = tapKinds, hasSwipe = HuaweiSwipeAction.availableFor(route).isNotEmpty(), - hasFixedSwipeVolume = route == HuaweiDeviceRoute.HUAWEI_FREEBUDS4E, - hasModernLongPressControls = route == HuaweiDeviceRoute.HUAWEI_FREEBUDS4E || + hasFixedSwipeVolume = route.isFreeBuds4Series, + hasModernLongPressControls = route.isFreeBuds4Series || route == HuaweiDeviceRoute.HUAWEI_FREEBUDS6I || route == HuaweiDeviceRoute.HUAWEI_FREEBUDS_PRO3 || route == HuaweiDeviceRoute.HUAWEI_FREEBUDS7I || @@ -930,7 +931,7 @@ internal fun huaweiGestureControlLayout(route: HuaweiDeviceRoute): HuaweiGesture hasModernPinchControls = route == HuaweiDeviceRoute.HUAWEI_FREEBUDS_PRO3 || route == HuaweiDeviceRoute.HUAWEI_FREEBUDS_PRO5, hasWearDetection = route == HuaweiDeviceRoute.HUAWEI_FREEBUDS6I || - route == HuaweiDeviceRoute.HUAWEI_FREEBUDS4E || + route.isFreeBuds4Series || route == HuaweiDeviceRoute.HUAWEI_FREEBUDS_PRO3 || route == HuaweiDeviceRoute.HUAWEI_FREEBUDS_PRO5 || route == HuaweiDeviceRoute.HUAWEI_FREECLIP, @@ -1013,7 +1014,7 @@ private fun longPressTitleRes(route: HuaweiDeviceRoute, side: HuaweiGestureSide) } private fun HuaweiDeviceRoute.isHoldGestureRoute(): Boolean = - this == HuaweiDeviceRoute.HUAWEI_FREEBUDS4E || this == HuaweiDeviceRoute.HUAWEI_FREEARC + isFreeBuds4Series || this == HuaweiDeviceRoute.HUAWEI_FREEARC @StringRes private fun HuaweiTapAction.labelRes(): Int = when (this) { diff --git a/app/src/main/java/moe/chenxy/huaweipods/ui/pages/PodDetailPage.kt b/app/src/main/java/moe/chenxy/huaweipods/ui/pages/PodDetailPage.kt index 8fb341c..3088cb0 100644 --- a/app/src/main/java/moe/chenxy/huaweipods/ui/pages/PodDetailPage.kt +++ b/app/src/main/java/moe/chenxy/huaweipods/ui/pages/PodDetailPage.kt @@ -73,6 +73,7 @@ import moe.chenxy.huaweipods.utils.miuiStrongToast.data.BatteryParams import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.Text import moe.chenxy.huaweipods.pods.HuaweiDeviceRoute +import moe.chenxy.huaweipods.pods.isFreeBuds4Series import moe.chenxy.huaweipods.pods.hasChargingCase import moe.chenxy.huaweipods.pods.isSupported import moe.chenxy.huaweipods.pods.supportsAnc @@ -341,11 +342,7 @@ private fun LazyListScope.podControlItems( if (deviceRoute == HuaweiDeviceRoute.HUAWEI_FREECLIP2) { item { - Card( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), - ) { - FreeClip2Controls(address = connectedDeviceAddress) - } + FreeClip2Controls(address = connectedDeviceAddress) } } @@ -410,12 +407,12 @@ private fun LazyListScope.podControlItems( } } - if (deviceRoute == HuaweiDeviceRoute.HUAWEI_FREEBUDS4E) { + if (deviceRoute.isFreeBuds4Series) { item { Card( modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), ) { - FreeBuds4eControls(address = connectedDeviceAddress) + FreeBuds4eControls(address = connectedDeviceAddress, route = deviceRoute) } } } diff --git a/app/src/main/res/values-zh-rCN/freeclip2_strings.xml b/app/src/main/res/values-zh-rCN/freeclip2_strings.xml index 7a2923f..e75385f 100644 --- a/app/src/main/res/values-zh-rCN/freeclip2_strings.xml +++ b/app/src/main/res/values-zh-rCN/freeclip2_strings.xml @@ -43,5 +43,10 @@ 低时延模式 双设备连接 充电盒提示音 - 低时延开启后会在每次连接时自动重新应用;其他无法回读的开关仍以本模块最后一次设置为准。 + 开关显示耳机回读状态,进入页面或返回前台时刷新。低时延在本模块成功开启后,会在下次连接时自动应用。 + 刷新耳机状态 + 从智慧音频返回后自动更新,也可点击重新读取 + 正在读取耳机状态… + 未读取到耳机状态,请刷新重试 + 未确认设置成功,请刷新耳机状态后重试 diff --git a/app/src/main/res/values/freeclip2_strings.xml b/app/src/main/res/values/freeclip2_strings.xml index 6547ec5..2c71268 100644 --- a/app/src/main/res/values/freeclip2_strings.xml +++ b/app/src/main/res/values/freeclip2_strings.xml @@ -43,5 +43,10 @@ Low latency mode Dual-device connection Case prompt sound - Low latency is reapplied after every connection when enabled. Other unreadable switches still reflect the last value set by this module. + Switches show device readback and refresh when you open or return to this page. Low latency is reapplied on reconnect after it is successfully enabled here. + Refresh headset state + Refreshes when returning from Smart Audio; tap to read again + Reading headset state… + Headset state unavailable. Refresh to retry. + Could not confirm the setting. Refresh headset state and retry. diff --git a/app/src/test/java/moe/chenxy/huaweipods/hook/HeadsetRefreshPolicyTest.kt b/app/src/test/java/moe/chenxy/huaweipods/hook/HeadsetRefreshPolicyTest.kt new file mode 100644 index 0000000..008fce4 --- /dev/null +++ b/app/src/test/java/moe/chenxy/huaweipods/hook/HeadsetRefreshPolicyTest.kt @@ -0,0 +1,32 @@ +package moe.chenxy.huaweipods.hook + +import org.junit.Assert.* +import org.junit.Test + +class HeadsetRefreshPolicyTest { + @Test + fun `retained detail views cannot poll after pause or screen off`() { + assertTrue(HeadsetRefreshPolicy.shouldQueryDetails(true, true, true, true, true)) + for (inactive in 0..4) { + val state = BooleanArray(5) { it != inactive } + assertFalse("inactive condition $inactive", HeadsetRefreshPolicy.shouldQueryDetails( + state[0], state[1], state[2], state[3], state[4], + )) + } + } + + @Test + fun `icon repair retains connection burst but avoids continuous fast ticks`() { + var elapsed = 0L + var count = 0 + while (true) { + elapsed += HeadsetRefreshPolicy.iconDelayMs(count) + if (elapsed > 3_600_000L) break + count++ + } + assertEquals(123, count) + assertEquals(2_500L, HeadsetRefreshPolicy.iconDelayMs(0)) + assertEquals(30_000L, HeadsetRefreshPolicy.iconDelayMs(4)) + assertEquals(30_000L, HeadsetRefreshPolicy.iconDelayMs(Int.MAX_VALUE)) + } +} diff --git a/app/src/test/java/moe/chenxy/huaweipods/pods/DeviceCapabilitiesTest.kt b/app/src/test/java/moe/chenxy/huaweipods/pods/DeviceCapabilitiesTest.kt index 641b00b..f9f148d 100644 --- a/app/src/test/java/moe/chenxy/huaweipods/pods/DeviceCapabilitiesTest.kt +++ b/app/src/test/java/moe/chenxy/huaweipods/pods/DeviceCapabilitiesTest.kt @@ -186,6 +186,7 @@ class DeviceCapabilitiesTest { fun `broadcast route codec uses stable values and round trips every enabled route`() { val expectedValues = linkedMapOf( 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", diff --git a/app/src/test/java/moe/chenxy/huaweipods/pods/FreeBuds4CompatibilityTest.kt b/app/src/test/java/moe/chenxy/huaweipods/pods/FreeBuds4CompatibilityTest.kt new file mode 100644 index 0000000..d2f8faf --- /dev/null +++ b/app/src/test/java/moe/chenxy/huaweipods/pods/FreeBuds4CompatibilityTest.kt @@ -0,0 +1,54 @@ +package moe.chenxy.huaweipods.pods + +import moe.chenxy.huaweipods.smartaudio.OfficialImageCatalogPolicy +import org.junit.Assert.* +import org.junit.Test + +/** Official ZA09 compatibility checks; these fixtures are not a FreeBuds 4 device capture. */ +class FreeBuds4CompatibilityTest { + private val route = HuaweiDeviceRoute.HUAWEI_FREEBUDS4 + + @Test + fun `FreeBuds 4 identity never resolves to 4E or 4i`() { + assertEquals(route, detectHuaweiDeviceRoute("HUAWEI FreeBuds 4")) + assertEquals(route, detectHuaweiDeviceRoute("FreeBuds 4")) + assertEquals(HuaweiDeviceRoute.HUAWEI_FREEBUDS4E, detectHuaweiDeviceRoute("FreeBuds 4E")) + assertEquals(HuaweiDeviceRoute.UNSUPPORTED, detectHuaweiDeviceRoute("FreeBuds 4i")) + assertEquals(route, HuaweiDeviceInfoRoutePolicy.routeForModelId("000129")) + assertEquals("000129", OfficialImageCatalogPolicy.modelIdForRoute(route)) + assertFalse(HuaweiDeviceInfoRoutePolicy.isCompatible(route, "000135")) + assertFalse(HuaweiDeviceInfoRoutePolicy.isCompatible(HuaweiDeviceRoute.HUAWEI_FREEBUDS4E, "000129")) + assertEquals(route, decodeHuaweiDeviceRouteFromBroadcast(encodeHuaweiDeviceRouteForBroadcast(route))) + } + + @Test + fun `ZA09 basic ANC uses modern packets and excludes transparency adaptive and deep`() { + assertArrayEquals(hex("5A0007002B0401020000D22D"), HuaweiAncPackets.mode(route, NoiseControlMode.OFF)) + assertArrayEquals(hex("5A0007002B04010201FFFFEC"), HuaweiAncPackets.mode(route, NoiseControlMode.NOISE_CANCELLATION, 0xFF)) + assertEquals(listOf(1, 0), route.ancLevelOptions.map { it.protocolValue }) + assertNull(HuaweiAncPackets.mode(route, NoiseControlMode.TRANSPARENCY)) + assertNull(HuaweiAncPackets.mode(route, NoiseControlMode.NOISE_CANCELLATION, 2)) + assertNull(HuaweiAncPackets.mode(route, NoiseControlMode.NOISE_CANCELLATION, 3)) + assertFalse(route.supportsAncDirectionDial) + assertFalse(route.supportsLowLatencyControl) + assertFalse(route.supportsBackgroundBatteryRefresh) + } + + @Test + fun `shared EQ gesture and wear paths retain conservative feature boundaries`() { + assertArrayEquals(hex("5A0006002B490101021F79"), HuaweiEqualizerCodec.buildBuiltInPresetPacket(route, 2)) + assertNull(HuaweiEqualizerCodec.buildBuiltInPresetPacket(route, 9)) + assertTrue(HuaweiEqualizerCodec.supportsStateRead(route)) + assertTrue(HuaweiWearDetectionController.supports(route)) + assertArrayEquals(hex("5A000600011F0201070A34"), HuaweiGestureController.buildDoubleTapPacket( + route, HuaweiGestureSide.RIGHT, HuaweiTapAction.PLAY_PREVIOUS, + )) + assertNull(HuaweiGestureController.buildTripleTapPacket(route, HuaweiGestureSide.LEFT, HuaweiTapAction.PLAY_NEXT)) + assertTrue(HuaweiSwipeAction.availableFor(route).isEmpty()) + assertEquals(3, FreeBudsPro3LongPressAction.NOISE_CONTROL.protocolValue(route)) + assertArrayEquals(HuaweiGestureController.buildGestureStateQuery(HuaweiDeviceRoute.HUAWEI_FREEBUDS4E), + HuaweiGestureController.buildGestureStateQuery(route)) + } + + private fun hex(value: String): ByteArray = value.chunked(2).map { it.toInt(16).toByte() }.toByteArray() +} diff --git a/app/src/test/java/moe/chenxy/huaweipods/pods/FreeClip2AudioTlvRegressionTest.kt b/app/src/test/java/moe/chenxy/huaweipods/pods/FreeClip2AudioTlvRegressionTest.kt new file mode 100644 index 0000000..d41aa77 --- /dev/null +++ b/app/src/test/java/moe/chenxy/huaweipods/pods/FreeClip2AudioTlvRegressionTest.kt @@ -0,0 +1,232 @@ +package moe.chenxy.huaweipods.pods + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class FreeClip2AudioTlvRegressionTest { + @Test + fun spatialAcceptsAllKnownModesAndScenes() { + for (mode in FreeClip2SpatialAudioMode.entries) { + for (scene in FreeClip2SpatialScene.entries) { + val packet = spatial( + 1, 1, 0x18, + 2, 1, mode.protocolValue, + 3, 1, scene.protocolValue, + ) + assertEquals( + FreeClip2AudioState(mode = mode, scene = scene), + HuaweiFreeClip2Controller.parseSpatialAudioState(packet), + ) + } + } + } + + @Test + fun spatialUsesFieldTypesRatherThanFixedOffsets() { + assertNull( + HuaweiFreeClip2Controller.parseSpatialAudioState( + spatial(1, 1, 0x18, 2, 1, 1, 4, 1, 2), + ), + ) + assertEquals( + FreeClip2AudioState( + mode = FreeClip2SpatialAudioMode.HEAD_TRACKING, + scene = FreeClip2SpatialScene.CINEMA, + ), + HuaweiFreeClip2Controller.parseSpatialAudioState( + spatial(3, 1, 2, 1, 1, 0x18, 2, 1, 1), + ), + ) + } + + @Test + fun spatialRejectsWrongSceneLengthsEvenWithValidCrc() { + val invalidScenes = listOf( + intArrayOf(3, 0), + intArrayOf(3, 2, 2, 0), + intArrayOf(3, 2, 2), + intArrayOf(3, 1), + ) + for (scene in invalidScenes) { + assertNull( + HuaweiFreeClip2Controller.parseSpatialAudioState( + spatial(1, 1, 0x18, 2, 1, 1, *scene), + ), + ) + } + } + + @Test + fun spatialRejectsDuplicateFieldsIncludingIdenticalDuplicates() { + val validFields = intArrayOf(1, 1, 0x18, 2, 1, 1, 3, 1, 2) + val duplicates = listOf( + intArrayOf(1, 1, 0x18), + intArrayOf(1, 1, 0x0B), + intArrayOf(2, 1, 1), + intArrayOf(2, 1, 2), + intArrayOf(3, 1, 2), + intArrayOf(3, 1, 0), + ) + for (duplicate in duplicates) { + assertNull( + HuaweiFreeClip2Controller.parseSpatialAudioState( + spatial(*validFields, *duplicate), + ), + ) + } + } + + @Test + fun spatialRejectsTrailingMalformedFieldsAndUnknownEnums() { + assertNull( + HuaweiFreeClip2Controller.parseSpatialAudioState( + spatial(1, 1, 0x18, 2, 1, 1, 3, 1, 2, 4), + ), + ) + assertNull( + HuaweiFreeClip2Controller.parseSpatialAudioState( + spatial(1, 1, 0x18, 2, 1, 0xFF, 3, 1, 2), + ), + ) + assertNull( + HuaweiFreeClip2Controller.parseSpatialAudioState( + spatial(1, 1, 0x18, 2, 1, 1, 3, 1, 0xFF), + ), + ) + } + + @Test + fun spatialDoesNotSearchForResponsePrefixInsideAnotherCommand() { + assertNull( + HuaweiFreeClip2Controller.parseSpatialAudioState( + frame(listOf(0x2B, 0x4A, 0x2B, 0xB4, 1, 1, 0x18, 2, 1, 1, 3, 1, 2)), + ), + ) + } + + @Test + fun malformedSpatialFrameDoesNotReplaceEarlierVerifiedState() { + val valid = spatial(1, 1, 0x18, 2, 1, 1, 3, 1, 2) + val malformed = spatial(1, 1, 0x18, 2, 1, 2, 4, 1, 0) + assertEquals( + HuaweiFreeClip2Controller.parseSpatialAudioState(valid), + HuaweiFreeClip2Controller.parseSpatialAudioState(valid + malformed), + ) + } + + @Test + fun soundEffectAcceptsMinimalReports() { + for (effect in FreeClip2SoundEffect.selectableEntries) { + val state = HuaweiFreeClip2Controller.parseSoundEffectState( + sound(2, 1, effect.protocolValue), + ) + assertEquals(effect, state?.effect) + assertEquals(effect.protocolValue, state?.equalizer?.selectedId) + } + } + + @Test + fun soundEffectDoesNotMatchAcrossFieldBoundaries() { + // Field 03 contains 02, followed by field 01 containing 09. There is + // no selected-effect field, despite the cross-boundary bytes 02 01 01. + assertNull( + HuaweiFreeClip2Controller.parseSoundEffectState( + sound(3, 1, 2, 1, 1, 9), + ), + ) + } + + @Test + fun soundEffectDoesNotMatchInsideFieldValues() { + assertNull( + HuaweiFreeClip2Controller.parseSoundEffectState( + sound(3, 3, 2, 1, 1), + ), + ) + } + + @Test + fun soundEffectRejectsWrongLengthsAndTruncatedFields() { + val invalidFields = listOf( + intArrayOf(2, 0), + intArrayOf(2, 2, 1, 9), + intArrayOf(2, 2, 1), + intArrayOf(2, 1), + intArrayOf(2, 1, 1, 3), + ) + for (fields in invalidFields) { + assertNull( + HuaweiFreeClip2Controller.parseSoundEffectState(sound(*fields)), + ) + } + } + + @Test + fun soundEffectRejectsDuplicateSelectedFields() { + assertNull( + HuaweiFreeClip2Controller.parseSoundEffectState( + sound(2, 1, 1, 2, 1, 1), + ), + ) + assertNull( + HuaweiFreeClip2Controller.parseSoundEffectState( + sound(2, 1, 1, 2, 1, 9), + ), + ) + } + + @Test + fun soundEffectAcceptsLegalVariableLengthFieldsWithoutScanningTheirValues() { + val state = HuaweiFreeClip2Controller.parseSoundEffectState( + sound(1, 1, 1, 3, 4, 2, 1, 1, 9, 2, 1, 9), + ) + assertEquals(FreeClip2SoundEffect.CLEAR_VOICE, state?.effect) + assertEquals(listOf(2, 1, 1, 9), state?.equalizer?.builtInIds) + } + + @Test + fun soundEffectPreservesCustomEqualizerReports() { + val state = HuaweiFreeClip2Controller.parseSoundEffectState( + sound(1, 1, 1, 2, 1, 0x64, 5, 1, 2, 6, 2, 0, 1), + ) + assertEquals(FreeClip2SoundEffect.CUSTOM, state?.effect) + assertEquals(0x64, state?.equalizer?.selectedId) + assertEquals(listOf(0, 1), state?.equalizer?.selectedGains) + } + + @Test + fun audioReportsStillRequireValidCrc() { + val spatial = spatial(1, 1, 0x18, 2, 1, 1, 3, 1, 2) + spatial[spatial.lastIndex] = (spatial.last().toInt() xor 1).toByte() + assertNull(HuaweiFreeClip2Controller.parseSpatialAudioState(spatial)) + + val sound = sound(2, 1, 1) + sound[sound.lastIndex] = (sound.last().toInt() xor 1).toByte() + assertNull(HuaweiFreeClip2Controller.parseSoundEffectState(sound)) + } + + private fun spatial(vararg fields: Int): ByteArray = + frame(listOf(0x2B, 0xB4) + fields.toList()) + + private fun sound(vararg fields: Int): ByteArray = + frame(listOf(0x2B, 0x4A) + fields.toList()) + + // Build complete frames independently of production helpers, including a + // fresh CRC for malformed TLVs so CRC rejection cannot hide parser defects. + private fun frame(body: List): ByteArray { + val payloadLength = body.size + 1 + val bytes = (listOf(0x5A, 0, payloadLength and 0xFF, payloadLength shr 8) + body) + .map { it.toByte() } + .toByteArray() + var crc = 0 + for (byte in bytes) { + crc = crc xor ((byte.toInt() and 0xFF) shl 8) + repeat(8) { + crc = if ((crc and 0x8000) != 0) (crc shl 1) xor 0x1021 else crc shl 1 + crc = crc and 0xFFFF + } + } + return bytes + byteArrayOf((crc shr 8).toByte(), crc.toByte()) + } +} diff --git a/app/src/test/java/moe/chenxy/huaweipods/pods/FreeClip2BooleanReadbackTest.kt b/app/src/test/java/moe/chenxy/huaweipods/pods/FreeClip2BooleanReadbackTest.kt new file mode 100644 index 0000000..5e59da7 --- /dev/null +++ b/app/src/test/java/moe/chenxy/huaweipods/pods/FreeClip2BooleanReadbackTest.kt @@ -0,0 +1,78 @@ +package moe.chenxy.huaweipods.pods + +import org.junit.Assert.* +import org.junit.Test + +/** 2026-07-29 FreeClip 2 实际 RX,去除设备身份与其他无关帧。 */ +class FreeClip2BooleanReadbackTest { + private val replies = hex( + "5A0006002B11010101DFE2" + + "5A0009002BB4010107020101BF85" + + "5A000C002BB4010102020101030101758A" + + "5A0006002B6C020100ED60" + + "5A0006002B2F0101004170" + + "5A0009002BB1020101030101186B" + + "5A000F002BB401010B020101030101040102C55B" + + "5A0006002B88010100FA2B", + ) + + @Test + fun `all eight switches use headset replies instead of preference defaults`() { + assertEquals(mapOf( + FreeClip2BooleanFeature.WEAR_DETECTION to true, + FreeClip2BooleanFeature.DROP_REMINDER to true, + FreeClip2BooleanFeature.ADAPTIVE_VOLUME to true, + FreeClip2BooleanFeature.LOW_LATENCY to false, + FreeClip2BooleanFeature.DUAL_DEVICE to false, + FreeClip2BooleanFeature.CASE_PROMPT_SOUND to true, + FreeClip2BooleanFeature.HEAD_MOTION_CONTROL to true, + FreeClip2BooleanFeature.NOISY_VOLUME_BOOST to false, + ), HuaweiFreeClip2Controller.parseBooleanSettings(replies)) + } + + @Test + fun `batched query contains only captured reads and no setters`() { + assertArrayEquals(hex( + "5A0005002B110100772A" + "5A0008002BB40101070200DDE9" + + "5A0008002BB401010202003619" + "5A0005002B6C0200B820" + + "5A0005002B2F0100A98E" + "5A0007002BB1020003007FAB" + + "5A0006002BB401010B289B" + "5A0005002B8801009182", + ), HuaweiFreeClip2Controller.booleanSettingsQueryPacket()) + } + + @Test + fun `missing replies ACKs and capability fields are unknown not off`() { + val unrelated = hex("5A0009002B6C7F04000186A0A497" + "5A0006002B6C030100DA50" + "5A0006002B88020101B35A") + assertTrue(HuaweiFreeClip2Controller.parseBooleanSettings(unrelated).values.all { it == null }) + assertTrue(HuaweiFreeClip2Controller.parseBooleanSettings(byteArrayOf()).values.all { it == null }) + val partial = HuaweiFreeClip2Controller.parseBooleanSettings(hex("5A0009002BB10201000301016EDF")) + assertEquals(false, partial[FreeClip2BooleanFeature.CASE_PROMPT_SOUND]) + assertNull(partial[FreeClip2BooleanFeature.WEAR_DETECTION]) + assertNull(partial[FreeClip2BooleanFeature.LOW_LATENCY]) + } + + @Test + fun `later official app changes replace earlier true values`() { + val updates = hex("5A0009002BB4010107020100AFA4" + "5A000C002BB4010102020100030101033E" + + "5A0009002BB10201000301016EDF" + "5A000F002BB401010B0201000301010401027D3A") + val values = HuaweiFreeClip2Controller.parseBooleanSettings(replies + updates) + assertEquals(false, values[FreeClip2BooleanFeature.DROP_REMINDER]) + assertEquals(false, values[FreeClip2BooleanFeature.ADAPTIVE_VOLUME]) + assertEquals(false, values[FreeClip2BooleanFeature.CASE_PROMPT_SOUND]) + assertEquals(false, values[FreeClip2BooleanFeature.HEAD_MOTION_CONTROL]) + assertEquals(true, values[FreeClip2BooleanFeature.WEAR_DETECTION]) + } + + @Test + fun `corrupt incomplete and invalid boolean fields are not accepted`() { + val frame = hex("5A0009002BB4010107020101BF85") + val corrupt = frame.copyOf().apply { this[lastIndex] = (this[lastIndex].toInt() xor 1).toByte() } + assertNull(HuaweiFreeClip2Controller.parseBooleanSettings(corrupt)[FreeClip2BooleanFeature.DROP_REMINDER]) + assertNull(HuaweiFreeClip2Controller.parseBooleanSettings(frame.copyOf(frame.size - 1))[FreeClip2BooleanFeature.DROP_REMINDER]) + val invalid = freeBuds7Frame(hex("2BB4010107020102")) + assertNull(HuaweiFreeClip2Controller.parseBooleanSettings(invalid)[FreeClip2BooleanFeature.DROP_REMINDER]) + assertEquals(true, HuaweiFreeClip2Controller.parseBooleanSettings(corrupt + frame)[FreeClip2BooleanFeature.DROP_REMINDER]) + } + + private fun hex(value: String): ByteArray = value.chunked(2).map { it.toInt(16).toByte() }.toByteArray() +} diff --git a/app/src/test/java/moe/chenxy/huaweipods/pods/FreeClip2LowLatencyHostTransactionTest.kt b/app/src/test/java/moe/chenxy/huaweipods/pods/FreeClip2LowLatencyHostTransactionTest.kt new file mode 100644 index 0000000..8f885ed --- /dev/null +++ b/app/src/test/java/moe/chenxy/huaweipods/pods/FreeClip2LowLatencyHostTransactionTest.kt @@ -0,0 +1,285 @@ +package moe.chenxy.huaweipods.pods + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** 测试实际宿主操作分发和生产事务,不依赖页面 UI 状态机。 */ +class FreeClip2LowLatencyHostTransactionTest { + @Test + fun oppositeReadbacksDoNotSaveTheSuccessfullyWrittenEnableCommand() { + val host = HostFixture(false) + val request = host.start(true) + host.writes[0].complete(true) + assertEquals(0, host.commits) + assertFalse(host.desired) + assertNull(host.appliedGeneration) + assertNull(host.policy.autoToken()) + host.reads[0](false) + assertEquals(0, host.commits) + host.reads[1](false) + assertArrayEquals(byteArrayOf(0, 0), request.responses.single()) + assertFalse(host.desired) + assertNull(host.appliedGeneration) + assertEquals(0, host.commits) + assertEquals(0, host.legacyCalls) + assertFalse(host.desired) // 重连自动应用读取的仍然是原关闭策略。 + } + + @Test + fun unknownReadbacksDoNotSavePolicy() { + val host = HostFixture(false) + val request = host.start(true) + host.writes[0].complete(true) + host.reads[0](null) + host.reads[1](null) + assertArrayEquals(byteArrayOf(0, 2), request.responses.single()) + assertFalse(host.desired) + assertNull(host.appliedGeneration) + assertEquals(0, host.commits) + assertEquals(0, host.legacyCalls) + } + + @Test + fun retryConfirmationCommitsExactlyOnceAndOnlyAfterReadback() { + val host = HostFixture(false) + val request = host.start(true) + host.writes[0].complete(true) + host.writes[0].complete(true) + assertEquals(1, host.reads.size) + assertTrue(request.responses.isEmpty()) + assertEquals(0, host.commits) + host.reads[0](false) + assertEquals(0, host.commits) + host.reads[1](true) + host.reads[0](true) + host.reads[1](true) + assertArrayEquals(byteArrayOf(1, 1), request.responses.single()) + assertTrue(host.desired) + assertEquals(1L, host.appliedGeneration) + assertEquals(1, host.commits) + assertEquals(0, host.legacyCalls) + } + + @Test + fun firstMatchingReadbackCommitsWithoutAnExtraQuery() { + val host = HostFixture(false) + val request = host.start(true) + host.writes[0].complete(true) + host.reads[0](true) + assertEquals(1, host.reads.size) + assertArrayEquals(byteArrayOf(1, 1), request.responses.single()) + assertEquals(1, host.commits) + assertTrue(host.desired) + } + + @Test + fun failedDisableKeepsThePreviouslyEnabledReconnectPolicy() { + val host = HostFixture(true) + val request = host.start(false) + host.writes[0].complete(true) + host.reads[0](true) + host.reads[1](true) + assertArrayEquals(byteArrayOf(0, 1), request.responses.single()) + assertTrue(host.desired) + assertEquals(0, host.commits) + } + + @Test + fun confirmedDisableSavesFalseRatherThanTreatingFalseAsMissing() { + val host = HostFixture(true) + val request = host.start(false) + host.writes[0].complete(true) + host.reads[0](false) + assertArrayEquals(byteArrayOf(1, 0), request.responses.single()) + assertFalse(host.desired) + assertNull(host.appliedGeneration) + assertEquals(1, host.commits) + } + + @Test + fun failedWriteDoesNotQueryOrCommit() { + val host = HostFixture(false) + val request = host.start(true) + host.writes[0].complete(false) + assertTrue(host.reads.isEmpty()) + assertArrayEquals(byteArrayOf(0, 2), request.responses.single()) + assertFalse(host.desired) + assertEquals(0, host.commits) + } + + @Test + fun expiredQueuedRequestCannotWriteOrCommit() { + val host = HostFixture(false) + val request = host.start(true) + request.current = false + assertFalse(host.writes[0].shouldWrite()) + host.writes[0].complete(true) + assertTrue(host.reads.isEmpty()) + assertNull(request.responses.single()) + assertFalse(host.desired) + assertEquals(0, host.commits) + } + + @Test + fun expiredRequestCannotCommitALateMatchingReadback() { + val host = HostFixture(false) + val request = host.start(true) + host.writes[0].complete(true) + host.reads[0](null) + request.current = false + host.reads[1](true) + assertNull(request.responses.single()) + assertFalse(host.desired) + assertNull(host.appliedGeneration) + assertEquals(0, host.commits) + } + + @Test + fun disconnectedSessionCannotCommitALateMatchingReadback() { + val host = HostFixture(false) + val request = host.start(true) + host.writes[0].complete(true) + host.reads[0](false) + host.generation++ + host.policy.resetSession() + host.reads[1](true) + assertNull(request.responses.single()) + assertFalse(host.desired) + assertNull(host.appliedGeneration) + assertEquals(0, host.commits) + } + + @Test + fun supersededConfirmationCannotCommitOrFinishTheNewManualRequest() { + val host = HostFixture(false) + val old = host.start(true) + host.writes[0].complete(true) + val latest = host.start(false) + host.reads[0](true) + assertArrayEquals(byteArrayOf(0, 2), old.responses.single()) + assertEquals(0, host.commits) + assertNull(host.policy.autoToken()) + host.writes[1].complete(true) + host.reads[1](false) + assertArrayEquals(byteArrayOf(1, 0), latest.responses.single()) + assertFalse(host.desired) + assertEquals(1, host.commits) + assertTrue(host.policy.autoToken() != null) + assertEquals(0, host.legacyCalls) + } + + @Test + fun finalCommitGuardCanRejectAnInvalidatedRequest() { + val host = HostFixture(false) + val request = host.start(true) + host.writes[0].complete(true) + host.rejectCommit = true + host.reads[0](true) + assertArrayEquals(byteArrayOf(0, 1), request.responses.single()) + assertFalse(host.desired) + assertNull(host.appliedGeneration) + assertEquals(0, host.commits) + } + + @Test + fun verifiedOperationRejectsOtherModelsAndMissingEnabledWithoutCallingLegacySetter() { + val host = HostFixture(false) + val wrongModel = host.start(true, route = HuaweiDeviceRoute.HUAWEI_FREEBUDS6I) + val missingEnabled = host.start(null) + assertNull(wrongModel.responses.single()) + assertNull(missingEnabled.responses.single()) + assertTrue(host.writes.isEmpty()) + assertEquals(0, host.commits) + assertEquals(0, host.legacyCalls) + } + + @Test + fun legacyOperationRetainsItsExistingDispatch() { + val host = HostFixture(false) + val request = host.start( + true, + route = HuaweiDeviceRoute.HUAWEI_FREEBUDS6I, + operation = HuaweiSharedControlPolicy.LOW_LATENCY_SET, + ) + assertArrayEquals(byteArrayOf(1), request.responses.single()) + assertEquals(1, host.legacyCalls) + assertTrue(host.writes.isEmpty()) + } + + private class Request { + var current = true + val responses = mutableListOf() + } + + private data class PendingWrite( + val shouldWrite: () -> Boolean, + val complete: (Boolean) -> Unit, + ) + + /** + * 与 HFP 宿主相同的边界:旧 setter 有写出即保存副作用;verified setter 使用生产事务, + * 最终提交在 token、会话和请求检查之后才更新持久策略及 applied generation。 + */ + private class HostFixture(initialDesired: Boolean) { + val policy = LowLatencyWritePolicy() + val writes = mutableListOf() + val reads = mutableListOf<(Boolean?) -> Unit>() + var generation = 1L + var desired = initialDesired + var appliedGeneration: Long? = null + var commits = 0 + var legacyCalls = 0 + var rejectCommit = false + + fun start( + enabled: Boolean?, + route: HuaweiDeviceRoute = HuaweiDeviceRoute.HUAWEI_FREECLIP2, + operation: String = HuaweiSharedControlPolicy.LOW_LATENCY_SET_VERIFIED, + ): Request { + val request = Request() + val requestedGeneration = generation + val handled = HuaweiSharedControlHost.dispatchLowLatency( + operation = operation, + route = route, + enabled = enabled, + writeLowLatency = { requested, complete -> + legacyCalls++ + desired = requested + complete(true) + }, + writeLowLatencyVerified = { requested, requestCurrent, complete -> + val token = policy.beginManual() + val stillCurrent = { requestCurrent() && policy.isCurrent(token) } + FreeClip2LowLatencyTransaction.run( + enabled = requested, + stillCurrent = stillCurrent, + write = { shouldWrite, written -> writes.add(PendingWrite(shouldWrite, written)) }, + read = { reads.add(it) }, + commit = { + if (!stillCurrent() || rejectCommit) { + false + } else { + desired = requested + appliedGeneration = requestedGeneration.takeIf { requested } + commits++ + true + } + }, + complete = { result -> + policy.finishManual(token) + complete(result) + }, + ) + }, + stillCurrent = { request.current && generation == requestedGeneration }, + reply = { request.responses.add(it) }, + ) + assertTrue(handled) + return request + } + } +} diff --git a/app/src/test/java/moe/chenxy/huaweipods/pods/HuaweiFreeBuds7iControllerTest.kt b/app/src/test/java/moe/chenxy/huaweipods/pods/HuaweiFreeBuds7iControllerTest.kt index cb67324..87536e4 100644 --- a/app/src/test/java/moe/chenxy/huaweipods/pods/HuaweiFreeBuds7iControllerTest.kt +++ b/app/src/test/java/moe/chenxy/huaweipods/pods/HuaweiFreeBuds7iControllerTest.kt @@ -35,13 +35,13 @@ class HuaweiFreeBuds7iControllerTest { FreeBuds7iBooleanFeature.HIGH_QUALITY_AUDIO.packet(true), ) assertArrayEquals( - hex("5A0009002BB401011802010240AF"), + hex("5A0009002BB401011802010170CC"), HuaweiFreeBuds7iController.spatialAudioModePacket( FreeClip2SpatialAudioMode.HEAD_TRACKING, ), ) assertArrayEquals( - hex("5A0009002BB401011802010170CC"), + hex("5A0009002BB401011802010240AF"), HuaweiFreeBuds7iController.spatialAudioModePacket( FreeClip2SpatialAudioMode.FIXED, ), @@ -53,11 +53,11 @@ class HuaweiFreeBuds7iControllerTest { } @Test - fun `7i spatial reports keep their captured mode order`() { - val fixed = HuaweiFreeBuds7iController.parseSpatialAudioState( + fun `7i spatial reports match user confirmed head tracking and fixed modes`() { + val headTracking = HuaweiFreeBuds7iController.parseSpatialAudioState( hex("5A000C002BB401011802010103010281DC"), ) - val headTracking = HuaweiFreeBuds7iController.parseSpatialAudioState( + val fixed = HuaweiFreeBuds7iController.parseSpatialAudioState( hex("5A000C002BB40101180201020301030A21"), ) diff --git a/app/src/test/java/moe/chenxy/huaweipods/smartaudio/OfficialImageCatalogPolicyTest.kt b/app/src/test/java/moe/chenxy/huaweipods/smartaudio/OfficialImageCatalogPolicyTest.kt index 7faf398..8c77591 100644 --- a/app/src/test/java/moe/chenxy/huaweipods/smartaudio/OfficialImageCatalogPolicyTest.kt +++ b/app/src/test/java/moe/chenxy/huaweipods/smartaudio/OfficialImageCatalogPolicyTest.kt @@ -9,6 +9,7 @@ class OfficialImageCatalogPolicyTest { fun `maps only routes backed by verified official model ids`() { val expected = mapOf( HuaweiDeviceRoute.HUAWEI_FREEBUDS3 to "000027", + HuaweiDeviceRoute.HUAWEI_FREEBUDS4 to "000129", HuaweiDeviceRoute.HUAWEI_FREEBUDS4E to "000135", HuaweiDeviceRoute.HUAWEI_FREEBUDS5 to "000141", HuaweiDeviceRoute.HUAWEI_FREEBUDS5I to "000145", diff --git a/app/src/test/java/moe/chenxy/huaweipods/ui/components/FreeClip2BooleanUiStateSessionTest.kt b/app/src/test/java/moe/chenxy/huaweipods/ui/components/FreeClip2BooleanUiStateSessionTest.kt new file mode 100644 index 0000000..2faf50d --- /dev/null +++ b/app/src/test/java/moe/chenxy/huaweipods/ui/components/FreeClip2BooleanUiStateSessionTest.kt @@ -0,0 +1,153 @@ +package moe.chenxy.huaweipods.ui.components + +import moe.chenxy.huaweipods.pods.FreeClip2BooleanFeature +import moe.chenxy.huaweipods.pods.FreeClip2HeadMotionAction +import moe.chenxy.huaweipods.pods.FreeClip2HeadMotionState +import org.junit.Assert.* +import org.junit.Test + +class FreeClip2BooleanUiStateSessionTest { + private val feature = FreeClip2BooleanFeature.LOW_LATENCY + private val head = FreeClip2HeadMotionState( + true, + FreeClip2HeadMotionAction.ANSWER_CALL, + FreeClip2HeadMotionAction.REJECT_CALL, + ) + + @Test + fun `paused read cannot overwrite resumed read or clear its busy flag`() { + val state = FreeClip2BooleanUiState() + val old = requireNotNull(state.beginRead()) + state.deactivate() + assertFalse(state.active) + assertFalse(state.busy) + state.activate() + val current = requireNotNull(state.beginRead()) + assertNotEquals(old, current) + + state.finishRead(old, mapOf(feature to true), head) + assertTrue(state.reading) + assertTrue(state.values.isEmpty()) + assertNull(state.head) + + state.finishRead(current, mapOf(feature to false), null) + assertFalse(state.busy) + assertEquals(false, state.values[feature]) + assertNull(state.head) + state.finishRead(current, mapOf(feature to true), head) + assertEquals(false, state.values[feature]) + assertNull(state.head) + } + + @Test + fun `pause cancels old UI once and stale write cannot finish a new write or persist`() { + val state = FreeClip2BooleanUiState() + val oldResults = mutableListOf() + val newResults = mutableListOf() + var persisted = 0 + val old = requireNotNull(state.beginWrite { oldResults.add(it) }) + state.deactivate() + state.deactivate() + assertEquals(listOf(false), oldResults) + assertFalse(state.writing) + assertFalse(state.busy) + + state.activate() + val current = requireNotNull(state.beginWrite { newResults.add(it) }) + state.finishWrite(old, true) { persisted++; true } + assertTrue(state.writing) + assertTrue(newResults.isEmpty()) + assertEquals(listOf(false), oldResults) + assertEquals(0, persisted) + + state.finishWrite(current, true) { persisted++; true } + assertFalse(state.busy) + assertEquals(listOf(true), newResults) + assertEquals(1, persisted) + state.finishWrite(current, true) { persisted++; true } + assertEquals(listOf(true), newResults) + assertEquals(1, persisted) + } + + @Test + fun `resume starts a fresh read without waiting for a missing old completion`() { + val state = FreeClip2BooleanUiState() + val results = mutableListOf() + val old = requireNotNull(state.beginWrite { results.add(it) }) + state.deactivate() + var refreshes = 0 + var readTicket: Long? = null + state.refresh = { + refreshes++ + readTicket = state.beginRead() + } + state.activate() + assertEquals(1, refreshes) + assertNotNull(readTicket) + assertTrue(state.reading) + assertFalse(state.writing) + + state.finishWrite(old, true) { fail("Stale write must not persist"); true } + assertEquals(1, refreshes) + assertTrue(state.reading) + assertEquals(listOf(false), results) + } + + @Test + fun `deactivation clears known values and rejects inactive work and late readbacks`() { + val state = FreeClip2BooleanUiState() + val read = requireNotNull(state.beginRead()) + state.finishRead(read, mapOf(feature to true), head) + val write = requireNotNull(state.beginWrite { }) + assertTrue(state.isCurrent(write)) + state.deactivate() + assertFalse(state.isCurrent(write)) + assertFalse(state.active) + assertFalse(state.busy) + assertTrue(state.values.isEmpty()) + assertNull(state.head) + assertNull(state.beginRead()) + val rejected = mutableListOf() + assertNull(state.beginWrite { rejected.add(it) }) + assertEquals(listOf(false), rejected) + state.finishRead(read, mapOf(feature to true), head) + state.finishWrite(write, true) { fail("Disposed write must not persist"); true } + assertTrue(state.values.isEmpty()) + assertNull(state.head) + assertFalse(state.busy) + } + + @Test + fun `unconfirmed writes never persist and persistence failure is reported`() { + val state = FreeClip2BooleanUiState() + val results = mutableListOf() + var persisted = 0 + val failed = requireNotNull(state.beginWrite { results.add(it) }) + state.finishWrite(failed, false) { persisted++; true } + assertEquals(0, persisted) + assertEquals(listOf(false), results) + assertFalse(state.busy) + + val confirmed = requireNotNull(state.beginWrite { results.add(it) }) + state.finishWrite(confirmed, true) { persisted++; false } + assertEquals(1, persisted) + assertEquals(listOf(false, false), results) + assertFalse(state.busy) + } + + @Test + fun `busy requests are rejected without replacing current completion`() { + val state = FreeClip2BooleanUiState() + val accepted = mutableListOf() + val rejected = mutableListOf() + val ticket = requireNotNull(state.beginWrite { accepted.add(it) }) + assertNull(state.beginRead()) + assertNull(state.beginWrite { rejected.add(it) }) + assertEquals(listOf(false), rejected) + assertTrue(state.isCurrent(ticket)) + assertTrue(state.writing) + state.finishWrite(ticket, true) + assertEquals(listOf(true), accepted) + assertFalse(state.busy) + } +}