From ad1fc53001f8f9a3b8c80d5259430e6ab12eb918 Mon Sep 17 00:00:00 2001 From: ByteJuno <87103000+ByteJuno@users.noreply.github.com> Date: Sun, 15 Nov 2026 12:40:51 +0800 Subject: [PATCH 1/5] feat: add bottom slide-in popup on connect Add a transparent Compose activity that slides in from the bottom when the pods connect, showing the pod image and battery. The popup appears immediately on connection while the RFCOMM battery is fetched and then filled in via the battery-changed broadcast. Behavior is gated by a settings toggle. (cherry picked from commit 5d51f00eeebe4ddc3eff6f637954a0493bab5a63) --- app/src/main/AndroidManifest.xml | 6 + .../chenxy/huaweipods/BottomPopupActivity.kt | 287 ++++++++++++++++++ .../moe/chenxy/huaweipods/PopupActivity.kt | 2 +- .../chenxy/huaweipods/config/ConfigManager.kt | 21 ++ .../huaweipods/pods/HuaweiHfpController.kt | 33 +- .../java/moe/chenxy/huaweipods/ui/MainUI.kt | 9 + .../huaweipods/ui/pages/SettingsPage.kt | 8 + app/src/main/res/values-zh-rCN/strings.xml | 8 + app/src/main/res/values/strings.xml | 8 + gradlew | 0 10 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/moe/chenxy/huaweipods/BottomPopupActivity.kt mode change 100644 => 100755 gradlew diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d6d1c05..c830901 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -96,6 +96,12 @@ + ColorSchemeMode.Light + 2 -> ColorSchemeMode.Dark + else -> ColorSchemeMode.System + } + AppTheme(colorSchemeMode = colorSchemeMode, accentMode = prefs.getInt("accent_mode", 0)) { + BottomPopupContent( + target = target, + onFinish = { finish() }, + ) + } + } + } + + private fun Intent.parcelableDevice(key: String): BluetoothDevice? = + getParcelableExtra(key, BluetoothDevice::class.java) + + @SuppressLint("MissingPermission") + private fun resolveTarget(device: BluetoothDevice, prefs: android.content.SharedPreferences): PopupDeviceTarget? { + val address = runCatching { device.address }.getOrNull() ?: return null + val deviceName = runCatching { + device.name?.takeIf(String::isNotBlank) + ?: device.alias?.takeIf(String::isNotBlank) + ?: "" + }.getOrDefault("") + val route = DeviceRoutePrefs.resolve(prefs = prefs, address = address, deviceName = deviceName) + return popupDeviceTargetOrNull(address, deviceName, route) + } +} + +private const val AUTO_DISMISS_MS = 6000L +private const val ANIM_MS = 280 + +@SuppressLint("UnspecifiedRegisterReceiverFlag") +@androidx.compose.runtime.Composable +private fun BottomPopupContent( + target: PopupDeviceTarget, + onFinish: () -> Unit, +) { + val context = LocalContext.current + val prefs = remember { + context.getSharedPreferences(ConfigManager.PREFS_NAME, Context.MODE_PRIVATE) + } + val isDark = isSystemInDarkTheme() + val cardColor = if (isDark) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + + var visible by remember { mutableStateOf(false) } + var finished by remember { mutableStateOf(false) } + var battery by remember { mutableStateOf(BatteryParams()) } + + fun close() { + if (finished) return + finished = true + visible = false + } + + val receiver = remember { + object : BroadcastReceiver() { + override fun onReceive(c: Context?, intent: Intent?) { + intent ?: return + val identity = PopupBroadcastIdentity( + address = intent.getStringExtra("address"), + deviceName = intent.getStringExtra("device_name"), + route = moe.chenxy.huaweipods.pods.decodeHuaweiDeviceRouteFromBroadcast( + intent.getStringExtra(HuaweiPodsAction.EXTRA_DEVICE_ROUTE), + ), + ) + if (!popupBroadcastMatchesTarget(target, identity)) return + when (HuaweiPodsAction.canonical(intent.action)) { + HuaweiPodsAction.ACTION_PODS_BATTERY_CHANGED -> { + intent.getParcelableExtra("status", BatteryParams::class.java)?.let { + battery = it + } + } + HuaweiPodsAction.ACTION_PODS_DISCONNECTED -> close() + } + } + } + } + + DisposableEffect(Unit) { + context.registerReceiver( + receiver, + IntentFilter().apply { + addHuaweiPodsAction(HuaweiPodsAction.ACTION_PODS_BATTERY_CHANGED) + addHuaweiPodsAction(HuaweiPodsAction.ACTION_PODS_DISCONNECTED) + }, + Context.RECEIVER_EXPORTED, + ) + // 让蓝牙进程立即回推一次当前状态,避免卡片先显示空电量。 + context.sendBroadcast(Intent(HuaweiPodsAction.ACTION_REFRESH_STATUS).apply { + putPopupTarget(target) + setPackage("com.android.bluetooth") + addFlags(Intent.FLAG_RECEIVER_FOREGROUND) + }) + onDispose { + runCatching { context.unregisterReceiver(receiver) } + } + } + + // 进场:首帧布局完成后再滑入。 + LaunchedEffect(Unit) { + visible = true + } + // 自动关闭。 + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(AUTO_DISMISS_MS) + close() + } + // 退出动画播完后真正结束 Activity。 + LaunchedEffect(finished) { + if (finished) { + kotlinx.coroutines.delay(ANIM_MS.toLong() + 30L) + onFinish() + } + } + + val boxBitmap = remember(target.address, target.route) { + runCatching { + PodImageLoader.loadBoxBitmap( + context = context, + prefs = prefs, + address = target.address, + verifiedRoute = target.route, + ) + }.getOrNull() + } + + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }, + ) { close() }, + contentAlignment = Alignment.BottomCenter, + ) { + AnimatedVisibility( + visible = visible, + enter = slideInVertically( + animationSpec = tween(ANIM_MS), + initialOffsetY = { it }, + ) + fadeIn(tween(ANIM_MS)), + exit = slideOutVertically( + animationSpec = tween(ANIM_MS), + targetOffsetY = { it }, + ) + fadeOut(tween(ANIM_MS)), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .padding(bottom = 24.dp) + .background(cardColor, RoundedCornerShape(28.dp)) + .clickable( + // 拦截卡片内部点击,避免点卡片也关闭。 + indication = null, + interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }, + onClick = {}, + ) + .padding(horizontal = 20.dp, vertical = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = target.deviceName.ifEmpty { stringResource(R.string.app_name) }, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(2.dp)) + Text( + text = stringResource(R.string.bottom_popup_connected), + fontSize = 13.sp, + color = Color.Gray, + ) + Spacer(Modifier.height(8.dp)) + if (boxBitmap != null) { + Image( + bitmap = boxBitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier.size(150.dp), + ) + } + Spacer(Modifier.height(8.dp)) + PodStatus( + batteryParams = battery, + showCase = target.route.hasChargingCase, + ) + Spacer(Modifier.height(14.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + TextButton( + text = stringResource(R.string.done), + onClick = { close() }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } +} diff --git a/app/src/main/java/moe/chenxy/huaweipods/PopupActivity.kt b/app/src/main/java/moe/chenxy/huaweipods/PopupActivity.kt index d7194d6..d8c7de2 100644 --- a/app/src/main/java/moe/chenxy/huaweipods/PopupActivity.kt +++ b/app/src/main/java/moe/chenxy/huaweipods/PopupActivity.kt @@ -705,7 +705,7 @@ internal fun popupBroadcastMatchesTarget( received.route == target.route } -private fun Intent.putPopupTarget(target: PopupDeviceTarget) { +internal fun Intent.putPopupTarget(target: PopupDeviceTarget) { putExtra("address", target.address) putExtra("device_name", target.deviceName) encodeHuaweiDeviceRouteForBroadcast(target.route)?.let { diff --git a/app/src/main/java/moe/chenxy/huaweipods/config/ConfigManager.kt b/app/src/main/java/moe/chenxy/huaweipods/config/ConfigManager.kt index 3d82fd5..01f408c 100644 --- a/app/src/main/java/moe/chenxy/huaweipods/config/ConfigManager.kt +++ b/app/src/main/java/moe/chenxy/huaweipods/config/ConfigManager.kt @@ -19,6 +19,7 @@ data class AppConfig( * 默认 0(全关):宿主的多段布局是给 AirPods 准备的,没验证过就不要替用户打开。 */ val multiBatteryMask: Int = 0, + val bottomPopupEnabled: Boolean = false, ) object ConfigManager { @@ -33,6 +34,7 @@ object ConfigManager { const val PREF_KEY_MORE_CLICK_ACTION = "more_click_action" const val PREF_KEY_LOW_BATTERY_REMINDER_ENABLED = "low_battery_reminder_enabled" const val PREF_KEY_MULTI_BATTERY_MASK = "multi_battery_mask" + const val PREF_KEY_BOTTOM_POPUP_ENABLED = "bottom_popup_enabled" const val MULTI_BATTERY_DTO_BIT = 1 const val MULTI_BATTERY_CARD_BIT = 2 const val MULTI_BATTERY_MASK_MAX = MULTI_BATTERY_DTO_BIT or MULTI_BATTERY_CARD_BIT @@ -82,6 +84,8 @@ object ConfigManager { fun lowBatteryReminderEnabled(): Boolean = current().lowBatteryReminderEnabled + fun bottomPopupEnabled(): Boolean = current().bottomPopupEnabled + /** 设备空间三段电量的位掩码,见 [AppConfig.multiBatteryMask]。 */ fun multiBatteryMask(): Int = current().multiBatteryMask @@ -128,6 +132,14 @@ object ConfigManager { save(prefs, service, config) } + fun updateBottomPopupEnabled( + prefs: SharedPreferences, + service: XposedService?, + enabled: Boolean, + ) { + save(prefs, service, current().copy(bottomPopupEnabled = enabled)) + } + fun save(prefs: SharedPreferences, config: AppConfig) { val oldConfig = cachedConfig val normalized = config.normalized() @@ -171,6 +183,7 @@ object ConfigManager { .putInt(PREF_KEY_MORE_CLICK_ACTION, config.moreClickAction) .putBoolean(PREF_KEY_LOW_BATTERY_REMINDER_ENABLED, config.lowBatteryReminderEnabled) .putInt(PREF_KEY_MULTI_BATTERY_MASK, config.multiBatteryMask) + .putBoolean(PREF_KEY_BOTTOM_POPUP_ENABLED, config.bottomPopupEnabled) .commit() } @@ -186,6 +199,7 @@ object ConfigManager { val directMoreClickAction = prefs.getInt(PREF_KEY_MORE_CLICK_ACTION, Int.MIN_VALUE) val directLowBatteryReminderEnabled = prefs.booleanOrNull(PREF_KEY_LOW_BATTERY_REMINDER_ENABLED) val directMultiBatteryMask = prefs.getInt(PREF_KEY_MULTI_BATTERY_MASK, Int.MIN_VALUE) + val directBottomPopupEnabled = prefs.booleanOrNull(PREF_KEY_BOTTOM_POPUP_ENABLED) val raw = prefs.getString(PREF_KEY_CONFIG_JSON, null) logPrefsSnapshot(source, prefs, raw) val config = raw?.let { @@ -201,6 +215,7 @@ object ConfigManager { moreClickAction = directMoreClickAction.takeIf { it != Int.MIN_VALUE } ?: config.moreClickAction, lowBatteryReminderEnabled = directLowBatteryReminderEnabled ?: config.lowBatteryReminderEnabled, multiBatteryMask = directMultiBatteryMask.takeIf { it != Int.MIN_VALUE } ?: config.multiBatteryMask, + bottomPopupEnabled = directBottomPopupEnabled ?: config.bottomPopupEnabled, ).normalized() } @@ -279,6 +294,12 @@ object ConfigManager { if (oldConfig.multiBatteryMask != newConfig.multiBatteryMask) { add("multiBatteryMask=${oldConfig.multiBatteryMask}->${newConfig.multiBatteryMask}") } + if (oldConfig.bottomPopupEnabled != newConfig.bottomPopupEnabled) { + add( + "bottomPopupEnabled=${oldConfig.bottomPopupEnabled}->" + + newConfig.bottomPopupEnabled, + ) + } } } } 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 fdef2d6..70d616b 100644 --- a/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiHfpController.kt +++ b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiHfpController.kt @@ -17,6 +17,7 @@ import android.os.SystemClock import java.util.UUID import moe.chenxy.huaweipods.BuildConfig import moe.chenxy.huaweipods.broadcast.HuaweiPodsBroadcastTrustPolicy +import moe.chenxy.huaweipods.BottomPopupActivity import moe.chenxy.huaweipods.config.ConfigManager import moe.chenxy.huaweipods.config.LowLatencyPrefs import moe.chenxy.huaweipods.hook.ColorOsSystemBatteryBridge @@ -100,6 +101,7 @@ object HuaweiHfpController { private var lowLatencyAppliedGeneration: Long? = null private val lowLatencyWritePolicy = LowLatencyWritePolicy() private val freeClip2AudioStateTracker = FreeClip2AudioStateTracker() + private var bottomPopupShownForGeneration = -1L private val freeBuds3SnapshotStore = FreeBuds3StateSnapshotStore() private val sessionStateLock = Any() private val mainHandler = Handler(Looper.getMainLooper()) @@ -485,9 +487,13 @@ object HuaweiHfpController { restored?.battery != null } if (restoredCachedBattery) scheduleCachedNotificationRestore() + // 开盖即弹:连接建立后立即弹出底部卡片,不等待较慢的 RFCOMM 电量查询; + // 电量稍后通过 ACTION_PODS_BATTERY_CHANGED 广播自动回填到卡片。 + maybeShowBottomPopup(context, device) + // 电量查询提到 RFCOMM 队列最前,让超级岛尽早拿到真实电量,不再被其他查询阻塞。 + requestPrivateBattery() requestOfficialImageIdentity(force = true) requestAncState(force = true) - requestPrivateBattery() scheduleAutoLowLatency() if (route == HuaweiDeviceRoute.HUAWEI_FREECLIP2) { requestFreeClip2AudioState(force = true) @@ -599,6 +605,7 @@ object HuaweiHfpController { sendConnected() sendBattery(battery) MiuiStrongToastUtil.showPodsNotificationByMiuiBt(context, battery, device) + maybeShowBottomPopup(context, device) Log.i(TAG, "Huawei battery parsed device=${device.address} values=${result.values}") battery } @@ -1529,6 +1536,7 @@ object HuaweiHfpController { normalizedBattery, activeDevice, ) + maybeShowBottomPopup(currentContext, activeDevice) Log.i(TAG, "Huawei RFCOMM battery updated device=$requestedAddress") } } @@ -1919,6 +1927,29 @@ object HuaweiHfpController { mainHandler.removeCallbacks(backgroundBatteryRefreshRunnable) } + private fun maybeShowBottomPopup( + context: Context, + device: BluetoothDevice, + ) { + if (!ConfigManager.bottomPopupEnabled()) return + if (bottomPopupShownForGeneration == sessionGeneration) return + bottomPopupShownForGeneration = sessionGeneration + runCatching { + val intent = Intent().apply { + // 模块代码运行在蓝牙进程,context.packageName 是 com.android.bluetooth; + // 必须显式指向模块自己的包,否则会去蓝牙包里找 Activity 而抛异常。 + setClassName(BuildConfig.APPLICATION_ID, BottomPopupActivity::class.java.name) + putExtra("android.bluetooth.device.extra.DEVICE", device) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + }.onSuccess { + Log.i(TAG, "Huawei bottom popup requested device=${device.address}") + }.onFailure { + Log.e(TAG, "Bottom popup launch failed safely device=${device.address}", it) + } + } + private fun requestAncState( force: Boolean = false, fallbackState: HuaweiAncState? = null, diff --git a/app/src/main/java/moe/chenxy/huaweipods/ui/MainUI.kt b/app/src/main/java/moe/chenxy/huaweipods/ui/MainUI.kt index 24c24ab..608303b 100644 --- a/app/src/main/java/moe/chenxy/huaweipods/ui/MainUI.kt +++ b/app/src/main/java/moe/chenxy/huaweipods/ui/MainUI.kt @@ -199,6 +199,9 @@ internal fun MainUI( val prefs = remember { context.getSharedPreferences(ConfigManager.PREFS_NAME, Context.MODE_PRIVATE) } val lifecyclePrefs = remember(context) { AppLifecyclePrefs(context) } val appConfig = remember { ConfigManager.refreshFromPrefs(prefs) } + val bottomPopupEnabled = remember { + mutableStateOf(appConfig.bottomPopupEnabled) + } val notificationClickAction = remember { mutableStateOf(appConfig.notificationClickAction) } val moreClickAction = remember { mutableStateOf(appConfig.moreClickAction) } val desktopIconHidden = remember { mutableStateOf(isLauncherIconHidden(context)) } @@ -1229,6 +1232,12 @@ internal fun MainUI( appLanguage.value = it onAppLanguageChange(it) }, + bottomPopupEnabled = bottomPopupEnabled, + onBottomPopupEnabledChange = { + bottomPopupEnabled.value = it + ConfigManager.updateBottomPopupEnabled(prefs, xposedService, it) + broadcastConfigChanged(context, "com.android.bluetooth") + }, notificationClickAction = notificationClickAction, onNotificationClickActionChange = { notificationClickAction.value = it diff --git a/app/src/main/java/moe/chenxy/huaweipods/ui/pages/SettingsPage.kt b/app/src/main/java/moe/chenxy/huaweipods/ui/pages/SettingsPage.kt index 5e2ca5e..83df8a0 100644 --- a/app/src/main/java/moe/chenxy/huaweipods/ui/pages/SettingsPage.kt +++ b/app/src/main/java/moe/chenxy/huaweipods/ui/pages/SettingsPage.kt @@ -49,6 +49,8 @@ fun SettingsPage( onPreviewLowBatteryReminder: () -> Unit = {}, appLanguage: MutableState = mutableStateOf(AppLocale.SYSTEM), onAppLanguageChange: (Int) -> Unit = {}, + bottomPopupEnabled: MutableState = mutableStateOf(false), + onBottomPopupEnabledChange: (Boolean) -> Unit = {}, notificationClickAction: MutableState = mutableStateOf(ConfigManager.NOTIFICATION_CLICK_MODULE_POPUP), onNotificationClickActionChange: (Int) -> Unit = {}, moreClickAction: MutableState = mutableStateOf(ConfigManager.MORE_CLICK_MODULE), @@ -194,6 +196,12 @@ fun SettingsPage( onCheckedChange = onMultiBatteryDtoEnabledChange, ) } + SwitchPreference( + title = stringResource(R.string.bottom_popup), + summary = stringResource(R.string.bottom_popup_summary), + checked = bottomPopupEnabled.value, + onCheckedChange = onBottomPopupEnabledChange, + ) OverlayDropdownPreference( title = stringResource(R.string.notification_click_action), summary = stringResource(R.string.notification_click_action_summary), diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 254a60b..fc4e0d9 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -37,6 +37,14 @@ 开启后会在每次连接时自动重新应用 防风噪 降低户外环境中的风声干扰 + + 融合中心低时延卡片 + 用融合中心的查找耳机卡片快速切换低时延,仅在支持的机型上生效 + 开盖底部弹窗 + 耳机开盖回连时,从底部滑出显示电量的卡片 + 已连接 + 已开启 + 已关闭 降噪 左耳双击 右耳双击 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f32ad6c..46414cb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -36,6 +36,14 @@ Automatically reapplied after every connection Wind noise reduction Reduce wind interference in outdoor environments + + Fusion Center low-latency card + Use the Find Earbuds card to toggle low latency on supported models + Bottom connection popup + Slide up a bottom card with battery levels when the earbuds reconnect after opening the case + Connected + On + Off NC Left double-tap Right double-tap diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 From 0c07e417a3b2c65731dcf3c1acdd4ebef139cdc3 Mon Sep 17 00:00:00 2001 From: ByteJuno <87103000+ByteJuno@users.noreply.github.com> Date: Sun, 15 Nov 2026 12:45:26 +0800 Subject: [PATCH 2/5] fix: address bottom popup review feedback - handle onNewIntent: when another device connects while the popup is still open, update the target device and reset the auto-dismiss timer - add isSessionRestore flag to connectPod; skip popup on hot-reload session restore, only show on real connections - default the bottom popup toggle to off (cherry picked from commit e48458dbd9a433087d6d8d52c91b5ec8e94d1886) --- .../chenxy/huaweipods/BottomPopupActivity.kt | 44 ++++++++++++++----- .../huaweipods/hook/HeadsetStateDispatcher.kt | 2 +- .../huaweipods/pods/HuaweiHfpController.kt | 13 ++++-- 3 files changed, 43 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/moe/chenxy/huaweipods/BottomPopupActivity.kt b/app/src/main/java/moe/chenxy/huaweipods/BottomPopupActivity.kt index 935dfa5..3ad6565 100644 --- a/app/src/main/java/moe/chenxy/huaweipods/BottomPopupActivity.kt +++ b/app/src/main/java/moe/chenxy/huaweipods/BottomPopupActivity.kt @@ -70,17 +70,20 @@ class BottomPopupActivity : ComponentActivity() { super.attachBaseContext(newBase) } + private var popupTarget by mutableStateOf(null) + @SuppressLint("UnspecifiedRegisterReceiverFlag") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val prefs = getSharedPreferences(ConfigManager.PREFS_NAME, Context.MODE_PRIVATE) - val bluetoothDevice = intent.parcelableDevice("android.bluetooth.device.extra.DEVICE") - val target = bluetoothDevice?.let { resolveTarget(it, prefs) } - if (target == null) { + val initial = intent.parcelableDevice("android.bluetooth.device.extra.DEVICE") + ?.let { resolveTarget(it, prefs) } + if (initial == null) { finish() return } + popupTarget = initial setContent { val colorSchemeMode = when (prefs.getInt("theme_mode", 0)) { @@ -89,14 +92,28 @@ class BottomPopupActivity : ComponentActivity() { else -> ColorSchemeMode.System } AppTheme(colorSchemeMode = colorSchemeMode, accentMode = prefs.getInt("accent_mode", 0)) { - BottomPopupContent( - target = target, - onFinish = { finish() }, - ) + val target = popupTarget + if (target != null) { + BottomPopupContent( + target = target, + onFinish = { finish() }, + ) + } } } } + // singleTop:旧弹窗未关闭时连接另一设备,复用本 Activity,更新设备信息, + // 卡片内部会据此重置电量与自动关闭计时。 + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + val prefs = getSharedPreferences(ConfigManager.PREFS_NAME, Context.MODE_PRIVATE) + val newTarget = intent.parcelableDevice("android.bluetooth.device.extra.DEVICE") + ?.let { resolveTarget(it, prefs) } ?: return + popupTarget = newTarget + setIntent(intent) + } + private fun Intent.parcelableDevice(key: String): BluetoothDevice? = getParcelableExtra(key, BluetoothDevice::class.java) @@ -139,7 +156,7 @@ private fun BottomPopupContent( visible = false } - val receiver = remember { + val receiver = remember(target) { object : BroadcastReceiver() { override fun onReceive(c: Context?, intent: Intent?) { intent ?: return @@ -163,7 +180,8 @@ private fun BottomPopupContent( } } - DisposableEffect(Unit) { + // 绑定 target:设备切换时反注册旧广播、为新设备重新注册并请求一次状态。 + DisposableEffect(target) { context.registerReceiver( receiver, IntentFilter().apply { @@ -187,8 +205,12 @@ private fun BottomPopupContent( LaunchedEffect(Unit) { visible = true } - // 自动关闭。 - LaunchedEffect(Unit) { + // 切换到新设备:先清空旧电量,等新设备的广播回填。 + LaunchedEffect(target.address) { + battery = BatteryParams() + } + // 自动关闭;key 为设备地址,切换设备时重新计时。 + LaunchedEffect(target.address) { kotlinx.coroutines.delay(AUTO_DISMISS_MS) close() } 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 036e7ef..6bdf03d 100644 --- a/app/src/main/java/moe/chenxy/huaweipods/hook/HeadsetStateDispatcher.kt +++ b/app/src/main/java/moe/chenxy/huaweipods/hook/HeadsetStateDispatcher.kt @@ -350,7 +350,7 @@ object HeadsetStateDispatcher : HookContext() { setHeadsetIcon(context, true, "audio-profile") startIconWatchdog(context) if (decision == AudioSessionDecision.CONNECT) { - HuaweiHfpController.connectPod(context, device) + HuaweiHfpController.connectPod(context, device, isSessionRestore = restore) savedState?.let(HuaweiHfpController::restoreHotReloadState) } } 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 70d616b..f5d15e1 100644 --- a/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiHfpController.kt +++ b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiHfpController.kt @@ -471,7 +471,11 @@ object HuaweiHfpController { } } - fun connectPod(context: Context, device: BluetoothDevice) { + fun connectPod( + context: Context, + device: BluetoothDevice, + isSessionRestore: Boolean = false, + ) { val route = device.huaweiDeviceRoute() if (!route.isSupported) { Log.w(TAG, "Huawei session skipped: unsupported device=${device.address}") @@ -487,9 +491,10 @@ object HuaweiHfpController { restored?.battery != null } if (restoredCachedBattery) scheduleCachedNotificationRestore() - // 开盖即弹:连接建立后立即弹出底部卡片,不等待较慢的 RFCOMM 电量查询; - // 电量稍后通过 ACTION_PODS_BATTERY_CHANGED 广播自动回填到卡片。 - maybeShowBottomPopup(context, device) + // 开盖即弹:仅在真实新连接时弹出底部卡片;模块热重载/进程重启恢复已有连接 + // (isSessionRestore=true)时不弹窗,避免用户没有重新开盖却被打扰。 + // 卡片不等待较慢的 RFCOMM 电量查询,电量稍后通过广播自动回填。 + if (!isSessionRestore) maybeShowBottomPopup(context, device) // 电量查询提到 RFCOMM 队列最前,让超级岛尽早拿到真实电量,不再被其他查询阻塞。 requestPrivateBattery() requestOfficialImageIdentity(force = true) From 016cc147b22c8b46da85f86a1d050af3f61e9269 Mon Sep 17 00:00:00 2001 From: ByteJuno <87103000+ByteJuno@users.noreply.github.com> Date: Sun, 15 Nov 2026 12:45:27 +0800 Subject: [PATCH 3/5] fix: reset bottom popup lifecycle when switching devices (cherry picked from commit fd26a900e8b69d69d74833a6639bb5e145d4f9a5) --- .../moe/chenxy/huaweipods/BottomPopupActivity.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/moe/chenxy/huaweipods/BottomPopupActivity.kt b/app/src/main/java/moe/chenxy/huaweipods/BottomPopupActivity.kt index 3ad6565..2af7205 100644 --- a/app/src/main/java/moe/chenxy/huaweipods/BottomPopupActivity.kt +++ b/app/src/main/java/moe/chenxy/huaweipods/BottomPopupActivity.kt @@ -33,6 +33,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -94,10 +95,13 @@ class BottomPopupActivity : ComponentActivity() { AppTheme(colorSchemeMode = colorSchemeMode, accentMode = prefs.getInt("accent_mode", 0)) { val target = popupTarget if (target != null) { - BottomPopupContent( - target = target, - onFinish = { finish() }, - ) + // 切换设备时重建卡片状态,同时取消旧设备尚未结束的关闭任务。 + key(target.address) { + BottomPopupContent( + target = target, + onFinish = { finish() }, + ) + } } } } From db03c7efda96befd23005a35b0cadee8bd5b2876 Mon Sep 17 00:00:00 2001 From: ByteJuno <87103000+ByteJuno@users.noreply.github.com> Date: Sun, 15 Nov 2026 13:15:07 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(upstream):=20=E4=BB=85=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=9C=AC=E6=AC=A1=E4=B8=89=E4=B8=AA=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=E7=BC=BA=E9=99=B7=EF=BC=9A=E6=8C=89=E8=BF=9E=E6=8E=A5=E4=BB=A3?= =?UTF-8?q?=E6=AC=A1=E4=BF=9D=E5=AD=98=E8=87=AA=E5=8A=A8=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=E8=AE=B8=E5=8F=AF=EF=BC=8C=E6=81=A2=E5=A4=8D=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E7=9A=84=20AT/RFCOMM=20=E5=BB=B6=E8=BF=9F=E5=9B=9E=E6=8A=A5?= =?UTF-8?q?=E4=B9=9F=E4=B8=8D=E8=83=BD=E9=87=8D=E6=96=B0=E8=8E=B7=E5=BE=97?= =?UTF-8?q?=E8=AE=B8=E5=8F=AF=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仅修复本次三个弹窗缺陷:按连接代次保存自动弹窗许可,恢复会话的 AT/RFCOMM 延迟回报也不能重新获得许可;通过校验实际 Bluetooth UID 的 ContentProvider 签发一次性启动凭据,并通过宿主 Binder 核实当前连接会话,首次启动和 singleTop 复用统一校验设置、凭据及会话;电量和断连广播先沿用 fork 的发送方认证,再匹配独立会话 ID 和设备身份,电量更新还须核实宿主会话仍有效。保留跨进程启动,不更改应用 ID、签名、版本、CI、发布流程或已有测试。新增纯逻辑回归测试;未执行 shell 或测试。 --- app/src/main/AndroidManifest.xml | 5 + .../chenxy/huaweipods/BottomPopupActivity.kt | 89 +++++----- .../huaweipods/BottomPopupAutoShowPolicy.kt | 23 +++ .../huaweipods/BottomPopupSessionProvider.kt | 160 ++++++++++++++++++ .../huaweipods/pods/HuaweiHfpController.kt | 64 +++++-- .../BottomPopupAutoShowPolicyTest.kt | 58 +++++++ 6 files changed, 333 insertions(+), 66 deletions(-) create mode 100644 app/src/main/java/moe/chenxy/huaweipods/BottomPopupAutoShowPolicy.kt create mode 100644 app/src/main/java/moe/chenxy/huaweipods/BottomPopupSessionProvider.kt create mode 100644 app/src/test/java/moe/chenxy/huaweipods/BottomPopupAutoShowPolicyTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c830901..fc12a34 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -96,6 +96,11 @@ + (null) + private var popupSession by mutableStateOf(null) - @SuppressLint("UnspecifiedRegisterReceiverFlag") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val prefs = getSharedPreferences(ConfigManager.PREFS_NAME, Context.MODE_PRIVATE) - val initial = intent.parcelableDevice("android.bluetooth.device.extra.DEVICE") - ?.let { resolveTarget(it, prefs) } + // The target comes from UID-authenticated registration, never from an arbitrary device extra. + val initial = BottomPopupSessions.consume(this, intent) if (initial == null) { finish() return } - popupTarget = initial + popupSession = initial setContent { val colorSchemeMode = when (prefs.getInt("theme_mode", 0)) { @@ -93,12 +92,12 @@ class BottomPopupActivity : ComponentActivity() { else -> ColorSchemeMode.System } AppTheme(colorSchemeMode = colorSchemeMode, accentMode = prefs.getInt("accent_mode", 0)) { - val target = popupTarget - if (target != null) { - // 切换设备时重建卡片状态,同时取消旧设备尚未结束的关闭任务。 - key(target.address) { + val session = popupSession + if (session != null) { + // 同地址重连也属于新会话,不能继承旧电量或关闭计时。 + key(session.id) { BottomPopupContent( - target = target, + session = session, onFinish = { finish() }, ) } @@ -107,31 +106,13 @@ class BottomPopupActivity : ComponentActivity() { } } - // singleTop:旧弹窗未关闭时连接另一设备,复用本 Activity,更新设备信息, - // 卡片内部会据此重置电量与自动关闭计时。 override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - val prefs = getSharedPreferences(ConfigManager.PREFS_NAME, Context.MODE_PRIVATE) - val newTarget = intent.parcelableDevice("android.bluetooth.device.extra.DEVICE") - ?.let { resolveTarget(it, prefs) } ?: return - popupTarget = newTarget + // Invalid/replayed launches must not replace a legitimate visible card. + val newSession = BottomPopupSessions.consume(this, intent) ?: return + popupSession = newSession setIntent(intent) } - - private fun Intent.parcelableDevice(key: String): BluetoothDevice? = - getParcelableExtra(key, BluetoothDevice::class.java) - - @SuppressLint("MissingPermission") - private fun resolveTarget(device: BluetoothDevice, prefs: android.content.SharedPreferences): PopupDeviceTarget? { - val address = runCatching { device.address }.getOrNull() ?: return null - val deviceName = runCatching { - device.name?.takeIf(String::isNotBlank) - ?: device.alias?.takeIf(String::isNotBlank) - ?: "" - }.getOrDefault("") - val route = DeviceRoutePrefs.resolve(prefs = prefs, address = address, deviceName = deviceName) - return popupDeviceTargetOrNull(address, deviceName, route) - } } private const val AUTO_DISMISS_MS = 6000L @@ -140,9 +121,10 @@ private const val ANIM_MS = 280 @SuppressLint("UnspecifiedRegisterReceiverFlag") @androidx.compose.runtime.Composable private fun BottomPopupContent( - target: PopupDeviceTarget, + session: BottomPopupSession, onFinish: () -> Unit, ) { + val target = session.target val context = LocalContext.current val prefs = remember { context.getSharedPreferences(ConfigManager.PREFS_NAME, Context.MODE_PRIVATE) @@ -160,10 +142,14 @@ private fun BottomPopupContent( visible = false } - val receiver = remember(target) { + val receiver = remember(session) { object : BroadcastReceiver() { override fun onReceive(c: Context?, intent: Intent?) { intent ?: return + // Authenticate before reading identity, session extras or Parcelable battery data. + if (!HuaweiPodsBroadcastTrustPolicy.isTrustedBluetoothStateSender(sentFromPackage)) return + if (finished || !BottomPopupSessions.isCurrent(session)) return + if (intent.getStringExtra(BottomPopupSessions.EXTRA_SESSION_ID) != session.id) return val identity = PopupBroadcastIdentity( address = intent.getStringExtra("address"), deviceName = intent.getStringExtra("device_name"), @@ -174,18 +160,23 @@ private fun BottomPopupContent( if (!popupBroadcastMatchesTarget(target, identity)) return when (HuaweiPodsAction.canonical(intent.action)) { HuaweiPodsAction.ACTION_PODS_BATTERY_CHANGED -> { + if (!BottomPopupSessions.isEnabled(context) || !BottomPopupSessions.isActive(session)) { + close() + return + } intent.getParcelableExtra("status", BatteryParams::class.java)?.let { battery = it } } + // The authenticated terminal event closes exactly this session. Its host may + // already have invalidated the validator before broadcast delivery completes. HuaweiPodsAction.ACTION_PODS_DISCONNECTED -> close() } } } } - // 绑定 target:设备切换时反注册旧广播、为新设备重新注册并请求一次状态。 - DisposableEffect(target) { + DisposableEffect(session) { context.registerReceiver( receiver, IntentFilter().apply { @@ -194,10 +185,9 @@ private fun BottomPopupContent( }, Context.RECEIVER_EXPORTED, ) - // 让蓝牙进程立即回推一次当前状态,避免卡片先显示空电量。 - context.sendBroadcast(Intent(HuaweiPodsAction.ACTION_REFRESH_STATUS).apply { + context.sendIdentitySharingBroadcast(Intent(HuaweiPodsAction.ACTION_REFRESH_STATUS).apply { putPopupTarget(target) - setPackage("com.android.bluetooth") + setPackage(HuaweiPodsBroadcastTrustPolicy.PACKAGE_BLUETOOTH) addFlags(Intent.FLAG_RECEIVER_FOREGROUND) }) onDispose { @@ -205,20 +195,23 @@ private fun BottomPopupContent( } } - // 进场:首帧布局完成后再滑入。 - LaunchedEffect(Unit) { + // Recheck after receiver registration and while visible, including host death/hot reload. + LaunchedEffect(session) { + if (!BottomPopupSessions.isEnabled(context) || !BottomPopupSessions.isActive(session)) { + close() + return@LaunchedEffect + } visible = true + while (!finished) { + kotlinx.coroutines.delay(500L) + if (!BottomPopupSessions.isEnabled(context) || !BottomPopupSessions.isActive(session)) close() + } } - // 切换到新设备:先清空旧电量,等新设备的广播回填。 - LaunchedEffect(target.address) { + LaunchedEffect(session.id) { battery = BatteryParams() - } - // 自动关闭;key 为设备地址,切换设备时重新计时。 - LaunchedEffect(target.address) { kotlinx.coroutines.delay(AUTO_DISMISS_MS) close() } - // 退出动画播完后真正结束 Activity。 LaunchedEffect(finished) { if (finished) { kotlinx.coroutines.delay(ANIM_MS.toLong() + 30L) diff --git a/app/src/main/java/moe/chenxy/huaweipods/BottomPopupAutoShowPolicy.kt b/app/src/main/java/moe/chenxy/huaweipods/BottomPopupAutoShowPolicy.kt new file mode 100644 index 0000000..fcbd94a --- /dev/null +++ b/app/src/main/java/moe/chenxy/huaweipods/BottomPopupAutoShowPolicy.kt @@ -0,0 +1,23 @@ +package moe.chenxy.huaweipods + +/** Accessed under the controller's session lock. Permission belongs to a connection, not a callback. */ +internal class BottomPopupAutoShowPolicy { + private var generation: Long? = null + private var allowed = false + private var shown = false + + fun beginSession(generation: Long, isSessionRestore: Boolean) { + if (this.generation == generation) return + this.generation = generation + allowed = !isSessionRestore + shown = false + } + + fun isAllowed(generation: Long): Boolean = this.generation == generation && allowed + + fun claim(generation: Long, enabled: Boolean): Boolean { + if (!enabled || !isAllowed(generation) || shown) return false + shown = true + return true + } +} diff --git a/app/src/main/java/moe/chenxy/huaweipods/BottomPopupSessionProvider.kt b/app/src/main/java/moe/chenxy/huaweipods/BottomPopupSessionProvider.kt new file mode 100644 index 0000000..3125dd7 --- /dev/null +++ b/app/src/main/java/moe/chenxy/huaweipods/BottomPopupSessionProvider.kt @@ -0,0 +1,160 @@ +package moe.chenxy.huaweipods + +import android.bluetooth.BluetoothAdapter +import android.content.ContentProvider +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.database.Cursor +import android.net.Uri +import android.os.Binder +import android.os.Bundle +import android.os.IBinder +import android.os.Parcel +import android.os.SystemClock +import java.util.UUID +import moe.chenxy.huaweipods.broadcast.HuaweiPodsBroadcastTrustPolicy +import moe.chenxy.huaweipods.config.ConfigManager +import moe.chenxy.huaweipods.pods.HuaweiDeviceRoute +import moe.chenxy.huaweipods.pods.isSupported + +/** + * Only the real Bluetooth UID may register a launch. Neither an Intent-supplied package name nor + * possession of BLUETOOTH_CONNECT is authentication. No URI read/write interface is exposed. + */ +class BottomPopupSessionProvider : ContentProvider() { + override fun onCreate(): Boolean = true + + override fun call(method: String, arg: String?, extras: Bundle?): Bundle? { + val ctx = context ?: return null + val packages = ctx.packageManager.getPackagesForUid(Binder.getCallingUid()) + if (packages?.any(HuaweiPodsBroadcastTrustPolicy::isTrustedBluetoothStateSender) != true) { + throw SecurityException("Only the Bluetooth UID may register a bottom popup session") + } + if (method != BottomPopupSessions.REGISTER) return null + if (!BottomPopupSessions.isEnabled(ctx)) return null + val data = extras ?: return null + val id = data.getString(BottomPopupSessions.EXTRA_SESSION_ID) + ?.takeIf { it.isNotBlank() && it.length <= 128 } ?: return null + val address = data.getString("address") + ?.takeIf(BluetoothAdapter::checkBluetoothAddress) ?: return null + val route = data.getString("route")?.let { + runCatching { HuaweiDeviceRoute.valueOf(it) }.getOrNull() + }?.takeIf { it.isSupported } ?: return null + val validator = data.getBinder("validator") ?: return null + val target = PopupDeviceTarget(address, data.getString("device_name").orEmpty(), route) + // Do not call back into the host here: it may hold its session lock during registration. + val token = BottomPopupSessions.register(id, target, validator) + return Bundle().apply { putString(BottomPopupSessions.EXTRA_LAUNCH_TOKEN, token) } + } + + override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? = null + override fun getType(uri: Uri): String? = null + override fun insert(uri: Uri, values: ContentValues?): Uri? = throw UnsupportedOperationException() + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = throw UnsupportedOperationException() + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = throw UnsupportedOperationException() +} + +internal class BottomPopupSession( + val id: String, + val target: PopupDeviceTarget, + val validator: IBinder, + val createdAt: Long, + var launchToken: String?, +) + +/** Wire constants are shared with the hooked Bluetooth process; registry state lives only in App. */ +internal object BottomPopupSessions { + const val EXTRA_SESSION_ID = "bottom_popup_session_id" + const val EXTRA_LAUNCH_TOKEN = "bottom_popup_launch_token" + const val REGISTER = "register" + private const val DESCRIPTOR = "io.github.artifical0.huaweipods.coloros.BottomPopupSession.v1" + private const val VALIDATE = IBinder.FIRST_CALL_TRANSACTION + private const val LAUNCH_TTL_MS = 10_000L + private val lock = Any() + private var current: BottomPopupSession? = null + + fun isEnabled(context: Context): Boolean = ConfigManager.refreshFromPrefs( + context.getSharedPreferences(ConfigManager.PREFS_NAME, Context.MODE_PRIVATE), + ).bottomPopupEnabled + + fun register(id: String, target: PopupDeviceTarget, validator: IBinder): String = synchronized(lock) { + val token = UUID.randomUUID().toString() + current = BottomPopupSession(id, target, validator, SystemClock.elapsedRealtime(), token) + token + } + + /** First launch and onNewIntent both consume a short-lived, single-use App-issued credential. */ + fun consume(context: Context, intent: Intent): BottomPopupSession? = runCatching { + if (!isEnabled(context)) return null + val token = intent.getStringExtra(EXTRA_LAUNCH_TOKEN) ?: return null + val session = synchronized(lock) { + val candidate = current ?: return null + val age = SystemClock.elapsedRealtime() - candidate.createdAt + if (candidate.launchToken != token || age !in 0..LAUNCH_TTL_MS) return null + candidate.launchToken = null + candidate + } + session.takeIf { isActive(it) } + }.getOrNull() + + fun isCurrent(session: BottomPopupSession): Boolean = synchronized(lock) { current === session } + + /** Generation and connection ownership are checked in the Bluetooth host, not inferred from MAC. */ + fun isActive(session: BottomPopupSession): Boolean { + if (!isCurrent(session)) return false + val active = runCatching { + val data = Parcel.obtain() + val reply = Parcel.obtain() + try { + data.writeInterfaceToken(DESCRIPTOR) + if (!session.validator.transact(VALIDATE, data, reply, 0)) return@runCatching false + reply.readException() + reply.readInt() == 1 + } finally { + data.recycle() + reply.recycle() + } + }.getOrDefault(false) + return active && isCurrent(session) + } + + fun requestLaunchToken( + context: Context, + sessionId: String, + address: String, + deviceName: String, + route: HuaweiDeviceRoute, + validator: IBinder, + ): String? = context.contentResolver.call( + Uri.parse("content://${BuildConfig.APPLICATION_ID}.bottompopup"), + REGISTER, + null, + Bundle().apply { + putString(EXTRA_SESSION_ID, sessionId) + putString("address", address) + putString("device_name", deviceName) + putString("route", route.name) + putBinder("validator", validator) + }, + )?.getString(EXTRA_LAUNCH_TOKEN) + + /** The capability never appears in broadcasts, and only the module UID may query it. */ + fun createHostValidator(context: Context, isActive: () -> Boolean): IBinder = object : Binder() { + init { attachInterface(null, DESCRIPTOR) } + + override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean { + if (code != VALIDATE) return super.onTransact(code, data, reply, flags) + data.enforceInterface(DESCRIPTOR) + data.enforceNoDataAvail() + val packages = context.packageManager.getPackagesForUid(getCallingUid()) + if (packages?.contains(BuildConfig.APPLICATION_ID) != true) { + throw SecurityException("Only the module UID may validate a bottom popup session") + } + val active = isActive() + reply?.writeNoException() + reply?.writeInt(if (active) 1 else 0) + return true + } + } +} 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 f5d15e1..ae1a1fb 100644 --- a/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiHfpController.kt +++ b/app/src/main/java/moe/chenxy/huaweipods/pods/HuaweiHfpController.kt @@ -18,6 +18,8 @@ import java.util.UUID import moe.chenxy.huaweipods.BuildConfig import moe.chenxy.huaweipods.broadcast.HuaweiPodsBroadcastTrustPolicy import moe.chenxy.huaweipods.BottomPopupActivity +import moe.chenxy.huaweipods.BottomPopupAutoShowPolicy +import moe.chenxy.huaweipods.BottomPopupSessions import moe.chenxy.huaweipods.config.ConfigManager import moe.chenxy.huaweipods.config.LowLatencyPrefs import moe.chenxy.huaweipods.hook.ColorOsSystemBatteryBridge @@ -101,7 +103,8 @@ object HuaweiHfpController { private var lowLatencyAppliedGeneration: Long? = null private val lowLatencyWritePolicy = LowLatencyWritePolicy() private val freeClip2AudioStateTracker = FreeClip2AudioStateTracker() - private var bottomPopupShownForGeneration = -1L + private val bottomPopupAutoShowPolicy = BottomPopupAutoShowPolicy() + private var bottomPopupSessionId = "" private val freeBuds3SnapshotStore = FreeBuds3StateSnapshotStore() private val sessionStateLock = Any() private val mainHandler = Handler(Looper.getMainLooper()) @@ -483,6 +486,8 @@ object HuaweiHfpController { } val restoredCachedBattery = synchronized(sessionStateLock) { ensureSession(context, device, route) + // 同一代次的重复 connectPod 不得把恢复会话升级为允许自动弹窗。 + bottomPopupAutoShowPolicy.beginSession(sessionGeneration, isSessionRestore) val restored = restoreFreeBuds3StateAfterProcessRestart(context, device, route) sendConnectionState("connecting") sendConnected() @@ -494,7 +499,7 @@ object HuaweiHfpController { // 开盖即弹:仅在真实新连接时弹出底部卡片;模块热重载/进程重启恢复已有连接 // (isSessionRestore=true)时不弹窗,避免用户没有重新开盖却被打扰。 // 卡片不等待较慢的 RFCOMM 电量查询,电量稍后通过广播自动回填。 - if (!isSessionRestore) maybeShowBottomPopup(context, device) + maybeShowBottomPopup(context, device) // 电量查询提到 RFCOMM 队列最前,让超级岛尽早拿到真实电量,不再被其他查询阻塞。 requestPrivateBattery() requestOfficialImageIdentity(force = true) @@ -1419,6 +1424,7 @@ object HuaweiHfpController { HuaweiL2capAncController.disconnect(previousDevice) } if (newSession) { + bottomPopupSessionId = UUID.randomUUID().toString() FreeClipControlHost.reset() // elapsedRealtime can tie during rapid reconnect; keep the wire epoch strictly ordered. freeClipSessionEpoch = maxOf(SystemClock.elapsedRealtime(), freeClipSessionEpoch + 1L) @@ -1936,22 +1942,42 @@ object HuaweiHfpController { context: Context, device: BluetoothDevice, ) { - if (!ConfigManager.bottomPopupEnabled()) return - if (bottomPopupShownForGeneration == sessionGeneration) return - bottomPopupShownForGeneration = sessionGeneration - runCatching { - val intent = Intent().apply { - // 模块代码运行在蓝牙进程,context.packageName 是 com.android.bluetooth; - // 必须显式指向模块自己的包,否则会去蓝牙包里找 Activity 而抛异常。 - setClassName(BuildConfig.APPLICATION_ID, BottomPopupActivity::class.java.name) - putExtra("android.bluetooth.device.extra.DEVICE", device) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - context.startActivity(intent) - }.onSuccess { - Log.i(TAG, "Huawei bottom popup requested device=${device.address}") - }.onFailure { - Log.e(TAG, "Bottom popup launch failed safely device=${device.address}", it) + synchronized(sessionStateLock) { + if (!this.device?.address.equals(device.address, ignoreCase = true)) return + val generation = sessionGeneration + val address = device.address + val route = sessionRoute + val sessionId = bottomPopupSessionId + if (!bottomPopupAutoShowPolicy.claim(generation, ConfigManager.bottomPopupEnabled())) return + runCatching { + val validator = BottomPopupSessions.createHostValidator(context) { + synchronized(sessionStateLock) { + isCurrentSession(generation, address, route) && + bottomPopupSessionId == sessionId && + bottomPopupAutoShowPolicy.isAllowed(generation) && + ConfigManager.bottomPopupEnabled() + } + } + val token = BottomPopupSessions.requestLaunchToken( + context = context, + sessionId = sessionId, + address = address, + deviceName = device.name ?: device.alias ?: "", + route = route, + validator = validator, + ) ?: return + val intent = Intent().apply { + // 模块运行在 Bluetooth UID;凭据由模块 Provider 验证真实调用 UID 后签发。 + setClassName(BuildConfig.APPLICATION_ID, BottomPopupActivity::class.java.name) + putExtra(BottomPopupSessions.EXTRA_LAUNCH_TOKEN, token) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + }.onSuccess { + Log.i(TAG, "Huawei bottom popup requested device=${device.address}") + }.onFailure { + Log.e(TAG, "Bottom popup launch failed safely device=${device.address}", it) + } } } @@ -2634,6 +2660,8 @@ object HuaweiHfpController { val currentDevice = device Intent(action).apply { putExtra("vendor", "huawei") + // 仅用于关联会话,不是启动凭据;发送方仍必须通过广播信任策略认证。 + putExtra(BottomPopupSessions.EXTRA_SESSION_ID, bottomPopupSessionId) putExtra(HuaweiPodsAction.EXTRA_EVENT_ELAPSED_REALTIME, SystemClock.elapsedRealtime()) encodeHuaweiDeviceRouteForBroadcast(sessionRoute)?.let { putExtra(HuaweiPodsAction.EXTRA_DEVICE_ROUTE, it) diff --git a/app/src/test/java/moe/chenxy/huaweipods/BottomPopupAutoShowPolicyTest.kt b/app/src/test/java/moe/chenxy/huaweipods/BottomPopupAutoShowPolicyTest.kt new file mode 100644 index 0000000..59eb22a --- /dev/null +++ b/app/src/test/java/moe/chenxy/huaweipods/BottomPopupAutoShowPolicyTest.kt @@ -0,0 +1,58 @@ +package moe.chenxy.huaweipods + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BottomPopupAutoShowPolicyTest { + @Test + fun restoredSessionSuppressesInitialAndEveryDelayedBatteryTrigger() { + val policy = BottomPopupAutoShowPolicy() + policy.beginSession(1L, isSessionRestore = true) + assertFalse(policy.isAllowed(1L)) + repeat(10) { assertFalse(policy.claim(1L, enabled = true)) } + } + + @Test + fun duplicateConnectCannotUpgradeRestoredSession() { + val policy = BottomPopupAutoShowPolicy() + policy.beginSession(1L, isSessionRestore = true) + policy.beginSession(1L, isSessionRestore = false) + assertFalse(policy.isAllowed(1L)) + assertFalse(policy.claim(1L, enabled = true)) + } + + @Test + fun onlyANewConnectionGenerationReenablesAutomaticPopup() { + val policy = BottomPopupAutoShowPolicy() + policy.beginSession(1L, isSessionRestore = true) + policy.beginSession(2L, isSessionRestore = false) + assertFalse(policy.claim(1L, enabled = true)) + assertTrue(policy.isAllowed(2L)) + assertTrue(policy.claim(2L, enabled = true)) + assertFalse(policy.claim(2L, enabled = true)) + } + + @Test + fun permissionDoesNotExistBeforeConnectionOwnershipIsEstablished() { + val policy = BottomPopupAutoShowPolicy() + assertFalse(policy.isAllowed(0L)) + assertFalse(policy.claim(0L, enabled = true)) + policy.beginSession(1L, isSessionRestore = false) + assertFalse(policy.isAllowed(2L)) + assertFalse(policy.claim(2L, enabled = true)) + } + + @Test + fun settingAndShownStateAreCheckedForEveryTrigger() { + val policy = BottomPopupAutoShowPolicy() + policy.beginSession(1L, isSessionRestore = false) + assertFalse(policy.claim(1L, enabled = false)) + assertTrue(policy.claim(1L, enabled = true)) + policy.beginSession(1L, isSessionRestore = false) + assertFalse(policy.claim(1L, enabled = true)) + policy.beginSession(2L, isSessionRestore = true) + assertFalse(policy.isAllowed(2L)) + assertFalse(policy.claim(2L, enabled = true)) + } +} From 34e2d2a0e8560c4366246854dc1f65e02e731af3 Mon Sep 17 00:00:00 2001 From: ByteJuno <87103000+ByteJuno@users.noreply.github.com> Date: Sun, 15 Nov 2026 14:04:37 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(upstream):=20=E4=BB=85=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=9C=AC=E6=AC=A1=E5=BA=95=E9=83=A8=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E7=9A=84=E4=B8=A4=E4=B8=AA=E7=94=9F=E5=91=BD?= =?UTF-8?q?=E5=91=A8=E6=9C=9F=E9=97=AE=E9=A2=98=EF=BC=9A=E9=A6=96=E6=AC=A1?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=8F=8A=20onNewIntent=20=E7=BB=A7=E7=BB=AD?= =?UTF-8?q?=E6=B6=88=E8=B4=B9=E4=B8=80=E6=AC=A1=E6=80=A7=E5=87=AD=E6=8D=AE?= =?UTF-8?q?=EF=BC=8CActivit=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仅修复本次底部连接卡片的两个生命周期问题:首次启动及 onNewIntent 继续消费一次性凭据,Activity 重建则通过已保存的会话标识重新关联,并核验该会话已获授权、仍为当前会话、宿主连接有效且弹窗开关开启;恢复失败不回退消费启动凭据。退出动画完成回调在 Activity 层按会话对象身份再次检查,防止旧卡片在 Compose 移除前关闭新卡片。新增设备切换、同地址重连、Activity 重建及恢复校验的仪器回归测试,不修改已有测试、Manifest、应用 ID、版本、签名、依赖或 CI/发布流程。受环境限制未执行测试。 --- .../BottomPopupLifecycleRegressionTest.kt | 227 ++++++++++++++++++ .../chenxy/huaweipods/BottomPopupActivity.kt | 23 +- .../huaweipods/BottomPopupSessionProvider.kt | 12 + 3 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 app/src/androidTest/java/moe/chenxy/huaweipods/BottomPopupLifecycleRegressionTest.kt diff --git a/app/src/androidTest/java/moe/chenxy/huaweipods/BottomPopupLifecycleRegressionTest.kt b/app/src/androidTest/java/moe/chenxy/huaweipods/BottomPopupLifecycleRegressionTest.kt new file mode 100644 index 0000000..8f6a940 --- /dev/null +++ b/app/src/androidTest/java/moe/chenxy/huaweipods/BottomPopupLifecycleRegressionTest.kt @@ -0,0 +1,227 @@ +package moe.chenxy.huaweipods + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.test.platform.app.InstrumentationRegistry +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import moe.chenxy.huaweipods.config.ConfigManager +import moe.chenxy.huaweipods.pods.HuaweiDeviceRoute +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class BottomPopupLifecycleRegressionTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val context: Context get() = instrumentation.targetContext + private val activities = mutableListOf() + private var hadPopupPreference = false + private var previousPopupPreference = false + + @Before + fun enablePopup() { + val prefs = context.getSharedPreferences(ConfigManager.PREFS_NAME, Context.MODE_PRIVATE) + hadPopupPreference = prefs.contains(ConfigManager.PREF_KEY_BOTTOM_POPUP_ENABLED) + previousPopupPreference = prefs.getBoolean(ConfigManager.PREF_KEY_BOTTOM_POPUP_ENABLED, false) + setEnabled(true) + } + + @After + fun cleanUp() { + instrumentation.runOnMainSync { + activities.forEach { if (!it.isDestroyed) it.finish() } + activities.clear() + } + instrumentation.waitForIdleSync() + val prefs = context.getSharedPreferences(ConfigManager.PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit().apply { + if (hadPopupPreference) { + putBoolean(ConfigManager.PREF_KEY_BOTTOM_POPUP_ENABLED, previousPopupPreference) + } else { + remove(ConfigManager.PREF_KEY_BOTTOM_POPUP_ENABLED) + } + }.commit() + ConfigManager.refreshFromPrefs(prefs) + } + + @Test + fun recreationReattachesAuthorizedSessionWithoutReusingLaunchToken() { + val registration = register(ADDRESS_A) + val original = launch(registration) + val authorized = BottomPopupSessions.restore(context, registration.id) + assertNotNull(authorized) + assertNull(BottomPopupSessions.consume(context, registration.intent)) + + val application = context.applicationContext as Application + val resumed = CountDownLatch(1) + val replacement = AtomicReference() + val callbacks = object : Application.ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, state: Bundle?) { + if (activity is BottomPopupActivity && activity !== original) { + activities.add(activity) + } + } + + override fun onActivityResumed(activity: Activity) { + if (activity is BottomPopupActivity && activity !== original) { + replacement.set(activity) + resumed.countDown() + } + } + + override fun onActivityStarted(activity: Activity) = Unit + override fun onActivityPaused(activity: Activity) = Unit + override fun onActivityStopped(activity: Activity) = Unit + override fun onActivitySaveInstanceState(activity: Activity, state: Bundle) = Unit + override fun onActivityDestroyed(activity: Activity) = Unit + } + application.registerActivityLifecycleCallbacks(callbacks) + try { + instrumentation.runOnMainSync { original.recreate() } + assertTrue("The authorized card must survive Activity recreation", resumed.await(5, TimeUnit.SECONDS)) + instrumentation.waitForIdleSync() + val recreated = replacement.get() + assertNotNull(recreated) + assertFalse(recreated.isFinishing) + assertFalse(recreated.isDestroyed) + assertSame(authorized, BottomPopupSessions.restore(context, registration.id)) + assertNull(BottomPopupSessions.consume(context, registration.intent)) + + // A restored Activity must own the session, not merely remain as an empty window. + instrumentation.runOnMainSync { recreated.finishSession(requireNotNull(authorized)) } + assertTrue(recreated.isFinishing) + } finally { + application.unregisterActivityLifecycleCallbacks(callbacks) + } + } + + @Test + fun oldExitCompletionCannotFinishCardAfterSwitchingDevice() { + assertOldExitCannotFinishReplacement(ADDRESS_B) + } + + @Test + fun oldExitCompletionCannotFinishCardAfterSameAddressReconnect() { + assertOldExitCannotFinishReplacement(ADDRESS_A) + } + + private fun assertOldExitCannotFinishReplacement(nextAddress: String) { + val first = register(ADDRESS_A) + val activity = launch(first) + val firstSession = requireNotNull(BottomPopupSessions.restore(context, first.id)) + var survivedOldCompletion = false + var replayDidNotReplaceCurrent = false + var currentCompletionFinished = false + + instrumentation.runOnMainSync { + // Model an old exit animation whose completion is ready to run. Deliver the new + // Intent and that completion in one main-thread turn, before Compose can recompose + // and cancel the old key's coroutine. No sleeps or cancellation timing assumptions. + val oldExitCompletion = { activity.finishSession(firstSession) } + val next = register(nextAddress) + activity.onNewIntent(next.intent) + val nextSession = requireNotNull(BottomPopupSessions.restore(context, next.id)) + oldExitCompletion() + survivedOldCompletion = !activity.isFinishing + + // The consumed launch must not displace the legitimate replacement either. + activity.onNewIntent(first.intent) + oldExitCompletion() + replayDidNotReplaceCurrent = !activity.isFinishing && + BottomPopupSessions.restore(context, next.id) === nextSession + + activity.finishSession(nextSession) + currentCompletionFinished = activity.isFinishing + } + + assertTrue("Old exit completion closed the replacement card", survivedOldCompletion) + assertTrue("A replay displaced the replacement session", replayDidNotReplaceCurrent) + assertTrue("The replacement card must still be able to finish itself", currentCompletionFinished) + } + + @Test + fun restorationDoesNotAuthorizePendingRegistrationOrConsumeItsToken() { + val registration = register(ADDRESS_A) + assertNull(BottomPopupSessions.restore(context, registration.id)) + val authorized = BottomPopupSessions.consume(context, registration.intent) + assertNotNull(authorized) + assertSame(authorized, BottomPopupSessions.restore(context, registration.id)) + assertNull(BottomPopupSessions.consume(context, registration.intent)) + } + + @Test + fun restorationRejectsMissingWrongAndReplacedSessionIds() { + val first = register(ADDRESS_A) + assertNotNull(BottomPopupSessions.consume(context, first.intent)) + assertNull(BottomPopupSessions.restore(context, null)) + assertNull(BottomPopupSessions.restore(context, UUID.randomUUID().toString())) + + // Same MAC does not grant permission to restore an earlier connection generation. + val replacement = register(ADDRESS_A) + assertNotNull(BottomPopupSessions.consume(context, replacement.intent)) + assertNull(BottomPopupSessions.restore(context, first.id)) + assertNotNull(BottomPopupSessions.restore(context, replacement.id)) + } + + @Test + fun restorationRevalidatesHostAndPopupPreference() { + var hostActive = true + val registration = register(ADDRESS_A) { hostActive } + val authorized = BottomPopupSessions.consume(context, registration.intent) + assertNotNull(authorized) + assertSame(authorized, BottomPopupSessions.restore(context, registration.id)) + + hostActive = false + assertNull(BottomPopupSessions.restore(context, registration.id)) + hostActive = true + setEnabled(false) + assertNull(BottomPopupSessions.restore(context, registration.id)) + setEnabled(true) + assertSame(authorized, BottomPopupSessions.restore(context, registration.id)) + assertNull(BottomPopupSessions.consume(context, registration.intent)) + } + + private fun setEnabled(enabled: Boolean) { + context.getSharedPreferences(ConfigManager.PREFS_NAME, Context.MODE_PRIVATE).edit() + .putBoolean(ConfigManager.PREF_KEY_BOTTOM_POPUP_ENABLED, enabled) + .commit() + } + + private data class Registration(val id: String, val intent: Intent) + + private fun register(address: String, active: () -> Boolean = { true }): Registration { + val id = UUID.randomUUID().toString() + val token = BottomPopupSessions.register( + id, + PopupDeviceTarget(address, "Lifecycle test", HuaweiDeviceRoute.HUAWEI_FREEBUDS3), + BottomPopupSessions.createHostValidator(context, active), + ) + return Registration(id, Intent(context, BottomPopupActivity::class.java).apply { + putExtra(BottomPopupSessions.EXTRA_LAUNCH_TOKEN, token) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }) + } + + private fun launch(registration: Registration): BottomPopupActivity { + val activity = instrumentation.startActivitySync(registration.intent) as BottomPopupActivity + instrumentation.runOnMainSync { activities.add(activity) } + instrumentation.waitForIdleSync() + assertFalse("An authenticated launch must remain visible", activity.isFinishing) + return activity + } + + private companion object { + const val ADDRESS_A = "00:11:22:33:44:55" + const val ADDRESS_B = "00:11:22:33:44:66" + } +} diff --git a/app/src/main/java/moe/chenxy/huaweipods/BottomPopupActivity.kt b/app/src/main/java/moe/chenxy/huaweipods/BottomPopupActivity.kt index 4364887..cfdbc12 100644 --- a/app/src/main/java/moe/chenxy/huaweipods/BottomPopupActivity.kt +++ b/app/src/main/java/moe/chenxy/huaweipods/BottomPopupActivity.kt @@ -77,8 +77,13 @@ class BottomPopupActivity : ComponentActivity() { super.onCreate(savedInstanceState) val prefs = getSharedPreferences(ConfigManager.PREFS_NAME, Context.MODE_PRIVATE) - // The target comes from UID-authenticated registration, never from an arbitrary device extra. - val initial = BottomPopupSessions.consume(this, intent) + // Only a fresh launch consumes a credential. Android-supplied saved state may reattach + // an already authorized session, but cannot authorize a pending or replaced session. + val initial = if (savedInstanceState == null) { + BottomPopupSessions.consume(this, intent) + } else { + BottomPopupSessions.restore(this, savedInstanceState.getString(STATE_SESSION_ID)) + } if (initial == null) { finish() return @@ -98,7 +103,7 @@ class BottomPopupActivity : ComponentActivity() { key(session.id) { BottomPopupContent( session = session, - onFinish = { finish() }, + onFinish = { finishSession(session) }, ) } } @@ -106,6 +111,17 @@ class BottomPopupActivity : ComponentActivity() { } } + override fun onSaveInstanceState(outState: Bundle) { + popupSession?.let { outState.putString(STATE_SESSION_ID, it.id) } + super.onSaveInstanceState(outState) + } + + internal fun finishSession(session: BottomPopupSession) { + // onNewIntent can run before Compose removes the old key and cancels its exit delay. + // Compare session objects, not device addresses (a reconnect is a new session too). + if (popupSession === session) finish() + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) // Invalid/replayed launches must not replace a legitimate visible card. @@ -115,6 +131,7 @@ class BottomPopupActivity : ComponentActivity() { } } +private const val STATE_SESSION_ID = "bottom_popup_authorized_session_id" private const val AUTO_DISMISS_MS = 6000L private const val ANIM_MS = 280 diff --git a/app/src/main/java/moe/chenxy/huaweipods/BottomPopupSessionProvider.kt b/app/src/main/java/moe/chenxy/huaweipods/BottomPopupSessionProvider.kt index 3125dd7..d89fa12 100644 --- a/app/src/main/java/moe/chenxy/huaweipods/BottomPopupSessionProvider.kt +++ b/app/src/main/java/moe/chenxy/huaweipods/BottomPopupSessionProvider.kt @@ -98,6 +98,18 @@ internal object BottomPopupSessions { session.takeIf { isActive(it) } }.getOrNull() + /** Saved Activity state is only an association, never a replacement launch credential. */ + fun restore(context: Context, sessionId: String?): BottomPopupSession? = runCatching { + if (sessionId == null || !isEnabled(context)) return null + val session = synchronized(lock) { + val candidate = current ?: return null + // An unconsumed registration must still go through consume(), including its TTL. + if (candidate.id != sessionId || candidate.launchToken != null) return null + candidate + } + session.takeIf { isActive(it) } + }.getOrNull() + fun isCurrent(session: BottomPopupSession): Boolean = synchronized(lock) { current === session } /** Generation and connection ownership are checked in the Bluetooth host, not inferred from MAC. */