Skip to content
Merged
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
12 changes: 4 additions & 8 deletions AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ import com.ichi2.utils.ImportResult
import com.ichi2.utils.ImportUtils
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 @@ -500,11 +501,6 @@ open class DeckPicker :
}
}

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

// ----------------------------------------------------------------------------
// ANDROID ACTIVITY METHODS
// ----------------------------------------------------------------------------
Expand Down Expand Up @@ -1446,9 +1442,10 @@ open class DeckPicker :
fun refreshState() {
// Due to the App Introduction, this may be called before permission has been granted.
if (syncOnResume && hasCollectionStoragePermissions()) {
syncOnResume = false
Timber.i("Performing Sync on Resume")
Permissions.requestNotificationPermissionsForSyncing(this)
sync()
syncOnResume = false
} else {
selectNavigationItem(R.id.nav_decks)
updateDeckList()
Expand Down Expand Up @@ -2031,8 +2028,6 @@ open class DeckPicker :
return
}

AccountActivity.checkNotificationPermission(this, notificationPermissionLauncher)

/** Nested function that makes the connection to
* the sync server and starts syncing the data */
fun doSync() {
Expand All @@ -2048,6 +2043,7 @@ open class DeckPicker :
Prefs.allowSyncOnMeteredConnections = isCheckboxChecked
}
}
refreshState()
} else {
doSync()
}
Expand Down
6 changes: 6 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package com.ichi2.anki

import android.Manifest
import android.content.Context
import android.content.SharedPreferences
import android.database.sqlite.SQLiteFullException
Expand All @@ -29,6 +30,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 @@ -213,6 +215,10 @@ enum class PermissionSet(
EXTERNAL_MANAGER(listOf(Permissions.MANAGE_EXTERNAL_STORAGE), PermissionsStartingAt30Fragment::class.java),

APP_PRIVATE(emptyList(), null),

/** Optional. */
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
NOTIFICATIONS(listOf(Manifest.permission.POST_NOTIFICATIONS), NotificationsPermissionFragment::class.java),
}

/**
Expand Down
26 changes: 0 additions & 26 deletions AnkiDroid/src/main/java/com/ichi2/anki/account/AccountActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,41 +19,15 @@ package com.ichi2.anki.account

import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.activity.result.ActivityResultLauncher
import androidx.core.os.bundleOf
import com.ichi2.anki.SingleFragmentActivity
import com.ichi2.anki.isLoggedIn
import com.ichi2.utils.Permissions

class AccountActivity : SingleFragmentActivity() {
companion object {
/** Sees if we want to go back to the DeckPicker after login*/
const val START_FROM_DECKPICKER = "START_FOR_RESULT"

/**
* 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 ?: return

if (!Permissions.canPostNotifications(context)) {
launcher.launch(permission)
}
}

/**
* Returns an [Intent] to launch either [LoggedInFragment] or [LoginFragment]
* based on the current login state.
Expand Down
10 changes: 2 additions & 8 deletions AnkiDroid/src/main/java/com/ichi2/anki/account/LoginFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import android.view.KeyEvent
import android.view.View
import android.widget.Button
import android.widget.ImageView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.net.toUri
import androidx.core.view.isVisible
Expand All @@ -50,6 +49,7 @@ import com.ichi2.anki.utils.hideKeyboard
import com.ichi2.anki.utils.openUrl
import com.ichi2.anki.withProgress
import com.ichi2.ui.TextInputEditField
import com.ichi2.utils.Permissions
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import timber.log.Timber
Expand All @@ -64,11 +64,6 @@ class LoginFragment : Fragment(R.layout.my_account) {
private lateinit var loginLogo: ImageView
private lateinit var loginButton: Button

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

override fun onViewCreated(
view: View,
savedInstanceState: Bundle?,
Expand Down Expand Up @@ -218,14 +213,13 @@ class LoginFragment : Fragment(R.layout.my_account) {
activity.setResult(RESULT_OK)
activity.finish()
} else {
AccountActivity.checkNotificationPermission(requireContext(), notificationPermissionLauncher)

val fragmentManager = activity.supportFragmentManager
fragmentManager
.beginTransaction()
.replace(R.id.fragment_container, LoggedInFragment())
.commit()
fragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
Permissions.requestNotificationPermissionsForSyncing(requireActivity())
}
}
is LoginState.Error -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,14 @@ class DeckPickerViewModel :

// HACK: dismiss a legacy progress bar
// TODO: Replace with better progress handling for first load/corrupt collections
val flowOfDecksReloaded = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
// This MutableSharedFlow has replay=1 due to a race condition between its collector being started
// and a possible early emission that occurs when the user is on a metered network and a dialog has to show up
// to ask the user if they want to trigger a sync. Normally, the spinning progress indicator is
// dismissed via an emission to this flow after the sync is completed, but if the metered network
// warning dialog appears, we should immediately refresh the UI in case the user decides not to sync.
// Otherwise, the progress indicator remains indefinitely. This replay=1 ensures that the collector will
// receive the dismissal event even if it starts after the emission.
val flowOfDecksReloaded = MutableSharedFlow<Unit>(extraBufferCapacity = 1, replay = 1)

/**
* Deletes the provided deck, child decks. and all cards inside.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,12 @@ 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.startDeckSelection
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 @@ -333,6 +335,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 due to review reminders ever before
if (!Prefs.reminderNotifsRequestShown) {
Permissions.showNotificationsPermissionBottomSheetIfNeeded(requireActivity(), parentFragmentManager) {
Prefs.reminderNotifsRequestShown = true
}
}

dismiss()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,14 @@

package com.ichi2.anki.reviewreminders

import android.Manifest
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.core.os.BundleCompat
import androidx.core.view.isVisible
Expand All @@ -28,6 +32,7 @@ import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.setFragmentResultListener
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.snackbar.Snackbar
import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.anki.CrashReportData.Companion.toCrashReportData
import com.ichi2.anki.R
Expand All @@ -39,12 +44,15 @@ import com.ichi2.anki.launchCatchingTask
import com.ichi2.anki.libanki.DeckId
import com.ichi2.anki.model.SelectableDeck
import com.ichi2.anki.services.AlarmManagerService
import com.ichi2.anki.settings.Prefs
import com.ichi2.anki.showError
import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider
import com.ichi2.anki.snackbar.SnackbarBuilder
import com.ichi2.anki.snackbar.showSnackbar
import com.ichi2.anki.utils.ext.showDialogFragment
import com.ichi2.anki.withProgress
import com.ichi2.utils.Permissions
import com.ichi2.utils.Permissions.requestPermissionThroughDialogOrSettings
import dev.androidbroadcast.vbpd.viewBinding
import kotlinx.serialization.SerializationException
import timber.log.Timber
Expand Down Expand Up @@ -76,6 +84,18 @@ class ScheduleReminders :
anchorView = binding.floatingActionButtonAdd
}

private var notificationPermissionSnackbar: Snackbar? = null

/**
* Launches the OS dialog for requesting notification permissions.
* If notification permissions are not granted, a small persistent Snackbar reminder about it shows up.
* When the user clicks the "Enable" action on the Snackbar, this launcher is used.
*/
private val notificationPermissionLauncher =
registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) { isGranted -> Timber.i("Notification permission result: $isGranted") }

/**
* The reminders currently being displayed in the UI. To make changes to this list show up on screen,
* use [triggerUIUpdate]. Note that editing this map does not also automatically write to the database.
Expand Down Expand Up @@ -432,6 +452,39 @@ class ScheduleReminders :
binding.noRemindersPlaceholder.isVisible = listToDisplay.isEmpty()
}

override fun onResume() {
super.onResume()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
checkForNotificationPermissions()
}
}

/**
* Shows a persistent snackbar if the user has not granted notification permissions.
*/
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private fun checkForNotificationPermissions() {
if (!Prefs.reminderNotifsRequestShown || Permissions.canPostNotifications(requireContext())) {
notificationPermissionSnackbar?.dismiss()
return
}

notificationPermissionSnackbar =
showSnackbar(
text = "Notifications are disabled",
duration = Snackbar.LENGTH_INDEFINITE,
) {
setAction("Enable") {
requestPermissionThroughDialogOrSettings(
activity = requireActivity(),
permission = Manifest.permission.POST_NOTIFICATIONS,
permissionRequestedFlag = Prefs::notificationsPermissionRequested,
permissionRequestLauncher = notificationPermissionLauncher,
)
}
}
}

companion object {
/**
* Arguments key for passing the [ReviewReminderScope] to open this fragment with.
Expand Down
51 changes: 51 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 @@ -266,6 +266,15 @@ open class PrefsRepository(

//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)
Copy link
Member

Choose a reason for hiding this comment

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

Given -

  • Permissions are valid for a specific device
  • SharedPreferences are considered app data and are subject to the app's data backup and restore functionality, possibly on another device

Then -

  • we may have a situation where the shared preference data related to permission have restored onto a device where they are no longer a valid representation of state

Uncertainty -

  • I'm not sure if we have configured our app data backup at all, with respect to Google's "auto-backup" system. If there is no configuration then by default shared prefs will be backed up. If we have opted out then this does not matter
  • I'm not sure if our Prefs system allows us to specify a different shared prefs xml file in user data for specific preferences or not

Action -

Or alternatively we can just note this possibility in the code, and ignore it. The failure case is after an app restore on to a new device, we will not request optional permissions. As long as there is a nice header on reminder edit dialog (as I mentioned separately) warning the user that notifications can't be shown, users should clue in

Copy link
Member Author

Choose a reason for hiding this comment

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

Good point. I like your proposed solution of noting and ignoring this though, as I've now implemented the little notification box in ScheduleReminders if notifications are disabled, irrespective of whatever Prefs are set. I've added some docs to Prefs to document this decision!

For curiosity's sake, I checked whether Google app data backup applies to AnkiDroid, and I think it does? Looking through the manifest and https://developer.android.com/identity/data/autobackup, it seems that it's enabled for everything in AnkiDroid's storage directory, though it turns off after collecting 25MB, which some collections may exceed in size, so whether what it collects is actually useful is a different story entirely.


// ************************************** Review Reminders ********************************** //

/**
Expand All @@ -278,6 +287,48 @@ open class PrefsRepository(
*/
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)

// *************************************** Permissions ************************************** //

// Flags for whether the system UI dialog for requesting certain permissions has been shown before.
// If the user has viewed the dialog at least once, we should check if they pressed "don't ask again"
// or pressed "deny" repeatedly (via [androidx.core.app.ActivityCompat.shouldShowRequestPermissionRationale]).
// This is because trying to show the system dialog again after the user has indicated they don't want to see it
// is likely tracked by Play Console statistics and may lead to lower Play Store discoverability.
//
// @see com.ichi2.anki.ui.windows.permissions.PermissionsFragment.requestPermissionThroughDialogOrSettings
// @see com.ichi2.utils.Permissions.isUserOpenToPermission

/**
* Whether the system UI dialog for requesting notification permissions has been shown before.
*
* Flags like [reminderNotifsRequestShown] etc. are not enough because those flags check if
* the BottomSheet dialog explaining the need for notification permissions has been shown before,
* whereas this flag checks if the system dialog has been shown before.
*
* If the user restores their data from a backup or migrates to a new device, this flag may be true
* when in reality notification permissions have not been requested for the device. This is most prominently
* an issue for the review reminders feature, so to ensure the user is able to receive review reminder notifications after
* a data restore / migration, a Snackbar noting that notification permissions are missing will be shown
* on the [com.ichi2.anki.reviewreminders.ScheduleReminders] fragment if notification permissions are not granted.
*
* @see com.ichi2.anki.reviewreminders.ScheduleReminders.checkForNotificationPermissions
*/
var notificationsPermissionRequested by booleanPref(R.string.notifications_permission_requested_key, false)

/**
* Whether the system UI dialog for requesting audio recording permissions has been shown before.
*/
var recordAudioPermissionRequested by booleanPref(R.string.record_audio_permission_requested_key, false)

// **************************************** Reviewer **************************************** //

val ignoreDisplayCutout by booleanPref(R.string.ignore_display_cutout_key, false)
Expand Down
Loading