Skip to content
Original file line number Diff line number Diff line change
@@ -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>(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"
}
}
4 changes: 4 additions & 0 deletions app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
android:label="@string/debug_capture_app_name"
tools:replace="android:label">

<activity
android:name=".ui.FreeClip2SettingsPreviewActivity"
android:exported="true" />

<activity
android:name=".debugcapture.DebugCaptureActivity"
android:exported="true"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package moe.chenxy.huaweipods.ui

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import moe.chenxy.huaweipods.R
import moe.chenxy.huaweipods.pods.FreeClip2BooleanFeature
import moe.chenxy.huaweipods.pods.FreeClip2HeadMotionAction
import moe.chenxy.huaweipods.pods.FreeClip2HeadMotionState
import moe.chenxy.huaweipods.pods.HuaweiDeviceRoute
import moe.chenxy.huaweipods.ui.components.FreeClip2BooleanUiState
import moe.chenxy.huaweipods.ui.components.FreeClip2SettingsGroups
import moe.chenxy.huaweipods.ui.components.HuaweiEqualizerPreference
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.SmallTitle
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.preference.ArrowPreference
import top.yukonga.miuix.kmp.theme.ColorSchemeMode

/** Debug-only visual fixture. It never opens Bluetooth or writes device preferences. */
class FreeClip2SettingsPreviewActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val dark = intent.getBooleanExtra("dark", true)
val unknown = intent.getBooleanExtra("unknown", false)
setContent {
AppTheme(colorSchemeMode = if (dark) ColorSchemeMode.Dark else ColorSchemeMode.Light) {
val state = remember {
FreeClip2BooleanUiState().apply {
if (!unknown) {
values = FreeClip2BooleanFeature.entries.associateWith { it == FreeClip2BooleanFeature.WEAR_DETECTION }
updateHead(FreeClip2HeadMotionState(false, FreeClip2HeadMotionAction.ANSWER_CALL, FreeClip2HeadMotionAction.REJECT_CALL))
}
change = { feature, enabled, done ->
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)
}
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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))
}
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading