diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt index e645f7dbca2..0c52faae93d 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt @@ -56,6 +56,7 @@ import io.homeassistant.companion.android.util.HAWebChromeClient import io.homeassistant.companion.android.util.HAWebViewClient import io.homeassistant.companion.android.util.HAWebViewClientFactory import io.homeassistant.companion.android.util.LifecycleHandler +import io.homeassistant.companion.android.util.UrlUtil import io.homeassistant.companion.android.util.hasSameOrigin import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException @@ -74,6 +75,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge @@ -306,6 +308,12 @@ internal class FrontendViewModel @VisibleForTesting constructor( */ private var pendingMoreInfoEntityId: String? = null + /** + * The latest [navigateTo] or [reloadFrontend] request; a newer request cancels one still + * waiting for the frontend handshake. + */ + private var externalNavigationJob: Job? = null + /** * The user's "Autoplay video" preference. * @@ -540,12 +548,90 @@ internal class FrontendViewModel @VisibleForTesting constructor( } fun switchServer(serverId: Int) { + // The user's choice supersedes an external navigation still waiting for the page + externalNavigationJob?.cancel() _viewState.update { FrontendViewState.LoadServer(serverId = serverId) } loadServer() } + /** + * Navigates to [target] on [serverId] for the webview notification command delivered to the + * running frontend. Another server is loaded from scratch directly at the target, the shown + * server is navigated in place. Only the latest external request is applied: bus messages + * sent before the frontend handshake are lost, so a request waits for the page and a newer + * request replaces a waiting one. + */ + fun navigateTo(target: FrontendTarget, serverId: Int) { + externalNavigationJob?.cancel() + externalNavigationJob = viewModelScope.launch { + if (!isCurrentServer(serverId)) { + loadServerAt(serverId, target) + return@launch + } + _viewState.first { it is FrontendViewState.Content } + if (!isCurrentServer(serverId)) { + // The shown server changed while waiting for the page, that navigation wins + return@launch + } + when (target) { + is FrontendTarget.EntityMoreInfo -> _webViewActions.emit( + WebViewAction.OpenMoreInfo(target.entityId), + ) + + is FrontendTarget.Path -> navigateToPath(target.path) + FrontendTarget.Default -> navigateToDefaultDashboard(_viewState.value.serverId) + } + } + } + + /** + * Reloads the frontend of [serverId] for the webview notification command delivered to the + * running frontend. Another server is simply loaded, which is a fresh page already. + */ + fun reloadFrontend(serverId: Int) { + externalNavigationJob?.cancel() + externalNavigationJob = viewModelScope.launch { + if (!isCurrentServer(serverId)) { + loadServerAt(serverId, FrontendTarget.Default) + return@launch + } + // Dropping the cache while the page is still loading can wedge the load + _webViewActions.emit( + if (_viewState.value is FrontendViewState.Content) { + WebViewAction.HardReload() + } else { + WebViewAction.Reload() + }, + ) + } + } + + private suspend fun navigateToPath(path: String) { + val serverId = _viewState.value.serverId + val version = serverManager.getServer(serverId)?.version + if (UrlUtil.isAbsoluteUrl(path) || !NavigateToMessage.isAvailable(version)) { + // The frontend navigation cannot leave the current origin, and servers without + // navigation support cannot use it at all: both get a full page load instead + loadServerAt(serverId, FrontendTarget.Path(path)) + } else { + // The frontend resolves relative paths against the current page, normalize to root + externalBusRepository.send(NavigateToMessage(path = "/" + path.trimStart('/'))) + } + } + + /** Whether [serverId] refers to the server the frontend currently shows. */ + private suspend fun isCurrentServer(serverId: Int): Boolean = + serverManager.getServer(serverId)?.id == serverManager.getServer(_viewState.value.serverId)?.id + + private fun loadServerAt(serverId: Int, target: FrontendTarget) { + _viewState.update { + FrontendViewState.LoadServer(serverId = serverId, target = target) + } + loadServer() + } + /** * Called from the security level configuration screen after the user makes a choice or discard. * The actual saving of the preference is handled by [io.homeassistant.companion.android.onboarding.locationforsecureconnection.LocationForSecureConnectionViewModel]. diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/WebViewAction.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/WebViewAction.kt index dad09c35842..e2cd8d082a1 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/WebViewAction.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/WebViewAction.kt @@ -77,6 +77,24 @@ sealed interface WebViewAction { } } + /** + * Reloads the current page ignoring cached resources, like a hard refresh in a desktop + * browser. The current document keeps being displayed and is destroyed once the reloaded page + * commits, which releases the native resources it holds, like WebRTC peer connections and + * camera streams. Nothing is released when the page cannot be loaded again. + * + * Note that [WebView.clearCache] clears the cache for the whole application, not only for the + * displayed page. + */ + data class HardReload(override val result: CompletableDeferred = CompletableDeferred()) : + AwaitableAction { + override fun run(webView: WebView) { + webView.clearCache(true) + webView.reload() + result.complete(Unit) + } + } + /** Perform haptic feedback on the WebView. */ data class Haptic(val type: HapticType, override val result: CompletableDeferred = CompletableDeferred()) : AwaitableAction { diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendTarget.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendTarget.kt index 8db574ed60b..755e8f7e1e2 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendTarget.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendTarget.kt @@ -26,12 +26,17 @@ sealed interface FrontendTarget : Parcelable { /** * Parses a raw path string into a [FrontendTarget]. * - * A `null` path maps to [Default]. + * A `null` or blank path maps to [Default]. */ - fun fromRawPath(path: String?): FrontendTarget = when { - path == null -> Default - path.startsWith(ENTITY_ID_PREFIX) -> EntityMoreInfo(path.removePrefix(ENTITY_ID_PREFIX)) - else -> Path(path) + fun fromRawPath(path: String?): FrontendTarget { + val trimmed = path?.trim() + return when { + trimmed.isNullOrEmpty() -> Default + // Matched ignoring case and surrounding spaces since the value is typed by hand + trimmed.startsWith(ENTITY_ID_PREFIX, ignoreCase = true) -> + EntityMoreInfo(trimmed.substring(ENTITY_ID_PREFIX.length).trim()) + else -> Path(trimmed) + } } /** diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchActivity.kt index 3a6c21848a3..8d92e7d52fa 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchActivity.kt @@ -29,10 +29,14 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.WindowInsetsControllerCompat import androidx.fragment.app.FragmentActivity +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.navigation.NavController +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import androidx.navigation.navOptions import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.withCreationCallback import dev.chrisbanes.haze.hazeSource @@ -44,6 +48,8 @@ import io.homeassistant.companion.android.common.compose.theme.HATheme import io.homeassistant.companion.android.common.sensors.SensorWorker import io.homeassistant.companion.android.common.util.CheckLocalNetworkPermissionUseCase import io.homeassistant.companion.android.common.util.SdkVersion +import io.homeassistant.companion.android.frontend.FrontendViewModel +import io.homeassistant.companion.android.frontend.navigation.FrontendRoute import io.homeassistant.companion.android.frontend.navigation.FrontendTarget import io.homeassistant.companion.android.launch.applock.HazeLockOverlay import io.homeassistant.companion.android.sensors.SensorReceiver @@ -56,8 +62,10 @@ import io.homeassistant.companion.android.util.compose.navigateToUri import io.homeassistant.companion.android.util.enableEdgeToEdgeCompat import io.homeassistant.companion.android.websocket.WebsocketManager import javax.inject.Inject +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize +import timber.log.Timber private const val DEEP_LINK_KEY = "deep_link_key" @@ -130,6 +138,12 @@ class LaunchActivity : AppCompatActivity() { */ data class NavigateTo(val target: FrontendTarget, val serverId: Int) : DeepLink + /** + * Reloads the frontend of [serverId], or opens it when it is not shown yet. + * @property serverId The ID of the server whose frontend is reloaded. + */ + data class ReloadFrontend(val serverId: Int) : DeepLink + /** * Opens the Wear OS device onboarding flow. * @property wearName The name of the Wear device being onboarded. @@ -205,6 +219,11 @@ class LaunchActivity : AppCompatActivity() { navController = navController, ) + NewDeepLinkEffect( + navController = navController, + viewModel = viewModel, + ) + HAApp( navController = navController, startDestination = (uiState as? LaunchUiState.Ready)?.startDestination, @@ -231,6 +250,12 @@ class LaunchActivity : AppCompatActivity() { } } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + IntentCompat.getParcelableExtra(intent, DEEP_LINK_KEY, DeepLink::class.java) + ?.let(viewModel::onNewDeepLink) + } + override fun onStart() { super.onStart() viewModel.refreshAppLockState() @@ -272,6 +297,52 @@ class LaunchActivity : AppCompatActivity() { } } +/** + * Applies deep links delivered to the already running [LaunchActivity] through + * [LaunchActivity.onNewIntent], so the webview notification command can act on an open frontend + * without restarting the app. A frontend shown on top handles its deep link in place through its + * [FrontendViewModel]; otherwise the destination resolved by the [viewModel] is opened, keeping + * the same Automotive policy as a launch. Deep links are held until the navigation graph exists + * ([LaunchUiState.Ready]) and only the latest one is kept while waiting. Other deep links are + * ignored since their entry points always start a fresh activity. + */ +@Composable +private fun NewDeepLinkEffect(navController: NavController, viewModel: LaunchViewModel) { + val currentEntry by navController.currentBackStackEntryAsState() + val frontendEntry = currentEntry?.takeIf { it.destination.hasRoute() } + val frontendViewModel: FrontendViewModel? = + frontendEntry?.let { hiltViewModel(viewModelStoreOwner = it) } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val isReady = uiState is LaunchUiState.Ready + + LaunchedEffect(frontendViewModel, isReady) { + if (!isReady) return@LaunchedEffect + viewModel.newDeepLink.filterNotNull().collect { deepLink -> + when (deepLink) { + is LaunchActivity.DeepLink.NavigateTo -> + frontendViewModel?.navigateTo(target = deepLink.target, serverId = deepLink.serverId) + ?: navController.navigateToNewDeepLinkDestination(viewModel, deepLink) + + is LaunchActivity.DeepLink.ReloadFrontend -> + frontendViewModel?.reloadFrontend(serverId = deepLink.serverId) + // A freshly opened frontend is already fully loaded + ?: navController.navigateToNewDeepLinkDestination(viewModel, deepLink) + + else -> Timber.w("Ignoring deep link only supported at launch: ${deepLink::class.simpleName}") + } + viewModel.onNewDeepLinkHandled() + } + } +} + +private fun NavController.navigateToNewDeepLinkDestination( + viewModel: LaunchViewModel, + deepLink: LaunchActivity.DeepLink, +) { + val destination = viewModel.newDeepLinkDestination(deepLink) ?: return + navigate(destination, navOptions { launchSingleTop = true }) +} + /** * Triggers biometric authentication when the app is locked. * diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchActivityExt.kt b/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchActivityExt.kt index 1e59ce9b2f0..57d9090bde9 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchActivityExt.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchActivityExt.kt @@ -16,6 +16,9 @@ internal fun Context.startLaunchInvitation(serverUrl: String) { internal fun Context.intentLaunchWithNavigateTo(target: FrontendTarget, serverId: Int): Intent = LaunchActivity.newInstance(this, LaunchActivity.DeepLink.NavigateTo(target, serverId)) +internal fun Context.intentLaunchReloadFrontend(serverId: Int): Intent = + LaunchActivity.newInstance(this, LaunchActivity.DeepLink.ReloadFrontend(serverId)) + internal fun Context.startLaunchWithNavigateTo(target: FrontendTarget, serverId: Int) { startActivity(intentLaunchWithNavigateTo(target, serverId)) } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchViewModel.kt index 6fb8c3cd548..e876c378584 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchViewModel.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchViewModel.kt @@ -146,6 +146,15 @@ internal class LaunchViewModel @VisibleForTesting constructor( */ val pipReadiness: StateFlow = _pipReadiness.asStateFlow() + private val _newDeepLink = MutableStateFlow(null) + + /** + * The latest deep link delivered through [LaunchActivity.onNewIntent] while the activity is + * already running, or `null` once handled. Conflated on purpose: commands arriving while none + * can be applied yet keep only the most recent one. + */ + val newDeepLink: StateFlow = _newDeepLink.asStateFlow() + init { viewModelScope.launch { cleanupServers() @@ -204,6 +213,40 @@ internal class LaunchViewModel @VisibleForTesting constructor( _pipReadiness.value = readiness } + /** Keeps the latest deep link received while the activity is already running. */ + fun onNewDeepLink(deepLink: LaunchActivity.DeepLink) { + _newDeepLink.value = deepLink + } + + /** Marks the current [newDeepLink] as applied. */ + fun onNewDeepLinkHandled() { + _newDeepLink.value = null + } + + /** + * The destination a [deepLink] received while the activity is already running navigates to + * when no frontend is shown, applying the same Automotive policy as a launch: the dedicated + * Automotive UI is never replaced by the WebView. Returns `null` for deep links only + * supported at launch. + */ + fun newDeepLinkDestination(deepLink: LaunchActivity.DeepLink): HAStartDestinationRoute? = when (deepLink) { + is LaunchActivity.DeepLink.NavigateTo -> + if (shouldNavigateToAutomotive) { + AutomotiveRoute + } else { + FrontendRoute(deepLink.target, deepLink.serverId) + } + + is LaunchActivity.DeepLink.ReloadFrontend -> + if (shouldNavigateToAutomotive) { + AutomotiveRoute + } else { + FrontendRoute(FrontendTarget.Default, deepLink.serverId) + } + + else -> null + } + private suspend fun handleInitialState(initialDeepLink: LaunchActivity.DeepLink?) { when (initialDeepLink) { is LaunchActivity.DeepLink.OpenOnboarding -> navigateToOnboarding( @@ -220,6 +263,10 @@ internal class LaunchViewModel @VisibleForTesting constructor( is LaunchActivity.DeepLink.NavigateTo, -> connectToServer(initialDeepLink.serverId, initialDeepLink.target) + // A launch is a fresh load already, nothing left to reload + is LaunchActivity.DeepLink.ReloadFrontend, + -> connectToServer(initialDeepLink.serverId, FrontendTarget.Default) + is LaunchActivity.DeepLink.OpenWearOnboarding -> navigateToWearOnboarding( wearName = initialDeepLink.wearName, urlToOnboard = initialDeepLink.urlToOnboard, diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/notifications/MessagingManager.kt b/app/src/main/kotlin/io/homeassistant/companion/android/notifications/MessagingManager.kt index e4622c4c020..924caa9f8f4 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/notifications/MessagingManager.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/notifications/MessagingManager.kt @@ -84,6 +84,7 @@ import io.homeassistant.companion.android.database.notification.NotificationItem import io.homeassistant.companion.android.database.settings.SettingsDao import io.homeassistant.companion.android.database.settings.WebsocketSetting import io.homeassistant.companion.android.frontend.navigation.FrontendTarget +import io.homeassistant.companion.android.launch.intentLaunchReloadFrontend import io.homeassistant.companion.android.launch.intentLaunchWithNavigateTo import io.homeassistant.companion.android.sensors.LocationSensorManager import io.homeassistant.companion.android.sensors.LocationSensorManager.Companion.setHighAccuracyModeIntervalSetting @@ -142,6 +143,7 @@ class MessagingManager @Inject constructor( const val INTENT_PREFIX = "intent:" const val MARKET_PREFIX = "https://play.google.com/store/apps/details?id=" const val SETTINGS_PREFIX = "settings://" + const val WEBVIEW_RELOAD = "reload" const val NOTIFICATION_HISTORY = "notification_history" const val NO_ACTION = "noAction" @@ -1999,14 +2001,18 @@ class MessagingManager @Inject constructor( private fun openWebview(title: String?, data: Map) { try { val serverId = data[THIS_SERVER_ID]!!.toInt() - val intent = if (title.isNullOrEmpty()) { - context.intentLaunchWithNavigateTo(FrontendTarget.Default, serverId) + // Trimmed and matched ignoring case since the value is typed by hand + val isReload = WEBVIEW_RELOAD.equals(title?.trim(), ignoreCase = true) + val intent = if (isReload) { + context.intentLaunchReloadFrontend(serverId) } else { context.intentLaunchWithNavigateTo(FrontendTarget.fromRawPath(title), serverId) } - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - intent.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK) - intent.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) + // Delivered to the running activity (onNewIntent) so an open frontend handles the + // deep link in place instead of being recreated; otherwise it starts fresh + intent.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP, + ) context.startActivity(intent) } catch (e: Exception) { Timber.e(e, "Unable to open webview") diff --git a/app/src/main/res/xml/changelog_master.xml b/app/src/main/res/xml/changelog_master.xml index ba1cbeccb2d..46e70152fa2 100755 --- a/app/src/main/res/xml/changelog_master.xml +++ b/app/src/main/res/xml/changelog_master.xml @@ -3,6 +3,7 @@ tools:ignore="MissingDefaultResource"> Android 17: added assistant volume level sensor + notification command for control + The command_webview notification command now navigates, opens the entity more info dialog, and reloads the frontend in place when it is already open, instead of relaunching the app Health Connect sleep duration sensor now ignores awake and out of bed time Bug fixes and dependency updates diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModelTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModelTest.kt index 5bdc03b812f..ee694101e18 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModelTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModelTest.kt @@ -40,6 +40,7 @@ import io.homeassistant.companion.android.frontend.exoplayer.ExoPlayerUiState import io.homeassistant.companion.android.frontend.exoplayer.FrontendExoPlayerManager import io.homeassistant.companion.android.frontend.externalbus.FrontendExternalBusRepository import io.homeassistant.companion.android.frontend.externalbus.incoming.HapticType +import io.homeassistant.companion.android.frontend.externalbus.outgoing.NavigateToMessage import io.homeassistant.companion.android.frontend.externalbus.outgoing.SuccessResultMessage import io.homeassistant.companion.android.frontend.filechooser.FileChooserManager import io.homeassistant.companion.android.frontend.gesture.FrontendGestureManager @@ -820,6 +821,254 @@ class FrontendViewModelTest { } } + /** Overrides the bus observer with a flow the test can emit [FrontendHandlerEvent.Connected] on. */ + private fun connectableMessageFlow(): MutableSharedFlow { + val messageFlow = MutableSharedFlow() + every { frontendBusObserver.messageResults() } returns messageFlow + every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( + UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), + ) + return messageFlow + } + + private fun mockCurrentServer(haVersion: HomeAssistantVersion? = HomeAssistantVersion(2025, 6, 0)) { + coEvery { serverManager.getServer(serverId) } returns mockServer( + url = "https://ha.test", + name = "test", + haVersion = haVersion, + serverId = serverId, + ) + } + + private fun mockOtherServer(otherServerId: Int) { + coEvery { serverManager.getServer(otherServerId) } returns mockServer( + url = "https://other.test", + name = "other", + serverId = otherServerId, + ) + } + + @Test + fun `Given a connected frontend when reloading its server then webViewActions emits HardReload`() = runTest { + mockCurrentServer() + val messageFlow = connectableMessageFlow() + val viewModel = createViewModel() + + viewModel.webViewActions.test { + messageFlow.emit(FrontendHandlerEvent.Connected) + advanceUntilIdle() + skipItems(2) // The connection handshake: history clear and theme color read + + viewModel.reloadFrontend(serverId) + + assertTrue(awaitItem() is WebViewAction.HardReload) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `Given a loading frontend when reloading its server then webViewActions emits a plain Reload`() = runTest { + mockCurrentServer() + every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( + UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), + ) + val viewModel = createViewModel() + + viewModel.webViewActions.test { + viewModel.reloadFrontend(serverId) + + assertTrue(awaitItem() is WebViewAction.Reload) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `Given a connected frontend when navigating to a path then it navigates over the external bus`() = runTest { + mockCurrentServer() + val messageFlow = connectableMessageFlow() + val viewModel = createViewModel() + messageFlow.emit(FrontendHandlerEvent.Connected) + advanceUntilIdle() + + viewModel.navigateTo(FrontendTarget.Path("/lovelace/cameras"), serverId) + advanceUntilIdle() + + coVerify { externalBusRepository.send(NavigateToMessage(path = "/lovelace/cameras")) } + } + + @Test + fun `Given a relative path when navigating then it is sent root relative`() = runTest { + mockCurrentServer() + val messageFlow = connectableMessageFlow() + val viewModel = createViewModel() + messageFlow.emit(FrontendHandlerEvent.Connected) + advanceUntilIdle() + + viewModel.navigateTo(FrontendTarget.Path("lovelace/cameras"), serverId) + advanceUntilIdle() + + // The frontend resolves relative paths against the current page, see issue #5381 + coVerify { externalBusRepository.send(NavigateToMessage(path = "/lovelace/cameras")) } + } + + @Test + fun `Given an absolute URL when navigating then the page is loaded instead of the frontend navigation`() = runTest { + mockCurrentServer() + val messageFlow = connectableMessageFlow() + val viewModel = createViewModel() + messageFlow.emit(FrontendHandlerEvent.Connected) + advanceUntilIdle() + + viewModel.navigateTo(FrontendTarget.Path("https://example.com/lovelace"), serverId) + advanceUntilIdle() + + coVerify(exactly = 0) { externalBusRepository.send(NavigateToMessage(path = "https://example.com/lovelace")) } + coVerify(exactly = 0) { externalBusRepository.send(NavigateToMessage(path = "/https://example.com/lovelace")) } + verify { urlManager.serverUrlFlow(serverId, FrontendTarget.Path("https://example.com/lovelace")) } + } + + @Test + fun `Given a navigation waiting for the page when the user switches server then it is dropped`() = runTest { + mockCurrentServer() + val otherServerId = serverId + 1 + mockOtherServer(otherServerId) + val messageFlow = connectableMessageFlow() + every { urlManager.serverUrlFlow(otherServerId, any()) } returns flowOf( + UrlLoadResult.Success(url = testUrlWithAuth, serverId = otherServerId), + ) + val viewModel = createViewModel() + + viewModel.navigateTo(FrontendTarget.Path("/lovelace/cameras"), serverId) + advanceUntilIdle() + viewModel.switchServer(otherServerId) + messageFlow.emit(FrontendHandlerEvent.Connected) + advanceUntilIdle() + + coVerify(exactly = 0) { externalBusRepository.send(NavigateToMessage(path = "/lovelace/cameras")) } + } + + @Test + fun `Given a server without navigation support when navigating to a path then the page is loaded at the target`() = runTest { + mockCurrentServer(haVersion = HomeAssistantVersion(2025, 5, 0)) + val messageFlow = connectableMessageFlow() + val viewModel = createViewModel() + messageFlow.emit(FrontendHandlerEvent.Connected) + advanceUntilIdle() + + viewModel.navigateTo(FrontendTarget.Path("/lovelace/cameras"), serverId) + advanceUntilIdle() + + coVerify(exactly = 0) { externalBusRepository.send(NavigateToMessage(path = "/lovelace/cameras")) } + verify { urlManager.serverUrlFlow(serverId, FrontendTarget.Path("/lovelace/cameras")) } + } + + @Test + fun `Given an entity target when navigating then webViewActions emits OpenMoreInfo`() = runTest { + mockCurrentServer() + val messageFlow = connectableMessageFlow() + val viewModel = createViewModel() + + viewModel.webViewActions.test { + messageFlow.emit(FrontendHandlerEvent.Connected) + advanceUntilIdle() + skipItems(2) // The connection handshake: history clear and theme color read + + viewModel.navigateTo(FrontendTarget.EntityMoreInfo("sun.sun"), serverId) + + assertEquals("sun.sun", (awaitItem() as WebViewAction.OpenMoreInfo).entityId) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `Given the default target when navigating then the default dashboard navigation is used`() = runTest { + mockCurrentServer() + val messageFlow = connectableMessageFlow() + val viewModel = createViewModel() + val job = launch { + viewModel.webViewActions.collect { + // The default dashboard navigation clears the history and awaits it + if (it is WebViewAction.ClearHistory) it.result.complete(Unit) + } + } + messageFlow.emit(FrontendHandlerEvent.Connected) + advanceUntilIdle() + + viewModel.navigateTo(FrontendTarget.Default, serverId) + advanceUntilIdle() + + coVerify { externalBusRepository.send(NavigateToMessage(path = "/", replace = true)) } + job.cancel() + } + + @Test + fun `Given a loading frontend when navigating then the request is delivered once connected`() = runTest { + mockCurrentServer() + val messageFlow = connectableMessageFlow() + val viewModel = createViewModel() + + viewModel.navigateTo(FrontendTarget.Path("/lovelace/cameras"), serverId) + // Stay within the connection timeout so the frontend keeps loading + advanceTimeBy(CONNECTION_TIMEOUT - 1.seconds) + coVerify(exactly = 0) { externalBusRepository.send(NavigateToMessage(path = "/lovelace/cameras")) } + + messageFlow.emit(FrontendHandlerEvent.Connected) + advanceUntilIdle() + + coVerify(exactly = 1) { externalBusRepository.send(NavigateToMessage(path = "/lovelace/cameras")) } + } + + @Test + fun `Given several navigations while loading when connected then only the latest is delivered`() = runTest { + mockCurrentServer() + val messageFlow = connectableMessageFlow() + val viewModel = createViewModel() + + viewModel.navigateTo(FrontendTarget.Path("/lovelace/old"), serverId) + viewModel.navigateTo(FrontendTarget.Path("/lovelace/new"), serverId) + // Stay within the connection timeout so the frontend keeps loading + advanceTimeBy(CONNECTION_TIMEOUT - 1.seconds) + + messageFlow.emit(FrontendHandlerEvent.Connected) + advanceUntilIdle() + + coVerify(exactly = 0) { externalBusRepository.send(NavigateToMessage(path = "/lovelace/old")) } + coVerify(exactly = 1) { externalBusRepository.send(NavigateToMessage(path = "/lovelace/new")) } + } + + @Test + fun `Given another server when navigating then it is loaded directly at the target`() = runTest { + mockCurrentServer() + val otherServerId = serverId + 1 + mockOtherServer(otherServerId) + val messageFlow = connectableMessageFlow() + val viewModel = createViewModel() + messageFlow.emit(FrontendHandlerEvent.Connected) + advanceUntilIdle() + + viewModel.navigateTo(FrontendTarget.Path("/lovelace/cameras"), otherServerId) + advanceUntilIdle() + + coVerify(exactly = 0) { externalBusRepository.send(NavigateToMessage(path = "/lovelace/cameras")) } + verify { urlManager.serverUrlFlow(otherServerId, FrontendTarget.Path("/lovelace/cameras")) } + } + + @Test + fun `Given another server when reloading then it is simply loaded`() = runTest { + mockCurrentServer() + val otherServerId = serverId + 1 + mockOtherServer(otherServerId) + val messageFlow = connectableMessageFlow() + val viewModel = createViewModel() + messageFlow.emit(FrontendHandlerEvent.Connected) + advanceUntilIdle() + + viewModel.reloadFrontend(otherServerId) + advanceUntilIdle() + + verify { urlManager.serverUrlFlow(otherServerId, FrontendTarget.Default) } + } + @Test fun `Given gesture returns SwitchServer when handled then viewState transitions to new server`() = runTest { every { frontendBusObserver.messageResults() } returns emptyFlow() diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/WebViewActionTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/WebViewActionTest.kt index ea549694a6e..691dec7cfcc 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/WebViewActionTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/WebViewActionTest.kt @@ -14,6 +14,7 @@ import io.mockk.mockkObject import io.mockk.slot import io.mockk.unmockkObject import io.mockk.verify +import io.mockk.verifyOrder import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -69,6 +70,19 @@ class WebViewActionTest { assertTrue(action.result.isCompleted) } + @Test + fun `Given HardReload when run then the cache is cleared before reloading and result completes`() = runTest { + val action = WebViewAction.HardReload() + + action.run(webView) + + verifyOrder { + webView.clearCache(true) + webView.reload() + } + assertTrue(action.result.isCompleted) + } + @Test fun `Given Haptic when run then HapticFeedbackPerformer is invoked with the type and result completes`() = runTest { val action = WebViewAction.Haptic(HapticType.Success) diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendTargetTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendTargetTest.kt index 4696f6f67ec..813f816d6c8 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendTargetTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendTargetTest.kt @@ -4,6 +4,8 @@ import io.homeassistant.companion.android.frontend.navigation.FrontendTarget.Com import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource class FrontendTargetTest { @@ -24,6 +26,39 @@ class FrontendTargetTest { ) } + @ParameterizedTest + @ValueSource( + strings = [ + "entityId:sun.sun", + "entityId: sun.sun", + "entityid:sun.sun", + "ENTITYID: sun.sun", + " entityId:sun.sun ", + ], + ) + fun `Given a hand typed entity form when fromRawPath then maps to EntityMoreInfo`(raw: String) { + assertEquals(FrontendTarget.EntityMoreInfo("sun.sun"), FrontendTarget.fromRawPath(raw)) + } + + @ParameterizedTest + @ValueSource(strings = ["", " "]) + fun `Given a blank path when fromRawPath then maps to Default`(raw: String) { + assertEquals(FrontendTarget.Default, FrontendTarget.fromRawPath(raw)) + } + + @Test + fun `Given a path with surrounding spaces when fromRawPath then the path is trimmed`() { + assertEquals(FrontendTarget.Path("/lovelace/0"), FrontendTarget.fromRawPath(" /lovelace/0 ")) + } + + @Test + fun `Given a path only containing the entity prefix when fromRawPath then stays a Path`() { + assertEquals( + FrontendTarget.Path("/lovelace/entityId:foo"), + FrontendTarget.fromRawPath("/lovelace/entityId:foo"), + ) + } + @Test fun `Given any target when toLegacyPath then round-trips through fromRawPath`() { samples.forEach { target -> diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/launch/LaunchViewModelTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/launch/LaunchViewModelTest.kt index 7d37c7e57e3..2afe8e7de03 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/launch/LaunchViewModelTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/launch/LaunchViewModelTest.kt @@ -390,6 +390,75 @@ class LaunchViewModelTest { ) } + @Test + fun `Given initial deep link is ReloadFrontend when creating viewModel, then navigate to frontend at the default dashboard`() = runTest { + val serverId = 42 + val server = mockk(relaxed = true) + every { workManager.enqueue(any()) } returns mockk() + + coEvery { serverManager.getServer(serverId) } returns server + coEvery { serverManager.isRegistered() } returns true + coEvery { serverManager.authenticationRepository().getSessionState() } returns SessionState.CONNECTED + val networkStateFlow = MutableStateFlow(NetworkState.READY_NET_VALIDATED) + coEvery { networkStatusMonitor.observeNetworkStatus(any()) } returns networkStateFlow + + createViewModel(LaunchActivity.DeepLink.ReloadFrontend(serverId)) + advanceUntilIdle() + assertEquals( + LaunchUiState.Ready(FrontendRoute(FrontendTarget.Default, serverId)), + viewModel.uiState.value, + ) + } + + @Test + fun `Given deep links received while running when queued then only the latest is kept until handled`() = runTest { + createViewModel(null) + val first = LaunchActivity.DeepLink.ReloadFrontend(1) + val second = LaunchActivity.DeepLink.NavigateTo(FrontendTarget.Path("/x"), 1) + + viewModel.onNewDeepLink(first) + viewModel.onNewDeepLink(second) + assertEquals(second, viewModel.newDeepLink.value) + + viewModel.onNewDeepLinkHandled() + assertNull(viewModel.newDeepLink.value) + } + + @Test + fun `Given full Automotive when resolving a running deep link then the Automotive UI is kept`() = runTest { + createViewModel(null, isAutomotive = true, isFullFlavor = true) + + assertEquals( + AutomotiveRoute, + viewModel.newDeepLinkDestination(LaunchActivity.DeepLink.NavigateTo(FrontendTarget.Path("/x"), 1)), + ) + assertEquals( + AutomotiveRoute, + viewModel.newDeepLinkDestination(LaunchActivity.DeepLink.ReloadFrontend(1)), + ) + } + + @Test + fun `Given a phone when resolving a running deep link then the frontend is the destination`() = runTest { + createViewModel(null) + + assertEquals( + FrontendRoute(FrontendTarget.Path("/x"), 1), + viewModel.newDeepLinkDestination(LaunchActivity.DeepLink.NavigateTo(FrontendTarget.Path("/x"), 1)), + ) + assertEquals( + FrontendRoute(FrontendTarget.Default, 1), + viewModel.newDeepLinkDestination(LaunchActivity.DeepLink.ReloadFrontend(1)), + ) + } + + @Test + fun `Given a launch only deep link when resolving then there is no running destination`() = runTest { + createViewModel(null) + + assertNull(viewModel.newDeepLinkDestination(LaunchActivity.DeepLink.OpenInvitation("https://ha.test"))) + } + @Test fun `Given initial deep link is OpenWearOnboarding and full flavor, when creating viewModel, then navigate to wear onboarding`() = runTest { createViewModel( diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/notifications/MessagingManagerWebViewCommandTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/notifications/MessagingManagerWebViewCommandTest.kt new file mode 100644 index 00000000000..a1cbb9c739d --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/notifications/MessagingManagerWebViewCommandTest.kt @@ -0,0 +1,161 @@ +package io.homeassistant.companion.android.notifications + +import android.app.Application +import android.content.Intent +import android.os.Looper +import androidx.test.core.app.ApplicationProvider +import dagger.hilt.android.testing.HiltTestApplication +import io.homeassistant.companion.android.common.data.integration.IntegrationRepository +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.common.notifications.NotificationData +import io.homeassistant.companion.android.database.server.Server +import io.homeassistant.companion.android.frontend.navigation.FrontendTarget +import io.homeassistant.companion.android.launch.LaunchActivity +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowSettings + +private const val SERVER_ID = 1 +private const val WEBHOOK_ID = "webhook" + +/** + * Covers the routing of the webview command only, [MessagingManager] has no further coverage yet. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = HiltTestApplication::class) +class MessagingManagerWebViewCommandTest { + + private val application = ApplicationProvider.getApplicationContext() + private lateinit var messagingManager: MessagingManager + + @Before + fun setUp() { + ShadowSettings.setCanDrawOverlays(true) + val serverManager = mockk(relaxed = true) + val integrationRepository = mockk(relaxed = true) + val server = mockk(relaxed = true) { + every { id } returns SERVER_ID + } + coEvery { serverManager.getServer(WEBHOOK_ID) } returns server + coEvery { serverManager.getServer(SERVER_ID) } returns server + coEvery { serverManager.integrationRepository(any()) } returns integrationRepository + coEvery { integrationRepository.isTrusted() } returns true + + messagingManager = MessagingManager( + context = application, + okHttpClientProvider = mockk(relaxed = true), + serverManager = serverManager, + prefsRepository = mockk(relaxed = true), + notificationDao = mockk(relaxed = true), + sensorRepository = mockk(relaxed = true), + settingsDao = mockk(relaxed = true), + textToSpeechClient = mockk(relaxed = true), + flashlightHelper = mockk(relaxed = true), + permissionRequestMediator = mockk(relaxed = true), + assistConfigManager = mockk(relaxed = true), + defaultAssistantManager = mockk(relaxed = true), + bluetoothSensorManager = mockk(relaxed = true), + ) + } + + private fun handleWebViewCommand(path: String) { + messagingManager.handleMessage( + mapOf( + NotificationData.MESSAGE to MessagingManager.COMMAND_WEBVIEW, + NotificationData.COMMAND to path, + NotificationData.WEBHOOK_ID to WEBHOOK_ID, + ), + "FCM", + ) + shadowOf(Looper.getMainLooper()).idle() + } + + /** The deep link of the started [LaunchActivity] intent, asserting the intent shape. */ + private fun startedDeepLink(): LaunchActivity.DeepLink { + val intent = checkNotNull(shadowOf(application).nextStartedActivity) { "No activity was started" } + assertEquals(LaunchActivity::class.java.name, intent.component?.className) + // Delivered to a running activity instead of recreating it + val expectedFlags = + Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + assertEquals(expectedFlags, intent.flags and expectedFlags) + val deepLink = intent.extras?.keySet()?.firstNotNullOfOrNull { + @Suppress("DEPRECATION") + intent.extras?.get(it) as? LaunchActivity.DeepLink + } + return checkNotNull(deepLink) { "The started intent carries no deep link" } + } + + @Test + fun `Given a path when receiving webview command then the frontend is launched at the target`() { + handleWebViewCommand("/lovelace/cameras") + + assertEquals( + LaunchActivity.DeepLink.NavigateTo(FrontendTarget.Path("/lovelace/cameras"), SERVER_ID), + startedDeepLink(), + ) + } + + @Test + fun `Given an entity target when receiving webview command then the frontend is launched at its more info`() { + handleWebViewCommand("entityId:sun.sun") + + assertEquals( + LaunchActivity.DeepLink.NavigateTo(FrontendTarget.EntityMoreInfo("sun.sun"), SERVER_ID), + startedDeepLink(), + ) + } + + @Test + fun `Given an empty command when receiving webview command then the frontend is launched at the default dashboard`() { + handleWebViewCommand("") + + assertEquals( + LaunchActivity.DeepLink.NavigateTo(FrontendTarget.Default, SERVER_ID), + startedDeepLink(), + ) + } + + @Test + fun `Given a path with surrounding spaces when receiving webview command then the target is trimmed`() { + handleWebViewCommand(" /lovelace/cameras ") + + assertEquals( + LaunchActivity.DeepLink.NavigateTo(FrontendTarget.Path("/lovelace/cameras"), SERVER_ID), + startedDeepLink(), + ) + } + + @Test + fun `Given the reload command when receiving webview command then the frontend is reloaded`() { + handleWebViewCommand("reload") + + assertEquals(LaunchActivity.DeepLink.ReloadFrontend(SERVER_ID), startedDeepLink()) + } + + @Test + fun `Given the reload command in mixed case with spaces when receiving webview command then the frontend is reloaded`() { + handleWebViewCommand(" Reload ") + + assertEquals(LaunchActivity.DeepLink.ReloadFrontend(SERVER_ID), startedDeepLink()) + } + + @Test + fun `Given no overlay permission when receiving webview command then no activity is started`() { + ShadowSettings.setCanDrawOverlays(false) + + handleWebViewCommand("/lovelace/cameras") + + assertNull(shadowOf(application).nextStartedActivity) + assertTrue(shadowOf(application).broadcastIntents.isEmpty()) + } +}