Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ import androidx.lifecycle.lifecycleScope
import com.google.android.gms.home.matter.Matter
import com.google.android.gms.home.matter.commissioning.SharedDeviceData
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.frontend.navigation.FrontendTarget
import io.homeassistant.companion.android.launch.startLaunchWithNavigateTo
import io.homeassistant.companion.android.matter.views.MatterCommissioningView
import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme
import io.homeassistant.companion.android.util.enableEdgeToEdgeCompat
import io.homeassistant.companion.android.webview.WebViewActivity
import kotlinx.coroutines.launch
import timber.log.Timber

Expand Down Expand Up @@ -100,7 +101,7 @@ class MatterCommissioningActivity : AppCompatActivity() {
}

private fun continueToApp(hideTransition: Boolean) {
startActivity(WebViewActivity.newInstance(this, null, viewModel.serverId))
startLaunchWithNavigateTo(FrontendTarget.Default, viewModel.serverId)
finish()
if (hideTransition) { // Disable activity start/stop animation
overridePendingTransition(0, 0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ import io.homeassistant.companion.android.assist.ui.AssistSheetView
import io.homeassistant.companion.android.common.assist.AssistViewModelBase
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.common.util.SdkVersion
import io.homeassistant.companion.android.frontend.navigation.FrontendTarget
import io.homeassistant.companion.android.launch.LaunchActivity
import io.homeassistant.companion.android.launch.intentLaunchWithNavigateTo
import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme
import io.homeassistant.companion.android.webview.WebViewActivity
import javax.inject.Inject
import kotlinx.coroutines.launch

Expand Down Expand Up @@ -137,9 +138,9 @@ class AssistActivity : BaseActivity() {
if (fromFrontend && viewModel.userCanManagePipelines) {
{
startActivity(
WebViewActivity.newInstance(
this,
"config/voice-assistants/assistants",
intentLaunchWithNavigateTo(
FrontendTarget.Path("config/voice-assistants/assistants"),
ServerManager.SERVER_ID_ACTIVE,
).apply {
flags += Intent.FLAG_ACTIVITY_NEW_TASK // Delivers data in onNewIntent
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,26 @@ import io.homeassistant.companion.android.common.data.integration.friendlyState
import io.homeassistant.companion.android.common.data.integration.getIcon
import io.homeassistant.companion.android.common.data.integration.isActive
import io.homeassistant.companion.android.common.util.SdkVersion
import io.homeassistant.companion.android.webview.WebViewActivity
import io.homeassistant.companion.android.frontend.navigation.FrontendTarget
import io.homeassistant.companion.android.launch.intentLaunchWithNavigateTo

@RequiresApi(Build.VERSION_CODES.R)
interface HaControl {

@SuppressLint("ResourceType")
fun createControl(context: Context, entity: Entity, info: HaControlInfo): Control {
val controlPath = "entityId:${info.entityId}"
val controlIntent =
context.applicationContext.intentLaunchWithNavigateTo(
FrontendTarget.EntityMoreInfo(info.entityId),
info.serverId,
)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val control = Control.StatefulBuilder(
info.systemId,
PendingIntent.getActivity(
context,
controlPath.hashCode(),
WebViewActivity.newInstance(
context.applicationContext,
controlPath,
info.serverId,
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
info.entityId.hashCode(),
controlIntent,
Comment thread
jpelgrom marked this conversation as resolved.
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE,
),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,15 @@ 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.frontend.navigation.FrontendTarget
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
import kotlinx.coroutines.launch
import timber.log.Timber

@AndroidEntryPoint
class HaControlsPanelActivity : AppCompatActivity() {
Expand Down Expand Up @@ -69,23 +67,14 @@ class HaControlsPanelActivity : AppCompatActivity() {
lifecycleScope.launch {
val serverId = prefsRepository.getControlsPanelServer() ?: serverManager.getServer()?.id
val path = prefsRepository.getControlsPanelPath()
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)
}
}
val intent = LaunchActivity.newInstance(
context = this@HaControlsPanelActivity,
deepLink = LaunchActivity.DeepLink.NavigateTo(
target = FrontendTarget.fromRawPath(path),
serverId = serverId ?: SERVER_ID_ACTIVE,
),
showWhenLocked = true,
)
startActivity(intent)
overridePendingTransition(0, 0) // Disable activity start/stop animation

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import io.homeassistant.companion.android.frontend.js.FrontendJsCallback
import io.homeassistant.companion.android.frontend.matterthread.FrontendMatterThreadHandler
import io.homeassistant.companion.android.frontend.navigation.FrontendEvent
import io.homeassistant.companion.android.frontend.navigation.FrontendRoute
import io.homeassistant.companion.android.frontend.navigation.FrontendTarget
import io.homeassistant.companion.android.frontend.permissions.PermissionManager
import io.homeassistant.companion.android.frontend.url.FrontendUrlManager
import io.homeassistant.companion.android.frontend.url.UrlLoadResult
Expand Down Expand Up @@ -108,7 +109,7 @@ private val FIRST_VIEW_EXCLUDED_URL_REGEX =
@HiltViewModel
internal class FrontendViewModel @VisibleForTesting constructor(
initialServerId: Int,
initialPath: String?,
initialTarget: FrontendTarget,
webViewClientFactory: HAWebViewClientFactory,
private val frontendBusObserver: FrontendBusObserver,
private val externalBusRepository: FrontendExternalBusRepository,
Expand Down Expand Up @@ -155,7 +156,7 @@ internal class FrontendViewModel @VisibleForTesting constructor(
@NamedKeyChain keyChainRepository: KeyChainRepository,
) : this(
initialServerId = savedStateHandle.toRoute<FrontendRoute>().serverId,
initialPath = savedStateHandle.toRoute<FrontendRoute>().path,
initialTarget = savedStateHandle.toRoute<FrontendRoute>().target,
webViewClientFactory = webViewClientFactory,
frontendBusObserver = frontendBusObserver,
externalBusRepository = externalBusRepository,
Expand Down Expand Up @@ -213,7 +214,7 @@ internal class FrontendViewModel @VisibleForTesting constructor(
private val _viewState = ViewStateManager(
FrontendViewState.LoadServer(
serverId = initialServerId,
path = initialPath,
target = initialTarget,
),
)
val viewState: StateFlow<FrontendViewState> = _viewState
Expand Down Expand Up @@ -288,6 +289,13 @@ internal class FrontendViewModel @VisibleForTesting constructor(
/** Job tracking the zoom settings flow collection - restarted on each page load. */
private var zoomObserverJob: Job? = null

/**
* Entity whose more-info dialog must be opened via JavaScript once the page finishes loading.
* Set for servers older than HA 2025.6 (see [UrlLoadResult.Success.moreInfoEntityId]) and
* cleared after it is dispatched in [onPageFinished].
*/
private var pendingMoreInfoEntityId: String? = null

/**
* The user's "Autoplay video" preference.
*
Expand Down Expand Up @@ -380,7 +388,10 @@ internal class FrontendViewModel @VisibleForTesting constructor(
when (event) {
is FrontendImprovHandler.Event.ReloadAtPath -> {
_viewState.update {
FrontendViewState.LoadServer(serverId = event.serverId, path = event.path)
FrontendViewState.LoadServer(
serverId = event.serverId,
target = FrontendTarget.Path(event.path),
)
}
}
}
Expand Down Expand Up @@ -725,14 +736,14 @@ internal class FrontendViewModel @VisibleForTesting constructor(
urlFlowJob = viewModelScope.launch {
permissionManager.checkLocalNetworkPermission()
val currentState = _viewState.value
val path = when (currentState) {
is FrontendViewState.LoadServer -> currentState.path
is FrontendViewState.Loading -> currentState.path
else -> null
val target = when (currentState) {
is FrontendViewState.LoadServer -> currentState.target
is FrontendViewState.Loading -> currentState.target
else -> FrontendTarget.Default
}
urlManager.serverUrlFlow(
serverId = currentState.serverId,
path = path,
target = target,
).collect { result ->
handleUrlResult(result)
}
Expand Down Expand Up @@ -911,11 +922,12 @@ internal class FrontendViewModel @VisibleForTesting constructor(
private fun handleUrlResult(result: UrlLoadResult) {
when (result) {
is UrlLoadResult.Success -> {
pendingMoreInfoEntityId = result.moreInfoEntityId
_viewState.update {
Comment on lines 924 to 926
FrontendViewState.Loading(
serverId = result.serverId,
url = result.url,
path = null,
target = FrontendTarget.Default,
)
}
}
Expand Down Expand Up @@ -1084,6 +1096,12 @@ internal class FrontendViewModel @VisibleForTesting constructor(
* to react to settings changes until the next page load restarts it.
*/
private fun onPageFinished(url: String?) {
// Open the more-info dialog for an older-server deep link now that the frontend has loaded.
pendingMoreInfoEntityId?.let { entityId ->
pendingMoreInfoEntityId = null
viewModelScope.launch { _webViewActions.emit(WebViewAction.OpenMoreInfo(entityId)) }
}

zoomObserverJob?.cancel()
zoomObserverJob = viewModelScope.launch {
prefsRepository.zoomSettingsFlow().collect { settings ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import io.homeassistant.companion.android.frontend.error.ErrorAction
import io.homeassistant.companion.android.frontend.error.FrontendConnectionError
import io.homeassistant.companion.android.frontend.exoplayer.ExoPlayerUiState
import io.homeassistant.companion.android.frontend.improv.ImprovUIState
import io.homeassistant.companion.android.frontend.navigation.FrontendTarget
import io.homeassistant.companion.android.util.compose.webview.BLANK_URL

/**
Expand Down Expand Up @@ -36,7 +37,8 @@ sealed interface FrontendViewState {
*
* The [url] is set to about:blank to clear the webview of any previously loaded URL
*/
data class LoadServer(override val serverId: Int, val path: String? = null) : FrontendViewState {
data class LoadServer(override val serverId: Int, val target: FrontendTarget = FrontendTarget.Default) :
FrontendViewState {
override val url: String = BLANK_URL
}

Expand All @@ -45,8 +47,11 @@ sealed interface FrontendViewState {
*
* The connection timeout starts when entering this state.
*/
data class Loading(override val serverId: Int, override val url: String, val path: String? = null) :
FrontendViewState
data class Loading(
override val serverId: Int,
override val url: String,
val target: FrontendTarget = FrontendTarget.Default,
) : FrontendViewState

/**
* Content state when the WebView is displaying the Home Assistant frontend.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import io.homeassistant.companion.android.frontend.haptic.HapticFeedbackPerforme
import io.homeassistant.companion.android.util.compose.webview.settings
import io.homeassistant.companion.android.util.sensitive
import kotlinx.coroutines.CompletableDeferred
import kotlinx.serialization.json.Json
import timber.log.Timber

/**
Expand Down Expand Up @@ -177,6 +178,29 @@ sealed interface WebViewAction {
webView.evaluateJavascript(viewportZoomScript(pinchToZoomEnabled)) {}
}
}

/**
* Opens the more-info dialog for [entityId] by dispatching the frontend's `hass-more-info`
* DOM event.
*
* Fallback for servers older than HA 2025.6, which ignore the `more-info-entity-id` URL query
* parameter. There is no external bus message to open more-info on those servers, so dispatching
* the frontend DOM event is the only option.
*/
data class OpenMoreInfo(val entityId: String) : WebViewAction {
// [entityId] originates from server/registry data, so it is treated as untrusted: it is
// JSON-encoded (quotes/backslashes escaped) so it cannot break out of the JS string literal.
private fun moreInfoScript(entityId: String): String {
val entityIdJson = Json.encodeToString(entityId)
return """document.querySelector("home-assistant")""" +
""".dispatchEvent(new CustomEvent("hass-more-info", { detail: { entityId: $entityIdJson }}))"""
}
Comment on lines +193 to +197

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 wdyt?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It's a fallback and has been pretty reliable as far as I'm aware, so I wouldn't change it now. It is only a JS error you'd get, not an app crash, right?


override fun run(webView: WebView) {
@OptIn(EvaluateJavascriptUsage::class)
webView.evaluateJavascript(moreInfoScript(entityId)) {}
}
}
}

/** Gates direct JavaScript evaluation in the WebView behind an explicit opt-in. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import io.homeassistant.companion.android.assist.AssistActivity
import io.homeassistant.companion.android.common.data.servers.ServerManager.Companion.SERVER_ID_ACTIVE
import io.homeassistant.companion.android.frontend.FrontendScreen
import io.homeassistant.companion.android.frontend.FrontendViewModel
import io.homeassistant.companion.android.frontend.navigation.FrontendTarget.Companion.toRawPath
import io.homeassistant.companion.android.launch.HAStartDestinationRoute
import io.homeassistant.companion.android.launch.LaunchActivity
import io.homeassistant.companion.android.launch.PipReadiness
Expand All @@ -41,15 +42,33 @@ internal data class FrontendActivityRoute(
)

@Serializable
internal data class FrontendRoute(val path: String? = null, val serverId: Int = SERVER_ID_ACTIVE) :
HAStartDestinationRoute
internal data class FrontendRoute
/**
* The destination is stored as the raw [rawPath] string rather than a custom-typed field on
* purpose: a custom `NavType` for a [FrontendTarget] field would force a `typeOf`/kotlin-reflect
* lookup on the main thread during navigation.
*
* Annotated `@VisibleForTesting` so the linter discourages building a route from a raw path;
* production code should use the [FrontendTarget] constructor instead. It remains used by the
* generated kotlinx.serialization route serializer.
*/
@VisibleForTesting constructor(
// TODO make this private when removing FrontendActivityRoute
val rawPath: String? = null,
Comment thread
TimoPtr marked this conversation as resolved.
val serverId: Int = SERVER_ID_ACTIVE,
Comment thread
TimoPtr marked this conversation as resolved.
) : HAStartDestinationRoute {

constructor(target: FrontendTarget, serverId: Int = SERVER_ID_ACTIVE) : this(target.toRawPath(), serverId)

val target: FrontendTarget get() = FrontendTarget.fromRawPath(rawPath)
}

internal fun NavController.navigateToFrontend(
path: String? = null,
target: FrontendTarget = FrontendTarget.Default,
serverId: Int = SERVER_ID_ACTIVE,
navOptions: NavOptions? = null,
) {
navigate(FrontendRoute(path, serverId), navOptions)
navigate(FrontendRoute(target, serverId), navOptions)
}

/**
Expand Down Expand Up @@ -158,7 +177,7 @@ internal fun NavGraphBuilder.frontendScreen(
} else {
composable<FrontendRoute> {
val route = it.toRoute<FrontendRoute>()
navController.navigate(FrontendActivityRoute(route.serverId, route.path))
navController.navigate(FrontendActivityRoute(route.serverId, route.rawPath))
navController.context.getActivity()?.finish()
}

Expand Down Expand Up @@ -214,12 +233,15 @@ internal fun FrontendEventHandler(
is FrontendEvent.NavigateToAssistSettings -> onNavigateToSettings(
SettingsActivity.Deeplink.AssistSettings,
)

is FrontendEvent.NavigateToAssist ->
onNavigateToAssist(event.serverId, event.pipelineId, event.startListening)

is FrontendEvent.OpenExternalLink -> onOpenExternalLink(event.uri)
is FrontendEvent.NavigateToDeveloperSettings -> onNavigateToSettings(
SettingsActivity.Deeplink.Developer,
)

is FrontendEvent.ShowServerSwitcher -> onShowServerSwitcher()
is FrontendEvent.NavigateToNfcWrite -> onNavigateToNfcWrite(event.messageId, event.tagId)
is FrontendEvent.LaunchMatterThreadIntent -> onLaunchMatterThreadIntent(event.intentSender)
Expand Down
Loading
Loading