Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
13 changes: 7 additions & 6 deletions AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -501,11 +502,6 @@ open class DeckPicker :
}
}

private val notificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
Timber.i("notification permission: %b", it)
}

// ----------------------------------------------------------------------------
// ANDROID ACTIVITY METHODS
// ----------------------------------------------------------------------------
Expand Down Expand Up @@ -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) {
Copy link
Member

Choose a reason for hiding this comment

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

This should likely be using shouldShowRequestPermissionRationale

https://developer.android.com/training/permissions/requesting#request-permission

Copy link
Member Author

Choose a reason for hiding this comment

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

@david-allison Yep, I've thought about that before. From the PR description:

I'm aware there is a shouldShowRequestPermissionRationale method which Google recommends using to check if we should request notification permissions from the user. I do not believe we should use it as it is incredibly confusing and has some very weird edge-case behaviour. See this post for more information. Rather, it makes good sense to use two quick preferences and carefully handle whether we have shown the request dialog ourselves. This is also more extensible in the future, should a future developer choose to request the notification permission in a different circumstance, too.

Basically, the internal workings of shouldShowRequestPermissionRationale are kind of counterintuitive and the name isn't really self-documenting. Google really should have designed it to return an enum instead of a boolean. See the top answer here and the top answer here for more information. The function only returns true if the app has requested the permission before and the user has clicked "deny" without clicking "don't ask again". It returns false when it's requesting a permission for the first time. I'd like to show the AnkiDroid notification request when the user performs an action that requires the permission for the first time, so shouldShowRequestPermissionRationale doesn't work for this use case. That's why I created a Pref.

I've also considered adding another case to trigger the permission request BottomSheet if the user has clicked "deny" without clicking "don't ask again", which we could use shouldShowRequestPermissionRationale for (i.e. we would show the BottomSheet both if the user is opening the app for the first time, via the Pref, OR if the user has clicked "deny" without clicking "don't ask again", via shouldShowRequestPermissionRationale). The problem with that is I don't see it happening very often. With this PR, the only times we request notification permissions from the user, we do it through the BottomSheet above. For a user to be logged as having clicked "deny" without clicking "don't ask again", they would have to 1. trigger the BottomSheet, 2. read the message, 3. click on the enabling toggle, and then 4. click deny. I don't see many users changing their minds after clicking the toggle to grant notification permissions (which is why, in that scenario, the BottomSheet currently opens up the OS settings to allow the user to grant notification permissions directly, assuming they performed a misclick or that something went wrong with launching the OS permission request dialog).

I will concede that the fact that we only show the BottomSheet at most two times to users (once on the first sync and once on the first review reminder created) is a bit weird. Perhaps we should have the BottomSheet have a chance to show up again if a user who hasn't granted notification permissions performs an action that requires notification permissions. For example, we currently only request notification permissions at most once when the user first performs a sync. Maybe we can request it again on the 5th sync and the 10th sync, and only then fully give up? This has the potential to be annoying to the user, though. Similarly with requesting notification permissions when the user creates review reminders: we currently only request permissions when the user creates a review reminder for the first time, and can potentially change it so that it requests it again on the 5th and 10th review reminder created, and never again after that. This change would mean editing the Prefs.syncNotifsRequestShown to be an Int instead of a Boolean, and incrementing it based on how many times the permission request BottomSheet has been shown.

Thoughts?

Permissions.requestNotificationsPermissionIfNeeded(context = this, supportFragmentManager) {
Prefs.syncNotifsRequestShown = true
}
}

/** Nested function that makes the connection to
* the sync server and starts syncing the data */
Expand Down
4 changes: 4 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
}

/**
Expand Down
45 changes: 2 additions & 43 deletions AnkiDroid/src/main/java/com/ichi2/anki/MyAccount.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -137,7 +126,7 @@ open class MyAccount : AnkiActivity() {
return
}
Timber.i("Attempting auto-login")
handleNewLogin(username, password, notificationPermissionLauncher)
handleNewLogin(username, password)
}

private fun login() {
Expand All @@ -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() {
Expand Down Expand Up @@ -332,7 +321,6 @@ open class MyAccount : AnkiActivity() {
private fun handleNewLogin(
username: String,
password: String,
resultLauncher: ActivityResultLauncher<String>,
) {
val endpoint = getEndpoint()
launchCatchingTask {
Expand All @@ -355,7 +343,6 @@ open class MyAccount : AnkiActivity() {
}
updateLogin(username, auth.hkey)
setResult(RESULT_OK)
checkNotificationPermission(this@MyAccount, resultLauncher)
finish()
}
}
Expand All @@ -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<String>,
) {
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)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}

Expand Down
18 changes: 18 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 ********************************** //

/**
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2025 Eric Li <[email protected]>
*
* 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 <http://www.gnu.org/licenses/>.
*/

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<PermissionsItem>(R.id.notification_permission)
Permissions.postNotification?.let {
notificationPermission.offerToGrantOrRevokeOnClick(
notificationPermissionLauncher,
arrayOf(Permissions.postNotification),
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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?) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* Copyright (c) 2025 Eric Li <[email protected]>
*
* 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 <http://www.gnu.org/licenses/>.
*/

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 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.google.android.material.button.MaterialButton
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<MaterialButton>(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)
}
}
}
Loading