Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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 @@ -54,6 +54,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.DpSize
Expand Down Expand Up @@ -259,6 +260,13 @@ class WebViewActivity :
private var playerLeft = mutableStateOf(0.dp)
private var statusBarColor = mutableStateOf<Color?>(null)
private var backgroundColor = mutableStateOf<Color?>(null)

/**
* Flag to know when the webview as been fully initialized (loadUrl called).
* It is important to know to avoid opening a full screen dialog that
* could prevent the loading of the webview.
*/
private var webViewInitialized = mutableStateOf(false)
private var failedConnection = "external"
private var clearHistory = false
private var moreInfoEntity = ""
Expand Down Expand Up @@ -309,6 +317,7 @@ class WebViewActivity :
}

setContent {
val coroutineScope = rememberCoroutineScope()
val player by remember { exoPlayer }
val playerSize by remember { playerSize }
val playerTop by remember { playerTop }
Expand All @@ -319,9 +328,12 @@ class WebViewActivity :
val backgroundColor by remember { backgroundColor }
var nightModeTheme by remember { mutableStateOf<NightModeTheme?>(null) }
val snackbarHostState = remember { snackbarHostState }
var webViewInitialized by remember { webViewInitialized }
var shouldAskForNotificationPermissionIfNeeded by remember { mutableStateOf(false) }

LaunchedEffect(Unit) {
nightModeTheme = nightModeManager.getCurrentNightMode()
shouldAskForNotificationPermissionIfNeeded = presenter.shouldAskForNotificationPermissionIfNeeded()
}

WebViewContentScreen(
Expand All @@ -333,13 +345,21 @@ class WebViewActivity :
playerLeft = playerLeft,
currentAppLocked,
customViewFromWebView,
shouldAskForNotificationPermissionIfNeeded = shouldAskForNotificationPermissionIfNeeded,
webViewInitialized = webViewInitialized,
nightModeTheme = nightModeTheme,
statusBarColor = statusBarColor,
backgroundColor = backgroundColor,
) { isFullScreen ->
isExoFullScreen = isFullScreen
if (isFullScreen) hideSystemUI() else showSystemUI()
}
onFullscreenClicked = { isFullScreen ->
isExoFullScreen = isFullScreen
if (isFullScreen) hideSystemUI() else showSystemUI()
},
onDiscardNotificationPermission = {
coroutineScope.launch {
presenter.discardNotificationPermission()
}
},
)
}

authenticator = Authenticator(this, this, ::authenticationResult)
Expand Down Expand Up @@ -401,6 +421,7 @@ class WebViewActivity :
}

override fun onPageFinished(view: WebView?, url: String?) {
webViewInitialized.value = true
if (clearHistory) {
webView.clearHistory()
clearHistory = false
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package io.homeassistant.companion.android.webview

import android.Manifest
import android.annotation.SuppressLint
import android.view.View
import android.webkit.WebView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
Expand All @@ -22,29 +24,48 @@ import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.Player
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionStatus
import com.google.accompanist.permissions.rememberPermissionState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
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.HAModalBottomSheet
import io.homeassistant.companion.android.common.compose.composable.HAPlainButton
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.HATheme
import io.homeassistant.companion.android.common.data.prefs.NightModeTheme
import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme
import io.homeassistant.companion.android.util.compose.media.player.HAMediaPlayer
import io.homeassistant.companion.android.util.compose.webview.HAWebView
import kotlinx.coroutines.launch

@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(ExperimentalHazeMaterialsApi::class)
Expand All @@ -58,18 +79,20 @@ internal fun WebViewContentScreen(
playerLeft: Dp,
currentAppLocked: Boolean,
customViewFromWebView: View?,
shouldAskForNotificationPermissionIfNeeded: Boolean,
webViewInitialized: Boolean,
onFullscreenClicked: (isFullscreen: Boolean) -> Unit,
onDiscardNotificationPermission: () -> Unit,
nightModeTheme: NightModeTheme? = null,
statusBarColor: Color? = null,
backgroundColor: Color? = null,
onFullscreenClicked: (isFullscreen: Boolean) -> Unit,
) {
HomeAssistantAppTheme {
HATheme {
Scaffold(
snackbarHost = {
SnackbarHost(
snackbarHostState,
modifier =
Modifier.windowInsetsPadding(WindowInsets.safeDrawing),
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing),
)
},
// Delegate the insets handling to the webview
Expand Down Expand Up @@ -107,6 +130,9 @@ internal fun WebViewContentScreen(
}
}
}
if (webViewInitialized && shouldAskForNotificationPermissionIfNeeded) {
NotificationPermission(onDiscardNotificationPermission)
}
}
}

Expand Down Expand Up @@ -165,6 +191,82 @@ private fun SafeHAWebView(
}
}

@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
@Composable
private fun NotificationPermission(onDiscardNotificationPermission: () -> Unit) {
val bottomSheetState = rememberStandardBottomSheetState(skipHiddenState = false)
val coroutineScope = rememberCoroutineScope()

// Track whether the bottom sheet has been dismissed to completely remove it from composition.
// This is necessary because Material 3's ModalBottomSheet creates a Dialog window that,
// even when hidden, can block touch events to underlying views like the WebView.
// By removing the composable entirely when dismissed (checking !isClosed), we ensure
// the Dialog window is destroyed and the WebView remains fully interactive.
var isClosed by remember { mutableStateOf(false) }

fun closeSheet() {
coroutineScope.launch {
bottomSheetState.hide()
isClosed = true
}
}

// By default on lower API the bottom sheet won't be displayed
@SuppressLint("InlinedApi")
val notificationPermission = rememberPermissionState(
permission = Manifest.permission.POST_NOTIFICATIONS,
previewPermissionStatus = PermissionStatus.Denied(true),
onPermissionResult = {
closeSheet()
},
)

if (notificationPermission.status is PermissionStatus.Denied && !isClosed) {
HAModalBottomSheet(
bottomSheetState = bottomSheetState,
onDismissRequest = {
closeSheet()
},
) {
Column(
modifier = Modifier
.padding(horizontal = HADimens.SPACE6)
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom))
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(HADimens.SPACE6),
) {
Text(
text = stringResource(commonR.string.notification_permission_dialog_title),
style = HATextStyle.Headline,
)
Text(
text = stringResource(commonR.string.notification_permission_dialog_content),
style = HATextStyle.Body,
)
HAAccentButton(
text = stringResource(commonR.string.notification_permission_dialog_allow),
onClick = {
notificationPermission.launchPermissionRequest()
},
modifier = Modifier.fillMaxWidth(),
)

HAPlainButton(
text = stringResource(commonR.string.notification_permission_dialog_denied),
onClick = {
onDiscardNotificationPermission()
closeSheet()
},
modifier = Modifier
.fillMaxWidth()
.padding(bottom = HADimens.SPACE6),
)
}
}
}
}

@Composable
private fun Color.Overlay(modifier: Modifier) {
Spacer(
Expand All @@ -184,6 +286,10 @@ private fun WebViewContentScreenPreview() {
playerTop = 0.dp,
playerLeft = 0.dp,
currentAppLocked = false,
shouldAskForNotificationPermissionIfNeeded = false,
webViewInitialized = true,
customViewFromWebView = null,
) { }
onFullscreenClicked = {},
onDiscardNotificationPermission = {},
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,7 @@ interface WebViewPresenter {
/** @return `true` if the app tried starting scanning or `false` if it was missing permissions */
fun startScanningForImprov(): Boolean
fun stopScanningForImprov(force: Boolean)

suspend fun discardNotificationPermission()
suspend fun shouldAskForNotificationPermissionIfNeeded(): Boolean
}
Original file line number Diff line number Diff line change
Expand Up @@ -581,4 +581,12 @@ class WebViewPresenterImpl @Inject constructor(
improvJob?.cancel()
}
}

override suspend fun discardNotificationPermission() {
prefsRepository.setAskNotificationPermission(false)
}

override suspend fun shouldAskForNotificationPermissionIfNeeded(): Boolean {
return prefsRepository.shouldAskNotificationPermission()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,12 @@ class WebViewContentScreenScreenshotTest {
playerTop = 0.dp,
playerLeft = 0.dp,
currentAppLocked = false,
shouldAskForNotificationPermissionIfNeeded = false,
webViewInitialized = true,
customViewFromWebView = null,
) { }
onFullscreenClicked = {},
onDiscardNotificationPermission = {},
)
}

@PreviewTest
Expand All @@ -38,8 +42,12 @@ class WebViewContentScreenScreenshotTest {
playerTop = 0.dp,
playerLeft = 0.dp,
currentAppLocked = true,
shouldAskForNotificationPermissionIfNeeded = false,
webViewInitialized = true,
customViewFromWebView = null,
) { }
onFullscreenClicked = {},
onDiscardNotificationPermission = {},
)
}

@PreviewTest
Expand All @@ -54,7 +62,32 @@ class WebViewContentScreenScreenshotTest {
playerTop = 50.dp,
playerLeft = 10.dp,
currentAppLocked = false,
shouldAskForNotificationPermissionIfNeeded = false,
webViewInitialized = true,
customViewFromWebView = null,
) { }
onFullscreenClicked = {},
onDiscardNotificationPermission = {},
)
}

@PreviewTest
@Preview
@Composable
fun `WebView request notification permission`() {
// TODO fix it
WebViewContentScreen(
webView = null,
player = null,
snackbarHostState = SnackbarHostState(),
playerSize = null,
playerTop = 0.dp,
playerLeft = 0.dp,
currentAppLocked = false,
shouldAskForNotificationPermissionIfNeeded = true,
webViewInitialized = true,
customViewFromWebView = null,
onFullscreenClicked = {},
onDiscardNotificationPermission = {},
)
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,8 @@ interface PrefsRepository {
suspend fun showPrivacyHint(): Boolean

suspend fun setShowPrivacyHint(showPrivacyHint: Boolean)

suspend fun setAskNotificationPermission(shouldAsk: Boolean)

suspend fun shouldAskNotificationPermission(): Boolean
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ private const val PREF_IMPROV_PERMISSION_DISPLAYED = "improv_permission_displaye
private const val PREF_GESTURE_ACTION_PREFIX = "gesture_action"
private const val PREF_CHANGE_LOG_POPUP_ENABLED = "change_log_popup_enabled"
private const val PREF_SHOW_PRIVACY_HINT = "show_privacy_hint"
private const val PREF_ASK_NOTIFICATION_PERMISSION = "ask_notification_permission"

/**
* This class ensure that when we use the local storage in [PrefsRepositoryImpl] the migrations has been made
Expand Down Expand Up @@ -363,4 +364,12 @@ class PrefsRepositoryImpl @Inject constructor(
override suspend fun setShowPrivacyHint(showPrivacyHint: Boolean) {
localStorage().putBoolean(PREF_SHOW_PRIVACY_HINT, showPrivacyHint)
}

override suspend fun setAskNotificationPermission(shouldAsk: Boolean) {
localStorage().putBoolean(PREF_ASK_NOTIFICATION_PERMISSION, shouldAsk)
}

override suspend fun shouldAskNotificationPermission(): Boolean {
return localStorage().getBooleanOrNull(PREF_ASK_NOTIFICATION_PERMISSION) ?: true
}
}
4 changes: 4 additions & 0 deletions common/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1493,4 +1493,8 @@
<string name="connection_error_github_content_description">Home Assistant GitHub</string>
<string name="connection_error_unknown_error">There was an error loading Home Assistant. Please review the connection settings and try again.</string>
<string name="back">Back</string>
<string name="notification_permission_dialog_title">Allow notifications?</string>
<string name="notification_permission_dialog_content">Enable notifications and get what\'s happening in your home, from detecting leaks to doors left open, you have full control over what it tells you.</string>
Copy link
Member

Choose a reason for hiding this comment

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

I get that this is copied from iOS but it seems like a very long sentence to me + the reference to 'it' in the last part is unclear. @dshokouhi Can you give native speaker input please? 😇

Suggestion:

Suggested change
<string name="notification_permission_dialog_content">Enable notifications and get what\'s happening in your home, from detecting leaks to doors left open, you have full control over what it tells you.</string>
<string name="notification_permission_dialog_content">Enable notifications and get what\'s happening in your home, from detecting leaks to doors left open. You have full control over the notifications you receive and what they tell you.</string>

Copy link
Member Author

Choose a reason for hiding this comment

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

I've applied the change but I keep this open in case anyone wants to give his opinion.

<string name="notification_permission_dialog_allow">Allow notifications</string>
<string name="notification_permission_dialog_denied">Do not allow</string>
</resources>