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/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d6d1c05..fc12a34 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -96,6 +96,17 @@ + + (null) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val prefs = getSharedPreferences(ConfigManager.PREFS_NAME, Context.MODE_PRIVATE) + // 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 + } + popupSession = initial + + setContent { + val colorSchemeMode = when (prefs.getInt("theme_mode", 0)) { + 1 -> ColorSchemeMode.Light + 2 -> ColorSchemeMode.Dark + else -> ColorSchemeMode.System + } + AppTheme(colorSchemeMode = colorSchemeMode, accentMode = prefs.getInt("accent_mode", 0)) { + val session = popupSession + if (session != null) { + // 同地址重连也属于新会话,不能继承旧电量或关闭计时。 + key(session.id) { + BottomPopupContent( + session = session, + onFinish = { finishSession(session) }, + ) + } + } + } + } + } + + 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. + val newSession = BottomPopupSessions.consume(this, intent) ?: return + popupSession = newSession + setIntent(intent) + } +} + +private const val STATE_SESSION_ID = "bottom_popup_authorized_session_id" +private const val AUTO_DISMISS_MS = 6000L +private const val ANIM_MS = 280 + +@SuppressLint("UnspecifiedRegisterReceiverFlag") +@androidx.compose.runtime.Composable +private fun BottomPopupContent( + session: BottomPopupSession, + onFinish: () -> Unit, +) { + val target = session.target + 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(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"), + 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 -> { + 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() + } + } + } + } + + DisposableEffect(session) { + context.registerReceiver( + receiver, + IntentFilter().apply { + addHuaweiPodsAction(HuaweiPodsAction.ACTION_PODS_BATTERY_CHANGED) + addHuaweiPodsAction(HuaweiPodsAction.ACTION_PODS_DISCONNECTED) + }, + Context.RECEIVER_EXPORTED, + ) + context.sendIdentitySharingBroadcast(Intent(HuaweiPodsAction.ACTION_REFRESH_STATUS).apply { + putPopupTarget(target) + setPackage(HuaweiPodsBroadcastTrustPolicy.PACKAGE_BLUETOOTH) + addFlags(Intent.FLAG_RECEIVER_FOREGROUND) + }) + onDispose { + runCatching { context.unregisterReceiver(receiver) } + } + } + + // 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(session.id) { + battery = BatteryParams() + kotlinx.coroutines.delay(AUTO_DISMISS_MS) + close() + } + 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/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..d89fa12 --- /dev/null +++ b/app/src/main/java/moe/chenxy/huaweipods/BottomPopupSessionProvider.kt @@ -0,0 +1,172 @@ +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() + + /** 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. */ + 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/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/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 fdef2d6..ae1a1fb 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,9 @@ 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.BottomPopupAutoShowPolicy +import moe.chenxy.huaweipods.BottomPopupSessions import moe.chenxy.huaweipods.config.ConfigManager import moe.chenxy.huaweipods.config.LowLatencyPrefs import moe.chenxy.huaweipods.hook.ColorOsSystemBatteryBridge @@ -100,6 +103,8 @@ object HuaweiHfpController { private var lowLatencyAppliedGeneration: Long? = null private val lowLatencyWritePolicy = LowLatencyWritePolicy() private val freeClip2AudioStateTracker = FreeClip2AudioStateTracker() + private val bottomPopupAutoShowPolicy = BottomPopupAutoShowPolicy() + private var bottomPopupSessionId = "" private val freeBuds3SnapshotStore = FreeBuds3StateSnapshotStore() private val sessionStateLock = Any() private val mainHandler = Handler(Looper.getMainLooper()) @@ -469,7 +474,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}") @@ -477,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() @@ -485,9 +496,14 @@ object HuaweiHfpController { restored?.battery != null } if (restoredCachedBattery) scheduleCachedNotificationRestore() + // 开盖即弹:仅在真实新连接时弹出底部卡片;模块热重载/进程重启恢复已有连接 + // (isSessionRestore=true)时不弹窗,避免用户没有重新开盖却被打扰。 + // 卡片不等待较慢的 RFCOMM 电量查询,电量稍后通过广播自动回填。 + maybeShowBottomPopup(context, device) + // 电量查询提到 RFCOMM 队列最前,让超级岛尽早拿到真实电量,不再被其他查询阻塞。 + requestPrivateBattery() requestOfficialImageIdentity(force = true) requestAncState(force = true) - requestPrivateBattery() scheduleAutoLowLatency() if (route == HuaweiDeviceRoute.HUAWEI_FREECLIP2) { requestFreeClip2AudioState(force = true) @@ -599,6 +615,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 } @@ -1407,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) @@ -1529,6 +1547,7 @@ object HuaweiHfpController { normalizedBattery, activeDevice, ) + maybeShowBottomPopup(currentContext, activeDevice) Log.i(TAG, "Huawei RFCOMM battery updated device=$requestedAddress") } } @@ -1919,6 +1938,49 @@ object HuaweiHfpController { mainHandler.removeCallbacks(backgroundBatteryRefreshRunnable) } + private fun maybeShowBottomPopup( + context: Context, + device: BluetoothDevice, + ) { + 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) + } + } + } + private fun requestAncState( force: Boolean = false, fallbackState: HuaweiAncState? = null, @@ -2598,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/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/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)) + } +} diff --git a/gradlew b/gradlew old mode 100644 new mode 100755