diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index f5a48763d561..14b80d9bc2f7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -184,6 +184,7 @@ import com.ichi2.utils.ImportUtils import com.ichi2.utils.ImportUtils.ImportResult import com.ichi2.utils.NetworkUtils import com.ichi2.utils.NetworkUtils.isActiveNetworkMetered +import com.ichi2.utils.Permissions import com.ichi2.utils.VersionUtils import com.ichi2.utils.cancelable import com.ichi2.utils.checkBoxPrompt @@ -501,11 +502,6 @@ open class DeckPicker : } } - private val notificationPermissionLauncher = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { - Timber.i("notification permission: %b", it) - } - // ---------------------------------------------------------------------------- // ANDROID ACTIVITY METHODS // ---------------------------------------------------------------------------- @@ -2121,7 +2117,12 @@ open class DeckPicker : return } - MyAccount.checkNotificationPermission(this, notificationPermissionLauncher) + // Request notification permissions from the user if they have not been requested ever before + if (!Prefs.syncNotifsRequestShown) { + Permissions.requestNotificationsPermissionIfNeeded(context = this, supportFragmentManager) { + Prefs.syncNotifsRequestShown = true + } + } /** Nested function that makes the connection to * the sync server and starts syncing the data */ diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt index 79f03bca8a5c..29e76a329093 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt @@ -29,6 +29,7 @@ import com.ichi2.anki.exception.StorageAccessException import com.ichi2.anki.servicelayer.PreferenceUpgradeService import com.ichi2.anki.servicelayer.PreferenceUpgradeService.setPreferencesUpToDate import com.ichi2.anki.servicelayer.ScopedStorageService.isLegacyStorage +import com.ichi2.anki.ui.windows.permissions.NotificationsPermissionFragment import com.ichi2.anki.ui.windows.permissions.PermissionsFragment import com.ichi2.anki.ui.windows.permissions.PermissionsStartingAt30Fragment import com.ichi2.anki.ui.windows.permissions.PermissionsUntil29Fragment @@ -192,6 +193,9 @@ enum class PermissionSet( EXTERNAL_MANAGER(listOf(Permissions.MANAGE_EXTERNAL_STORAGE), PermissionsStartingAt30Fragment::class.java), APP_PRIVATE(emptyList(), null), + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + NOTIFICATIONS(Permissions.postNotification?.let { listOf(it) } ?: emptyList(), NotificationsPermissionFragment::class.java), } /** diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/MyAccount.kt b/AnkiDroid/src/main/java/com/ichi2/anki/MyAccount.kt index 6fd0925cb88a..5e712b51ac9b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/MyAccount.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/MyAccount.kt @@ -13,8 +13,6 @@ ****************************************************************************************/ package com.ichi2.anki -import android.content.Context -import android.content.pm.PackageManager import android.content.res.Configuration import android.os.Build import android.os.Bundle @@ -27,10 +25,7 @@ import android.widget.Button import android.widget.ImageView import android.widget.TextView import androidx.activity.OnBackPressedCallback -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.widget.Toolbar -import androidx.core.content.ContextCompat import androidx.core.view.isVisible import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout @@ -43,7 +38,6 @@ import com.ichi2.anki.utils.ext.removeFragmentFromContainer import com.ichi2.anki.utils.ext.showDialogFragment import com.ichi2.ui.TextInputEditField import com.ichi2.utils.AdaptionUtil.isUserATestClient -import com.ichi2.utils.Permissions import net.ankiweb.rsdroid.exceptions.BackendSyncException import timber.log.Timber @@ -98,11 +92,6 @@ open class MyAccount : AnkiActivity() { } } - private val notificationPermissionLauncher = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { - Timber.i("notification permission: %b", it) - } - override fun onCreate(savedInstanceState: Bundle?) { if (showedActivityFailedScreen(savedInstanceState)) { return @@ -137,7 +126,7 @@ open class MyAccount : AnkiActivity() { return } Timber.i("Attempting auto-login") - handleNewLogin(username, password, notificationPermissionLauncher) + handleNewLogin(username, password) } private fun login() { @@ -146,7 +135,7 @@ open class MyAccount : AnkiActivity() { inputMethodManager.hideSoftInputFromWindow(username.windowToken, 0) val username = username.text.toString().trim() // trim spaces, issue 1586 val password = password.text.toString() - handleNewLogin(username, password, notificationPermissionLauncher) + handleNewLogin(username, password) } private fun logout() { @@ -332,7 +321,6 @@ open class MyAccount : AnkiActivity() { private fun handleNewLogin( username: String, password: String, - resultLauncher: ActivityResultLauncher, ) { val endpoint = getEndpoint() launchCatchingTask { @@ -355,7 +343,6 @@ open class MyAccount : AnkiActivity() { } updateLogin(username, auth.hkey) setResult(RESULT_OK) - checkNotificationPermission(this@MyAccount, resultLauncher) finish() } } @@ -364,33 +351,5 @@ open class MyAccount : AnkiActivity() { @KotlinCleanup("change to enum") internal const val STATE_LOG_IN = 1 internal const val STATE_LOGGED_IN = 2 - - /** - * Displays a system prompt: "Allow AnkiDroid to send you notifications" - * - * [launcher] receives a callback result (`boolean`) unless: - * * Permissions were already granted - * * We are < API 33 - * - * Permissions may permanently be denied, in which case [launcher] immediately - * receives a failure result - */ - fun checkNotificationPermission( - context: Context, - launcher: ActivityResultLauncher, - ) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - return - } - val permission = Permissions.postNotification - if (permission != null && - ContextCompat.checkSelfPermission( - context, - permission, - ) != PackageManager.PERMISSION_GRANTED - ) { - launcher.launch(permission) - } - } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt index d01caac4d39f..1927be696b59 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt @@ -47,9 +47,11 @@ import com.ichi2.anki.launchCatchingTask import com.ichi2.anki.libanki.Consts import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.model.SelectableDeck +import com.ichi2.anki.settings.Prefs import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.utils.ext.showDialogFragment import com.ichi2.utils.DisplayUtils.resizeWhenSoftInputShown +import com.ichi2.utils.Permissions import com.ichi2.utils.customView import com.ichi2.utils.negativeButton import com.ichi2.utils.neutralButton @@ -287,6 +289,14 @@ class AddEditReminderDialog : DialogFragment() { putParcelable(ScheduleReminders.ADD_EDIT_DIALOG_RESULT_REQUEST_KEY, reminderToBeReturned) }, ) + + // Request notification permissions from the user if they have not been requested ever before + if (!Prefs.reminderNotifsRequestShown) { + Permissions.requestNotificationsPermissionIfNeeded(requireContext(), parentFragmentManager) { + Prefs.reminderNotifsRequestShown = true + } + } + dismiss() } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt b/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt index 51f668235be8..0069dadbb5b5 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt @@ -220,6 +220,15 @@ object Prefs { //endregion + /** + * Whether the sync process has requested notification permissions before. + * We only want to request notification permissions for the sync feature if the dialog has never been shown + * for this reason before. + * + * @see reminderNotifsRequestShown + */ + var syncNotifsRequestShown by booleanPref(R.string.sync_notifs_request_shown_key, defaultValue = false) + // ************************************** Review Reminders ********************************** // /** @@ -232,6 +241,15 @@ object Prefs { */ var reviewReminderNextFreeId by intPref(R.string.review_reminders_next_free_id, defaultValue = 0) + /** + * Whether the review reminder feature has requested notification permissions before. + * We only want to request notification permissions for the review reminder feature if the dialog has never been + * shown for this reason before. + * + * @see syncNotifsRequestShown + */ + var reminderNotifsRequestShown by booleanPref(R.string.reminder_notifs_request_shown_key, defaultValue = false) + // **************************************** Reviewer **************************************** // val ignoreDisplayCutout by booleanPref(R.string.ignore_display_cutout_key, false) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/NotificationsPermissionFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/NotificationsPermissionFragment.kt new file mode 100644 index 000000000000..a4873d127de9 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/NotificationsPermissionFragment.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Eric Li + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.ui.windows.permissions + +import android.os.Build +import android.os.Bundle +import android.view.View +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import com.ichi2.anki.R +import com.ichi2.utils.Permissions +import timber.log.Timber + +/** + * Permissions fragment shown on the [PermissionsBottomSheet] for requesting notification permissions + * from the user. This permission only needs to be requested at or above API 33. + * + * Requested permissions: + * 1. Notifications: [Permissions.postNotification]. + * Used to view and cancel sync progress. + * Used for review reminder notifications. + */ +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +class NotificationsPermissionFragment : PermissionsFragment(R.layout.notifications_permission) { + /** + * Launches the OS dialog for requesting notification permissions. + */ + private val notificationPermissionLauncher = + registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions(), + ) { requestedPermissions -> + Timber.i("Notification permission result: $requestedPermissions") + if (!requestedPermissions.all { it.value }) { + showToastAndOpenAppSettingsScreen(R.string.manually_grant_permissions) + } + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + val notificationPermission = view.findViewById(R.id.notification_permission) + Permissions.postNotification?.let { + notificationPermission.offerToGrantOrRevokeOnClick( + notificationPermissionLauncher, + arrayOf(Permissions.postNotification), + ) + } + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivity.kt index e4d6a6cfcfac..a4d94e8a7147 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivity.kt @@ -42,7 +42,8 @@ import com.ichi2.themes.setTransparentStatusBar * * Easily reusable * * Doesn't need to block any UI elements or background routines that depends on a permission. * Nor needs to add callbacks after the permissions are granted - * * TODO Show which permissions are mandatory and which are optional + * + * To request optional permissions from the user, prefer [PermissionsBottomSheet] instead. */ class PermissionsActivity : AnkiActivity() { override fun onCreate(savedInstanceState: Bundle?) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsBottomSheet.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsBottomSheet.kt new file mode 100644 index 000000000000..24dc21d47b64 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsBottomSheet.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Eric Li + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.ui.windows.permissions + +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import androidx.annotation.RequiresApi +import androidx.core.os.BundleCompat +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.commit +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.ichi2.anki.PermissionSet +import com.ichi2.anki.R + +/** + * BottomSheet that requests permissions from the user. + * + * The full-screen [PermissionsActivity] which launches on initial app installation should be used to request + * mandatory permissions from the user that AnkiDroid cannot run without. This more relaxed BottomSheet + * should be used to request optional permissions from the user, and can be launched as the user gradually + * encounters features that require permissions rather than being shoved in the face of every first-time user. + */ +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +class PermissionsBottomSheet : BottomSheetDialogFragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? = inflater.inflate(R.layout.permissions_bottom_sheet, container, false) + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + val closeButton = view.findViewById(R.id.close_button) + closeButton.setOnClickListener { dismiss() } + + val permissionSet = + requireNotNull(BundleCompat.getParcelable(requireArguments(), PERMISSION_SET_ARGUMENT_KEY, PermissionSet::class.java)) { + "Permission set cannot be null" + } + val permissionsFragment = + requireNotNull(permissionSet.permissionsFragment?.getDeclaredConstructor()?.newInstance()) { + "invalid permissionsFragment" + } + view.post { + childFragmentManager.commit { + replace(R.id.bottom_sheet_fragment_container, permissionsFragment) + } + } + } + + companion object { + /** + * Unique fragment tag for launching this bottom sheet. + */ + private const val FRAGMENT_TAG = "notifications_bottom_sheet" + + /** + * Arguments key for the [PermissionSet] to launch this BottomSheet with. + */ + private const val PERMISSION_SET_ARGUMENT_KEY = "permission_set" + + /** + * Starts this BottomSheet with the provided [PermissionSet]. + */ + fun launch( + fragmentManager: FragmentManager, + permissionsSet: PermissionSet, + ) { + val bottomSheet = + PermissionsBottomSheet().apply { + arguments = + Bundle().apply { + putParcelable(PERMISSION_SET_ARGUMENT_KEY, permissionsSet) + } + } + bottomSheet.show(fragmentManager, FRAGMENT_TAG) + } + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/Permissions.kt b/AnkiDroid/src/main/java/com/ichi2/utils/Permissions.kt index f73d543e89f0..d16f80f8320a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/Permissions.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/Permissions.kt @@ -24,7 +24,10 @@ import android.os.Build import android.os.Environment import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentManager +import com.ichi2.anki.PermissionSet import com.ichi2.anki.common.utils.android.isRobolectric +import com.ichi2.anki.ui.windows.permissions.PermissionsBottomSheet import com.ichi2.compat.CompatHelper.Companion.getPackageInfoCompat import com.ichi2.compat.PackageInfoFlagsCompat import com.ichi2.utils.Permissions.MANAGE_EXTERNAL_STORAGE @@ -52,6 +55,36 @@ object Permissions { null } + /** + * Returns whether AnkiDroid should request notification permissions from the user. + */ + private fun shouldRequestNotificationPermissions(context: Context): Boolean { + val permission = postNotification ?: return false // Null if below API 33 + val grantedStatus = ContextCompat.checkSelfPermission(context, permission) + return (grantedStatus != PackageManager.PERMISSION_GRANTED) + } + + /** + * Shows the [com.ichi2.anki.ui.windows.permissions.NotificationsPermissionFragment] in the [PermissionsBottomSheet] + * if notification permissions have not been granted. Does nothing if the permission does not need to + * be requested (i.e. API < 33) or if the permission has already been granted. + * + * @param context Used for checking whether notification permissions have been granted. + * @param fragmentManager Used for launching the BottomSheet, if necessary. + * @param callback Executed only if the BottomSheet is actually shown. + */ + fun requestNotificationsPermissionIfNeeded( + context: Context, + fragmentManager: FragmentManager, + callback: () -> Unit, + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && shouldRequestNotificationPermissions(context)) { + Timber.i("Showing notifications bottom sheet") + PermissionsBottomSheet.launch(fragmentManager, PermissionSet.NOTIFICATIONS) + callback() + } + } + @RequiresApi(Build.VERSION_CODES.TIRAMISU) val tiramisuAudioPermission = Manifest.permission.READ_MEDIA_AUDIO diff --git a/AnkiDroid/src/main/res/layout/notifications_permission.xml b/AnkiDroid/src/main/res/layout/notifications_permission.xml new file mode 100644 index 000000000000..c8b43dac139a --- /dev/null +++ b/AnkiDroid/src/main/res/layout/notifications_permission.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/AnkiDroid/src/main/res/layout/permissions_bottom_sheet.xml b/AnkiDroid/src/main/res/layout/permissions_bottom_sheet.xml new file mode 100644 index 000000000000..92110f133f63 --- /dev/null +++ b/AnkiDroid/src/main/res/layout/permissions_bottom_sheet.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + diff --git a/AnkiDroid/src/main/res/values/preferences.xml b/AnkiDroid/src/main/res/values/preferences.xml index 7bd395e28946..ff0d9999ee4d 100644 --- a/AnkiDroid/src/main/res/values/preferences.xml +++ b/AnkiDroid/src/main/res/values/preferences.xml @@ -27,6 +27,7 @@ username lastSyncTime currentSyncUri + SyncNotifsRequestShown backupLimitsScreen @@ -205,6 +206,7 @@ reviewRemindersScreen reviewRemindersNextFreeId + reminderNotifsRequestShown devOptionsKey trigger_crash_preference