tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit a69ab4fc578f5ad2c6d24bc55da1d08e6d41542e
parent df6809ea3938e70cfea8ebf1485f46145d509a39
Author: andrei popa <anpopa@mozilla.com>
Date:   Mon,  6 Oct 2025 12:33:10 +0000

Bug 1986429 - Re-prompt for notification opt-in when accepting web notifications r=android-reviewers,android-l10n-reviewers,twhite,delphine

Differential Revision: https://phabricator.services.mozilla.com/D263369

Diffstat:
Mmobile/android/android-components/components/feature/sitepermissions/build.gradle | 16+++++++++++++++-
Amobile/android/android-components/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/NotificationPermissionDialogFragment.kt | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amobile/android/android-components/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/PermissionDialog.kt | 128+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmobile/android/android-components/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/SitePermissionsDialogFragment.kt | 40+++++++++++++++++++++++++++++++++++++++-
Mmobile/android/android-components/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/SitePermissionsFeature.kt | 10+++++++++-
Amobile/android/android-components/components/feature/sitepermissions/src/main/res/drawable/ic_system_permission_dialog.xml | 13+++++++++++++
Mmobile/android/android-components/components/feature/sitepermissions/src/main/res/values/strings.xml | 7++++++-
Mmobile/android/android-components/components/feature/sitepermissions/src/test/java/mozilla/components/feature/sitepermissions/SitePermissionsDialogFragmentTest.kt | 17++++++++++++++---
Mmobile/android/android-components/components/feature/sitepermissions/src/test/java/mozilla/components/feature/sitepermissions/SitePermissionsFeatureTest.kt | 9+++++++--
Mmobile/android/android-components/docs/changelog.md | 2++
10 files changed, 341 insertions(+), 9 deletions(-)

diff --git a/mobile/android/android-components/components/feature/sitepermissions/build.gradle b/mobile/android/android-components/components/feature/sitepermissions/build.gradle @@ -2,7 +2,9 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +plugins { + alias(libs.plugins.kotlin.compose) +} apply plugin: 'com.android.library' apply plugin: 'kotlin-android' @@ -29,6 +31,10 @@ android { } namespace = 'mozilla.components.feature.sitepermissions' + + buildFeatures { + compose = true + } } dependencies { @@ -38,6 +44,14 @@ dependencies { implementation project(':components:support-ktx') implementation project(':components:support-utils') implementation project(':components:ui-icons') + implementation project(':components:compose-base') + + implementation platform(libs.androidx.compose.bom) + implementation libs.androidx.compose.foundation + implementation libs.androidx.compose.ui.tooling.preview + implementation libs.androidx.compose.ui + implementation libs.androidx.compose.material3 + debugImplementation libs.androidx.compose.ui.tooling implementation libs.androidx.constraintlayout implementation libs.androidx.core.ktx diff --git a/mobile/android/android-components/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/NotificationPermissionDialogFragment.kt b/mobile/android/android-components/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/NotificationPermissionDialogFragment.kt @@ -0,0 +1,108 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.feature.sitepermissions + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.DialogFragment +import mozilla.components.compose.base.theme.AcornTheme +import mozilla.components.compose.base.theme.acornDarkColorScheme +import mozilla.components.compose.base.theme.acornLightColorScheme + +/** + * A dialog to be displayed to explain to the user why notification access is required. + * It is intended to be shown when the application has already obtained site-level permission but also + * needs the corresponding system-level permission. + */ +class NotificationPermissionDialogFragment(val positiveButtonAction: () -> Unit) : + DialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + with(requireBundle()) { + val title = getString(KEY_TITLE_STRING, "") + val message = getString(KEY_MESSAGE_STRING, "") + val positiveButtonLabel = getString(KEY_POSITIVE_TEXT, "") + val negativeButtonLabel = getString(KEY_NEGATIVE_TEXT, "") + + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + + setContent { + val colors = + if (isSystemInDarkTheme()) acornDarkColorScheme() else acornLightColorScheme() + + AcornTheme(colorScheme = colors) { + PermissionDialog( + icon = R.drawable.ic_system_permission_dialog, + title = title, + message = message, + positiveButtonLabel = positiveButtonLabel, + negativeButtonLabel = negativeButtonLabel, + onConfirmRequest = positiveButtonAction, + dialogAbuseMillisLimit = 500, + onDismissRequest = { dismiss() }, + ) + } + } + } + } + } + + /** + * Static functionality of [NotificationPermissionDialogFragment]. + */ + companion object { + /** + * A builder method for creating a [NotificationPermissionDialogFragment] + */ + fun newInstance( + dialogTitleString: String, + dialogMessageString: String, + positiveButtonText: String, + negativeButtonText: String, + positiveButtonAction: () -> Unit, + ): NotificationPermissionDialogFragment { + val fragment = NotificationPermissionDialogFragment(positiveButtonAction) + val arguments = fragment.arguments ?: Bundle() + + with(arguments) { + putString(KEY_TITLE_STRING, dialogTitleString) + + putString(KEY_MESSAGE_STRING, dialogMessageString) + + putString(KEY_POSITIVE_TEXT, positiveButtonText) + + putString(KEY_NEGATIVE_TEXT, negativeButtonText) + } + + fragment.arguments = arguments + + return fragment + } + + private const val KEY_POSITIVE_TEXT = "KEY_POSITIVE_TEXT" + + private const val KEY_NEGATIVE_TEXT = "KEY_NEGATIVE_TEXT" + + private const val KEY_TITLE_STRING = "KEY_TITLE_STRING" + + private const val KEY_MESSAGE_STRING = "KEY_MESSAGE_STRING" + + const val FRAGMENT_TAG = "NOTIFICATION_PERMISSION_DIALOG_FRAGMENT" + } + + private fun requireBundle(): Bundle { + return arguments ?: throw IllegalStateException("Fragment $this arguments is not set.") + } +} diff --git a/mobile/android/android-components/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/PermissionDialog.kt b/mobile/android/android-components/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/PermissionDialog.kt @@ -0,0 +1,128 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.feature.sitepermissions + +import androidx.annotation.DrawableRes +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import mozilla.components.compose.base.theme.AcornTheme +import mozilla.components.support.ktx.util.PromptAbuserDetector + +/** + * Reusable composable for a permission dialog. + * Includes a [PromptAbuserDetector] to better control dialog abuse. + * + * @param title Text displayed as the dialog title. + * @param message The message text providing additional information. + * @param icon Optional drawable resource for an icon. + * @param positiveButtonLabel Text label for the positive action button. + * @param negativeButtonLabel Text label for the negative action button. + * @param dialogAbuseMillisLimit Represents a customized timeout used to avoid prompt abuse. + * @param onConfirmRequest Action to perform when the positive button is clicked. + * @param onDismissRequest Action to perform on dialog dismissal. + */ +@Composable +fun PermissionDialog( + title: String, + message: String, + @DrawableRes icon: Int? = null, + positiveButtonLabel: String, + negativeButtonLabel: String, + dialogAbuseMillisLimit: Int = 0, + onConfirmRequest: () -> Unit, + onDismissRequest: () -> Unit, +) { + val promptAbuserDetector = + PromptAbuserDetector(dialogAbuseMillisLimit) + + LaunchedEffect(Unit) { + promptAbuserDetector.updateJSDialogAbusedState() + } + + AlertDialog( + icon = { + icon?.let { + Icon( + painter = painterResource(icon), + contentDescription = null, + tint = AcornTheme.colors.iconSecondary, + ) + } + }, + title = { + Text( + text = title, + textAlign = TextAlign.Center, + color = AcornTheme.colors.formDefault, + ) + }, + text = { + Text(text = message) + }, + confirmButton = { + DialogButton(text = positiveButtonLabel) { + if (promptAbuserDetector.areDialogsBeingAbused()) { + promptAbuserDetector.updateJSDialogAbusedState() + } else { + onConfirmRequest() + onDismissRequest() + } + } + }, + dismissButton = { + DialogButton( + text = negativeButtonLabel, + onClick = onDismissRequest, + ) + }, + onDismissRequest = onDismissRequest, + ) +} + +/** + * Reusable composable for a dialog button with text. + */ +@Composable +private fun DialogButton( + text: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onClick: () -> Unit, +) { + TextButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + ) { + Text( + modifier = modifier, + text = text, + ) + } +} + +@Preview +@Composable +private fun PermissionDialogPreview() { + AcornTheme { + PermissionDialog( + icon = R.drawable.ic_system_permission_dialog, + message = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer sodales laoreet commodo.", + title = "Dialog title", + positiveButtonLabel = "Go to settings", + negativeButtonLabel = "Cancel", + onConfirmRequest = { }, + onDismissRequest = { }, + ) + } +} diff --git a/mobile/android/android-components/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/SitePermissionsDialogFragment.kt b/mobile/android/android-components/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/SitePermissionsDialogFragment.kt @@ -7,8 +7,11 @@ package mozilla.components.feature.sitepermissions import android.annotation.SuppressLint import android.app.Dialog import android.content.DialogInterface +import android.content.Intent +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.graphics.Color import android.os.Bundle +import android.provider.Settings import android.view.LayoutInflater import android.view.View import android.view.View.VISIBLE @@ -22,9 +25,11 @@ import android.widget.TextView import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AppCompatDialogFragment import androidx.appcompat.content.res.AppCompatResources +import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toDrawable import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.ktx.android.content.appName import mozilla.components.support.ktx.kotlin.ifNullOrEmpty import mozilla.components.support.ktx.util.PromptAbuserDetector @@ -182,7 +187,9 @@ internal open class SitePermissionsDialogFragment : AppCompatDialogFragment() { permissionRequestId, sessionId, userSelectionCheckBox, - ) + ) { + if (!areSystemNotificationsEnabled()) showSettingsPrompt() + } dismiss() } } @@ -225,6 +232,37 @@ internal open class SitePermissionsDialogFragment : AppCompatDialogFragment() { return rootView } + private fun areSystemNotificationsEnabled() = + NotificationManagerCompat.from(requireContext()).areNotificationsEnabled() + + private fun showSettingsPrompt() { + with(requireContext()) { + NotificationPermissionDialogFragment.newInstance( + dialogTitleString = title, + dialogMessageString = getString( + R.string.mozac_feature_sitepermissions_notification_permission_rationale_dialog_message, + appName, + ), + positiveButtonText = getString( + R.string.mozac_feature_sitepermissions_notification_permission_rationale_dialog_settings_label, + ), + negativeButtonText = getString( + R.string.mozac_feature_sitepermissions_notification_permission_rationale_dialog_dismiss_label, + ), + positiveButtonAction = { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + flags = FLAG_ACTIVITY_NEW_TASK + } + startActivity(intent) + }, + ).showNow( + parentFragmentManager, + NotificationPermissionDialogFragment.FRAGMENT_TAG, + ) + } + } + private fun showDoNotAskAgainCheckbox( containerView: View, checked: Boolean, diff --git a/mobile/android/android-components/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/SitePermissionsFeature.kt b/mobile/android/android-components/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/SitePermissionsFeature.kt @@ -357,12 +357,17 @@ class SitePermissionsFeature( internal fun onContentPermissionGranted( permissionRequest: PermissionRequest, shouldStore: Boolean, + onCheckSystemNotificationPermission: () -> Unit = {}, ) { permissionRequest.grant() if (shouldStore) { getCurrentContentState()?.let { contentState -> storeSitePermissions(contentState, permissionRequest, ALLOWED) } + val requestedPermission = permissionRequest.permissions[0] + if (requestedPermission is ContentNotification) { + onCheckSystemNotificationPermission() + } } else { storage.saveTemporary(permissionRequest) } @@ -372,10 +377,13 @@ class SitePermissionsFeature( permissionId: String, sessionId: String, shouldStore: Boolean, + onCheckSystemNotificationPermission: () -> Unit = {}, ) { findRequestedPermission(permissionId)?.let { permissionRequest -> consumePermissionRequest(permissionRequest, sessionId) - onContentPermissionGranted(permissionRequest, shouldStore) + onContentPermissionGranted(permissionRequest, shouldStore) { + onCheckSystemNotificationPermission() + } if (!permissionRequest.containsVideoAndAudioSources()) { emitPermissionAllowed(permissionRequest.permissions.first()) diff --git a/mobile/android/android-components/components/feature/sitepermissions/src/main/res/drawable/ic_system_permission_dialog.xml b/mobile/android/android-components/components/feature/sitepermissions/src/main/res/drawable/ic_system_permission_dialog.xml @@ -0,0 +1,13 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M4.5,4C3.12,4 2,5.12 2,6.5V16.5C2,17.88 3.12,19 4.5,19H1V20.75H23V19H19.5C20.88,19 22,17.88 22,16.5V6.5C22,5.12 20.88,4 19.5,4H4.5ZM3.75,6.5C3.75,6.086 4.086,5.75 4.5,5.75H19.5C19.914,5.75 20.25,6.086 20.25,6.5V16.5C20.25,16.914 19.914,17.25 19.5,17.25H4.5C4.086,17.25 3.75,16.914 3.75,16.5V6.5Z" + android:fillColor="#5B5B66" + android:fillType="evenOdd"/> +</vector> diff --git a/mobile/android/android-components/components/feature/sitepermissions/src/main/res/values/strings.xml b/mobile/android/android-components/components/feature/sitepermissions/src/main/res/values/strings.xml @@ -35,7 +35,12 @@ <!-- Alternate variant of the text for a negative button in a permission request dialog, this button will not give access to this permission--> <string name="mozac_feature_sitepermissions_block">Block</string> - + <!-- Message of a dialog offering more context about the app level notification permission request. %1$s will be replaced with the name of the app. --> + <string name="mozac_feature_sitepermissions_notification_permission_rationale_dialog_message">You’ll need to allow notifications in %1$s to receive them from this website.</string> + <!-- Button label in a notification permission request dialog to dismiss the dialog. --> + <string name="mozac_feature_sitepermissions_notification_permission_rationale_dialog_dismiss_label">Cancel</string> + <!-- Button label in a notification permission request dialog. It will let the user go to Android Settings to grant the permission. --> + <string name="mozac_feature_sitepermissions_notification_permission_rationale_dialog_settings_label">Go to settings</string> <!-- Text for a checkbox in a permission request dialog, this will allow to not show again the prompt--> <string name="mozac_feature_sitepermissions_do_not_ask_again_on_this_site2">Remember decision for this site</string> <!-- Text for a checkbox in a permission request dialog, this will allow to not show again the prompt--> diff --git a/mobile/android/android-components/components/feature/sitepermissions/src/test/java/mozilla/components/feature/sitepermissions/SitePermissionsDialogFragmentTest.kt b/mobile/android/android-components/components/feature/sitepermissions/src/test/java/mozilla/components/feature/sitepermissions/SitePermissionsDialogFragmentTest.kt @@ -15,6 +15,8 @@ import androidx.fragment.app.FragmentTransaction import androidx.test.ext.junit.runners.AndroidJUnit4 import mozilla.components.feature.sitepermissions.SitePermissionsFeature.PromptsStyling import mozilla.components.support.ktx.util.PromptAbuserDetector +import mozilla.components.support.test.any +import mozilla.components.support.test.eq import mozilla.components.support.test.mock import mozilla.components.support.test.robolectric.testContext import org.junit.After @@ -299,7 +301,12 @@ class SitePermissionsDialogFragmentTest { val positiveButton = dialog.findViewById<Button>(R.id.allow_button) positiveButton.performClick() - verify(mockFeature).onPositiveButtonPress(permissionRequestId, "sessionId", false) + verify(mockFeature).onPositiveButtonPress( + eq(permissionRequestId), + eq("sessionId"), + eq(false), + any(), + ) } @Test @@ -428,8 +435,12 @@ class SitePermissionsDialogFragmentTest { val positiveButton = dialog.findViewById<Button>(R.id.allow_button) positiveButton.performClick() - verify(mockFeature) - .onPositiveButtonPress(permissionRequestId, "sessionId", true) + verify(mockFeature).onPositiveButtonPress( + eq(permissionRequestId), + eq("sessionId"), + eq(true), + any(), + ) } @Test diff --git a/mobile/android/android-components/components/feature/sitepermissions/src/test/java/mozilla/components/feature/sitepermissions/SitePermissionsFeatureTest.kt b/mobile/android/android-components/components/feature/sitepermissions/src/test/java/mozilla/components/feature/sitepermissions/SitePermissionsFeatureTest.kt @@ -355,6 +355,7 @@ class SitePermissionsFeatureTest { @Test fun `GIVEN shouldStore true WHEN onContentPermissionGranted() THEN storeSitePermissions() called`() { // given + doReturn(listOf(ContentNotification())).`when`(mockPermissionRequest).permissions doNothing().`when`(sitePermissionFeature) .storeSitePermissions(any(), any(), any(), any()) @@ -383,6 +384,7 @@ class SitePermissionsFeatureTest { @Test fun `GIVEN permissionRequest WHEN onPositiveButtonPress() THEN consumePermissionRequest, onContentPermissionGranted are called`() { // given + doReturn(listOf(ContentNotification())).`when`(mockPermissionRequest).permissions doNothing().`when`(sitePermissionFeature).consumePermissionRequest(any(), any()) doNothing().`when`(sitePermissionFeature) .onContentPermissionGranted(mockPermissionRequest, true) @@ -396,8 +398,11 @@ class SitePermissionsFeatureTest { // then verify(sitePermissionFeature) .consumePermissionRequest(mockPermissionRequest, SESSION_ID) - verify(sitePermissionFeature) - .onContentPermissionGranted(mockPermissionRequest, true) + verify(sitePermissionFeature).onContentPermissionGranted( + eq(mockPermissionRequest), + eq(true), + any(), + ) } @Test diff --git a/mobile/android/android-components/docs/changelog.md b/mobile/android/android-components/docs/changelog.md @@ -11,6 +11,8 @@ permalink: /changelog/ * ⚠️ **Breaking change**: Updated the `AppServicesInitializer.init` to take in a configuration object instead of individual components. * **feature-framebusting** * 🆕 New `GeckoSession.PromptDelegate.RedirectPrompt` prompt that is displayed when a third-party redirect is blocked. [Bug 1988107](https://bugzilla.mozilla.org/show_bug.cgi?id=1988107) +* **feature-sitepermissions** + * 🚒 Bug fixed [Bug 1986429](https://bugzilla.mozilla.org/show_bug.cgi?id=1986429). Ensure that when accepting website notification permission, the user is prompted for system-level notification opt-in, if the system permission was not enabled. # 144.0 * **feature-customtabs**