Skip to content

Commit

Permalink
feat: Request permissions with a permission screen
Browse files Browse the repository at this point in the history
Simplifies things by not having to handle callbacks or blocking UI elements/background tasks while getting a permission.

Also, it can be easily launched in other screens if necessary, which can avoid crashes like ankidroid#13518

Additionally, adds a better UI/UX for the user
  • Loading branch information
BrayanDSO committed Jul 13, 2023
1 parent 2399976 commit 73874d2
Show file tree
Hide file tree
Showing 3 changed files with 20 additions and 158 deletions.
2 changes: 1 addition & 1 deletion AnkiDroid/src/main/java/com/ichi2/anki/CollectionHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ open class CollectionHelper {
// TODO Tracked in https://github.com/ankidroid/Anki-Android/issues/5304
@CheckResult
fun getDefaultAnkiDroidDirectory(context: Context): String {
val legacyStorage = StartupStoragePermissionManager.selectAnkiDroidFolder(context) != AppPrivateFolder
val legacyStorage = selectAnkiDroidFolder(context) != AppPrivateFolder
return if (!legacyStorage) {
File(getAppSpecificExternalAnkiDroidDirectory(context), "AnkiDroid").absolutePath
} else {
Expand Down
53 changes: 13 additions & 40 deletions AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@

package com.ichi2.anki

import android.Manifest
import android.app.Activity
import android.content.*
import android.database.SQLException
Expand All @@ -37,6 +36,7 @@ import android.util.TypedValue
import android.view.*
import android.view.View.OnLongClickListener
import android.widget.*
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AlertDialog
Expand Down Expand Up @@ -85,9 +85,6 @@ import com.ichi2.anki.exception.ImportExportException
import com.ichi2.anki.export.ActivityExportingDelegate
import com.ichi2.anki.export.ExportType
import com.ichi2.anki.notetype.ManageNotetypes
import com.ichi2.anki.permissions.PermissionManager
import com.ichi2.anki.permissions.PermissionsRequestRawResults
import com.ichi2.anki.permissions.PermissionsRequestResults
import com.ichi2.anki.preferences.AdvancedSettingsFragment
import com.ichi2.anki.preferences.sharedPrefs
import com.ichi2.anki.receiver.SdCardReceiver
Expand All @@ -101,6 +98,7 @@ import com.ichi2.anki.services.getMediaMigrationState
import com.ichi2.anki.snackbar.showSnackbar
import com.ichi2.anki.stats.AnkiStatsTaskHandler
import com.ichi2.anki.ui.dialogs.storageMigrationFailedDialogIsShownOrPending
import com.ichi2.anki.ui.windows.permissions.PermissionsActivity
import com.ichi2.anki.web.HostNumFactory
import com.ichi2.anki.widgets.DeckAdapter
import com.ichi2.annotations.NeedsTest
Expand Down Expand Up @@ -134,7 +132,6 @@ import org.json.JSONException
import timber.log.Timber
import java.io.File
import java.lang.Runnable
import java.lang.ref.WeakReference
import kotlin.math.roundToLong
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.ExperimentalTime
Expand Down Expand Up @@ -248,15 +245,9 @@ open class DeckPicker :
private lateinit var mCustomStudyDialogFactory: CustomStudyDialogFactory
private lateinit var mContextMenuFactory: DeckPickerContextMenu.Factory

private lateinit var startupStoragePermissionManager: StartupStoragePermissionManager

// used for check media
private val checkMediaStoragePermissionCheck = PermissionManager.register(
this,
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE),
useCallbackIfActivityRecreated = true,
callback = callbackHandlingStoragePermissionsCheckForCheckMedia(WeakReference(this))
)
private val permissionScreenLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
recreate()
}

private var migrateStorageAfterMediaSyncCompleted = false

Expand Down Expand Up @@ -329,7 +320,6 @@ open class DeckPicker :
// Then set theme and content view
super.onCreate(savedInstanceState)

startupStoragePermissionManager = StartupStoragePermissionManager.register(this, useCallbackIfActivityRecreated = false)
// handle the first load: display the app introduction
if (!hasShownAppIntro()) {
val appIntro = Intent(this, IntroductionActivity::class.java)
Expand Down Expand Up @@ -495,11 +485,11 @@ open class DeckPicker :
* This method triggers backups, sync, and may re-show dialogs
* that may have been dismissed. Make this run only once?
*/
fun handleStartup() {
val storagePermissionsResult = startupStoragePermissionManager.checkPermissions()
if (storagePermissionsResult.requiresPermissionDialog) {
private fun handleStartup() {
val ankiDroidFolder = selectAnkiDroidFolder(this)
if (!ankiDroidFolder.hasRequiredPermissions(this)) {
Timber.i("postponing startup code - dialog shown")
startupStoragePermissionManager.displayStoragePermissionDialog()
permissionScreenLauncher.launch(PermissionsActivity.getIntent(this, ankiDroidFolder.permissionSet))
return
}

Expand Down Expand Up @@ -1536,7 +1526,10 @@ open class DeckPicker :
* If has the storage permission, job is scheduled, otherwise storage permission is asked first.
*/
override fun mediaCheck() {
checkMediaStoragePermissionCheck.launchDialogOrExecuteCallbackNow()
launchCatchingTask {
val mediaCheckResult = checkMedia() ?: return@launchCatchingTask
showMediaCheckDialog(MediaCheckDialog.DIALOG_MEDIA_CHECK_RESULTS, mediaCheckResult)
}
}

override fun deleteUnused(unused: List<String>) {
Expand Down Expand Up @@ -2505,26 +2498,6 @@ open class DeckPicker :
private const val AUTOMATIC_SYNC_MINIMAL_INTERVAL_IN_MINUTES: Long = 10
private const val SWIPE_TO_SYNC_TRIGGER_DISTANCE = 400

/**
* Handles a [PermissionsRequestResults] for storage permissions to launch 'checkMedia'
* Static to avoid a context leak
*/
fun callbackHandlingStoragePermissionsCheckForCheckMedia(deckPickerRef: WeakReference<DeckPicker>): (permissionResultRaw: PermissionsRequestRawResults) -> Unit {
fun handleStoragePermissionsCheckForCheckMedia(permissionResultRaw: PermissionsRequestRawResults) {
val deckPicker = deckPickerRef.get() ?: return
val permissionResult = PermissionsRequestResults.from(deckPicker, permissionResultRaw)
if (permissionResult.allGranted) {
deckPicker.launchCatchingTask {
val mediaCheckResult = deckPicker.checkMedia() ?: return@launchCatchingTask
deckPicker.showMediaCheckDialog(MediaCheckDialog.DIALOG_MEDIA_CHECK_RESULTS, mediaCheckResult)
}
} else {
showThemedToast(deckPicker, R.string.check_media_failed, true)
}
}
return ::handleStoragePermissionsCheckForCheckMedia
}

// Animation utility methods used by renderPage() method
fun fadeIn(view: View?, duration: Int, translation: Float = 0f, startAction: Runnable? = Runnable { view!!.visibility = View.VISIBLE }): ViewPropertyAnimator {
view!!.alpha = 0f
Expand Down
123 changes: 6 additions & 117 deletions AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,13 @@ import android.os.Parcelable
import androidx.annotation.CheckResult
import androidx.annotation.RequiresApi
import androidx.core.content.edit
import com.ichi2.anki.permissions.PermissionManager
import com.ichi2.anki.permissions.PermissionsRequestResults
import com.ichi2.anki.permissions.finishActivityAndShowAppPermissionManagementScreen
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.Full30and31PermissionsFragment
import com.ichi2.anki.ui.windows.permissions.PermissionsFragment
import com.ichi2.anki.ui.windows.permissions.PermissionsUntil29Fragment
import com.ichi2.anki.ui.windows.permissions.TiramisuPermissionsFragment
import com.ichi2.annotations.NeedsTest
import com.ichi2.utils.Permissions
import com.ichi2.utils.VersionUtils.pkgVersionName
import kotlinx.parcelize.Parcelize
Expand Down Expand Up @@ -203,119 +199,12 @@ internal fun selectAnkiDroidFolder(
}
}

/**
* Logic related to [DeckPicker] startup - required permissions for storage
* Handles: Accept, Deny + Permanent Deny of permissions
*
* Designed to allow expansion for more complex logic
*/
@NeedsTest("New User: Accepts permission")
@NeedsTest("New User: Denies permission then accepts")
@NeedsTest("New User: Denies permission then denies permanently")
@NeedsTest("New User: Denies permission permanently")
@NeedsTest("Existing User: Permission Granted")
@NeedsTest("Existing User: System removed permission")
@NeedsTest("Existing User: Changes Deck")
class StartupStoragePermissionManager private constructor(
private val deckPicker: DeckPicker,
ankidroidFolder: AnkiDroidFolder,
useCallbackIfActivityRecreated: Boolean
) {
private var timesRequested: Int = 0
private val requiredPermissions = when (ankidroidFolder) {
is AnkiDroidFolder.AppPrivateFolder -> noPermissionDialogRequired
is AnkiDroidFolder.PublicFolder -> ankidroidFolder.permissionSet.permissions.toTypedArray()
}

/**
* Show "Please grant AnkiDroid the ‘Storage’ permission to continue" and open Android settings
* for AnkiDroid's permissions
*/
private fun onPermissionPermanentlyDenied() {
// User denied access to file storage so show error toast and display "App Info"
UIUtils.showThemedToast(deckPicker, R.string.startup_no_storage_permission, false)
// note: this may not be defined on some Phones. In which case we still have a toast
deckPicker.finishActivityAndShowAppPermissionManagementScreen()
}

private fun onRegularStartup() {
deckPicker.invalidateOptionsMenu()
deckPicker.handleStartup()
}

private fun retryPermissionRequest(displayError: Boolean) {
if (timesRequested < 3) {
displayStoragePermissionDialog()
} else {
if (displayError) {
Timber.w("doing nothing - app is probably broken")
CrashReportService.sendExceptionReport("Multiple errors obtaining permissions", "InitialActivity::permissionManager")
}
onPermissionPermanentlyDenied()
}
}
fun selectAnkiDroidFolder(context: Context): AnkiDroidFolder {
val canAccessLegacyStorage = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || Environment.isExternalStorageLegacy()
val currentFolderIsAccessibleAndLegacy = canAccessLegacyStorage && isLegacyStorage(context, setCollectionPath = false) == true

private val permissionManager = PermissionManager.register(
activity = deckPicker,
permissions = requiredPermissions,
useCallbackIfActivityRecreated = useCallbackIfActivityRecreated,
callback = { permissionDialogResultRaw ->
val permissionDialogResult = PermissionsRequestResults.from(deckPicker, permissionDialogResultRaw)
with(permissionDialogResult) {
when {
allGranted -> onRegularStartup()
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && hasPermanentlyDeniedPermissions -> onPermissionPermanentlyDenied()
// try again (recurse), we need the permission
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && hasTemporarilyDeniedPermissions -> retryPermissionRequest(displayError = false)
hasRejectedPermissions -> retryPermissionRequest(displayError = false)
cancelled -> {
if (timesRequested == 1) {
UIUtils.showThemedToast(deckPicker, R.string.something_wrong, false)
}
retryPermissionRequest(displayError = true)
}
}
}
}
return selectAnkiDroidFolder(
canManageExternalStorage = Permissions.canManageExternalStorage(context),
currentFolderIsAccessibleAndLegacy = currentFolderIsAccessibleAndLegacy
)

fun displayStoragePermissionDialog() {
timesRequested++
permissionManager.launchPermissionDialog()
}

fun checkPermissions() = permissionManager.checkPermissions()

companion object {
/** If no permissions are provided, no dialog is shown */
private val noPermissionDialogRequired = emptyArray<String>()

/**
* This **must** be called unconditionally, as part of initialization path inside after `super.onCreate`
* */
fun register(
deckPicker: DeckPicker,
useCallbackIfActivityRecreated: Boolean
): StartupStoragePermissionManager {
// This must be called unconditionally due to the use of PermissionManager.register
// This must be called after `onCreate` due to the use of deckPicker as a context

val permissionRequest = selectAnkiDroidFolder(deckPicker)
return StartupStoragePermissionManager(
deckPicker,
permissionRequest,
useCallbackIfActivityRecreated = useCallbackIfActivityRecreated
)
}

fun selectAnkiDroidFolder(context: Context): AnkiDroidFolder {
val canAccessLegacyStorage = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || Environment.isExternalStorageLegacy()
val currentFolderIsAccessibleAndLegacy = canAccessLegacyStorage && isLegacyStorage(context, setCollectionPath = false) == true

return selectAnkiDroidFolder(
canManageExternalStorage = Permissions.canManageExternalStorage(context),
currentFolderIsAccessibleAndLegacy = currentFolderIsAccessibleAndLegacy
)
}
}
}

0 comments on commit 73874d2

Please sign in to comment.