diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c84fff808fe..3b1bad393a0 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -145,6 +145,18 @@
+
+
+
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/controls/HaControlsPanelActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/controls/HaControlsPanelActivity.kt
index 1c6d6f41c5f..d5c5ccae771 100644
--- a/app/src/main/kotlin/io/homeassistant/companion/android/controls/HaControlsPanelActivity.kt
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/controls/HaControlsPanelActivity.kt
@@ -23,9 +23,12 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.getSystemService
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
+import io.homeassistant.companion.android.WIPFeature
import io.homeassistant.companion.android.common.R as commonR
import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
import io.homeassistant.companion.android.common.data.servers.ServerManager
+import io.homeassistant.companion.android.common.data.servers.ServerManager.Companion.SERVER_ID_ACTIVE
+import io.homeassistant.companion.android.launch.LaunchActivity
import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme
import io.homeassistant.companion.android.webview.WebViewActivity
import javax.inject.Inject
@@ -66,16 +69,24 @@ class HaControlsPanelActivity : AppCompatActivity() {
lifecycleScope.launch {
val serverId = prefsRepository.getControlsPanelServer() ?: serverManager.getServer()?.id
val path = prefsRepository.getControlsPanelPath()
- Timber.d("Launching WebView…")
- startActivity(
+ val intent = if (WIPFeature.USE_FRONTEND_V2) {
+ Timber.d("Launching LaunchActivity…")
+ LaunchActivity.newInstance(
+ context = this@HaControlsPanelActivity,
+ deepLink = LaunchActivity.DeepLink.NavigateTo(path = path, serverId = serverId ?: SERVER_ID_ACTIVE),
+ showWhenLocked = true,
+ )
+ } else {
+ Timber.d("Launching WebView…")
WebViewActivity.newInstance(
context = this@HaControlsPanelActivity,
path = path,
serverId = serverId,
).apply {
putExtra(WebViewActivity.EXTRA_SHOW_WHEN_LOCKED, true)
- },
- )
+ }
+ }
+ startActivity(intent)
overridePendingTransition(0, 0) // Disable activity start/stop animation
// The device controls panel can flicker if this activity finishes to quickly, so handle
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchActivity.kt
index b3be19c9499..5b6d3194b6d 100644
--- a/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchActivity.kt
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchActivity.kt
@@ -1,6 +1,7 @@
package io.homeassistant.companion.android.launch
import android.app.PictureInPictureParams
+import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
@@ -56,6 +57,17 @@ import kotlinx.parcelize.Parcelize
private const val DEEP_LINK_KEY = "deep_link_key"
+/**
+ * Fully qualified class name of the non-exported `` declared in the manifest.
+ *
+ * Trusted in-process callers route through this alias to bring up the dashboard over the
+ * keyguard. [LaunchActivity.onCreate] only calls [android.app.Activity.setShowWhenLocked] when
+ * the inbound intent's component matches it — because the alias is `android:exported="false"`,
+ * external apps cannot use it and therefore cannot force the activity to render over the lock
+ * screen by themselves.
+ */
+private const val LOCK_SCREEN_ALIAS_CLASS = "io.homeassistant.companion.android.launch.LaunchOverLockScreen"
+
/**
* Main entry point of the application, responsible for holding the whole navigation graph
* and triggering lifecycle-based refresh of background work.
@@ -113,8 +125,21 @@ class LaunchActivity : AppCompatActivity() {
}
companion object {
- fun newInstance(context: Context, deepLink: DeepLink? = null): Intent {
- return Intent(context, LaunchActivity::class.java).apply {
+ /**
+ * Builds an intent to start [LaunchActivity].
+ *
+ * @param showWhenLocked when `true`, routes through the non-exported
+ * `LaunchOverLockScreen` activity-alias so the dashboard renders over the keyguard.
+ * Intended for trusted in-process callers (e.g. the device controls panel) — external
+ * apps cannot reach the alias and therefore cannot opt into this behavior.
+ */
+ fun newInstance(context: Context, deepLink: DeepLink? = null, showWhenLocked: Boolean = false): Intent {
+ return Intent().apply {
+ component = if (showWhenLocked) {
+ ComponentName(context, LOCK_SCREEN_ALIAS_CLASS)
+ } else {
+ ComponentName(context, LaunchActivity::class.java)
+ }
if (deepLink != null) {
putExtra(DEEP_LINK_KEY, deepLink)
}
@@ -131,6 +156,15 @@ class LaunchActivity : AppCompatActivity() {
)
override fun onCreate(savedInstanceState: Bundle?) {
+ // Must run before super.onCreate so the window flag is set before the platform decides
+ // whether to draw over the keyguard. Gated on the non-exported [LOCK_SCREEN_ALIAS_CLASS]
+ // so external apps reaching the public LAUNCHER intent-filter cannot force this on.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 &&
+ intent.component?.className == LOCK_SCREEN_ALIAS_CLASS
+ ) {
+ setShowWhenLocked(true)
+ }
+
super.onCreate(savedInstanceState)
val splashScreen = installSplashScreen()
diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/launch/LaunchActivityTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/launch/LaunchActivityTest.kt
index 81f031e8433..f67082a4871 100644
--- a/app/src/test/kotlin/io/homeassistant/companion/android/launch/LaunchActivityTest.kt
+++ b/app/src/test/kotlin/io/homeassistant/companion/android/launch/LaunchActivityTest.kt
@@ -2,6 +2,7 @@ package io.homeassistant.companion.android.launch
import android.app.Activity
import android.content.Context
+import android.content.Intent
import android.content.pm.PackageManager
import android.util.Rational
import androidx.lifecycle.Lifecycle
@@ -151,6 +152,45 @@ class LaunchActivityTest {
}
}
+ @Test
+ fun `Given showWhenLocked is true when launched then activity is shown over the lock screen`() {
+ val intent = LaunchActivity.newInstance(ApplicationProvider.getApplicationContext(), showWhenLocked = true)
+
+ ActivityScenario.launch(intent).use { scenario ->
+ scenario.onActivity { activity ->
+ assertTrue(shadowOf(activity).showWhenLocked)
+ }
+ }
+ }
+
+ @Test
+ fun `Given showWhenLocked is false when launched then activity is not shown over the lock screen`() {
+ val intent = LaunchActivity.newInstance(ApplicationProvider.getApplicationContext(), showWhenLocked = false)
+
+ ActivityScenario.launch(intent).use { scenario ->
+ scenario.onActivity { activity ->
+ assertFalse(shadowOf(activity).showWhenLocked)
+ }
+ }
+ }
+
+ @Test
+ fun `Given intent targets LaunchActivity directly with legacy extra then activity is not shown over the lock screen`() {
+ // Models a hostile or stale caller that targets the exported LaunchActivity component
+ // directly and tries to opt into the lock-screen behavior via the legacy extra. The
+ // gating now lives on the non-exported alias, so direct-component intents must never
+ // flip the window flag — regardless of any extra they carry.
+ val intent = Intent(ApplicationProvider.getApplicationContext(), LaunchActivity::class.java).apply {
+ putExtra("show_when_locked", true)
+ }
+
+ ActivityScenario.launch(intent).use { scenario ->
+ scenario.onActivity { activity ->
+ assertFalse(shadowOf(activity).showWhenLocked)
+ }
+ }
+ }
+
private fun setPipFeatureAvailable(available: Boolean) {
val context = ApplicationProvider.getApplicationContext()
shadowOf(context.packageManager)
diff --git a/automotive/lint-baseline.xml b/automotive/lint-baseline.xml
index f13bfd77a7b..60398f89b24 100644
--- a/automotive/lint-baseline.xml
+++ b/automotive/lint-baseline.xml
@@ -1,5 +1,5 @@
-
+
@@ -96,7 +96,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -107,7 +107,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -338,7 +338,7 @@
errorLine2=" ~~~~~~~~~~~~~~">
@@ -382,7 +382,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -811,7 +811,7 @@
errorLine2="~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -899,7 +899,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -910,7 +910,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -921,7 +921,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1068,6 +1068,17 @@
column="1"/>
+
+
+
+
@@ -1108,7 +1119,7 @@
errorLine2=" ^">
@@ -1119,7 +1130,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1130,7 +1141,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1152,7 +1163,7 @@
errorLine2="~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1350,7 +1361,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1361,7 +1372,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1372,7 +1383,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1383,7 +1394,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1394,7 +1405,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1405,7 +1416,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -2230,7 +2241,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -2241,7 +2252,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -2252,7 +2263,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -2263,7 +2274,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -2927,7 +2938,7 @@
errorLine2="^">
@@ -3433,21 +3444,10 @@
errorLine2=" ~~~~~~~~~~~~~~~">
-
-
-
-
+
+
+