Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Permission screen #30

Closed
wants to merge 13 commits into from
4 changes: 4 additions & 0 deletions AnkiDroid/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,10 @@
android:exported="false"
android:configChanges="keyboardHidden|orientation|screenSize"
/>
<activity
android:name=".ui.windows.permissions.PermissionsActivity"
android:exported="false"
/>
<activity
android:name=".ModelFieldEditor"
android:label="@string/model_editor_label"
Expand Down
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
177 changes: 45 additions & 132 deletions AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,20 @@ import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import android.os.Environment
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.annotations.NeedsTest
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.utils.Permissions
import com.ichi2.utils.VersionUtils.pkgVersionName
import kotlinx.parcelize.Parcelize
import timber.log.Timber

/** Utilities for launching the first activity (currently the DeckPicker) */
Expand Down Expand Up @@ -125,27 +128,45 @@ object InitialActivity {
}
}

/**
* Whether we should try a startup with a permission dialog + folder which is safe from uninstalling
* or go straight into AnkiDroid
*/
sealed interface AnkiDroidFolder {
sealed class AnkiDroidFolder(val permissionSet: PermissionSet) {
/**
* AnkiDroid will use the folder ~/AnkiDroid by default
* To access it, we must first get [requiredPermissions].
* To access it, we must first get [permissionSet].permissions.
* This folder is not deleted when the user uninstalls the app, which reduces the risk of data loss,
* but increase the risk of space used on their storage when they don't want to.
* It can not be used on the play store starting with Sdk 30.
**/
class PublicFolder(val requiredPermissions: Array<String>) : AnkiDroidFolder
class PublicFolder(requiredPermissions: PermissionSet) : AnkiDroidFolder(requiredPermissions)

/**
* AnkiDroid will use the app-private folder: `~/Android/data/com.ichi2.anki[.A]/files/AnkiDroid`.
* The user may delete when they uninstall the app, risking data loss.
* No permission dialog is required.
* Google will not allow [android.Manifest.permission.MANAGE_EXTERNAL_STORAGE], so this is default on the Play Store.
*/
object AppPrivateFolder : AnkiDroidFolder
object AppPrivateFolder : AnkiDroidFolder(PermissionSet.APP_PRIVATE)

fun hasRequiredPermissions(context: Context): Boolean {
return Permissions.hasAllPermissions(context, permissionSet.permissions)
}
}

@Parcelize
enum class PermissionSet(val permissions: List<String>, val permissionsFragment: Class<out PermissionsFragment>?) : Parcelable {
LEGACY_ACCESS(Permissions.legacyStorageAccessPermissions, PermissionsUntil29Fragment::class.java),

@RequiresApi(Build.VERSION_CODES.R)
EXTERNAL_MANAGER(listOf(Permissions.MANAGE_EXTERNAL_STORAGE), Full30and31PermissionsFragment::class.java),

/** [Permissions.legacyStorageAccessPermissions] are asked to get access to media,
* so the "check media" function can work, and media is synced */
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
TIRAMISU_EXTERNAL_MANAGER(
permissions = listOf(Permissions.MANAGE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE),
permissionsFragment = TiramisuPermissionsFragment::class.java
),

APP_PRIVATE(emptyList(), null);
}

/**
Expand All @@ -163,135 +184,27 @@ internal fun selectAnkiDroidFolder(
// since it's fast & safe up to & including 'Q'
// If a user upgrades their OS from Android 10 to 11 then storage speed is severely reduced
// and a user should use one of the below options to provide faster speeds
return AnkiDroidFolder.PublicFolder(
arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE
)
)
return AnkiDroidFolder.PublicFolder(PermissionSet.LEGACY_ACCESS)
}

// If the user can manage external storage, we can access the safe folder & access is fast
return if (canManageExternalStorage) {
AnkiDroidFolder.PublicFolder(arrayOf(Manifest.permission.MANAGE_EXTERNAL_STORAGE))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
AnkiDroidFolder.PublicFolder(PermissionSet.TIRAMISU_EXTERNAL_MANAGER)
} else {
AnkiDroidFolder.PublicFolder(PermissionSet.EXTERNAL_MANAGER)
}
} else {
return AnkiDroidFolder.AppPrivateFolder
}
}

/**
* 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.requiredPermissions
}

/**
* 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()
}
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 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()
}
}

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
)
}
}
}
Loading