Skip to content
Open
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
7 changes: 4 additions & 3 deletions AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,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.InternetPermissionFragment
import com.ichi2.anki.ui.windows.permissions.NotificationsPermissionFragment
import com.ichi2.anki.ui.windows.permissions.PermissionsFragment
import com.ichi2.anki.ui.windows.permissions.PermissionsStartingAt30Fragment
Expand Down Expand Up @@ -209,12 +210,12 @@ enum class PermissionSet(
val permissions: List<String>,
val permissionsFragment: Class<out PermissionsFragment>?,
) : Parcelable {
LEGACY_ACCESS(Permissions.legacyStorageAccessPermissions, PermissionsUntil29Fragment::class.java),
LEGACY_ACCESS(Permissions.legacyStorageAccessStartupPermissions, PermissionsUntil29Fragment::class.java),

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

APP_PRIVATE(emptyList(), null),
APP_PRIVATE(Permissions.appPrivateStartupPermissions, InternetPermissionFragment::class.java),

/** Optional. */
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
Expand Down
2 changes: 2 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 @@ -329,6 +329,8 @@ open class PrefsRepository(
*/
var recordAudioPermissionRequested by booleanPref(R.string.record_audio_permission_requested_key, false)

var internetPermissionRequested by booleanPref(R.string.internet_permission_requested_key, 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,38 @@
/*
* Copyright (c) 2025 Akshita Tiwary <akshita.andev16@gmail.com>
*
* 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.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import com.ichi2.anki.R
import com.ichi2.anki.databinding.AboutLayoutBinding
import com.ichi2.anki.databinding.InternetPermissionFragmentBinding
import com.ichi2.utils.Permissions
import dev.androidbroadcast.vbpd.viewBinding

class InternetPermissionFragment : PermissionsFragment(R.layout.internet_permission_fragment) {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
) = InternetPermissionFragmentBinding
.inflate(inflater, container, false)
.apply { internetPermission.initializeInternetPermissionItem() }
.root
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,26 @@
*/
package com.ichi2.anki.ui.windows.permissions

import android.Manifest
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.LayoutRes
import androidx.annotation.RequiresApi
import androidx.core.os.bundleOf
import androidx.core.view.allViews
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResult
import com.ichi2.anki.R
import com.ichi2.anki.common.annotations.NeedsTest
import com.ichi2.anki.settings.Prefs
import com.ichi2.utils.Permissions
import com.ichi2.utils.Permissions.openAppSettingsScreen
import com.ichi2.utils.Permissions.requestPermissionThroughDialogOrSettings
import com.ichi2.utils.Permissions.showToastAndOpenAppSettingsScreen
import timber.log.Timber

Expand All @@ -48,6 +55,19 @@ abstract class PermissionsFragment(

protected fun hasAllPermissions() = permissionsItems.all { it.areGranted }

private val internetLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) {
// No need to explicitly do anything, onResume handles updating the UI
Timber.i("Internet permission granted")
} else {
Timber.i("Internet permission denied")
showToastAndOpenAppSettingsScreen(
getString(R.string.permission_required_message, getString(R.string.internet_access_title)),
)
}
}

override fun onResume() {
super.onResume()
permissionsItems.forEach { it.updateSwitchCheckedStatus() }
Expand Down Expand Up @@ -85,6 +105,28 @@ abstract class PermissionsFragment(
}
}

@NeedsTest("Shows the permission item when INTERNET permission is denied")
@NeedsTest("Hides the permission item when INTERNET permission is already granted")
protected fun PermissionsItem.initializeInternetPermissionItem() {
if (Permissions.hasPermission(requireContext(), Manifest.permission.INTERNET)) {
// If internet permission is already granted (which is the case for most of devices), hide the permission item.
this.isVisible = false
return
}
// On devices such as Xiaomi, which allow user to deny internet permissions, show internet permission item.
setOnPermissionsRequested { areAlreadyGranted ->
if (!areAlreadyGranted) {
Timber.d("Requesting for internet permission")
requestPermissionThroughDialogOrSettings(
requireActivity(),
Manifest.permission.INTERNET,
Prefs::internetPermissionRequested,
internetLauncher,
)
}
}
}

/**
* 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,7 @@ class PermissionsStartingAt30Fragment : PermissionsFragment(R.layout.fragment_pe
.apply {
allFilesPermission
.requestExternalStorageOnClick(accessAllFilesLauncher)
internetPermission
.initializeInternetPermissionItem()
}.root
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ class PermissionsUntil29Fragment : PermissionsFragment(R.layout.fragment_permiss
) = FragmentPermissionsUntil29Binding
.inflate(inflater, container, false)
.apply {
internetPermission.initializeInternetPermissionItem()
storagePermission.setOnPermissionsRequested { areAlreadyGranted ->
if (areAlreadyGranted) return@setOnPermissionsRequested
if (userCanGrantWriteExternalStorage()) {
Expand Down
20 changes: 19 additions & 1 deletion AnkiDroid/src/main/java/com/ichi2/utils/Permissions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -169,10 +169,23 @@ object Permissions {
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
val tiramisuAudioPermission = Manifest.permission.READ_MEDIA_AUDIO

val legacyStorageAccessPermissions =
val legacyStorageAccessStartupPermissions =
listOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.INTERNET,
)

@RequiresApi(Build.VERSION_CODES.R)
val externalManagerStorageAccessStartupPermissions =
listOf(
Manifest.permission.MANAGE_EXTERNAL_STORAGE,
Manifest.permission.INTERNET,
)

val appPrivateStartupPermissions =
listOf(
Manifest.permission.INTERNET,
)

const val RECORD_AUDIO_PERMISSION = Manifest.permission.RECORD_AUDIO
Expand Down Expand Up @@ -335,4 +348,9 @@ object Permissions {
showThemedToast(requireContext(), message, false)
openAppSettingsScreen()
}

fun Fragment.showToastAndOpenAppSettingsScreen(message: String) {
showThemedToast(requireContext(), message, false)
openAppSettingsScreen()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,13 @@
app:permissionIcon="@drawable/ic_save_white"
app:permission="@string/manage_external_storage_permission"
/>

<com.ichi2.anki.ui.windows.permissions.PermissionsItem
android:id="@+id/internet_permission"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:permissionTitle="@string/internet_access_title"
app:permissionSummary="@string/permission_access_summary"
app:permission="@string/manage_internet_permission"
/>
</LinearLayout>
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,12 @@
app:permissionIcon="@drawable/ic_save_white"
app:permissions="@array/legacy_storage_permissions"
/>
<com.ichi2.anki.ui.windows.permissions.PermissionsItem
android:id="@+id/internet_permission"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:permissionTitle="@string/internet_access_title"
app:permissionSummary="@string/permission_access_summary"
app:permission="@string/manage_internet_permission"
/>
</LinearLayout>
19 changes: 19 additions & 0 deletions AnkiDroid/src/main/res/layout/internet_permission_fragment.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?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.InternetPermissionFragment">

<com.ichi2.anki.ui.windows.permissions.PermissionsItem
android:id="@+id/internet_permission"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:permissionTitle="@string/internet_access_title"
app:permissionSummary="@string/permission_access_summary"
app:permission="@string/manage_internet_permission"
/>
</LinearLayout>
5 changes: 5 additions & 0 deletions AnkiDroid/src/main/res/values/01-core.xml
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,11 @@

<!-- Instant Note Editor -->
<string name="instant_card" comment="Used in the Android system context menu/share dialog to quickly create a note. This does not need to be translated literally, translate the concept of quickly adding a Cloze note with a popup. ">Instant card</string>

<!-- Internet Permissions -->
<string name="internet_access_title" comment="used for notifying user to unable internet access">Internet</string>
<string name="permission_access_summary">Required for the app to work</string>
<string name="permission_required_message">Please grant AnkiDroid the ‘%s’ permission to continue</string>

<!-- Multiple profile -->
<string name="add_profile">Add profile</string>
Expand Down
1 change: 1 addition & 0 deletions AnkiDroid/src/main/res/values/constants.xml
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@
</string-array>

<string name="manage_external_storage_permission">android.permission.MANAGE_EXTERNAL_STORAGE</string>
<string name="manage_internet_permission">android.permission.INTERNET</string>
<string-array name="legacy_storage_permissions">
<item>android.permission.READ_EXTERNAL_STORAGE</item>
<item>android.permission.WRITE_EXTERNAL_STORAGE</item>
Expand Down
2 changes: 2 additions & 0 deletions AnkiDroid/src/main/res/values/preferences.xml
Original file line number Diff line number Diff line change
Expand Up @@ -263,4 +263,6 @@
<string name="reviewer_frame_style_key">reviewerFrameStyle</string>
<string name="reviewer_toolbar_position_key">reviewerToolbarPosition</string>

<!-- Onboarding -->
<string name="internet_permission_requested_key">internetPermissionRequested</string>
Copy link
Member

Choose a reason for hiding this comment

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

@ankidroid/core We should pick an architectural pattern:

  • is preferences.xml for all string keys
  • or should it only be used for the settings, with other screens having their own XML?

I prefer the latter

</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import com.ichi2.anki.preferences.sharedPrefs
import com.ichi2.testutils.DbUtils
import com.ichi2.testutils.common.Flaky
import com.ichi2.testutils.common.OS
import com.ichi2.testutils.grantPermissions
import com.ichi2.utils.ResourceLoader
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.equalTo
Expand Down
28 changes: 28 additions & 0 deletions AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
// noinspection MissingCopyrightHeader #8659
package com.ichi2.anki

import android.Manifest.permission.INTERNET
import android.annotation.SuppressLint
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.Menu
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.core.content.IntentCompat
import androidx.core.content.edit
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.view.children
Expand All @@ -29,6 +32,8 @@ import com.ichi2.anki.libanki.DeckId
import com.ichi2.anki.observability.ChangeManager
import com.ichi2.anki.preferences.sharedPrefs
import com.ichi2.anki.settings.Prefs
import com.ichi2.anki.ui.windows.permissions.PermissionsActivity
import com.ichi2.anki.ui.windows.permissions.PermissionsActivity.Companion.PERMISSIONS_SET_EXTRA
import com.ichi2.anki.utils.Destination
import com.ichi2.anki.utils.ext.defaultConfig
import com.ichi2.anki.utils.ext.dismissAllDialogFragments
Expand All @@ -38,8 +43,10 @@ import com.ichi2.testutils.common.Flaky
import com.ichi2.testutils.common.OS
import com.ichi2.testutils.ext.addBasicNoteWithOp
import com.ichi2.testutils.ext.menu
import com.ichi2.testutils.grantPermissions
import com.ichi2.testutils.grantWritePermissions
import com.ichi2.testutils.revokeWritePermissions
import com.ichi2.testutils.withDeniedPermissions
import com.ichi2.testutils.withWritePermissions
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.containsInAnyOrder
Expand All @@ -64,6 +71,7 @@ import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.Robolectric
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows
import org.robolectric.Shadows.shadowOf
import org.robolectric.shadows.ShadowDialog
import org.robolectric.shadows.ShadowLooper
import timber.log.Timber
Expand Down Expand Up @@ -674,6 +682,26 @@ class DeckPickerTest : RobolectricTest() {
disableNullCollection()
}

@Test
fun `when INTERNET is denied, PermissionsActivity is shown`() =
runTest {
withDeniedPermissions(INTERNET) {
deckPicker {
val intent = assertNotNull(shadowOf(this@deckPicker).nextStartedActivity)

assertThat(
intent.component?.shortClassName,
equalTo(PermissionsActivity::class.java.name),
)

val extra = IntentCompat.getParcelableExtra(intent, PERMISSIONS_SET_EXTRA, PermissionSet::class.java)

assertNotNull(extra)
assertThat(extra.permissions, equalTo(listOf(INTERNET)))
}
}
}

enum class CollectionType(
val assetFile: String,
private val deckName: String,
Expand Down
2 changes: 2 additions & 0 deletions AnkiDroid/src/test/java/com/ichi2/anki/InitialActivityTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ class InitialActivityTest : RobolectricTest() {
arrayOf(
android.Manifest.permission.READ_EXTERNAL_STORAGE,
android.Manifest.permission.WRITE_EXTERNAL_STORAGE,
android.Manifest.permission.INTERNET,
)

// force a safe startup before Q
Expand Down Expand Up @@ -161,6 +162,7 @@ class InitialActivityTest : RobolectricTest() {
arrayOf(
android.Manifest.permission.READ_EXTERNAL_STORAGE,
android.Manifest.permission.WRITE_EXTERNAL_STORAGE,
android.Manifest.permission.INTERNET,
)

selectAnkiDroidFolder(
Expand Down
4 changes: 4 additions & 0 deletions AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import com.ichi2.testutils.ProductionCollectionManager
import com.ichi2.testutils.common.FailOnUnhandledExceptionRule
import com.ichi2.testutils.common.IgnoreFlakyTestsInCIRule
import com.ichi2.testutils.filter
import com.ichi2.testutils.grantPermissions
import com.ichi2.utils.InMemorySQLiteOpenHelperFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
Expand Down Expand Up @@ -173,6 +174,9 @@ open class RobolectricTest :

// BUG: We do not reset the MetaDB
MetaDB.closeDB()

// https://github.com/ankidroid/Anki-Android/pull/19004#discussion_r2739833965
grantPermissions(Manifest.permission.INTERNET)
}

protected open fun useLegacyHelper(): Boolean = false
Expand Down
Loading
Loading