Explorar o código

Merge pull request #9873 from nextcloud/optional-files-permission

Make external storage permission optional
Tobias Kaminsky %!s(int64=3) %!d(string=hai) anos
pai
achega
91520971cd

+ 2 - 3
app/src/androidTest/java/com/nextcloud/client/GrantStoragePermissionRule.kt

@@ -21,10 +21,10 @@
 
 package com.nextcloud.client
 
+import android.Manifest
 import android.os.Build
 import androidx.test.platform.app.InstrumentationRegistry
 import androidx.test.rule.GrantPermissionRule
-import com.owncloud.android.utils.PermissionUtil
 import org.junit.rules.TestRule
 import org.junit.runner.Description
 import org.junit.runners.model.Statement
@@ -35,8 +35,7 @@ class GrantStoragePermissionRule private constructor() {
         @JvmStatic
         fun grant(): TestRule = when {
             Build.VERSION.SDK_INT < Build.VERSION_CODES.R -> GrantPermissionRule.grant(
-                PermissionUtil
-                    .getExternalStoragePermission()
+                Manifest.permission.WRITE_EXTERNAL_STORAGE
             )
             else -> GrantManageExternalStoragePermissionRule()
         }

+ 2 - 0
app/src/main/AndroidManifest.xml

@@ -35,6 +35,8 @@
     <uses-permission
         android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
         tools:ignore="ScopedStorage" />
+    <uses-permission
+        android:name="android.permission.READ_EXTERNAL_STORAGE" />
 
     <uses-permission android:name="android.permission.CAMERA" />
     <uses-permission android:name="android.permission.VIBRATE" />

+ 4 - 0
app/src/main/java/com/nextcloud/client/preferences/AppPreferences.java

@@ -373,4 +373,8 @@ public interface AppPreferences {
     void setPdfZoomTipShownCount(int count);
 
     int getPdfZoomTipShownCount();
+
+    boolean isStoragePermissionRequested();
+
+    void setStoragePermissionRequested(boolean value);
 }

+ 12 - 0
app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java

@@ -97,6 +97,8 @@ public final class AppPreferencesImpl implements AppPreferences {
 
     private static final String PREF__PDF_ZOOM_TIP_SHOWN = "pdf_zoom_tip_shown";
 
+    private static final String PREF__STORAGE_PERMISSION_REQUESTED = "storage_permission_requested";
+
     private final Context context;
     private final SharedPreferences preferences;
     private final CurrentAccountProvider currentAccountProvider;
@@ -696,6 +698,16 @@ public final class AppPreferencesImpl implements AppPreferences {
         return preferences.getInt(PREF__PDF_ZOOM_TIP_SHOWN, 0);
     }
 
+    @Override
+    public boolean isStoragePermissionRequested() {
+        return preferences.getBoolean(PREF__STORAGE_PERMISSION_REQUESTED, false);
+    }
+
+    @Override
+    public void setStoragePermissionRequested(boolean value) {
+        preferences.edit().putBoolean(PREF__STORAGE_PERMISSION_REQUESTED, value).apply();
+    }
+
     @VisibleForTesting
     public int computeBruteForceDelay(int count) {
         return (int) Math.min(count / 3d, 10);

+ 6 - 22
app/src/main/java/com/owncloud/android/datamodel/MediaProvider.java

@@ -21,18 +21,14 @@
 
 package com.owncloud.android.datamodel;
 
-import android.app.Activity;
 import android.content.ContentResolver;
 import android.database.Cursor;
 import android.net.Uri;
 import android.provider.MediaStore;
 import android.util.Log;
 
-import com.google.android.material.snackbar.Snackbar;
 import com.owncloud.android.MainApp;
-import com.owncloud.android.R;
 import com.owncloud.android.utils.PermissionUtil;
-import com.owncloud.android.utils.theme.ThemeSnackbarUtils;
 
 import java.io.File;
 import java.util.ArrayList;
@@ -42,6 +38,8 @@ import java.util.Map;
 
 import javax.annotation.Nullable;
 
+import androidx.appcompat.app.AppCompatActivity;
+
 /**
  * Media queries to gain access to media lists for the device.
  */
@@ -72,7 +70,7 @@ public final class MediaProvider {
      * @return list with media folders
      */
     public static List<MediaFolder> getImageFolders(ContentResolver contentResolver, int itemLimit,
-                                                    @Nullable final Activity activity, boolean getWithoutActivity) {
+                                                    @Nullable final AppCompatActivity activity, boolean getWithoutActivity) {
         // check permissions
         checkPermissions(activity);
 
@@ -171,29 +169,15 @@ public final class MediaProvider {
         return filePath != null && filePath.lastIndexOf('/') > 0 && new File(filePath).exists();
     }
 
-    private static void checkPermissions(@Nullable Activity activity) {
+    private static void checkPermissions(@Nullable AppCompatActivity activity) {
         if (activity != null &&
             !PermissionUtil.checkExternalStoragePermission(activity.getApplicationContext())) {
-            // Check if we should show an explanation
-            if (PermissionUtil
-                .shouldShowRequestPermissionRationale(activity, PermissionUtil.getExternalStoragePermission())) {
-                // Show explanation to the user and then request permission
-                Snackbar snackbar = Snackbar.make(activity.findViewById(R.id.ListLayout),
-                                                  R.string.permission_storage_access, Snackbar.LENGTH_INDEFINITE)
-                    .setAction(R.string.common_ok, v -> PermissionUtil.requestExternalStoragePermission(activity));
-
-                ThemeSnackbarUtils.colorSnackbar(activity.getApplicationContext(), snackbar);
-
-                snackbar.show();
-            } else {
-                // No explanation needed, request the permission.
-                PermissionUtil.requestExternalStoragePermission(activity);
-            }
+            PermissionUtil.requestExternalStoragePermission(activity, true);
         }
     }
 
     public static List<MediaFolder> getVideoFolders(ContentResolver contentResolver, int itemLimit,
-                                                    @Nullable final Activity activity, boolean getWithoutActivity) {
+                                                    @Nullable final AppCompatActivity activity, boolean getWithoutActivity) {
         // check permissions
         checkPermissions(activity);
 

+ 13 - 31
app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.java

@@ -121,7 +121,6 @@ import com.owncloud.android.utils.PermissionUtil;
 import com.owncloud.android.utils.PushUtils;
 import com.owncloud.android.utils.StringUtils;
 import com.owncloud.android.utils.theme.ThemeButtonUtils;
-import com.owncloud.android.utils.theme.ThemeSnackbarUtils;
 import com.owncloud.android.utils.theme.ThemeToolbarUtils;
 
 import org.greenrobot.eventbus.EventBus;
@@ -316,22 +315,7 @@ public class FileDisplayActivity extends FileActivity
         super.onPostCreate(savedInstanceState);
 
 
-        if (!PermissionUtil.checkExternalStoragePermission(this)) {
-            // Check if we should show an explanation
-            if (PermissionUtil.shouldShowRequestPermissionRationale(this,
-                                                                    PermissionUtil.getExternalStoragePermission())) {
-                // Show explanation to the user and then request permission
-                Snackbar snackbar = Snackbar.make(binding.rootLayout,
-                                                  R.string.permission_storage_access,
-                                                  Snackbar.LENGTH_INDEFINITE)
-                    .setAction(R.string.common_ok, v -> PermissionUtil.requestExternalStoragePermission(this));
-                ThemeSnackbarUtils.colorSnackbar(this, snackbar);
-                snackbar.show();
-            } else {
-                // No explanation needed, request the permission.
-                PermissionUtil.requestExternalStoragePermission(this);
-            }
-        }
+        PermissionUtil.requestExternalStoragePermission(this);
 
         if (getIntent().getParcelableExtra(OCFileListFragment.SEARCH_EVENT) != null) {
             switchToSearchFragment(savedInstanceState);
@@ -399,7 +383,7 @@ public class FileDisplayActivity extends FileActivity
     public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
                                            @NonNull int[] grantResults) {
         switch (requestCode) {
-            case PermissionUtil.PERMISSIONS_EXTERNAL_STORAGE: {
+            case PermissionUtil.PERMISSIONS_EXTERNAL_STORAGE:
                 // If request is cancelled, result arrays are empty.
                 if (grantResults.length > 0
                     && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
@@ -407,24 +391,16 @@ public class FileDisplayActivity extends FileActivity
                     EventBus.getDefault().post(new TokenPushEvent());
                     syncAndUpdateFolder(true);
                     // toggle on is save since this is the only scenario this code gets accessed
-                } else {
-                    // permission denied --> do nothing
-                    return;
                 }
-                return;
-            }
-            case PermissionUtil.PERMISSIONS_CAMERA: {
+                break;
+            case PermissionUtil.PERMISSIONS_CAMERA:
                 // If request is cancelled, result arrays are empty.
                 if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                     // permission was granted
                     getFileOperationsHelper()
                         .uploadFromCamera(this, FileDisplayActivity.REQUEST_CODE__UPLOAD_FROM_CAMERA);
-                } else {
-                    // permission denied
-                    return;
                 }
-                return;
-            }
+                break;
             default:
                 super.onRequestPermissionsResult(requestCode, permissions, grantResults);
         }
@@ -856,6 +832,8 @@ public class FileDisplayActivity extends FileActivity
                 },
                 DELAY_TO_REQUEST_OPERATIONS_LATER
                                     );
+        } else if (requestCode == PermissionUtil.REQUEST_CODE_MANAGE_ALL_FILES) {
+            syncAndUpdateFolder(true);
         } else {
             super.onActivityResult(requestCode, resultCode, data);
         }
@@ -1097,7 +1075,7 @@ public class FileDisplayActivity extends FileActivity
         OCFileListFragment ocFileListFragment = (OCFileListFragment) leftFragment;
 
         ocFileListFragment.setLoading(mSyncInProgress);
-        syncAndUpdateFolder(false);
+        syncAndUpdateFolder(false, true);
 
         OCFile startFile = null;
         if (getIntent() != null && getIntent().getParcelableExtra(EXTRA_FILE) != null) {
@@ -2243,11 +2221,15 @@ public class FileDisplayActivity extends FileActivity
     }
 
     private void syncAndUpdateFolder(boolean ignoreETag) {
+        syncAndUpdateFolder(ignoreETag, false);
+    }
+
+    private void syncAndUpdateFolder(boolean ignoreETag, boolean ignoreFocus) {
         OCFileListFragment listOfFiles = getListOfFilesFragment();
         if (listOfFiles != null && !listOfFiles.isSearchFragment()) {
             OCFile folder = listOfFiles.getCurrentFile();
             if (folder != null) {
-                startSyncFolderOperation(folder, ignoreETag);
+                startSyncFolderOperation(folder, ignoreETag, ignoreFocus);
             }
         }
     }

+ 24 - 23
app/src/main/java/com/owncloud/android/ui/activity/SyncedFoldersActivity.kt

@@ -514,24 +514,28 @@ class SyncedFoldersActivity :
             android.R.id.home -> finish()
             R.id.action_create_custom_folder -> {
                 Log.d(TAG, "Show custom folder dialog")
-                val emptyCustomFolder = SyncedFolderDisplayItem(
-                    SyncedFolder.UNPERSISTED_ID,
-                    null,
-                    null,
-                    true,
-                    false,
-                    true,
-                    false,
-                    account.name,
-                    FileUploader.LOCAL_BEHAVIOUR_FORGET,
-                    NameCollisionPolicy.ASK_USER.serialize(),
-                    false,
-                    clock.currentTime,
-                    null,
-                    MediaFolderType.CUSTOM,
-                    false
-                )
-                onSyncFolderSettingsClick(0, emptyCustomFolder)
+                if (PermissionUtil.checkExternalStoragePermission(this)) {
+                    val emptyCustomFolder = SyncedFolderDisplayItem(
+                        SyncedFolder.UNPERSISTED_ID,
+                        null,
+                        null,
+                        true,
+                        false,
+                        true,
+                        false,
+                        account.name,
+                        FileUploader.LOCAL_BEHAVIOUR_FORGET,
+                        NameCollisionPolicy.ASK_USER.serialize(),
+                        false,
+                        clock.currentTime,
+                        null,
+                        MediaFolderType.CUSTOM,
+                        false
+                    )
+                    onSyncFolderSettingsClick(0, emptyCustomFolder)
+                } else {
+                    PermissionUtil.requestExternalStoragePermission(this, true)
+                }
                 result = super.onOptionsItemSelected(item)
             }
             else -> result = super.onOptionsItemSelected(item)
@@ -751,17 +755,14 @@ class SyncedFoldersActivity :
     ) {
         when (requestCode) {
             PermissionUtil.PERMISSIONS_EXTERNAL_STORAGE -> {
-
                 // If request is cancelled, result arrays are empty.
                 if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                     // permission was granted
-
                     load(getItemsDisplayedPerFolder(), true)
                 } else {
-                    // permission denied --> do nothing
-                    return
+                    // permission denied --> request again
+                    PermissionUtil.requestExternalStoragePermission(this, true)
                 }
-                return
             }
             else -> super.onRequestPermissionsResult(requestCode, permissions, grantResults)
         }

+ 29 - 31
app/src/main/java/com/owncloud/android/ui/activity/UploadFilesActivity.java

@@ -37,7 +37,6 @@ import android.widget.Spinner;
 import android.widget.TextView;
 
 import com.google.android.material.button.MaterialButton;
-import com.google.android.material.snackbar.Snackbar;
 import com.nextcloud.client.account.User;
 import com.nextcloud.client.di.Injectable;
 import com.nextcloud.client.preferences.AppPreferences;
@@ -59,7 +58,6 @@ import com.owncloud.android.utils.PermissionUtil;
 import com.owncloud.android.utils.theme.ThemeButtonUtils;
 import com.owncloud.android.utils.theme.ThemeColorUtils;
 import com.owncloud.android.utils.theme.ThemeDrawableUtils;
-import com.owncloud.android.utils.theme.ThemeSnackbarUtils;
 import com.owncloud.android.utils.theme.ThemeToolbarUtils;
 import com.owncloud.android.utils.theme.ThemeUtils;
 
@@ -265,10 +263,21 @@ public class UploadFilesActivity extends DrawerActivity implements LocalFileList
         Log_OC.d(TAG, "onCreate() end");
     }
 
+    private void requestPermissions() {
+        PermissionUtil.requestExternalStoragePermission(this, true);
+    }
+
     public void showToolbarSpinner() {
         mToolbarSpinner.setVisibility(View.VISIBLE);
     }
 
+
+    @Override
+    protected void onResume() {
+        super.onResume();
+        requestPermissions();
+    }
+
     private void fillDirectoryDropdown() {
         File currentDir = mCurrentDir;
         while (currentDir != null && currentDir.getParentFile() != null) {
@@ -324,25 +333,10 @@ public class UploadFilesActivity extends DrawerActivity implements LocalFileList
 
     private void checkLocalStoragePathPickerPermission() {
         if (!PermissionUtil.checkExternalStoragePermission(this)) {
-            // Check if we should show an explanation
-            if (PermissionUtil.shouldShowRequestPermissionRationale(this,
-                                                                    PermissionUtil.getExternalStoragePermission())) {
-                // Show explanation to the user and then request permission
-                Snackbar snackbar = Snackbar.make(findViewById(android.R.id.content),
-                                                  R.string.permission_storage_access,
-                                                  Snackbar.LENGTH_INDEFINITE)
-                    .setAction(R.string.common_ok, v -> PermissionUtil.requestExternalStoragePermission(this));
-                ThemeSnackbarUtils.colorSnackbar(this, snackbar);
-                snackbar.show();
-            } else {
-                // No explanation needed, request the permission.
-                PermissionUtil.requestExternalStoragePermission(this);
-            }
-
-            return;
+            requestPermissions();
+        } else {
+            showLocalStoragePathPickerDialog();
         }
-
-        showLocalStoragePathPickerDialog();
     }
 
     private void showLocalStoragePathPickerDialog() {
@@ -642,20 +636,24 @@ public class UploadFilesActivity extends DrawerActivity implements LocalFileList
             finish();
 
         } else if (v.getId() == R.id.upload_files_btn_upload) {
-            if (mCurrentDir != null) {
-                preferences.setUploadFromLocalLastPath(mCurrentDir.getAbsolutePath());
-            }
-            if (mLocalFolderPickerMode) {
-                Intent data = new Intent();
+            if (PermissionUtil.checkExternalStoragePermission(this)) {
                 if (mCurrentDir != null) {
-                    data.putExtra(EXTRA_CHOSEN_FILES, mCurrentDir.getAbsolutePath());
+                    preferences.setUploadFromLocalLastPath(mCurrentDir.getAbsolutePath());
+                }
+                if (mLocalFolderPickerMode) {
+                    Intent data = new Intent();
+                    if (mCurrentDir != null) {
+                        data.putExtra(EXTRA_CHOSEN_FILES, mCurrentDir.getAbsolutePath());
+                    }
+                    setResult(RESULT_OK, data);
+
+                    finish();
+                } else {
+                    new CheckAvailableSpaceTask(this, mFileListFragment.getCheckedFilePaths())
+                        .execute(mBehaviourSpinner.getSelectedItemPosition() == 0);
                 }
-                setResult(RESULT_OK, data);
-
-                finish();
             } else {
-                new CheckAvailableSpaceTask(this, mFileListFragment.getCheckedFilePaths())
-                    .execute(mBehaviourSpinner.getSelectedItemPosition() == 0);
+                requestPermissions();
             }
         }
     }

+ 104 - 0
app/src/main/java/com/owncloud/android/ui/dialog/StoragePermissionDialogFragment.kt

@@ -0,0 +1,104 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author Álvaro Brey Vilas
+ * Copyright (C) 2022 Álvaro Brey Vilas
+ * Copyright (C) 2022 Nextcloud GmbH
+ *
+ * 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 <https://www.gnu.org/licenses/>.
+ */
+package com.owncloud.android.ui.dialog
+
+import android.app.Dialog
+import android.os.Build
+import android.os.Bundle
+import android.view.View
+import androidx.annotation.RequiresApi
+import androidx.appcompat.app.AlertDialog
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.owncloud.android.R
+import com.owncloud.android.databinding.StoragePermissionDialogBinding
+import com.owncloud.android.ui.dialog.StoragePermissionDialogFragment.Listener
+import com.owncloud.android.utils.theme.ThemeButtonUtils
+
+/**
+ * Dialog that shows permission options in SDK >= 30
+ *
+ * Allows choosing "full access" (MANAGE_ALL_FILES) or "read-only media" (READ_EXTERNAL_STORAGE)
+ *
+ * @param listener a [Listener] for button clicks. The dialog will auto-dismiss after the callback is called.
+ * @param permissionRequired Whether the permission is absolutely required by the calling component.
+ * This changes the texts to a more strict version.
+ */
+@RequiresApi(Build.VERSION_CODES.R)
+class StoragePermissionDialogFragment(val listener: Listener, val permissionRequired: Boolean = false) :
+    DialogFragment() {
+    private lateinit var binding: StoragePermissionDialogBinding
+
+    override fun onStart() {
+        super.onStart()
+        dialog?.let {
+            val alertDialog = it as AlertDialog
+            ThemeButtonUtils.themeBorderlessButton(alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE))
+        }
+    }
+
+    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+        // Inflate the layout for the dialog
+        val inflater = requireActivity().layoutInflater
+        binding = StoragePermissionDialogBinding.inflate(inflater, null, false)
+
+        val view: View = binding.root
+        val explanationResource = when {
+            permissionRequired -> R.string.file_management_permission_text
+            else -> R.string.file_management_permission_optional_text
+        }
+        binding.storagePermissionExplanation.text = getString(explanationResource, getString(R.string.app_name))
+
+        // Setup layout
+        ThemeButtonUtils.colorPrimaryButton(binding.btnFullAccess, context)
+        binding.btnFullAccess.setOnClickListener {
+            listener.onClickFullAccess()
+            dismiss()
+        }
+        ThemeButtonUtils.themeBorderlessButton(binding.btnReadOnly)
+        binding.btnReadOnly.setOnClickListener {
+            listener.onClickMediaReadOnly()
+            dismiss()
+        }
+
+        // Build the dialog
+        val titleResource = when {
+            permissionRequired -> R.string.file_management_permission
+            else -> R.string.file_management_permission_optional
+        }
+        val dialog = MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_ownCloud_Dialog)
+            .setTitle(titleResource)
+            .setView(view)
+            .setNegativeButton(R.string.common_cancel) { _, _ ->
+                listener.onCancel()
+                dismiss()
+            }
+            .create()
+
+        return dialog
+    }
+
+    interface Listener {
+        fun onCancel()
+        fun onClickFullAccess()
+        fun onClickMediaReadOnly()
+    }
+}

+ 122 - 42
app/src/main/java/com/owncloud/android/utils/PermissionUtil.kt

@@ -28,16 +28,21 @@ import android.app.Activity
 import android.content.Context
 import android.content.Intent
 import android.content.pm.PackageManager
+import android.content.pm.ResolveInfo
 import android.net.Uri
 import android.os.Build
 import android.os.Environment
 import android.provider.Settings
 import androidx.annotation.RequiresApi
-import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.app.AppCompatActivity
 import androidx.core.app.ActivityCompat
 import androidx.core.content.ContextCompat
+import com.google.android.material.snackbar.Snackbar
+import com.nextcloud.client.preferences.AppPreferences
+import com.nextcloud.client.preferences.AppPreferencesImpl
 import com.owncloud.android.R
-import com.owncloud.android.utils.theme.ThemeButtonUtils
+import com.owncloud.android.ui.dialog.StoragePermissionDialogFragment
+import com.owncloud.android.utils.theme.ThemeSnackbarUtils
 
 object PermissionUtil {
     const val PERMISSIONS_EXTERNAL_STORAGE = 1
@@ -49,6 +54,8 @@ object PermissionUtil {
 
     const val REQUEST_CODE_MANAGE_ALL_FILES = 19203
 
+    const val PERMISSION_CHOICE_DIALOG_TAG = "PERMISSION_CHOICE_DIALOG"
+
     /**
      * Wrapper method for ContextCompat.checkSelfPermission().
      * Determine whether *the app* has been granted a particular permission.
@@ -76,70 +83,143 @@ object PermissionUtil {
         ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)
 
     /**
-     * For SDK < 30, we can do whatever we want using WRITE_EXTERNAL_STORAGE.
-     * For SDK above 30, scoped storage is in effect, and WRITE_EXTERNAL_STORAGE is useless. However, we do still need
-     * READ_EXTERNAL_STORAGE to read and upload files from folders that we don't manage and are not public access.
+     * Determine whether the app has been granted external storage permissions depending on SDK.
      *
-     * @return The relevant external storage permission, depending on SDK
-     */
-    @JvmStatic
-    fun getExternalStoragePermission(): String = when {
-        Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Manifest.permission.MANAGE_EXTERNAL_STORAGE
-        else -> Manifest.permission.WRITE_EXTERNAL_STORAGE
-    }
-
-    /**
-     * Determine whether *the app* has been granted external storage permissions depending on SDK.
+     * For sdk >= 30 we use the storage manager special permission for full access, or READ_EXTERNAL_STORAGE
+     * for limited access
+     *
+     * Under sdk 30 we use WRITE_EXTERNAL_STORAGE
      *
      * @return `true` if app has the permission, or `false` if not.
      */
     @JvmStatic
     fun checkExternalStoragePermission(context: Context): Boolean = when {
-        Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Environment.isExternalStorageManager()
-        else -> checkSelfPermission(context, getExternalStoragePermission())
+        Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Environment.isExternalStorageManager() || checkSelfPermission(
+            context,
+            Manifest.permission.READ_EXTERNAL_STORAGE
+        )
+        else -> checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE)
     }
 
     /**
-     * Request relevant external storage permission depending on SDK.
+     * Request relevant external storage permission depending on SDK, if needed.
+     *
+     * Activities should implement [Activity.onRequestPermissionsResult]
+     * and handle the [PERMISSIONS_EXTERNAL_STORAGE] code, as well as [Activity.onActivityResult]
+     * with `requestCode=`[REQUEST_CODE_MANAGE_ALL_FILES]
      *
      * @param activity The target activity.
+     * @param permissionRequired for SDK >=30 specifically, show again even if already denied in the past
      */
     @JvmStatic
-    fun requestExternalStoragePermission(activity: Activity) = when {
-        Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> requestManageFilesPermission(activity)
-        else -> {
+    @JvmOverloads
+    fun requestExternalStoragePermission(activity: AppCompatActivity, permissionRequired: Boolean = false) {
+        if (!checkExternalStoragePermission(activity)) {
+            when {
+                Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
+                    if (canRequestAllFilesPermission(activity)) {
+                        // can request All Files, show choice
+                        showPermissionChoiceDialog(activity, permissionRequired)
+                    } else {
+                        // can not request all files, request READ_EXTERNAL_STORAGE
+                        requestStoragePermission(activity, Manifest.permission.READ_EXTERNAL_STORAGE)
+                    }
+                }
+                else -> requestStoragePermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE)
+            }
+        }
+    }
+    /**
+     * Request a storage permission
+     */
+    private fun requestStoragePermission(activity: Activity, permission: String) {
+        fun doRequest() {
             ActivityCompat.requestPermissions(
-                activity, arrayOf(getExternalStoragePermission()),
+                activity, arrayOf(permission),
                 PERMISSIONS_EXTERNAL_STORAGE
             )
         }
+
+        // Check if we should show an explanation
+        if (shouldShowRequestPermissionRationale(activity, permission)) {
+            // Show explanation to the user and then request permission
+            Snackbar
+                .make(
+                    activity.findViewById(android.R.id.content),
+                    R.string.permission_storage_access,
+                    Snackbar.LENGTH_INDEFINITE
+                )
+                .setAction(R.string.common_ok) {
+                    doRequest()
+                }
+                .also {
+                    ThemeSnackbarUtils.colorSnackbar(activity, it)
+                }
+                .show()
+        } else {
+            // No explanation needed, request the permission.
+            doRequest()
+        }
     }
 
     @RequiresApi(Build.VERSION_CODES.R)
-    private fun requestManageFilesPermission(activity: Activity) {
-        val alertDialog = AlertDialog.Builder(activity, R.style.Theme_ownCloud_Dialog)
-            .setTitle(R.string.file_management_permission)
-            .setMessage(
-                String.format(
-                    activity.getString(R.string.file_management_permission_text),
-                    activity.getString(R.string.app_name)
-                )
-            )
-            .setCancelable(false)
-            .setPositiveButton(R.string.common_ok) { dialog, _ ->
-                val intent = Intent().apply {
-                    action = Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION
-                    data = Uri.parse("package:${activity.applicationContext.packageName}")
+    private fun canRequestAllFilesPermission(context: Context) =
+        manifestHasAllFilesPermission(context) && hasManageAllFilesActivity(context)
+
+    @RequiresApi(Build.VERSION_CODES.R)
+    private fun hasManageAllFilesActivity(context: Context): Boolean {
+        val intent = getManageAllFilesIntent(context)
+
+        val launchables: List<ResolveInfo> = context.packageManager
+            .queryIntentActivities(intent, PackageManager.GET_RESOLVED_FILTER)
+        return launchables.isNotEmpty()
+    }
+
+    @RequiresApi(Build.VERSION_CODES.R)
+    private fun manifestHasAllFilesPermission(context: Context): Boolean {
+        val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS)
+        return packageInfo?.requestedPermissions?.contains(Manifest.permission.MANAGE_EXTERNAL_STORAGE) ?: false
+    }
+
+    /**
+     * sdk >= 30: Choice between All Files access or read_external_storage
+     */
+    @RequiresApi(Build.VERSION_CODES.R)
+    private fun showPermissionChoiceDialog(activity: AppCompatActivity, permissionRequired: Boolean) {
+        val preferences: AppPreferences = AppPreferencesImpl.fromContext(activity)
+
+        if (!preferences.isStoragePermissionRequested || permissionRequired) {
+            if (activity.supportFragmentManager.findFragmentByTag(PERMISSION_CHOICE_DIALOG_TAG) == null) {
+                val listener = object : StoragePermissionDialogFragment.Listener {
+                    override fun onCancel() {
+                        preferences.isStoragePermissionRequested = true
+                    }
+
+                    override fun onClickFullAccess() {
+                        preferences.isStoragePermissionRequested = true
+                        val intent = getManageAllFilesIntent(activity)
+                        activity.startActivityForResult(intent, REQUEST_CODE_MANAGE_ALL_FILES)
+                        preferences.isStoragePermissionRequested = true
+                    }
+
+                    override fun onClickMediaReadOnly() {
+                        preferences.isStoragePermissionRequested = true
+                        requestStoragePermission(activity, Manifest.permission.READ_EXTERNAL_STORAGE)
+                    }
                 }
-                activity.startActivityForResult(intent, REQUEST_CODE_MANAGE_ALL_FILES)
-                dialog.dismiss()
+                val dialogFragment = StoragePermissionDialogFragment(listener, permissionRequired)
+                dialogFragment.show(activity.supportFragmentManager, PERMISSION_CHOICE_DIALOG_TAG)
             }
-            .create()
-
-        alertDialog.show()
-        ThemeButtonUtils.themeBorderlessButton(alertDialog.getButton(AlertDialog.BUTTON_POSITIVE))
+        }
     }
 
+    @RequiresApi(Build.VERSION_CODES.R)
+    private fun getManageAllFilesIntent(context: Context) =
+        Intent().apply {
+            action = Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION
+            data = Uri.parse("package:${context.applicationContext.packageName}")
+        }
+
     /**
      * request camera permission.
      *

+ 58 - 0
app/src/main/res/layout/storage_permission_dialog.xml

@@ -0,0 +1,58 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Nextcloud Android client application
+  ~
+  ~ @author Álvaro Brey Vilas
+  ~ Copyright (C) 2022 Álvaro Brey Vilas
+  ~ Copyright (C) 2022 Nextcloud GmbH
+  ~
+  ~ 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 <https://www.gnu.org/licenses/>.
+  -->
+<androidx.constraintlayout.widget.ConstraintLayout 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:clickable="true"
+    android:focusable="true"
+    android:orientation="vertical"
+    android:paddingHorizontal="?dialogPreferredPadding">
+
+    <TextView
+        android:id="@+id/storage_permission_explanation"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        app:layout_constraintTop_toTopOf="parent"
+        tools:text="@string/file_management_permission_optional_text" />
+
+    <com.google.android.material.button.MaterialButton
+        android:layout_marginTop="@dimen/standard_padding"
+        android:id="@+id/btn_full_access"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:text="@string/storage_permission_full_access"
+        android:theme="@style/Button.Primary"
+        app:cornerRadius="@dimen/button_corner_radius"
+        app:layout_constraintTop_toBottomOf="@id/storage_permission_explanation" />
+
+    <com.google.android.material.button.MaterialButton
+        android:id="@+id/btn_read_only"
+        style="@style/OutlinedButton"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:text="@string/storage_permission_media_read_only"
+        app:cornerRadius="@dimen/button_corner_radius"
+        app:layout_constraintTop_toBottomOf="@id/btn_full_access" />
+
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 5 - 1
app/src/main/res/values/strings.xml

@@ -998,10 +998,14 @@
     <string name="search_error">Error getting search results</string>
     <string name="load_more_results">Load more results</string>
     <string name="file_management_permission">Permissions needed</string>
-    <string name="file_management_permission_text">%1$s needs file management permissions to work properly. Please enable it in the following screen to continue.</string>
+    <string name="file_management_permission_optional">Storage permissions</string>
+    <string name="file_management_permission_text">%1$s needs file management permissions to upload files. You can choose full access to all files, or read-only access to photos and videos.</string>
+    <string name="file_management_permission_optional_text">%1$s works best with permissions to access storage. You can choose full access to all files, or read-only access to photos and videos.</string>
     <string name="file_list_empty_unified_search_no_results">No results found for your query</string>
     <string name="file_list_empty_gallery">Found no images or videos</string>
     <string name="error_creating_file_from_template">Error creating file from template</string>
     <string name="no_send_app">No app available for sending the selected files</string>
     <string name="pdf_zoom_tip">Tap on a page to zoom in</string>
+    <string name="storage_permission_full_access">Full access</string>
+    <string name="storage_permission_media_read_only">Media read-only</string>
 </resources>