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
19 changes: 19 additions & 0 deletions AnkiDroid/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
but this means preserveLegacyExternalStorage is inactive, and users do not have access to
the ~/AnkiDroid public directory
-->

<!-- Permissions used by AnkiDroid
If you add any new ones that require explicit user permission, please update the
AllPermissionsExplanation fragment (see ui.windows.permissions) -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage"/>
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.INTERNET" />
Expand Down Expand Up @@ -343,6 +347,21 @@
android:exported="false"
android:configChanges="keyboardHidden|orientation|screenSize"
/>

<!-- The activity that shows up when the user selects a more-info button in the OS permission settings for AnkiDroid
The more-info button only shows up in the OS settings at or above API 31
See https://developer.android.com/training/permissions/explaining-access#privacy-dashboard -->
<activity
android:name=".ui.windows.permissions.AllPermissionsExplanationActivity"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE"
android:exported="true"
tools:targetApi="31">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE_FOR_PERIOD" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name=".ui.windows.permissions.PermissionsActivity"
android:exported="false"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* 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 androidx.annotation.RequiresApi
import androidx.fragment.app.commit
import com.ichi2.anki.AnkiActivity
import com.ichi2.anki.R
import com.ichi2.themes.setTransparentStatusBar

/**
* When the user opens the Android settings app and navigates to AnkiDroid's permissions,
* there will be a "more info" button which will launch this activity. See
* [the docs](https://developer.android.com/training/permissions/explaining-access#privacy-dashboard).
* This button in the Android settings app is only visible at or above API 31.
*
* This activity is used to host the [AllPermissionsExplanationFragment] fragment.
*/
@RequiresApi(Build.VERSION_CODES.S)
class AllPermissionsExplanationActivity : AnkiActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
if (showedActivityFailedScreen(savedInstanceState)) {
return
}
super.onCreate(savedInstanceState)
setContentView(R.layout.all_permissions_explanation_activity)
setTransparentStatusBar()

supportFragmentManager.commit {
replace(R.id.fragment_container, AllPermissionsExplanationFragment())
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* 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 androidx.core.view.isVisible
import com.ichi2.anki.R
import com.ichi2.utils.Permissions
import timber.log.Timber

/**
* Permissions explanation screen that appears when the user clicks on the extra info buttons next to the permissions
* AnkiDroid requests in the OS settings screen. Explains the permissions AnkiDroid requests and provides switches for
* toggling them on or off.
*
* See [the docs](https://developer.android.com/training/permissions/explaining-access#privacy-dashboard).
*/
@RequiresApi(Build.VERSION_CODES.S)
class AllPermissionsExplanationFragment : PermissionsFragment(R.layout.all_permissions_explanation_fragment) {
/**
* Attempts to open the dialog for granting permissions. Falls back to opening the OS settings if the dialog fails to
* show up or if the permissions are rejected by the user. The dialog may fail to show up if the user has previously denied the
* permissions multiple times, if the user selects "don't ask again" on the permissions dialog, etc.
*/
private val permissionRequestLauncher =
registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { requestedPermissions ->
Timber.i("Permission result: $requestedPermissions")
if (!requestedPermissions.all { it.value }) {
showToastAndOpenAppSettingsScreen(R.string.manually_grant_permissions)
}
}

/**
* Activity launcher for the external storage management permission.
*/
private val accessAllFilesLauncher =
registerForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) {}

override fun onViewCreated(
view: View,
savedInstanceState: Bundle?,
) {
super.onViewCreated(view, savedInstanceState)

val externalStoragePermission = view.findViewById<PermissionsItem>(R.id.manage_external_storage_permission_item)
val notificationPermission = view.findViewById<PermissionsItem>(R.id.post_notification_permission_item)
val recordAudioPermission = view.findViewById<PermissionsItem>(R.id.record_audio_permission_item)

val shouldRequestExternalStorage = Permissions.canManageExternalStorage(requireContext())
if (shouldRequestExternalStorage) {
externalStoragePermission.apply {
isVisible = true
requestExternalStorageOnClick(accessAllFilesLauncher)
}
}
view.findViewById<View>(R.id.heading_required_permissions).isVisible = shouldRequestExternalStorage

Permissions.postNotification?.let {
notificationPermission.apply {
isVisible = true
offerToGrantOrRevokeOnClick(permissionRequestLauncher, arrayOf(it))
}
}

recordAudioPermission.apply {
isVisible = true
offerToGrantOrRevokeOnClick(
permissionRequestLauncher,
arrayOf(Permissions.recordAudioPermission),
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import androidx.core.os.bundleOf
import androidx.core.view.allViews
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResult
import com.ichi2.anki.R
import com.ichi2.anki.showThemedToast
import timber.log.Timber

Expand Down Expand Up @@ -94,6 +95,38 @@ abstract class PermissionsFragment(
}
}

/**
* Set this PermissionItem so that when it is clicked, the app requests external file management permissions
* from the user.
*/
@RequiresApi(Build.VERSION_CODES.R)
protected fun PermissionsItem.requestExternalStorageOnClick(launcher: ActivityResultLauncher<Intent>) {
setOnPermissionsRequested { areAlreadyGranted ->
if (!areAlreadyGranted) launcher.showManageAllFilesScreen()
}
}

/**
* If these permissions are already granted, open the OS settings to allow the user to disable them, as
* it is impossible to programmatically revoke a permission. If the permissions have not been granted,
* use [permissionRequestLauncher] to try and grant them. Note that [permissionRequestLauncher] also falls back
* to opening the OS settings if the dialog fails to show up. This may happen if the user has previously
* denied the permissions multiple times, selected "don't ask again" on the permissions dialog, etc.
*/
protected fun PermissionsItem.offerToGrantOrRevokeOnClick(
permissionRequestLauncher: ActivityResultLauncher<Array<String>>,
permissions: Array<String>,
) {
setOnPermissionsRequested { areAlreadyGranted ->
if (areAlreadyGranted) {
// Offer the ability to revoke the permission
showToastAndOpenAppSettingsScreen(R.string.revoke_permissions)
} else {
permissionRequestLauncher.launch(permissions)
}
}
}

companion object {
const val PERMISSIONS_FRAGMENT_RESULT_KEY = "PERMISSION_FRAGMENT_RESULT"
const val HAS_ALL_PERMISSIONS_KEY = "HAS_ALL_PERMISSIONS"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ class PermissionsStartingAt30Fragment : PermissionsFragment(R.layout.permissions
view: View,
savedInstanceState: Bundle?,
) {
view.findViewById<PermissionsItem>(R.id.all_files_permission).setOnPermissionsRequested {
accessAllFilesLauncher.showManageAllFilesScreen()
}
view
.findViewById<PermissionsItem>(R.id.all_files_permission)
.requestExternalStorageOnClick(accessAllFilesLauncher)
}
}
4 changes: 3 additions & 1 deletion AnkiDroid/src/main/java/com/ichi2/utils/Permissions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ object Permissions {
Manifest.permission.WRITE_EXTERNAL_STORAGE,
)

fun canRecordAudio(context: Context): Boolean = hasPermission(context, Manifest.permission.RECORD_AUDIO)
val recordAudioPermission = Manifest.permission.RECORD_AUDIO

fun canRecordAudio(context: Context): Boolean = hasPermission(context, recordAudioPermission)

/**
* Whether the app is granted [permission]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/root_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.windows.permissions.AllPermissionsExplanationActivity">

<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">

<com.google.android.material.textview.MaterialTextView
android:id="@+id/headline"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="32dp"
android:text="@string/permissions_screen_optional_headline"
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
android:textColor="?android:attr/textColorPrimary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<ScrollView
android:id="@+id/permissions_scroll_area"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/headline"
app:layout_constraintBottom_toTopOf="@id/continue_button">

<androidx.fragment.app.FragmentContainerView
android:id="@+id/fragment_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />

</ScrollView>

</androidx.constraintlayout.widget.ConstraintLayout>

</androidx.coordinatorlayout.widget.CoordinatorLayout>
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".ui.windows.permissions.AllPermissionsExplanationFragment">

<TextView
android:id="@+id/heading_required_permissions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:textColor="@color/material_blue_500"
android:textStyle="bold"
android:text="@string/heading_required_permissions" />

<com.ichi2.anki.ui.windows.permissions.PermissionsItem
android:id="@+id/manage_external_storage_permission_item"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:permissionTitle="@string/all_files_access_title"
app:permissionSummary="@string/storage_access_summary"
app:permissionIcon="@drawable/ic_save_white"
app:permission="@string/manage_external_storage_permission"
android:visibility="gone"
tools:visibility="visible"
/>

<TextView
android:id="@+id/heading_optional_permissions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:textColor="@color/material_blue_500"
android:textStyle="bold"
android:text="@string/heading_optional_permissions" />

<com.ichi2.anki.ui.windows.permissions.PermissionsItem
android:id="@+id/post_notification_permission_item"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:permissionTitle="@string/notification_pref"
app:permissionSummary="@string/notifications_permission_explanation"
app:permissionIcon="@drawable/ic_notifications"
app:permission="@string/post_notification_permission"
android:visibility="gone"
tools:visibility="visible"
/>

<com.ichi2.anki.ui.windows.permissions.PermissionsItem
android:id="@+id/record_audio_permission_item"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:permissionTitle="@string/microphone"
app:permissionSummary="@string/microphone_permission_explanation"
app:permissionIcon="@drawable/ic_action_mic"
app:permission="@string/record_audio_permission"
android:visibility="gone"
tools:visibility="visible"
/>

</LinearLayout>
2 changes: 1 addition & 1 deletion AnkiDroid/src/main/res/layout/permissions_item.xml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
app:layout_constraintEnd_toStartOf="@id/switch_widget"
app:layout_constraintStart_toStartOf="@+id/guideline_front"
app:layout_constraintTop_toBottomOf="@id/title"
tools:maxLines="3"
tools:maxLines="4"
tools:text="A summary about why the permission should be given" />

<com.google.android.material.materialswitch.MaterialSwitch
Expand Down
8 changes: 8 additions & 0 deletions AnkiDroid/src/main/res/values/01-core.xml
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,19 @@

<!-- Permissions screen -->
<string name="permissions_screen_headline">AnkiDroid needs some permissions to work</string>
<string name="permissions_screen_optional_headline" comment="Explains that there are optional permissions that can be granted to AnkiDroid.">AnkiDroid works best with these permissions</string>
<string name="storage_access_title">Storage access</string>
<string name="storage_access_summary">Saves your collection in a safe place that will not be deleted if the app is uninstalled</string>
<string name="all_files_access_title"
comment="Name of the “All files access” permission from Android 10 and after. See [android.permission.MANAGE_EXTERNAL_STORAGE] on https://developer.android.com/training/data-storage/manage-all-files for context.">All files access</string>
<string name="image_occlusion">Image Occlusion</string>
<string name="microphone" comment="Title of the microphone permission in the permissions explanation screen.">Microphone</string>
<string name="microphone_permission_explanation" comment="Description explaining why AnkiDroid needs the microphone permission, shown on the permissions explanation screen.">Allows you to add sounds to cards and enables voice playback while reviewing</string>
<string name="notifications_permission_explanation" comment="Description explaining why AnkiDroid needs the notification permission.">Enables background media syncing and allows you to create review reminders</string>
<string name="heading_required_permissions" comment="Heading for the section of required permissions that the app needs to function, shown in the permissions explanation screen.">Required</string>
<string name="heading_optional_permissions" comment="Heading for the section of optional permissions that the app needs to function, shown in the permissions explanation screen.">Optional</string>"
<string name="manually_grant_permissions" comment="Pop-up message that is shown to the user when they want to grant an app permission that must be granted via the OS settings screen.">This permission must be manually granted from Settings</string>
<string name="revoke_permissions" comment="Pop-up message that is shown to the user when they are revoking an app permission.">This permission must be manually revoked from Settings</string>

<string name="remove_account" comment="open a website to start the deletion of an AnkiWeb account">Remove account</string>

Expand Down
2 changes: 2 additions & 0 deletions AnkiDroid/src/main/res/values/constants.xml
Original file line number Diff line number Diff line change
Expand Up @@ -341,5 +341,7 @@
<item>android.permission.READ_EXTERNAL_STORAGE</item>
<item>android.permission.WRITE_EXTERNAL_STORAGE</item>
</string-array>
<string name="post_notification_permission">android.permission.POST_NOTIFICATIONS</string>
<string name="record_audio_permission">android.permission.RECORD_AUDIO</string>

</resources>
Loading
Loading