Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
Expand Down Expand Up @@ -147,6 +148,8 @@ internal fun FrontendScreen(
val keepScreenOnEnabled by viewModel.keepScreenOnEnabled.collectAsStateWithLifecycle()
val improvScanRequested by viewModel.improvScanRequested.collectAsStateWithLifecycle()

FrontendVisibleLifecycleEffect(viewModel::setFrontendVisible)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Look at the pattern in this file we already have a Effect high level composable where to put such Effect.


// The fullscreen View handed over by the WebView is Activity-scoped. Keep it in screen
// state so it does not leak across configuration changes via the ViewModel.
var customView by remember { mutableStateOf<View?>(null) }
Expand Down Expand Up @@ -425,6 +428,16 @@ internal fun WebViewStopLifecycleEffect(webView: WebView?) {
}
}

/** Publishes whether the frontend is shown while the lifecycle is started. */
@VisibleForTesting
@Composable
internal fun FrontendVisibleLifecycleEffect(setFrontendVisible: (Boolean) -> Unit) {
LifecycleStartEffect(setFrontendVisible) {
setFrontendVisible(true)
onStopOrDispose { setFrontendVisible(false) }
}
}

/**
* Calls [onLeavingApp] with the WebView's current URL when the host activity stops (the app leaves
* the foreground). Uses the activity lifecycle — not the navigation back-stack entry — so in-app
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ 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.ReloadRequestMediator
import io.homeassistant.companion.android.util.WebViewNavigationMediator
import io.homeassistant.companion.android.util.hasSameOrigin
import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
Expand All @@ -74,6 +76,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
Expand Down Expand Up @@ -129,6 +132,8 @@ internal class FrontendViewModel @VisibleForTesting constructor(
private val improvHandler: FrontendImprovHandler,
private val barcodeScannerHandler: FrontendBarcodeScannerHandler,
private val matterThreadHandler: FrontendMatterThreadHandler,
private val reloadRequestMediator: ReloadRequestMediator,
private val webViewNavigationMediator: WebViewNavigationMediator,
@NamedKeyChain private val keyChainRepository: KeyChainRepository,
) : ViewModel(),
FrontendConnectionErrorStateProvider {
Expand All @@ -154,6 +159,8 @@ internal class FrontendViewModel @VisibleForTesting constructor(
improvHandler: FrontendImprovHandler,
barcodeScannerHandler: FrontendBarcodeScannerHandler,
matterThreadHandler: FrontendMatterThreadHandler,
reloadRequestMediator: ReloadRequestMediator,
webViewNavigationMediator: WebViewNavigationMediator,
@NamedKeyChain keyChainRepository: KeyChainRepository,
) : this(
initialServerId = savedStateHandle.toRoute<FrontendRoute>().serverId,
Expand All @@ -176,6 +183,8 @@ internal class FrontendViewModel @VisibleForTesting constructor(
improvHandler = improvHandler,
barcodeScannerHandler = barcodeScannerHandler,
matterThreadHandler = matterThreadHandler,
reloadRequestMediator = reloadRequestMediator,
webViewNavigationMediator = webViewNavigationMediator,
keyChainRepository = keyChainRepository,
)

Expand Down Expand Up @@ -306,6 +315,8 @@ internal class FrontendViewModel @VisibleForTesting constructor(
*/
private var pendingMoreInfoEntityId: String? = null

private var isFrontendVisible = false

/**
* The user's "Autoplay video" preference.
*
Expand Down Expand Up @@ -358,6 +369,35 @@ internal class FrontendViewModel @VisibleForTesting constructor(
}
}

viewModelScope.launch {
reloadRequestMediator.eventFlow.collect {
// Dropping the cache while the page is still loading can wedge the load
_webViewActions.emit(
if (_viewState.value is FrontendViewState.Content) {
WebViewAction.HardReload()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why doing a hard reload?

} else {
WebViewAction.Reload()
},
)
}
}

viewModelScope.launch {
// Requests are only made for servers supporting navigate, no version check is needed.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are assuming that the mediator is always going to gate the version, but it might change in the future.

// Bus messages sent before the frontend handshake are lost, so each request waits for
// it and only the latest request is kept while waiting
webViewNavigationMediator.navigationRequests.collectLatest { target ->

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get your idea here, but I think to be more generic we should not rely on an in memory flow that notify this VM. Instead I would prefer to use a deep link and not restarting the LaunchActivity, and having the notification command open the deep link. This way it is more flexible, to do that we need to handle on the LaunchActivity new intent coming in, I didn't check how feasible it is.

_viewState.first { it is FrontendViewState.Content }
when (target) {
is FrontendTarget.EntityMoreInfo -> _webViewActions.emit(
WebViewAction.OpenMoreInfo(target.entityId),
)
is FrontendTarget.Path -> externalBusRepository.send(NavigateToMessage(path = target.path))
FrontendTarget.Default -> externalBusRepository.send(NavigateToMessage(path = "/"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not using navigateToDefaultDashboard?

}
}
}

viewModelScope.launch {
frontendBusObserver.messageResults().collect { result ->
handleMessageResult(result)
Expand Down Expand Up @@ -543,6 +583,9 @@ internal class FrontendViewModel @VisibleForTesting constructor(
_viewState.update {
FrontendViewState.LoadServer(serverId = serverId)
}
if (isFrontendVisible) {
webViewNavigationMediator.setVisibleServer(serverId)
}
loadServer()
}

Expand Down Expand Up @@ -682,6 +725,12 @@ internal class FrontendViewModel @VisibleForTesting constructor(
}
}

/** Publishes the shown server while the frontend is visible so the webview command can act on it in place. */
fun setFrontendVisible(visible: Boolean) {
isFrontendVisible = visible
webViewNavigationMediator.setVisibleServer(_viewState.value.serverId.takeIf { visible })
}

/**
* Clears the WebView history and navigates the frontend to the server's default dashboard.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Unit> = CompletableDeferred()) :
AwaitableAction<Unit> {
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<Unit> = CompletableDeferred()) :
AwaitableAction<Unit> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import io.homeassistant.companion.android.database.notification.NotificationDao
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.externalbus.outgoing.NavigateToMessage
import io.homeassistant.companion.android.frontend.navigation.FrontendTarget
import io.homeassistant.companion.android.launch.intentLaunchWithNavigateTo
import io.homeassistant.companion.android.sensors.LocationSensorManager
Expand All @@ -96,7 +97,9 @@ import io.homeassistant.companion.android.settings.assist.AssistConfigManager
import io.homeassistant.companion.android.settings.assist.DefaultAssistantManager
import io.homeassistant.companion.android.util.FlashlightHelper
import io.homeassistant.companion.android.util.PermissionRequestMediator
import io.homeassistant.companion.android.util.ReloadRequestMediator
import io.homeassistant.companion.android.util.UrlUtil
import io.homeassistant.companion.android.util.WebViewNavigationMediator
import io.homeassistant.companion.android.util.sensitive
import io.homeassistant.companion.android.vehicle.HaCarAppService
import io.homeassistant.companion.android.websocket.WebsocketManager
Expand Down Expand Up @@ -132,6 +135,8 @@ class MessagingManager @Inject constructor(
private val textToSpeechClient: TextToSpeechClient,
private val flashlightHelper: FlashlightHelper,
private val permissionRequestMediator: PermissionRequestMediator,
private val reloadRequestMediator: ReloadRequestMediator,
private val webViewNavigationMediator: WebViewNavigationMediator,
private val assistConfigManager: AssistConfigManager,
private val defaultAssistantManager: DefaultAssistantManager,
private val bluetoothSensorManager: BluetoothSensorManager,
Expand All @@ -142,6 +147,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"

Expand Down Expand Up @@ -816,10 +822,14 @@ class MessagingManager @Inject constructor(
}

COMMAND_WEBVIEW -> {
if (!Settings.canDrawOverlays(context)) {
notifyMissingPermission(message, serverId)
} else {
openWebview(command, data)
val isReload = WEBVIEW_RELOAD.equals(command?.trim(), ignoreCase = true)
when {
isReload && isFrontendVisible(serverId) -> reloadRequestMediator.emitReloadRequestEvent()
!isReload && canNavigateFrontendInApp(serverId = serverId, path = command) ->
webViewNavigationMediator.requestNavigation(FrontendTarget.fromRawPath(command))
!Settings.canDrawOverlays(context) -> notifyMissingPermission(message, serverId)
// A fresh launch is already reloaded, so reload falls back to the default dashboard
else -> openWebview(title = command?.takeUnless { isReload }, data = data)
}
}

Expand Down Expand Up @@ -1996,17 +2006,31 @@ class MessagingManager @Inject constructor(
}
}

private fun isFrontendVisible(serverId: String): Boolean {
val targetServerId = serverId.toIntOrNull() ?: return false
return webViewNavigationMediator.visibleServerId.value == targetServerId
}

private suspend fun canNavigateFrontendInApp(serverId: String, path: String?): Boolean {
if (!isFrontendVisible(serverId)) {
return false
}
val targetServerId = serverId.toIntOrNull() ?: return false
if (!NavigateToMessage.isAvailable(serverManager.getServer(targetServerId)?.version)) {
return false
}
// Trimmed and matched ignoring case since the value is typed by hand
val target = path?.trim().orEmpty()
val launchPrefixes = listOf(APP_PREFIX, INTENT_PREFIX, SETTINGS_PREFIX, DEEP_LINK_PREFIX)
return launchPrefixes.none { target.startsWith(it, ignoreCase = true) } && !UrlUtil.isAbsoluteUrl(target)
}

private fun openWebview(title: String?, data: Map<String, String>) {
try {
val serverId = data[THIS_SERVER_ID]!!.toInt()
val intent = if (title.isNullOrEmpty()) {
context.intentLaunchWithNavigateTo(FrontendTarget.Default, serverId)
} else {
context.intentLaunchWithNavigateTo(FrontendTarget.fromRawPath(title), serverId)
}
val intent = 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)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
context.startActivity(intent)
} catch (e: Exception) {
Timber.e(e, "Unable to open webview")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package io.homeassistant.companion.android.util

import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow

/**
* Carries reload requests from the server commands to the frontend. Requests emitted while the
* frontend is not open are dropped since there is nothing to reload.
*/
@Singleton
class ReloadRequestMediator @Inject constructor() {

private val _eventFlow = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val eventFlow = _eventFlow.asSharedFlow()

fun emitReloadRequestEvent() {
_eventFlow.tryEmit(Unit)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package io.homeassistant.companion.android.util

import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.frontend.navigation.FrontendTarget
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow

/**
* Carries webview navigation requests from the server commands to the visible frontend.
*/
@Singleton
class WebViewNavigationMediator @Inject constructor() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you really need to know if the frontend is visible??


private val _visibleServerId = MutableStateFlow<Int?>(null)

/** The server shown by the visible frontend, or `null` when no frontend is visible. */
val visibleServerId: StateFlow<Int?> = _visibleServerId.asStateFlow()

private val _navigationRequests = MutableSharedFlow<FrontendTarget>(extraBufferCapacity = 1)
val navigationRequests = _navigationRequests.asSharedFlow()

/** [ServerManager.SERVER_ID_ACTIVE] is normalized to `null` since it does not identify a server. */
fun setVisibleServer(serverId: Int?) {
_visibleServerId.value = serverId?.takeUnless { it == ServerManager.SERVER_ID_ACTIVE }
}

fun requestNavigation(target: FrontendTarget) {
_navigationRequests.tryEmit(target)
}
}
Loading