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
@@ -1,25 +1,23 @@
package io.homeassistant.companion.android.frontend.navigation

import android.content.ComponentName
import androidx.activity.compose.LocalActivity
import androidx.navigation.ActivityNavigator
import androidx.navigation.ActivityNavigatorDestinationBuilder
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.activity
import androidx.navigation.compose.composable
import androidx.navigation.get
import androidx.navigation.toRoute
import io.homeassistant.companion.android.common.data.servers.ServerManager.Companion.SERVER_ID_ACTIVE
import io.homeassistant.companion.android.launcher.HAStartDestinationRoute
import io.homeassistant.companion.android.util.getActivity
import io.homeassistant.companion.android.webview.WebViewActivity
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
internal data class FrontendActivityRoute(
val path: String? = null,
// Override the serial name to match the name in WebViewActivity
@SerialName("server") val serverId: Int = SERVER_ID_ACTIVE,
val path: String? = null,
)

@Serializable
Expand All @@ -44,27 +42,19 @@ internal fun NavController.navigateToFrontend(
* to [FrontendRoute]. This route will then navigate to [FrontendActivityRoute] and finish the
* current activity. This behavior is necessary until `WebViewActivity` is replaced with a
* composable NavGraph entry, allowing for more direct navigation.
*
* Note: Security level verification is handled by [WebViewActivity] before loading any URL.
* If the security level is not set, [WebViewActivity] will show the
* [io.homeassistant.companion.android.settings.ConnectionSecurityLevelFragment].
*/
internal fun NavGraphBuilder.frontendScreen(navController: NavController) {
composable<FrontendRoute> {
val dummy = it.toRoute<FrontendRoute>()
navController.navigate(FrontendActivityRoute(dummy.path, dummy.serverId))
val activity = LocalActivity.current
activity?.finish()
val route = it.toRoute<FrontendRoute>()
navController.navigate(FrontendActivityRoute(route.serverId, route.path))
navController.context.getActivity()?.finish()
}

// TODO replace with strong types when WebViewActivity is available to onboarding module
// Inspired from activity<T> { } to be able to give a ComponentName instead of a class since :onboarding doesn't know :app
val destination = ActivityNavigatorDestinationBuilder(
provider[ActivityNavigator::class],
FrontendActivityRoute::class,
emptyMap(),
).build().setComponentName(
ComponentName(
navController.context,
"io.homeassistant.companion.android.webview.WebViewActivity",
),
)

addDestination(destination)
activity<FrontendActivityRoute> {
activityClass = WebViewActivity::class
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import io.homeassistant.companion.android.automotive.navigation.AutomotiveRoute
import io.homeassistant.companion.android.common.compose.theme.HATheme
import io.homeassistant.companion.android.common.util.isAutomotive
import io.homeassistant.companion.android.frontend.navigation.FrontendRoute
import io.homeassistant.companion.android.launcher.HAStartDestinationRoute
import io.homeassistant.companion.android.onboarding.OnboardingRoute
import io.homeassistant.companion.android.onboarding.WearOnboardingRoute
import io.homeassistant.companion.android.util.compose.HAApp
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import io.homeassistant.companion.android.onboarding.localfirst.navigation.Local
import io.homeassistant.companion.android.onboarding.localfirst.navigation.localFirstScreen
import io.homeassistant.companion.android.onboarding.localfirst.navigation.navigateToLocalFirst
import io.homeassistant.companion.android.onboarding.locationforsecureconnection.navigation.LocationForSecureConnectionRoute
import io.homeassistant.companion.android.onboarding.locationforsecureconnection.navigation.URL_SECURITY_LEVEL_DOCUMENTATION
import io.homeassistant.companion.android.onboarding.locationforsecureconnection.navigation.locationForSecureConnectionScreen
import io.homeassistant.companion.android.onboarding.locationforsecureconnection.navigation.navigateToLocationForSecureConnection
import io.homeassistant.companion.android.onboarding.locationsharing.navigation.LocationSharingRoute
Expand Down Expand Up @@ -197,9 +198,7 @@ internal fun NavGraphBuilder.onboarding(
)
locationForSecureConnectionScreen(
onHelpClick = {
navController.navigateToUri(
"https://companion.home-assistant.io/docs/getting_started/connection-security-level",
)
navController.navigateToUri(URL_SECURITY_LEVEL_DOCUMENTATION)
},
onGotoNextScreen = { allowInsecureConnection, serverId ->
if (allowInsecureConnection) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ internal fun LocationForSecureConnectionScreen(
) {
Scaffold(
modifier = modifier,
topBar = { HATopBar(onBackClick = onBackClick, onHelpClick = onHelpClick) },
topBar = { HATopBar(onHelpClick = onHelpClick, onCloseClick = onBackClick) },
contentWindowInsets = WindowInsets.safeDrawing,
) { contentPadding ->
LocationForSecureConnectionContent(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import androidx.navigation.toRoute
import io.homeassistant.companion.android.onboarding.locationforsecureconnection.LocationForSecureConnectionScreen
import kotlinx.serialization.Serializable

internal const val URL_SECURITY_LEVEL_DOCUMENTATION =
"https://companion.home-assistant.io/docs/getting_started/connection-security-level"

@Serializable
internal data class LocationForSecureConnectionRoute(val serverId: Int)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.OnBackPressedCallback
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumeWindowInsets
Expand All @@ -31,6 +32,7 @@ import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.common.compose.theme.HATheme
import io.homeassistant.companion.android.onboarding.locationforsecureconnection.LocationForSecureConnectionScreen
import io.homeassistant.companion.android.onboarding.locationforsecureconnection.LocationForSecureConnectionViewModel
import io.homeassistant.companion.android.onboarding.locationforsecureconnection.navigation.URL_SECURITY_LEVEL_DOCUMENTATION

/**
* Fragment wrapper for [io.homeassistant.companion.android.onboarding.locationforsecureconnection.LocationForSecureConnectionScreen] to enable usage in Fragment-based navigation.
Expand All @@ -46,11 +48,21 @@ import io.homeassistant.companion.android.onboarding.locationforsecureconnection
* This fragment is temporary and should be removed once the app fully migrates to Compose Navigation.
*/
@AndroidEntryPoint
class ConnectionSecurityLevelFragment : Fragment() {
class ConnectionSecurityLevelFragment private constructor() : Fragment() {

companion object {
const val RESULT_KEY = "connection_security_level_result"
const val EXTRA_SERVER = "server_id"
private const val EXTRA_SERVER = "server_id"
private const val EXTRA_HANDLE_ALL_INSETS = "handle_all_insets"

fun newInstance(serverId: Int, handleAllInsets: Boolean = false): ConnectionSecurityLevelFragment {
return ConnectionSecurityLevelFragment().apply {
arguments = bundleOf(
EXTRA_SERVER to serverId,
EXTRA_HANDLE_ALL_INSETS to handleAllInsets,
)
}
}
}

private val viewModel: LocationForSecureConnectionViewModel by createViewModelLazy(
Expand All @@ -67,6 +79,18 @@ class ConnectionSecurityLevelFragment : Fragment() {
},
)

private val backPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
setFragmentResult(RESULT_KEY, Bundle())
parentFragmentManager.popBackStack()
}
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requireActivity().onBackPressedDispatcher.addCallback(this, backPressedCallback)
}

@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return ComposeView(requireContext()).apply {
Expand All @@ -75,7 +99,16 @@ class ConnectionSecurityLevelFragment : Fragment() {
val uriHandler = LocalUriHandler.current

// Remaining insets to apply in settings activity
val insets = WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)
val insets =
if (arguments?.getBoolean(EXTRA_HANDLE_ALL_INSETS) ==
true
) {
WindowInsets.safeDrawing
} else {
WindowInsets.safeDrawing.only(
WindowInsetsSides.Bottom,
)
}

HATheme {
Scaffold(
Expand All @@ -95,12 +128,11 @@ class ConnectionSecurityLevelFragment : Fragment() {
parentFragmentManager.popBackStack()
},
onBackClick = {
setFragmentResult(RESULT_KEY, Bundle())
parentFragmentManager.popBackStack()
},
onHelpClick = {
uriHandler.openUri(
"https://companion.home-assistant.io/docs/getting_started/connection-security-level",
)
uriHandler.openUri(URL_SECURITY_LEVEL_DOCUMENTATION)
},
onShowSnackbar = { message, action ->
snackbarHostState.showSnackbar(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,11 +182,7 @@ class ServerSettingsFragment :
findPreference<Preference>("connection_security_level")?.let {
it.setOnPreferenceClickListener {
parentFragmentManager.commit {
replace(
R.id.content_full_screen,
ConnectionSecurityLevelFragment::class.java,
Bundle().apply { putInt(ConnectionSecurityLevelFragment.EXTRA_SERVER, serverId) },
)
replace(R.id.content_full_screen, ConnectionSecurityLevelFragment.newInstance(serverId))
addToBackStack(null)
}
return@setOnPreferenceClickListener true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ import io.homeassistant.companion.android.launcher.LauncherActivity
import io.homeassistant.companion.android.nfc.WriteNfcTag
import io.homeassistant.companion.android.sensors.SensorReceiver
import io.homeassistant.companion.android.sensors.SensorWorker
import io.homeassistant.companion.android.settings.ConnectionSecurityLevelFragment
import io.homeassistant.companion.android.settings.SettingsActivity
import io.homeassistant.companion.android.settings.server.ServerChooserFragment
import io.homeassistant.companion.android.themes.NightModeManager
Expand Down Expand Up @@ -1389,8 +1390,16 @@ class WebViewActivity :
if (openInApp) {
loadedUrl = url
clearHistory = !keepHistory
webView.loadUrl(url)
waitForConnection()
lifecycleScope.launch {
if (!presenter.shouldSetSecurityLevel()) {
webView.loadUrl(url)
waitForConnection()
} else {
val serverId = presenter.getActiveServer()
Timber.d("Security level not set for server $serverId, showing ConnectionSecurityLevelFragment")
showConnectionSecurityLevelFragment(serverId)
}
}
} else {
try {
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
Expand All @@ -1401,6 +1410,26 @@ class WebViewActivity :
}
}

private fun showConnectionSecurityLevelFragment(serverId: Int) {
supportFragmentManager.setFragmentResultListener(
ConnectionSecurityLevelFragment.RESULT_KEY,
this,
) { _, _ ->
Timber.d("Security level screen exited by user, proceeding with URL loading")
supportFragmentManager.clearFragmentResultListener(ConnectionSecurityLevelFragment.RESULT_KEY)

if (::loadedUrl.isInitialized) {
webView.loadUrl(loadedUrl)
waitForConnection()
}
}

supportFragmentManager.beginTransaction()
.replace(android.R.id.content, ConnectionSecurityLevelFragment.newInstance(serverId, true))
.addToBackStack(null)
.commit()
}

override fun setStatusBarAndBackgroundColor(statusBarColor: Int, backgroundColor: Int) {
// Set background colors
if (statusBarColor != 0) {
Expand Down Expand Up @@ -1676,18 +1705,22 @@ class WebViewActivity :
}

private fun waitForConnection() {
Handler(Looper.getMainLooper()).postDelayed(
{
if (
!isConnected &&
!loadedUrl.toHttpUrl().pathSegments.first().contains("api") &&
!loadedUrl.toHttpUrl().pathSegments.first().contains("local")
) {
showError(errorType = ErrorType.TIMEOUT_EXTERNAL_BUS)
}
},
CONNECTION_DELAY,
)
if (supportFragmentManager.backStackEntryCount > 0) {
Timber.i("Fragments ${supportFragmentManager.fragments} displayed, skipping connection wait")
} else {
Handler(Looper.getMainLooper()).postDelayed(
{
if (
!isConnected &&
!loadedUrl.toHttpUrl().pathSegments.first().contains("api") &&
!loadedUrl.toHttpUrl().pathSegments.first().contains("local")
) {
showError(errorType = ErrorType.TIMEOUT_EXTERNAL_BUS)
}
},
CONNECTION_DELAY,
)
}
}

override fun sendExternalBusMessage(message: ExternalBusMessage) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ interface WebViewPresenter {

suspend fun isSsidUsed(): Boolean

/**
* Checks whether the user needs to configure their insecure connection preference.
*
* @return `true` if the server uses a plain text (HTTP) URL and the user has not yet set their
* preference for allowing insecure connections, `false` otherwise
*/
suspend fun shouldSetSecurityLevel(): Boolean

suspend fun getAuthorizationHeader(): String

suspend fun parseWebViewColor(webViewColor: String): Int
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,13 @@ class WebViewPresenterImpl @Inject constructor(
override suspend fun isSsidUsed(): Boolean =
serverManager.getServer(serverId)?.connection?.internalSsids?.isNotEmpty() == true

override suspend fun shouldSetSecurityLevel(): Boolean {
if (serverManager.getServer(serverId)?.connection?.hasPlainTextUrl() == false) {
return false
}
return serverManager.integrationRepository(serverId).getAllowInsecureConnection() == null
}

override suspend fun getAuthorizationHeader(): String {
return serverManager.getServer(serverId)?.let {
serverManager.authenticationRepository(serverId).buildBearerToken()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ class LocationForSecureConnectionScreenshotTest {
}
}

@PreviewTest
@HAPreviews
@Composable
fun `LocationForSecureConnection with back navigation`() {
HAThemeForPreview {
LocationForSecureConnectionScreen(
onAllowInsecureConnection = { _ -> },
onHelpClick = {},
onBackClick = {},
onShowSnackbar = { _, _ -> true },
initialAllowInsecureConnection = null,
)
}
}

@PreviewTest
@HAPreviews
@Composable
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading