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