From a72501d1ebc05a085c650b95bf72dd72ef734edf Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Wed, 20 May 2026 11:00:25 +0200 Subject: [PATCH 1/3] Use LaunchActivity in HaControlsPanel --- .../controls/HaControlsPanelActivity.kt | 19 ++++++++++++---- .../android/launch/LaunchActivity.kt | 13 ++++++++++- .../android/launch/LaunchActivityTest.kt | 22 +++++++++++++++++++ 3 files changed, 49 insertions(+), 5 deletions(-) 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..1678c327588 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 @@ -56,6 +56,8 @@ import kotlinx.parcelize.Parcelize private const val DEEP_LINK_KEY = "deep_link_key" +private const val EXTRA_SHOW_WHEN_LOCKED = "show_when_locked" + /** * Main entry point of the application, responsible for holding the whole navigation graph * and triggering lifecycle-based refresh of background work. @@ -113,11 +115,14 @@ class LaunchActivity : AppCompatActivity() { } companion object { - fun newInstance(context: Context, deepLink: DeepLink? = null): Intent { + fun newInstance(context: Context, deepLink: DeepLink? = null, showWhenLocked: Boolean = false): Intent { return Intent(context, LaunchActivity::class.java).apply { if (deepLink != null) { putExtra(DEEP_LINK_KEY, deepLink) } + if (showWhenLocked) { + putExtra(EXTRA_SHOW_WHEN_LOCKED, showWhenLocked) + } } } } @@ -131,6 +136,12 @@ 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. Only applied when the caller opts in explicitly. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 && intent.hasExtra(EXTRA_SHOW_WHEN_LOCKED)) { + setShowWhenLocked(intent.getBooleanExtra(EXTRA_SHOW_WHEN_LOCKED, false)) + } + 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..ff5a3ba02e7 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 @@ -151,6 +151,28 @@ 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) + } + } + } + private fun setPipFeatureAvailable(available: Boolean) { val context = ApplicationProvider.getApplicationContext() shadowOf(context.packageManager) From 477f0ad1a9f0fc68a75641f5f4e9ef3bc6511b19 Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Wed, 20 May 2026 11:27:12 +0200 Subject: [PATCH 2/3] Use Activity Alias to enforce only internal logic can display on lock --- app/src/main/AndroidManifest.xml | 12 +++ .../android/launch/LaunchActivity.kt | 39 ++++++++-- .../android/launch/LaunchActivityTest.kt | 18 +++++ automotive/lint-baseline.xml | 76 +++++++++---------- 4 files changed, 99 insertions(+), 46 deletions(-) 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/launch/LaunchActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchActivity.kt index 1678c327588..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,7 +57,16 @@ import kotlinx.parcelize.Parcelize private const val DEEP_LINK_KEY = "deep_link_key" -private const val EXTRA_SHOW_WHEN_LOCKED = "show_when_locked" +/** + * 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 @@ -115,14 +125,24 @@ class LaunchActivity : AppCompatActivity() { } companion object { + /** + * 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(context, LaunchActivity::class.java).apply { + 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) } - if (showWhenLocked) { - putExtra(EXTRA_SHOW_WHEN_LOCKED, showWhenLocked) - } } } } @@ -137,9 +157,12 @@ 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. Only applied when the caller opts in explicitly. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 && intent.hasExtra(EXTRA_SHOW_WHEN_LOCKED)) { - setShowWhenLocked(intent.getBooleanExtra(EXTRA_SHOW_WHEN_LOCKED, false)) + // 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) 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 ff5a3ba02e7..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 @@ -173,6 +174,23 @@ class LaunchActivityTest { } } + @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=" ~~~~~~~~~~~~~~~"> - - - - Date: Wed, 20 May 2026 12:44:15 +0200 Subject: [PATCH 3/3] Add to automotive --- automotive/src/main/AndroidManifest.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/automotive/src/main/AndroidManifest.xml b/automotive/src/main/AndroidManifest.xml index 7d0ef6c5d79..bf28975dee2 100644 --- a/automotive/src/main/AndroidManifest.xml +++ b/automotive/src/main/AndroidManifest.xml @@ -271,6 +271,18 @@ + + +