Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,18 @@
</intent-filter>
</activity-alias>

<!--
Non-exported entry point that lets internal callers (currently the device controls panel)
bring up the dashboard over the keyguard. LaunchActivity only honors
setShowWhenLocked when the inbound intent is routed through this alias, so external apps
hitting the public LaunchActivity intent-filters cannot force the activity to render over
the lock screen.
-->
<activity-alias
android:name="io.homeassistant.companion.android.launch.LaunchOverLockScreen"
android:exported="false"
android:targetActivity="io.homeassistant.companion.android.launch.LaunchActivity" />

<!-- Start things like SensorWorker on device boot -->
<receiver android:name=".websocket.WebsocketBroadcastReceiver"
android:exported="true">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 `<activity-alias>` 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.
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Comment on lines +159 to +166
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jpelgrom what do you think?

Copy link
Copy Markdown
Member Author

@TimoPtr TimoPtr May 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've applied the logic and used an alias and check the component name within the Intent. I hope it's enough.


super.onCreate(savedInstanceState)
val splashScreen = installSplashScreen()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<LaunchActivity>(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<LaunchActivity>(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<LaunchActivity>(intent).use { scenario ->
scenario.onActivity { activity ->
assertFalse(shadowOf(activity).showWhenLocked)
}
}
}

private fun setPipFeatureAvailable(available: Boolean) {
val context = ApplicationProvider.getApplicationContext<Context>()
shadowOf(context.packageManager)
Expand Down
Loading