diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsActivity.kt index 43f22cc3402..df1a65835b6 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsActivity.kt @@ -27,6 +27,7 @@ import io.homeassistant.companion.android.settings.notification.NotificationHist import io.homeassistant.companion.android.settings.qs.ManageTilesFragment import io.homeassistant.companion.android.settings.sensor.SensorDetailFragment import io.homeassistant.companion.android.settings.server.ServerSettingsFragment +import io.homeassistant.companion.android.settings.ssid.SsidFragment import io.homeassistant.companion.android.settings.websocket.WebsocketSettingFragment import io.homeassistant.companion.android.util.applySafeDrawingInsets import javax.inject.Inject @@ -62,6 +63,7 @@ class SettingsActivity : BaseActivity() { @Parcelize sealed interface Deeplink : Parcelable { data object Developer : Deeplink + data class HomeNetwork(val serverId: Int) : Deeplink data object NotificationHistory : Deeplink data class QSTile(val tileId: String) : Deeplink data class Sensor(val sensorId: String) : Deeplink @@ -100,12 +102,17 @@ class SettingsActivity : BaseActivity() { SettingsFragment::class.java } Deeplink.Developer -> DeveloperSettingsFragment::class.java + is Deeplink.HomeNetwork -> SsidFragment::class.java Deeplink.NotificationHistory -> NotificationHistoryFragment::class.java is Deeplink.Sensor -> SensorDetailFragment::class.java is Deeplink.QSTile -> ManageTilesFragment::class.java else -> SettingsFragment::class.java }, when (settingsNavigation) { + is Deeplink.HomeNetwork -> { + Bundle().apply { putInt(SsidFragment.EXTRA_SERVER, settingsNavigation.serverId) } + } + is Deeplink.Sensor -> { SensorDetailFragment.newInstance(settingsNavigation.sensorId).arguments } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt index acf256df2ef..ddc50e2e60a 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt @@ -104,6 +104,7 @@ import io.homeassistant.companion.android.common.util.toJsonObject import io.homeassistant.companion.android.common.util.toJsonObjectOrNull import io.homeassistant.companion.android.database.authentication.Authentication import io.homeassistant.companion.android.database.authentication.AuthenticationDao +import io.homeassistant.companion.android.database.server.SecurityStatus import io.homeassistant.companion.android.databinding.DialogAuthenticationBinding import io.homeassistant.companion.android.improv.ui.ImprovPermissionDialog import io.homeassistant.companion.android.improv.ui.ImprovSetupDialog @@ -131,6 +132,7 @@ import io.homeassistant.companion.android.webview.externalbus.ExternalConfigResp import io.homeassistant.companion.android.webview.externalbus.ExternalEntityAddToAction import io.homeassistant.companion.android.webview.externalbus.NavigateTo import io.homeassistant.companion.android.webview.externalbus.ShowSidebar +import io.homeassistant.companion.android.webview.insecure.BlockInsecureFragment import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -1392,8 +1394,7 @@ class WebViewActivity : clearHistory = !keepHistory lifecycleScope.launch { if (!presenter.shouldSetSecurityLevel()) { - webView.loadUrl(url) - waitForConnection() + secureLoadUrl(url) } else { val serverId = presenter.getActiveServer() Timber.d("Security level not set for server $serverId, showing ConnectionSecurityLevelFragment") @@ -1410,6 +1411,42 @@ class WebViewActivity : } } + /** + * Make sure we only load the url if the securityLevel and current states allow it. + */ + private suspend fun secureLoadUrl(url: String) { + fun loadAndWait() { + webView.loadUrl(url) + waitForConnection() + } + + if (url.startsWith("https")) { + loadAndWait() + return + } + + val allowInsecureConnection = presenter.getAllowInsecureConnection() + + when (allowInsecureConnection) { + null, true -> loadAndWait() + false -> { + val status = serverManager.getServer( + presenter.getActiveServer(), + )?.connection?.currentSecurityStatusForUrl(this, url) + when (status) { + is SecurityStatus.Insecure, null -> { + showBlockInsecureFragment( + serverId = presenter.getActiveServer(), + missingHomeSetup = status?.missingHomeSetup ?: false, + missingLocation = status?.missingLocation ?: false, + ) + } + SecurityStatus.Secure -> loadAndWait() + } + } + } + } + private fun showConnectionSecurityLevelFragment(serverId: Int) { supportFragmentManager.setFragmentResultListener( ConnectionSecurityLevelFragment.RESULT_KEY, @@ -1419,8 +1456,9 @@ class WebViewActivity : supportFragmentManager.clearFragmentResultListener(ConnectionSecurityLevelFragment.RESULT_KEY) if (::loadedUrl.isInitialized) { - webView.loadUrl(loadedUrl) - waitForConnection() + lifecycleScope.launch { + secureLoadUrl(loadedUrl) + } } } @@ -1430,6 +1468,33 @@ class WebViewActivity : .commit() } + private fun showBlockInsecureFragment(serverId: Int, missingHomeSetup: Boolean, missingLocation: Boolean) { + supportFragmentManager.setFragmentResultListener( + BlockInsecureFragment.RESULT_KEY, + this, + ) { _, _ -> + Timber.d("Block insecure screen exited by user, retrying URL loading") + supportFragmentManager.clearFragmentResultListener(BlockInsecureFragment.RESULT_KEY) + + if (::loadedUrl.isInitialized) { + lifecycleScope.launch { + secureLoadUrl(loadedUrl) + } + } + } + supportFragmentManager.beginTransaction() + .replace( + android.R.id.content, + BlockInsecureFragment.newInstance( + serverId = serverId, + missingHomeSetup = missingHomeSetup, + missingLocation = missingLocation, + ), + ) + .addToBackStack(null) + .commit() + } + override fun setStatusBarAndBackgroundColor(statusBarColor: Int, backgroundColor: Int) { // Set background colors if (statusBarColor != 0) { diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenter.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenter.kt index a975f5f0679..a3796577c92 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenter.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenter.kt @@ -60,6 +60,8 @@ interface WebViewPresenter { */ suspend fun shouldSetSecurityLevel(): Boolean + suspend fun getAllowInsecureConnection(): Boolean? + suspend fun getAuthorizationHeader(): String suspend fun parseWebViewColor(webViewColor: String): Int diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt index 4bebbd7d7f4..f4696689b54 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt @@ -362,6 +362,9 @@ class WebViewPresenterImpl @Inject constructor( return serverManager.integrationRepository(serverId).getAllowInsecureConnection() == null } + override suspend fun getAllowInsecureConnection(): Boolean? = + serverManager.integrationRepository(serverId).getAllowInsecureConnection() + override suspend fun getAuthorizationHeader(): String { return serverManager.getServer(serverId)?.let { serverManager.authenticationRepository(serverId).buildBearerToken() diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/insecure/BlockInsecureFragment.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/insecure/BlockInsecureFragment.kt new file mode 100644 index 00000000000..c463d7889f9 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/insecure/BlockInsecureFragment.kt @@ -0,0 +1,113 @@ +package io.homeassistant.companion.android.webview.insecure + +import android.content.Intent +import android.os.Bundle +import android.provider.Settings +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalUriHandler +import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResult +import dagger.hilt.android.AndroidEntryPoint +import io.homeassistant.companion.android.common.compose.theme.HATheme +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.common.util.DisabledLocationHandler +import io.homeassistant.companion.android.settings.ConnectionSecurityLevelFragment +import io.homeassistant.companion.android.settings.SettingsActivity + +/** + * Fragment explaining why the current connection is blocked. + */ +@AndroidEntryPoint +class BlockInsecureFragment private constructor() : Fragment() { + + companion object { + const val RESULT_KEY = "block_insecure_result" + private const val EXTRA_SERVER = "server_id" + private const val EXTRA_MISSING_HOME_SETUP = "missing_home_setup" + private const val EXTRA_MISSING_LOCATION = "missing_location" + + fun newInstance(serverId: Int, missingHomeSetup: Boolean, missingLocation: Boolean): BlockInsecureFragment { + return BlockInsecureFragment().apply { + arguments = Bundle().apply { + putInt(EXTRA_SERVER, serverId) + putBoolean(EXTRA_MISSING_HOME_SETUP, missingHomeSetup) + putBoolean(EXTRA_MISSING_LOCATION, missingLocation) + } + } + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val serverId = arguments?.getInt(EXTRA_SERVER, ServerManager.SERVER_ID_ACTIVE) ?: ServerManager.SERVER_ID_ACTIVE + val missingHomeSetup = arguments?.getBoolean(EXTRA_MISSING_HOME_SETUP, false) ?: false + val missingLocation = arguments?.getBoolean(EXTRA_MISSING_LOCATION, false) ?: false + + return ComposeView(requireContext()).apply { + setContent { + val uriHandler = LocalUriHandler.current + HATheme { + BlockInsecureScreen( + missingHomeSetup = missingHomeSetup, + missingLocation = missingLocation, + onRetry = ::retryAndClose, + onHelpClick = { + uriHandler.openUri( + "https://companion.home-assistant.io/docs/getting_started/connection-security-level/", + ) + }, + onOpenSettings = { + startActivity(SettingsActivity.newInstance(requireContext())) + }, + onChangeSecurityLevel = { + showConnectionSecurityLevelFragment(serverId) + }, + onOpenLocationSettings = ::openLocationSettings, + onConfigureHomeNetwork = { + startActivity( + SettingsActivity.newInstance( + context = requireContext(), + screen = SettingsActivity.Deeplink.HomeNetwork(serverId), + ), + ) + }, + ) + } + } + } + } + + private fun openLocationSettings() { + if (DisabledLocationHandler.isLocationEnabled(requireContext())) { + retryAndClose() + return + } + val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) + addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) + } + if (intent.resolveActivity(requireContext().packageManager) == null) { + intent.action = Settings.ACTION_SETTINGS + } + startActivity(intent) + } + + private fun retryAndClose() { + setFragmentResult(RESULT_KEY, Bundle()) + parentFragmentManager.popBackStack() + } + + private fun showConnectionSecurityLevelFragment(serverId: Int) { + val fragment = ConnectionSecurityLevelFragment.newInstance( + serverId = serverId, + handleAllInsets = true, + ) + parentFragmentManager.beginTransaction() + .replace(android.R.id.content, fragment) + .addToBackStack(null) + .commit() + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreen.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreen.kt new file mode 100644 index 00000000000..4a5a0208078 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreen.kt @@ -0,0 +1,216 @@ +package io.homeassistant.companion.android.webview.insecure + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.HelpOutline +import androidx.compose.material.icons.outlined.Replay +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.mikepenz.iconics.compose.Image +import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial +import io.homeassistant.companion.android.common.R as commonR +import io.homeassistant.companion.android.common.compose.composable.HAAccentButton +import io.homeassistant.companion.android.common.compose.composable.HABanner +import io.homeassistant.companion.android.common.compose.composable.HAPlainButton +import io.homeassistant.companion.android.common.compose.composable.HATopBar +import io.homeassistant.companion.android.common.compose.theme.HADimens +import io.homeassistant.companion.android.common.compose.theme.HATextStyle +import io.homeassistant.companion.android.common.compose.theme.HAThemeForPreview +import io.homeassistant.companion.android.common.compose.theme.LocalHAColorScheme +import io.homeassistant.companion.android.common.compose.theme.MaxButtonWidth +import io.homeassistant.companion.android.util.compose.HAPreviews +import io.homeassistant.companion.android.util.compose.rememberLocationPermission + +private val MaxContentWidth = MaxButtonWidth + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +internal fun BlockInsecureScreen( + missingHomeSetup: Boolean, + missingLocation: Boolean, + onRetry: () -> Unit, + onHelpClick: () -> Unit, + onOpenSettings: () -> Unit, + onChangeSecurityLevel: () -> Unit, + onOpenLocationSettings: () -> Unit, + onConfigureHomeNetwork: () -> Unit, + modifier: Modifier = Modifier, +) { + val locationPermissions = rememberLocationPermission( + onPermissionResult = { + onOpenLocationSettings() + }, + ) + + Scaffold( + contentWindowInsets = WindowInsets.safeDrawing, + topBar = { TopBar(onRetry = onRetry, onHelpClick = onHelpClick) }, + modifier = modifier, + ) { contentPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(contentPadding) + .padding(horizontal = HADimens.SPACE4), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(HADimens.SPACE6), + ) { + Header() + + if (missingLocation) { + FixBanner( + text = stringResource(commonR.string.block_insecure_missing_location), + actionText = stringResource(commonR.string.block_insecure_action_enable_location), + onFixClick = locationPermissions::launchMultiplePermissionRequest, + ) + } + + if (missingHomeSetup) { + FixBanner( + text = stringResource(commonR.string.block_insecure_missing_home_setup), + actionText = stringResource(commonR.string.block_insecure_action_configure_home), + onFixClick = onConfigureHomeNetwork, + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + BottomButtons(onOpenSettings = onOpenSettings, onChangeSecurityLevel = onChangeSecurityLevel) + } + } +} + +@Composable +private fun TopBar(onRetry: () -> Unit, onHelpClick: () -> Unit) { + HATopBar( + navigationIcon = { + IconButton(onClick = onRetry) { + Icon( + imageVector = Icons.Outlined.Replay, + contentDescription = stringResource(commonR.string.block_insecure_retry), + ) + } + }, + actions = { + IconButton(onClick = onHelpClick) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.HelpOutline, + contentDescription = stringResource(commonR.string.get_help), + ) + } + }, + ) +} + +@Composable +private fun ColumnScope.Header() { + Image( + modifier = Modifier + .padding(all = 20.dp) + .size(120.dp), + asset = CommunityMaterial.Icon2.cmd_lock_open_alert, + colorFilter = ColorFilter.tint(LocalHAColorScheme.current.colorOnPrimaryNormal), + contentDescription = null, + ) + + Text( + text = stringResource(commonR.string.block_insecure_title), + style = HATextStyle.Headline, + modifier = Modifier.widthIn(max = MaxContentWidth), + ) + + Text( + text = stringResource(commonR.string.block_insecure_content), + style = HATextStyle.Body, + modifier = Modifier.widthIn(max = MaxContentWidth), + ) +} + +@Composable +private fun FixBanner(text: String, actionText: String, onFixClick: () -> Unit) { + HABanner( + modifier = Modifier + .width(MaxContentWidth), + ) { + Column { + Text( + text = text, + style = HATextStyle.Body.copy(textAlign = TextAlign.Start), + ) + Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { + HAAccentButton( + text = actionText, + onClick = { + onFixClick() + }, + ) + } + } + } +} + +@Composable +private fun BottomButtons(onOpenSettings: () -> Unit, onChangeSecurityLevel: () -> Unit) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(HADimens.SPACE4), + ) { + HAAccentButton( + text = stringResource(commonR.string.block_insecure_open_settings), + onClick = onOpenSettings, + modifier = Modifier + .fillMaxWidth(), + ) + + HAPlainButton( + text = stringResource(commonR.string.block_insecure_change_security_level), + onClick = onChangeSecurityLevel, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = HADimens.SPACE6), + ) + } +} + +@HAPreviews +@Composable +private fun BlockInsecureScreenPreview() { + HAThemeForPreview { + BlockInsecureScreen( + missingHomeSetup = true, + missingLocation = true, + onRetry = {}, + onHelpClick = {}, + onOpenSettings = {}, + onChangeSecurityLevel = {}, + onOpenLocationSettings = {}, + onConfigureHomeNetwork = {}, + ) + } +} diff --git a/app/src/screenshotTest/kotlin/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest.kt b/app/src/screenshotTest/kotlin/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest.kt new file mode 100644 index 00000000000..6272df936f1 --- /dev/null +++ b/app/src/screenshotTest/kotlin/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest.kt @@ -0,0 +1,81 @@ +package io.homeassistant.companion.android.webview.insecure + +import androidx.compose.runtime.Composable +import com.android.tools.screenshot.PreviewTest +import io.homeassistant.companion.android.common.compose.theme.HAThemeForPreview +import io.homeassistant.companion.android.util.compose.HAPreviews + +class BlockInsecureScreenshotTest { + + @PreviewTest + @HAPreviews + @Composable + fun `BlockInsecure both missing`() { + HAThemeForPreview { + BlockInsecureScreen( + missingHomeSetup = true, + missingLocation = true, + onRetry = {}, + onHelpClick = {}, + onOpenSettings = {}, + onChangeSecurityLevel = {}, + onOpenLocationSettings = {}, + onConfigureHomeNetwork = {}, + ) + } + } + + @PreviewTest + @HAPreviews + @Composable + fun `BlockInsecure missing location only`() { + HAThemeForPreview { + BlockInsecureScreen( + missingHomeSetup = false, + missingLocation = true, + onRetry = {}, + onHelpClick = {}, + onOpenSettings = {}, + onChangeSecurityLevel = {}, + onOpenLocationSettings = {}, + onConfigureHomeNetwork = {}, + ) + } + } + + @PreviewTest + @HAPreviews + @Composable + fun `BlockInsecure missing home setup only`() { + HAThemeForPreview { + BlockInsecureScreen( + missingHomeSetup = true, + missingLocation = false, + onRetry = {}, + onHelpClick = {}, + onOpenSettings = {}, + onChangeSecurityLevel = {}, + onOpenLocationSettings = {}, + onConfigureHomeNetwork = {}, + ) + } + } + + @PreviewTest + @HAPreviews + @Composable + fun `BlockInsecure no missing`() { + HAThemeForPreview { + BlockInsecureScreen( + missingHomeSetup = false, + missingLocation = false, + onRetry = {}, + onHelpClick = {}, + onOpenSettings = {}, + onChangeSecurityLevel = {}, + onOpenLocationSettings = {}, + onConfigureHomeNetwork = {}, + ) + } + } +} diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure both missing_foldable_c908f502_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure both missing_foldable_c908f502_0.png new file mode 100644 index 00000000000..ee8b9d063b8 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure both missing_foldable_c908f502_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure both missing_phone_e05166be_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure both missing_phone_e05166be_0.png new file mode 100644 index 00000000000..6e1bd2adde8 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure both missing_phone_e05166be_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure both missing_phone_landscape_9e00b29d_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure both missing_phone_landscape_9e00b29d_0.png new file mode 100644 index 00000000000..c5ecfa42b3e Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure both missing_phone_landscape_9e00b29d_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure both missing_small_phone_66e7bbf2_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure both missing_small_phone_66e7bbf2_0.png new file mode 100644 index 00000000000..333911eab18 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure both missing_small_phone_66e7bbf2_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure both missing_tablet_2f22c4ea_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure both missing_tablet_2f22c4ea_0.png new file mode 100644 index 00000000000..af4f4a204ca Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure both missing_tablet_2f22c4ea_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure both missing_tablet_landscape_62cae397_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure both missing_tablet_landscape_62cae397_0.png new file mode 100644 index 00000000000..5b9ae74fdd2 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure both missing_tablet_landscape_62cae397_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing home setup only_foldable_c908f502_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing home setup only_foldable_c908f502_0.png new file mode 100644 index 00000000000..a19075dac53 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing home setup only_foldable_c908f502_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing home setup only_phone_e05166be_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing home setup only_phone_e05166be_0.png new file mode 100644 index 00000000000..8e1c5f86118 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing home setup only_phone_e05166be_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing home setup only_phone_landscape_9e00b29d_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing home setup only_phone_landscape_9e00b29d_0.png new file mode 100644 index 00000000000..c5ecfa42b3e Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing home setup only_phone_landscape_9e00b29d_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing home setup only_small_phone_66e7bbf2_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing home setup only_small_phone_66e7bbf2_0.png new file mode 100644 index 00000000000..333911eab18 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing home setup only_small_phone_66e7bbf2_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing home setup only_tablet_2f22c4ea_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing home setup only_tablet_2f22c4ea_0.png new file mode 100644 index 00000000000..6dbfa2cbe03 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing home setup only_tablet_2f22c4ea_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing home setup only_tablet_landscape_62cae397_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing home setup only_tablet_landscape_62cae397_0.png new file mode 100644 index 00000000000..d237e9039ab Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing home setup only_tablet_landscape_62cae397_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing location only_foldable_c908f502_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing location only_foldable_c908f502_0.png new file mode 100644 index 00000000000..3c6f1ec76b6 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing location only_foldable_c908f502_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing location only_phone_e05166be_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing location only_phone_e05166be_0.png new file mode 100644 index 00000000000..932e7847234 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing location only_phone_e05166be_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing location only_phone_landscape_9e00b29d_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing location only_phone_landscape_9e00b29d_0.png new file mode 100644 index 00000000000..c5ecfa42b3e Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing location only_phone_landscape_9e00b29d_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing location only_small_phone_66e7bbf2_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing location only_small_phone_66e7bbf2_0.png new file mode 100644 index 00000000000..333911eab18 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing location only_small_phone_66e7bbf2_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing location only_tablet_2f22c4ea_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing location only_tablet_2f22c4ea_0.png new file mode 100644 index 00000000000..e565a7f6fa3 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing location only_tablet_2f22c4ea_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing location only_tablet_landscape_62cae397_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing location only_tablet_landscape_62cae397_0.png new file mode 100644 index 00000000000..df459406e0f Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure missing location only_tablet_landscape_62cae397_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure no missing_foldable_c908f502_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure no missing_foldable_c908f502_0.png new file mode 100644 index 00000000000..b99594b2682 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure no missing_foldable_c908f502_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure no missing_phone_e05166be_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure no missing_phone_e05166be_0.png new file mode 100644 index 00000000000..e7fc6385384 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure no missing_phone_e05166be_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure no missing_phone_landscape_9e00b29d_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure no missing_phone_landscape_9e00b29d_0.png new file mode 100644 index 00000000000..c5ecfa42b3e Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure no missing_phone_landscape_9e00b29d_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure no missing_small_phone_66e7bbf2_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure no missing_small_phone_66e7bbf2_0.png new file mode 100644 index 00000000000..333911eab18 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure no missing_small_phone_66e7bbf2_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure no missing_tablet_2f22c4ea_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure no missing_tablet_2f22c4ea_0.png new file mode 100644 index 00000000000..531720adf8e Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure no missing_tablet_2f22c4ea_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure no missing_tablet_landscape_62cae397_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure no missing_tablet_landscape_62cae397_0.png new file mode 100644 index 00000000000..e39b9e1aff2 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenshotTest/BlockInsecure no missing_tablet_landscape_62cae397_0.png differ diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenTest.kt new file mode 100644 index 00000000000..cb7b6c23f0a --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/webview/insecure/BlockInsecureScreenTest.kt @@ -0,0 +1,187 @@ +package io.homeassistant.companion.android.webview.insecure + +import androidx.activity.compose.LocalActivityResultRegistryOwner +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.ActivityResultRegistryOwner +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import io.homeassistant.companion.android.HiltComponentActivity +import io.homeassistant.companion.android.common.R as commonR +import io.homeassistant.companion.android.testing.unit.ConsoleLogRule +import io.homeassistant.companion.android.testing.unit.stringResource +import io.homeassistant.companion.android.util.LocationPermissionActivityResultRegistry +import org.junit.Rule +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(application = HiltTestApplication::class) +@HiltAndroidTest +class BlockInsecureScreenTest { + @get:Rule(order = 0) + var consoleLog = ConsoleLogRule() + + @get:Rule(order = 1) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 2) + val composeTestRule = createAndroidComposeRule() + + @Test + fun `Given screen displayed with missing location when clicking fix then location permission is requested`() { + composeTestRule.apply { + testScreen(missingLocation = true, missingHomeSetup = false) { + // Verify the banner text is displayed + onNodeWithText(stringResource(commonR.string.block_insecure_missing_location)) + .performScrollTo() + .assertIsDisplayed() + + // Click the enable location button + onNodeWithText(stringResource(commonR.string.block_insecure_action_enable_location)) + .performScrollTo() + .performClick() + + registry.assertLocationPermissionRequested() + assertTrue(openLocationSettingsClicked) + } + } + } + + @Test + fun `Given screen displayed with missing home setup when clicking fix then configure home network is triggered`() { + composeTestRule.apply { + testScreen(missingLocation = false, missingHomeSetup = true) { + onNodeWithText(stringResource(commonR.string.block_insecure_missing_home_setup)) + .performScrollTo() + .assertIsDisplayed() + + // Click the configure home network button + onNodeWithText(stringResource(commonR.string.block_insecure_action_configure_home)) + .performScrollTo() + .performClick() + + assertTrue(configureHomeNetworkClicked) + } + } + } + + @Test + fun `Given screen displayed when clicking open settings then open settings is triggered`() { + composeTestRule.apply { + testScreen(missingLocation = false, missingHomeSetup = false) { + onNodeWithText(stringResource(commonR.string.block_insecure_open_settings)) + .performScrollTo() + .assertIsDisplayed() + .performClick() + + assertTrue(openSettingsClicked) + } + } + } + + @Test + fun `Given screen displayed when clicking change security level then change security level is triggered`() { + composeTestRule.apply { + testScreen(missingLocation = false, missingHomeSetup = false) { + onNodeWithText(stringResource(commonR.string.block_insecure_change_security_level)) + .performScrollTo() + .assertIsDisplayed() + .performClick() + + assertTrue(changeSecurityLevelClicked) + } + } + } + + @Test + fun `Given screen displayed with both missing location and home setup then both banners are shown`() { + composeTestRule.apply { + testScreen(missingLocation = true, missingHomeSetup = true) { + onNodeWithText(stringResource(commonR.string.block_insecure_missing_location)) + .performScrollTo() + .assertIsDisplayed() + onNodeWithText(stringResource(commonR.string.block_insecure_missing_home_setup)) + .performScrollTo() + .assertIsDisplayed() + } + } + } + + @Test + fun `Given screen displayed with no missing then both banners are hidden`() { + composeTestRule.apply { + testScreen(missingLocation = false, missingHomeSetup = false) { + onNodeWithText(stringResource(commonR.string.block_insecure_missing_location)) + .assertIsNotDisplayed() + onNodeWithText(stringResource(commonR.string.block_insecure_missing_home_setup)) + .assertIsNotDisplayed() + } + } + } + + private class TestHelper(locationPermissionGranted: Boolean) { + var retryClicked = false + var helpClicked = false + var openSettingsClicked = false + var changeSecurityLevelClicked = false + var openLocationSettingsClicked = false + var configureHomeNetworkClicked = false + + val registry = LocationPermissionActivityResultRegistry(locationPermissionGranted) + } + + @OptIn(ExperimentalPermissionsApi::class) + private fun AndroidComposeTestRule<*, *>.testScreen( + missingLocation: Boolean, + missingHomeSetup: Boolean, + locationPermissionGranted: Boolean = true, + block: TestHelper.() -> Unit, + ) { + TestHelper(locationPermissionGranted).apply { + setContent { + CompositionLocalProvider( + LocalActivityResultRegistryOwner provides object : ActivityResultRegistryOwner { + override val activityResultRegistry: ActivityResultRegistry = registry + }, + ) { + BlockInsecureScreen( + missingHomeSetup = missingHomeSetup, + missingLocation = missingLocation, + onRetry = { retryClicked = true }, + onHelpClick = { helpClicked = true }, + onOpenSettings = { openSettingsClicked = true }, + onChangeSecurityLevel = { changeSecurityLevelClicked = true }, + onOpenLocationSettings = { openLocationSettingsClicked = true }, + onConfigureHomeNetwork = { configureHomeNetworkClicked = true }, + ) + } + } + onNodeWithText(stringResource(commonR.string.block_insecure_title)).assertIsDisplayed() + onNodeWithText(stringResource(commonR.string.block_insecure_content)).assertIsDisplayed() + + onNodeWithContentDescription(stringResource(commonR.string.get_help)).assertIsDisplayed().performClick() + assertTrue(helpClicked) + + onNodeWithContentDescription(stringResource(commonR.string.block_insecure_retry)) + .assertIsDisplayed() + .performClick() + assertTrue(retryClicked) + + block() + } + } +} diff --git a/build-logic/convention/src/main/kotlin/AndroidComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidComposeConventionPlugin.kt index 3c1da01d502..2a36bdc148e 100644 --- a/build-logic/convention/src/main/kotlin/AndroidComposeConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidComposeConventionPlugin.kt @@ -37,8 +37,8 @@ class AndroidComposeConventionPlugin : Plugin { tasks.withType().configureEach { // Hack until we get the update of the screenshot libray // https://issuetracker.google.com/issues/444048026 - // 3g is the minimal value for our tests to pass currently - maxHeapSize = "3g" + // 4g is the minimal value for our tests to pass currently + maxHeapSize = "4g" } androidConfig { diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/database/server/ServerConnectionInfo.kt b/common/src/main/kotlin/io/homeassistant/companion/android/database/server/ServerConnectionInfo.kt index bf40f43e144..9f064e8394d 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/database/server/ServerConnectionInfo.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/database/server/ServerConnectionInfo.kt @@ -1,16 +1,26 @@ package io.homeassistant.companion.android.database.server +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat import androidx.room.ColumnInfo import androidx.room.Ignore import androidx.room.TypeConverter import io.homeassistant.companion.android.common.data.network.NetworkHelper import io.homeassistant.companion.android.common.data.network.WifiHelper +import io.homeassistant.companion.android.common.util.DisabledLocationHandler import io.homeassistant.companion.android.common.util.kotlinJsonMapper import java.net.URL import kotlinx.serialization.SerializationException import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import timber.log.Timber +sealed interface SecurityStatus { + data object Secure : SecurityStatus + data class Insecure(val missingHomeSetup: Boolean, val missingLocation: Boolean) : SecurityStatus +} + data class ServerConnectionInfo( @ColumnInfo(name = "external_url") val externalUrl: String, @@ -142,6 +152,39 @@ data class ServerConnectionInfo( false } } + + /** + * Determines the security status of the connection for a given URL. + * + * A connection is considered [SecurityStatus.Secure] when: + * - The URL uses HTTPS, or + * - The device is currently on an internal network (VPN, Ethernet, or configured SSID) + * + * When insecure, [SecurityStatus.Insecure] provides guidance on what's missing: + * - [SecurityStatus.Insecure.missingHomeSetup]: No internal URL, VPN, or Ethernet configured + * - [SecurityStatus.Insecure.missingLocation]: Location permission denied or location services disabled, + * which prevents SSID-based internal network detection + * + * @param context Android context for checking location permission and services + * @param url The URL being used for the connection + * @return [SecurityStatus.Secure] if the connection is secure, [SecurityStatus.Insecure] otherwise + */ + fun currentSecurityStatusForUrl(context: Context, url: String): SecurityStatus { + if (url.startsWith("https://") || isInternal(false)) return SecurityStatus.Secure + + val missingHomeSetup = internalSsids.isEmpty() && !(internalVpn ?: false) && !(internalEthernet ?: false) + + val hasLocationPermission = ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION, + ) == PackageManager.PERMISSION_GRANTED + val isLocationEnabled = DisabledLocationHandler.isLocationEnabled(context) + + return SecurityStatus.Insecure( + missingHomeSetup = missingHomeSetup, + missingLocation = !hasLocationPermission || !isLocationEnabled, + ) + } } class InternalSsidTypeConverter { diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 89590059a08..5fb5f9bce55 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -1472,6 +1472,15 @@ Next Save Location permission denied. Allow access or choose the \'Less secure\' option. + Insecure connection blocked + This connection is not secure because it uses HTTP. You have chosen to block insecure connections when not at home. + The app cannot determine whether you are at home because no home network has been configured. + Location access is required to detect your home network. Android requires this because network information can be used to infer your location. + Enable location + Configure home network + Retry + Open settings + Change security level No file name Your Home Assistant server requires TLS client certificate authentication. Select a TLS client certificate file to install it on your Wear OS device. Next diff --git a/common/src/test/kotlin/io/homeassistant/companion/android/database/server/ServerConnectionInfoTest.kt b/common/src/test/kotlin/io/homeassistant/companion/android/database/server/ServerConnectionInfoTest.kt new file mode 100644 index 00000000000..ce30196aa61 --- /dev/null +++ b/common/src/test/kotlin/io/homeassistant/companion/android/database/server/ServerConnectionInfoTest.kt @@ -0,0 +1,335 @@ +package io.homeassistant.companion.android.database.server + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.location.LocationManager +import androidx.core.content.ContextCompat +import io.homeassistant.companion.android.common.data.network.NetworkHelper +import io.homeassistant.companion.android.common.data.network.WifiHelper +import io.homeassistant.companion.android.common.util.DisabledLocationHandler +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class ServerConnectionInfoTest { + private val context: Context = mockk(relaxed = true) + private val wifiHelper: WifiHelper = mockk() + private val networkHelper: NetworkHelper = mockk() + private val locationManager: LocationManager = mockk() + + @BeforeEach + fun setup() { + mockkStatic(ContextCompat::class) + mockkObject(DisabledLocationHandler) + every { context.getSystemService(LocationManager::class.java) } returns locationManager + } + + @AfterEach + fun tearDown() { + unmockkAll() + } + + private fun createServerConnectionInfo( + externalUrl: String = "https://external.example.com", + internalUrl: String? = null, + cloudUrl: String? = null, + internalSsids: List = emptyList(), + internalEthernet: Boolean? = null, + internalVpn: Boolean? = null, + ): ServerConnectionInfo { + return ServerConnectionInfo( + externalUrl = externalUrl, + internalUrl = internalUrl, + cloudUrl = cloudUrl, + internalSsids = internalSsids, + internalEthernet = internalEthernet, + internalVpn = internalVpn, + ).apply { + this.wifiHelper = this@ServerConnectionInfoTest.wifiHelper + this.networkHelper = this@ServerConnectionInfoTest.networkHelper + } + } + + @Test + fun `Given HTTPS URL when checking security status then returns Secure`() { + val serverInfo = createServerConnectionInfo() + val httpsUrl = "https://example.com" + + val result = serverInfo.currentSecurityStatusForUrl(context, httpsUrl) + + assertInstanceOf(SecurityStatus.Secure::class.java, result) + } + + @Test + fun `Given HTTP URL when device is on internal network via ethernet then returns Secure`() { + val serverInfo = createServerConnectionInfo( + internalUrl = "http://192.168.1.1:8123", + internalEthernet = true, + ) + every { networkHelper.isUsingEthernet() } returns true + val httpUrl = "http://192.168.1.1:8123" + + val result = serverInfo.currentSecurityStatusForUrl(context, httpUrl) + + assertInstanceOf(SecurityStatus.Secure::class.java, result) + } + + @Test + fun `Given HTTP URL when device is on internal network via VPN then returns Secure`() { + val serverInfo = createServerConnectionInfo( + internalUrl = "http://192.168.1.1:8123", + internalVpn = true, + ) + every { networkHelper.isUsingVpn() } returns true + every { networkHelper.isUsingEthernet() } returns false + val httpUrl = "http://192.168.1.1:8123" + + val result = serverInfo.currentSecurityStatusForUrl(context, httpUrl) + + assertInstanceOf(SecurityStatus.Secure::class.java, result) + } + + @Test + fun `Given HTTP URL when device is on internal network via SSID then returns Secure`() { + val serverInfo = createServerConnectionInfo( + internalUrl = "http://192.168.1.1:8123", + internalSsids = listOf("HomeWiFi"), + ) + every { networkHelper.isUsingEthernet() } returns false + every { networkHelper.isUsingVpn() } returns false + every { wifiHelper.isUsingSpecificWifi(listOf("HomeWiFi")) } returns true + every { wifiHelper.isUsingWifi() } returns true + val httpUrl = "http://192.168.1.1:8123" + + val result = serverInfo.currentSecurityStatusForUrl(context, httpUrl) + + assertInstanceOf(SecurityStatus.Secure::class.java, result) + } + + @Test + fun `Given HTTP URL when not on internal network and no home setup then returns Insecure with missingHomeSetup true`() { + val serverInfo = createServerConnectionInfo( + externalUrl = "http://external.example.com", + internalUrl = null, + internalVpn = false, + internalEthernet = false, + ) + every { + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) + } returns PackageManager.PERMISSION_GRANTED + every { DisabledLocationHandler.isLocationEnabled(context) } returns true + + val httpUrl = "http://external.example.com" + + val result = serverInfo.currentSecurityStatusForUrl(context, httpUrl) + + assertInstanceOf(SecurityStatus.Insecure::class.java, result) + val insecure = result as SecurityStatus.Insecure + assertTrue(insecure.missingHomeSetup) + assertFalse(insecure.missingLocation) + } + + @Test + fun `Given HTTP URL when not on internal network and has internalSSID then returns Insecure with missingHomeSetup false`() { + val serverInfo = createServerConnectionInfo( + externalUrl = "http://external.example.com", + internalUrl = "http://192.168.1.1:8123", + internalVpn = false, + internalEthernet = false, + internalSsids = listOf("helloworld"), + ) + every { networkHelper.isUsingEthernet() } returns false + every { networkHelper.isUsingVpn() } returns false + every { wifiHelper.isUsingWifi() } returns true + every { wifiHelper.isUsingSpecificWifi(any()) } returns false + every { + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) + } returns PackageManager.PERMISSION_GRANTED + every { DisabledLocationHandler.isLocationEnabled(context) } returns true + + val httpUrl = "http://external.example.com" + + val result = serverInfo.currentSecurityStatusForUrl(context, httpUrl) + + assertInstanceOf(SecurityStatus.Insecure::class.java, result) + val insecure = result as SecurityStatus.Insecure + assertFalse(insecure.missingHomeSetup) + assertFalse(insecure.missingLocation) + } + + @Test + fun `Given HTTP URL when not on internal network and VPN enabled then returns Insecure with missingHomeSetup false`() { + val serverInfo = createServerConnectionInfo( + externalUrl = "http://external.example.com", + internalUrl = null, + internalVpn = true, + internalEthernet = false, + ) + every { networkHelper.isUsingVpn() } returns false + every { networkHelper.isUsingEthernet() } returns false + every { + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) + } returns PackageManager.PERMISSION_GRANTED + every { DisabledLocationHandler.isLocationEnabled(context) } returns true + + val httpUrl = "http://external.example.com" + + val result = serverInfo.currentSecurityStatusForUrl(context, httpUrl) + + assertInstanceOf(SecurityStatus.Insecure::class.java, result) + val insecure = result as SecurityStatus.Insecure + assertFalse(insecure.missingHomeSetup) + } + + @Test + fun `Given HTTP URL when not on internal network and Ethernet enabled then returns Insecure with missingHomeSetup false`() { + val serverInfo = createServerConnectionInfo( + externalUrl = "http://external.example.com", + internalUrl = null, + internalVpn = false, + internalEthernet = true, + ) + every { networkHelper.isUsingEthernet() } returns false + every { + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) + } returns PackageManager.PERMISSION_GRANTED + every { DisabledLocationHandler.isLocationEnabled(context) } returns true + + val httpUrl = "http://external.example.com" + + val result = serverInfo.currentSecurityStatusForUrl(context, httpUrl) + + assertInstanceOf(SecurityStatus.Insecure::class.java, result) + val insecure = result as SecurityStatus.Insecure + assertFalse(insecure.missingHomeSetup) + } + + @Test + fun `Given HTTP URL when location permission denied then returns Insecure with missingLocation true`() { + val serverInfo = createServerConnectionInfo( + externalUrl = "http://external.example.com", + internalUrl = "http://192.168.1.1:8123", + internalVpn = false, + internalEthernet = false, + ) + every { networkHelper.isUsingEthernet() } returns false + every { networkHelper.isUsingVpn() } returns false + every { wifiHelper.isUsingSpecificWifi(any()) } returns false + every { + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) + } returns PackageManager.PERMISSION_DENIED + every { DisabledLocationHandler.isLocationEnabled(context) } returns true + + val httpUrl = "http://external.example.com" + + val result = serverInfo.currentSecurityStatusForUrl(context, httpUrl) + + assertInstanceOf(SecurityStatus.Insecure::class.java, result) + val insecure = result as SecurityStatus.Insecure + assertTrue(insecure.missingLocation) + } + + @Test + fun `Given HTTP URL when location services disabled then returns Insecure with missingLocation true`() { + val serverInfo = createServerConnectionInfo( + externalUrl = "http://external.example.com", + internalUrl = "http://192.168.1.1:8123", + internalVpn = false, + internalEthernet = false, + ) + every { networkHelper.isUsingEthernet() } returns false + every { networkHelper.isUsingVpn() } returns false + every { wifiHelper.isUsingSpecificWifi(any()) } returns false + every { + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) + } returns PackageManager.PERMISSION_GRANTED + every { DisabledLocationHandler.isLocationEnabled(context) } returns false + + val httpUrl = "http://external.example.com" + + val result = serverInfo.currentSecurityStatusForUrl(context, httpUrl) + + assertInstanceOf(SecurityStatus.Insecure::class.java, result) + val insecure = result as SecurityStatus.Insecure + assertTrue(insecure.missingLocation) + } + + @Test + fun `Given HTTP URL when both location permission denied and services disabled then returns Insecure with missingLocation true`() { + val serverInfo = createServerConnectionInfo( + externalUrl = "http://external.example.com", + internalUrl = "http://192.168.1.1:8123", + internalVpn = false, + internalEthernet = false, + ) + every { networkHelper.isUsingEthernet() } returns false + every { networkHelper.isUsingVpn() } returns false + every { wifiHelper.isUsingSpecificWifi(any()) } returns false + every { + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) + } returns PackageManager.PERMISSION_DENIED + every { DisabledLocationHandler.isLocationEnabled(context) } returns false + + val httpUrl = "http://external.example.com" + + val result = serverInfo.currentSecurityStatusForUrl(context, httpUrl) + + assertInstanceOf(SecurityStatus.Insecure::class.java, result) + val insecure = result as SecurityStatus.Insecure + assertTrue(insecure.missingLocation) + } + + @Test + fun `Given HTTP URL when both missingHomeSetup and missingLocation conditions met then returns Insecure with both flags true`() { + val serverInfo = createServerConnectionInfo( + externalUrl = "http://external.example.com", + internalUrl = null, + internalVpn = false, + internalEthernet = false, + ) + every { + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) + } returns PackageManager.PERMISSION_DENIED + every { DisabledLocationHandler.isLocationEnabled(context) } returns false + + val httpUrl = "http://external.example.com" + + val result = serverInfo.currentSecurityStatusForUrl(context, httpUrl) + + assertInstanceOf(SecurityStatus.Insecure::class.java, result) + val insecure = result as SecurityStatus.Insecure + assertTrue(insecure.missingHomeSetup) + assertTrue(insecure.missingLocation) + } + + @Test + fun `Given HTTP URL with null internalVpn and internalEthernet when checking security then missingHomeSetup is true`() { + val serverInfo = createServerConnectionInfo( + externalUrl = "http://external.example.com", + internalUrl = null, + internalVpn = null, + internalEthernet = null, + ) + every { + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) + } returns PackageManager.PERMISSION_GRANTED + every { DisabledLocationHandler.isLocationEnabled(context) } returns true + + val httpUrl = "http://external.example.com" + + val result = serverInfo.currentSecurityStatusForUrl(context, httpUrl) + + assertInstanceOf(SecurityStatus.Insecure::class.java, result) + val insecure = result as SecurityStatus.Insecure + assertTrue(insecure.missingHomeSetup) + } +}