Skip to content

Commit a69ab4f

Browse files
author
andrei popa
committed
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
1 parent df6809e commit a69ab4f

File tree

10 files changed

+341
-9
lines changed

10 files changed

+341
-9
lines changed

mobile/android/android-components/components/feature/sitepermissions/build.gradle

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
* License, v. 2.0. If a copy of the MPL was not distributed with this
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

5-
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
5+
plugins {
6+
alias(libs.plugins.kotlin.compose)
7+
}
68

79
apply plugin: 'com.android.library'
810
apply plugin: 'kotlin-android'
@@ -29,6 +31,10 @@ android {
2931
}
3032

3133
namespace = 'mozilla.components.feature.sitepermissions'
34+
35+
buildFeatures {
36+
compose = true
37+
}
3238
}
3339

3440
dependencies {
@@ -38,6 +44,14 @@ dependencies {
3844
implementation project(':components:support-ktx')
3945
implementation project(':components:support-utils')
4046
implementation project(':components:ui-icons')
47+
implementation project(':components:compose-base')
48+
49+
implementation platform(libs.androidx.compose.bom)
50+
implementation libs.androidx.compose.foundation
51+
implementation libs.androidx.compose.ui.tooling.preview
52+
implementation libs.androidx.compose.ui
53+
implementation libs.androidx.compose.material3
54+
debugImplementation libs.androidx.compose.ui.tooling
4155

4256
implementation libs.androidx.constraintlayout
4357
implementation libs.androidx.core.ktx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
package mozilla.components.feature.sitepermissions
6+
7+
import android.os.Bundle
8+
import android.view.LayoutInflater
9+
import android.view.View
10+
import android.view.ViewGroup
11+
import androidx.compose.foundation.isSystemInDarkTheme
12+
import androidx.compose.ui.platform.ComposeView
13+
import androidx.compose.ui.platform.ViewCompositionStrategy
14+
import androidx.fragment.app.DialogFragment
15+
import mozilla.components.compose.base.theme.AcornTheme
16+
import mozilla.components.compose.base.theme.acornDarkColorScheme
17+
import mozilla.components.compose.base.theme.acornLightColorScheme
18+
19+
/**
20+
* A dialog to be displayed to explain to the user why notification access is required.
21+
* It is intended to be shown when the application has already obtained site-level permission but also
22+
* needs the corresponding system-level permission.
23+
*/
24+
class NotificationPermissionDialogFragment(val positiveButtonAction: () -> Unit) :
25+
DialogFragment() {
26+
27+
override fun onCreateView(
28+
inflater: LayoutInflater,
29+
container: ViewGroup?,
30+
savedInstanceState: Bundle?,
31+
): View {
32+
with(requireBundle()) {
33+
val title = getString(KEY_TITLE_STRING, "")
34+
val message = getString(KEY_MESSAGE_STRING, "")
35+
val positiveButtonLabel = getString(KEY_POSITIVE_TEXT, "")
36+
val negativeButtonLabel = getString(KEY_NEGATIVE_TEXT, "")
37+
38+
return ComposeView(requireContext()).apply {
39+
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
40+
41+
setContent {
42+
val colors =
43+
if (isSystemInDarkTheme()) acornDarkColorScheme() else acornLightColorScheme()
44+
45+
AcornTheme(colorScheme = colors) {
46+
PermissionDialog(
47+
icon = R.drawable.ic_system_permission_dialog,
48+
title = title,
49+
message = message,
50+
positiveButtonLabel = positiveButtonLabel,
51+
negativeButtonLabel = negativeButtonLabel,
52+
onConfirmRequest = positiveButtonAction,
53+
dialogAbuseMillisLimit = 500,
54+
onDismissRequest = { dismiss() },
55+
)
56+
}
57+
}
58+
}
59+
}
60+
}
61+
62+
/**
63+
* Static functionality of [NotificationPermissionDialogFragment].
64+
*/
65+
companion object {
66+
/**
67+
* A builder method for creating a [NotificationPermissionDialogFragment]
68+
*/
69+
fun newInstance(
70+
dialogTitleString: String,
71+
dialogMessageString: String,
72+
positiveButtonText: String,
73+
negativeButtonText: String,
74+
positiveButtonAction: () -> Unit,
75+
): NotificationPermissionDialogFragment {
76+
val fragment = NotificationPermissionDialogFragment(positiveButtonAction)
77+
val arguments = fragment.arguments ?: Bundle()
78+
79+
with(arguments) {
80+
putString(KEY_TITLE_STRING, dialogTitleString)
81+
82+
putString(KEY_MESSAGE_STRING, dialogMessageString)
83+
84+
putString(KEY_POSITIVE_TEXT, positiveButtonText)
85+
86+
putString(KEY_NEGATIVE_TEXT, negativeButtonText)
87+
}
88+
89+
fragment.arguments = arguments
90+
91+
return fragment
92+
}
93+
94+
private const val KEY_POSITIVE_TEXT = "KEY_POSITIVE_TEXT"
95+
96+
private const val KEY_NEGATIVE_TEXT = "KEY_NEGATIVE_TEXT"
97+
98+
private const val KEY_TITLE_STRING = "KEY_TITLE_STRING"
99+
100+
private const val KEY_MESSAGE_STRING = "KEY_MESSAGE_STRING"
101+
102+
const val FRAGMENT_TAG = "NOTIFICATION_PERMISSION_DIALOG_FRAGMENT"
103+
}
104+
105+
private fun requireBundle(): Bundle {
106+
return arguments ?: throw IllegalStateException("Fragment $this arguments is not set.")
107+
}
108+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
package mozilla.components.feature.sitepermissions
6+
7+
import androidx.annotation.DrawableRes
8+
import androidx.compose.material3.AlertDialog
9+
import androidx.compose.material3.Icon
10+
import androidx.compose.material3.Text
11+
import androidx.compose.material3.TextButton
12+
import androidx.compose.runtime.Composable
13+
import androidx.compose.runtime.LaunchedEffect
14+
import androidx.compose.ui.Modifier
15+
import androidx.compose.ui.res.painterResource
16+
import androidx.compose.ui.text.style.TextAlign
17+
import androidx.compose.ui.tooling.preview.Preview
18+
import mozilla.components.compose.base.theme.AcornTheme
19+
import mozilla.components.support.ktx.util.PromptAbuserDetector
20+
21+
/**
22+
* Reusable composable for a permission dialog.
23+
* Includes a [PromptAbuserDetector] to better control dialog abuse.
24+
*
25+
* @param title Text displayed as the dialog title.
26+
* @param message The message text providing additional information.
27+
* @param icon Optional drawable resource for an icon.
28+
* @param positiveButtonLabel Text label for the positive action button.
29+
* @param negativeButtonLabel Text label for the negative action button.
30+
* @param dialogAbuseMillisLimit Represents a customized timeout used to avoid prompt abuse.
31+
* @param onConfirmRequest Action to perform when the positive button is clicked.
32+
* @param onDismissRequest Action to perform on dialog dismissal.
33+
*/
34+
@Composable
35+
fun PermissionDialog(
36+
title: String,
37+
message: String,
38+
@DrawableRes icon: Int? = null,
39+
positiveButtonLabel: String,
40+
negativeButtonLabel: String,
41+
dialogAbuseMillisLimit: Int = 0,
42+
onConfirmRequest: () -> Unit,
43+
onDismissRequest: () -> Unit,
44+
) {
45+
val promptAbuserDetector =
46+
PromptAbuserDetector(dialogAbuseMillisLimit)
47+
48+
LaunchedEffect(Unit) {
49+
promptAbuserDetector.updateJSDialogAbusedState()
50+
}
51+
52+
AlertDialog(
53+
icon = {
54+
icon?.let {
55+
Icon(
56+
painter = painterResource(icon),
57+
contentDescription = null,
58+
tint = AcornTheme.colors.iconSecondary,
59+
)
60+
}
61+
},
62+
title = {
63+
Text(
64+
text = title,
65+
textAlign = TextAlign.Center,
66+
color = AcornTheme.colors.formDefault,
67+
)
68+
},
69+
text = {
70+
Text(text = message)
71+
},
72+
confirmButton = {
73+
DialogButton(text = positiveButtonLabel) {
74+
if (promptAbuserDetector.areDialogsBeingAbused()) {
75+
promptAbuserDetector.updateJSDialogAbusedState()
76+
} else {
77+
onConfirmRequest()
78+
onDismissRequest()
79+
}
80+
}
81+
},
82+
dismissButton = {
83+
DialogButton(
84+
text = negativeButtonLabel,
85+
onClick = onDismissRequest,
86+
)
87+
},
88+
onDismissRequest = onDismissRequest,
89+
)
90+
}
91+
92+
/**
93+
* Reusable composable for a dialog button with text.
94+
*/
95+
@Composable
96+
private fun DialogButton(
97+
text: String,
98+
modifier: Modifier = Modifier,
99+
enabled: Boolean = true,
100+
onClick: () -> Unit,
101+
) {
102+
TextButton(
103+
onClick = onClick,
104+
modifier = modifier,
105+
enabled = enabled,
106+
) {
107+
Text(
108+
modifier = modifier,
109+
text = text,
110+
)
111+
}
112+
}
113+
114+
@Preview
115+
@Composable
116+
private fun PermissionDialogPreview() {
117+
AcornTheme {
118+
PermissionDialog(
119+
icon = R.drawable.ic_system_permission_dialog,
120+
message = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer sodales laoreet commodo.",
121+
title = "Dialog title",
122+
positiveButtonLabel = "Go to settings",
123+
negativeButtonLabel = "Cancel",
124+
onConfirmRequest = { },
125+
onDismissRequest = { },
126+
)
127+
}
128+
}

mobile/android/android-components/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/SitePermissionsDialogFragment.kt

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@ package mozilla.components.feature.sitepermissions
77
import android.annotation.SuppressLint
88
import android.app.Dialog
99
import android.content.DialogInterface
10+
import android.content.Intent
11+
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
1012
import android.graphics.Color
1113
import android.os.Bundle
14+
import android.provider.Settings
1215
import android.view.LayoutInflater
1316
import android.view.View
1417
import android.view.View.VISIBLE
@@ -22,9 +25,11 @@ import android.widget.TextView
2225
import androidx.annotation.VisibleForTesting
2326
import androidx.appcompat.app.AppCompatDialogFragment
2427
import androidx.appcompat.content.res.AppCompatResources
28+
import androidx.core.app.NotificationManagerCompat
2529
import androidx.core.content.ContextCompat
2630
import androidx.core.graphics.drawable.toDrawable
2731
import mozilla.components.support.base.log.logger.Logger
32+
import mozilla.components.support.ktx.android.content.appName
2833
import mozilla.components.support.ktx.kotlin.ifNullOrEmpty
2934
import mozilla.components.support.ktx.util.PromptAbuserDetector
3035

@@ -182,7 +187,9 @@ internal open class SitePermissionsDialogFragment : AppCompatDialogFragment() {
182187
permissionRequestId,
183188
sessionId,
184189
userSelectionCheckBox,
185-
)
190+
) {
191+
if (!areSystemNotificationsEnabled()) showSettingsPrompt()
192+
}
186193
dismiss()
187194
}
188195
}
@@ -225,6 +232,37 @@ internal open class SitePermissionsDialogFragment : AppCompatDialogFragment() {
225232
return rootView
226233
}
227234

235+
private fun areSystemNotificationsEnabled() =
236+
NotificationManagerCompat.from(requireContext()).areNotificationsEnabled()
237+
238+
private fun showSettingsPrompt() {
239+
with(requireContext()) {
240+
NotificationPermissionDialogFragment.newInstance(
241+
dialogTitleString = title,
242+
dialogMessageString = getString(
243+
R.string.mozac_feature_sitepermissions_notification_permission_rationale_dialog_message,
244+
appName,
245+
),
246+
positiveButtonText = getString(
247+
R.string.mozac_feature_sitepermissions_notification_permission_rationale_dialog_settings_label,
248+
),
249+
negativeButtonText = getString(
250+
R.string.mozac_feature_sitepermissions_notification_permission_rationale_dialog_dismiss_label,
251+
),
252+
positiveButtonAction = {
253+
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
254+
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
255+
flags = FLAG_ACTIVITY_NEW_TASK
256+
}
257+
startActivity(intent)
258+
},
259+
).showNow(
260+
parentFragmentManager,
261+
NotificationPermissionDialogFragment.FRAGMENT_TAG,
262+
)
263+
}
264+
}
265+
228266
private fun showDoNotAskAgainCheckbox(
229267
containerView: View,
230268
checked: Boolean,

mobile/android/android-components/components/feature/sitepermissions/src/main/java/mozilla/components/feature/sitepermissions/SitePermissionsFeature.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,12 +357,17 @@ class SitePermissionsFeature(
357357
internal fun onContentPermissionGranted(
358358
permissionRequest: PermissionRequest,
359359
shouldStore: Boolean,
360+
onCheckSystemNotificationPermission: () -> Unit = {},
360361
) {
361362
permissionRequest.grant()
362363
if (shouldStore) {
363364
getCurrentContentState()?.let { contentState ->
364365
storeSitePermissions(contentState, permissionRequest, ALLOWED)
365366
}
367+
val requestedPermission = permissionRequest.permissions[0]
368+
if (requestedPermission is ContentNotification) {
369+
onCheckSystemNotificationPermission()
370+
}
366371
} else {
367372
storage.saveTemporary(permissionRequest)
368373
}
@@ -372,10 +377,13 @@ class SitePermissionsFeature(
372377
permissionId: String,
373378
sessionId: String,
374379
shouldStore: Boolean,
380+
onCheckSystemNotificationPermission: () -> Unit = {},
375381
) {
376382
findRequestedPermission(permissionId)?.let { permissionRequest ->
377383
consumePermissionRequest(permissionRequest, sessionId)
378-
onContentPermissionGranted(permissionRequest, shouldStore)
384+
onContentPermissionGranted(permissionRequest, shouldStore) {
385+
onCheckSystemNotificationPermission()
386+
}
379387

380388
if (!permissionRequest.containsVideoAndAudioSources()) {
381389
emitPermissionAllowed(permissionRequest.permissions.first())
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!-- This Source Code Form is subject to the terms of the Mozilla Public
2+
- License, v. 2.0. If a copy of the MPL was not distributed with this
3+
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
4+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
5+
android:width="24dp"
6+
android:height="24dp"
7+
android:viewportWidth="24"
8+
android:viewportHeight="24">
9+
<path
10+
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"
11+
android:fillColor="#5B5B66"
12+
android:fillType="evenOdd"/>
13+
</vector>

0 commit comments

Comments
 (0)