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
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
Copy link
Member

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.

Copy link
Member Author

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.


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) {
Copy link
Member

Choose a reason for hiding this comment

The 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
fun onSecurityLevelConfigured(serverId: Int) {
fun onSecurityLevelShown(serverId: Int) {

connectionSecurityLevelShown[serverId] = true
}
}
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
}
Loading
Loading