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=" ~~~~~~~~~~~~~~~"> - - - - + + +