-
-
Notifications
You must be signed in to change notification settings - Fork 886
Add frontend url manager #6382
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
TimoPtr
wants to merge
1
commit into
feature/server_session_manager
Choose a base branch
from
feature/frontend_url_manager
base: feature/server_session_manager
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+633
−0
Open
Add frontend url manager #6382
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
162 changes: 162 additions & 0 deletions
162
app/src/main/kotlin/io/homeassistant/companion/android/frontend/url/FrontendUrlManager.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,162 @@ | ||||||
| package io.homeassistant.companion.android.frontend.url | ||||||
|
|
||||||
| import dagger.hilt.android.scopes.ViewModelScoped | ||||||
| import io.homeassistant.companion.android.common.data.servers.ServerManager | ||||||
| import io.homeassistant.companion.android.common.data.servers.UrlState | ||||||
| import io.homeassistant.companion.android.frontend.session.ServerSessionManager | ||||||
| import io.homeassistant.companion.android.frontend.session.SessionCheckResult | ||||||
| import io.homeassistant.companion.android.util.UrlUtil | ||||||
| import java.net.URL | ||||||
| import javax.inject.Inject | ||||||
| import kotlinx.coroutines.flow.Flow | ||||||
| import kotlinx.coroutines.flow.flow | ||||||
| import okhttp3.HttpUrl.Companion.toHttpUrlOrNull | ||||||
| import timber.log.Timber | ||||||
|
|
||||||
| /** | ||||||
| * Manages URL resolution and security checks for the frontend. | ||||||
| * | ||||||
| * This class handles: | ||||||
| * - Server URL resolution with path handling | ||||||
| * - Session authentication verification | ||||||
| * - Insecure connection detection | ||||||
| * - Security level configuration tracking | ||||||
| * - Adding `external_auth=1` query parameter to signal the Home Assistant frontend | ||||||
| * that authentication will be provided via the JavaScript bridge | ||||||
| */ | ||||||
| @ViewModelScoped | ||||||
| class FrontendUrlManager @Inject constructor( | ||||||
| private val serverManager: ServerManager, | ||||||
| private val sessionManager: ServerSessionManager, | ||||||
| ) { | ||||||
|
|
||||||
| /** | ||||||
| * Tracks whether the ConnectionSecurityLevel has been shown for each server | ||||||
| * during this viewModel lifecycle. Once shown for a specific server, the screen | ||||||
| * won't be shown again for that server. | ||||||
| * | ||||||
| * Key: server ID, Value: `true` if already shown | ||||||
| */ | ||||||
| private val connectionSecurityLevelShown = hashMapOf<Int, Boolean>() | ||||||
|
|
||||||
| /** | ||||||
| * Retrieve URL for server. Returns a Flow that emits URL updates when connection state changes. | ||||||
| * | ||||||
| * The path parameter is only applied to the first emission to handle deep links. | ||||||
| * Subsequent emissions (e.g., when switching between internal/external URLs) use only the base URL. | ||||||
| * | ||||||
| * @param serverId The server ID to use (can be [ServerManager.SERVER_ID_ACTIVE]) | ||||||
| * @param path Optional path to append to the initial URL (e.g., deep link path) | ||||||
| * @return Flow of [UrlLoadResult] that emits when URL state changes | ||||||
| */ | ||||||
| fun serverUrlFlow(serverId: Int, path: String?): Flow<UrlLoadResult> = flow { | ||||||
| val server = serverManager.getServer(serverId) | ||||||
| if (server == null) { | ||||||
| Timber.e("Server not found for id: $serverId") | ||||||
| emit(UrlLoadResult.ServerNotFound(serverId)) | ||||||
| return@flow | ||||||
| } | ||||||
|
|
||||||
| val actualServerId = server.id | ||||||
| if (sessionManager.isSessionConnected(actualServerId) is SessionCheckResult.NotConnected) { | ||||||
| Timber.w("Session not connected for server: $actualServerId") | ||||||
| emit(UrlLoadResult.SessionNotConnected(actualServerId)) | ||||||
| return@flow | ||||||
| } | ||||||
|
|
||||||
| serverManager.activateServer(actualServerId) | ||||||
|
|
||||||
| var pathConsumed = false | ||||||
| serverManager.connectionStateProvider(actualServerId).urlFlow().collect { urlState -> | ||||||
| val currentPath = if (pathConsumed) null else path | ||||||
| pathConsumed = true | ||||||
|
|
||||||
| val result = handleUrlState( | ||||||
| serverId = actualServerId, | ||||||
| urlState = urlState, | ||||||
| path = currentPath, | ||||||
| ) | ||||||
| emit(result) | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| private suspend fun handleUrlState(serverId: Int, urlState: UrlState, path: String?): UrlLoadResult { | ||||||
| return when (urlState) { | ||||||
| is UrlState.HasUrl -> { | ||||||
| buildUrl( | ||||||
| baseUrl = urlState.url, | ||||||
| serverId = serverId, | ||||||
| path = path, | ||||||
| ) | ||||||
| } | ||||||
|
|
||||||
| UrlState.InsecureState -> { | ||||||
| Timber.w("Insecure connection blocked for server: $serverId") | ||||||
| val securityState = serverManager.connectionStateProvider(serverId).getSecurityState() | ||||||
| UrlLoadResult.InsecureBlocked( | ||||||
| serverId = serverId, | ||||||
| missingHomeSetup = !securityState.hasHomeSetup, | ||||||
| missingLocation = !securityState.locationEnabled, | ||||||
| ) | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| private suspend fun buildUrl(baseUrl: URL?, serverId: Int, path: String?): UrlLoadResult { | ||||||
| // Build URL with path (skip path handling if it starts with "entityId:") | ||||||
| val urlToLoad = if (path != null && !path.startsWith("entityId:")) { | ||||||
| UrlUtil.handle(baseUrl, path) | ||||||
| } else { | ||||||
| baseUrl | ||||||
| } | ||||||
|
|
||||||
| if (urlToLoad == null) { | ||||||
| Timber.e("No URL available for server: $serverId") | ||||||
| return UrlLoadResult.NoUrlAvailable(serverId) | ||||||
| } | ||||||
|
|
||||||
| // Check if security level needs to be configured before loading | ||||||
| val shouldShowSecurityLevel = shouldSetSecurityLevel(serverId) && | ||||||
| !connectionSecurityLevelShown.getOrPut(serverId) { false } | ||||||
|
|
||||||
| if (shouldShowSecurityLevel) { | ||||||
| Timber.d("Security level not set for server $serverId, showing SecurityLevelRequired") | ||||||
| return UrlLoadResult.SecurityLevelRequired(serverId) | ||||||
| } | ||||||
|
|
||||||
| // Add external_auth=1 query parameter for authentication | ||||||
| val httpUrl = urlToLoad.toString().toHttpUrlOrNull() | ||||||
| if (httpUrl == null) { | ||||||
| Timber.e("Failed to parse URL: $urlToLoad") | ||||||
| return UrlLoadResult.NoUrlAvailable(serverId) | ||||||
| } | ||||||
|
|
||||||
| val urlWithAuth = httpUrl.newBuilder() | ||||||
| .addQueryParameter("external_auth", "1") | ||||||
| .build() | ||||||
| .toString() | ||||||
|
|
||||||
| Timber.d("Loading server URL: $urlWithAuth") | ||||||
| return UrlLoadResult.Success(url = urlWithAuth, serverId = serverId) | ||||||
| } | ||||||
|
|
||||||
| private suspend fun shouldSetSecurityLevel(serverId: Int): Boolean { | ||||||
| val connection = serverManager.getServer(serverId)?.connection ?: return false | ||||||
| if (!connection.hasPlainTextUrl) { | ||||||
| return false | ||||||
| } | ||||||
| return connection.allowInsecureConnection == null | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Mark security level as configured for server. | ||||||
| * | ||||||
| * After calling this, [serverUrlFlow] will no longer return [UrlLoadResult.SecurityLevelRequired] | ||||||
| * for this server during the current session. | ||||||
| * | ||||||
| * @param serverId The server ID that had security level configured | ||||||
| */ | ||||||
| fun onSecurityLevelConfigured(serverId: Int) { | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The user can ignore the screen so it is not known whether or not they actually configured it.
Suggested change
|
||||||
| connectionSecurityLevelShown[serverId] = true | ||||||
| } | ||||||
| } | ||||||
68 changes: 68 additions & 0 deletions
68
app/src/main/kotlin/io/homeassistant/companion/android/frontend/url/UrlLoadResult.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| package io.homeassistant.companion.android.frontend.url | ||
|
|
||
| /** | ||
| * Result of loading a server URL from [FrontendUrlManager.serverUrlFlow]. | ||
| * | ||
| * Each result type indicates either success or a specific condition that prevents | ||
| * loading the frontend, allowing the UI to show the appropriate screen. | ||
| */ | ||
| sealed interface UrlLoadResult { | ||
|
|
||
| /** | ||
| * URL ready to load in the WebView. | ||
| */ | ||
| data class Success(val url: String, val serverId: Int) : UrlLoadResult | ||
|
|
||
| /** | ||
| * Server not found in the database. | ||
| * | ||
| * This typically indicates the server was deleted or an invalid ID was provided. | ||
| * | ||
| * @property serverId The server ID that was not found | ||
| */ | ||
| data class ServerNotFound(val serverId: Int) : UrlLoadResult | ||
|
|
||
| /** | ||
| * Session not authenticated. The user needs to log in again. | ||
| * | ||
| * This occurs when [SessionState.ANONYMOUS] is detected, indicating | ||
| * the stored credentials are invalid or expired. | ||
| * | ||
| * @property serverId The server ID with unauthenticated session | ||
| */ | ||
| data class SessionNotConnected(val serverId: Int) : UrlLoadResult | ||
|
|
||
| /** | ||
| * Insecure (HTTP) connection was blocked based on user's security settings. | ||
| * | ||
| * The UI should show the blocked insecure connection screen with options | ||
| * to configure home network detection or proceed anyway. | ||
| * | ||
| * @property serverId The server ID with blocked insecure connection | ||
| * @property missingHomeSetup True if home network Wi-Fi SSID/BSSID is not configured | ||
| * @property missingLocation True if location permission is needed for home network detection | ||
| */ | ||
| data class InsecureBlocked(val serverId: Int, val missingHomeSetup: Boolean, val missingLocation: Boolean) : | ||
| UrlLoadResult | ||
|
|
||
| /** | ||
| * Server has a plain-text (HTTP) URL but the user hasn't configured their security preference. | ||
| * | ||
| * The UI should show the security level configuration screen where the user | ||
| * can choose to allow or block insecure connections. After configuration, | ||
| * call [FrontendUrlManager.onSecurityLevelConfigured] to proceed. | ||
| * | ||
| * @property serverId The server ID requiring security level configuration | ||
| */ | ||
| data class SecurityLevelRequired(val serverId: Int) : UrlLoadResult | ||
|
|
||
| /** | ||
| * No URL could be resolved for the server. | ||
| * | ||
| * This can occur when neither internal nor external URL is configured, | ||
| * or when the URL cannot be parsed. | ||
| * | ||
| * @property serverId The server ID with no available URL | ||
| */ | ||
| data class NoUrlAvailable(val serverId: Int) : UrlLoadResult | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it make sense to not consume the path until the state actually returns an URL?
Example use case: I have sent a notification which deeplinks to something, but when I tap the notification I'm not yet connected to my home network so loading is blocked, and when I connect to Wi-Fi it reloads but ignores the path as it was already consumed by the blocked screen.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure if we want to change the behavior on this PR, but yes it would make sense. I saw also this PR #6403 that is somehow related.