Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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)
Comment thread
markfrancisonly marked this conversation as resolved.
Outdated

// 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 Down Expand Up @@ -129,6 +131,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 +158,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 +182,8 @@ internal class FrontendViewModel @VisibleForTesting constructor(
improvHandler = improvHandler,
barcodeScannerHandler = barcodeScannerHandler,
matterThreadHandler = matterThreadHandler,
reloadRequestMediator = reloadRequestMediator,
webViewNavigationMediator = webViewNavigationMediator,
keyChainRepository = keyChainRepository,
)

Expand Down Expand Up @@ -306,6 +314,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 +368,25 @@ internal class FrontendViewModel @VisibleForTesting constructor(
}
}

viewModelScope.launch {
reloadRequestMediator.eventFlow.collect {
_webViewActions.emit(WebViewAction.HardReload())
}
}
Comment thread
markfrancisonly marked this conversation as resolved.
Outdated

viewModelScope.launch {
// Requests are only made for servers supporting navigate, no version check is needed
webViewNavigationMediator.navigationRequests.collect { target ->
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 = "/"))
Comment thread
markfrancisonly marked this conversation as resolved.
Outdated
}
}
}
Comment thread
markfrancisonly marked this conversation as resolved.
Outdated

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

Expand Down Expand Up @@ -682,6 +714,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 @@ -28,10 +28,15 @@ sealed interface FrontendTarget : Parcelable {
*
* A `null` 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 == null -> 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(path)
Comment thread
markfrancisonly marked this conversation as resolved.
Outdated
}
}

/**
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,20 @@ 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?.ifBlank {
null
},
),
)
!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,6 +2012,29 @@ 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
}
return path.isNullOrEmpty() ||
(
!path.startsWith(APP_PREFIX) &&
!path.startsWith(INTENT_PREFIX) &&
!path.startsWith(SETTINGS_PREFIX) &&
!path.startsWith(DEEP_LINK_PREFIX) &&
!UrlUtil.isAbsoluteUrl(path)
)
Comment thread
markfrancisonly marked this conversation as resolved.
Outdated
}

private fun openWebview(title: String?, data: Map<String, String>) {
try {
val serverId = data[THIS_SERVER_ID]!!.toInt()
Expand All @@ -2005,8 +2044,7 @@ class MessagingManager @Inject constructor(
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() {
Comment thread
markfrancisonly marked this conversation as resolved.
Outdated

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