فهرست منبع

Choice between All Files and read-only external storage in SDK >=30

- Allow users to choose between MANAGE_EXTERNAL_STORAGE (full access) and READ_EXTERNAL_STORAGE (media read-only) in sdk >=30, with a dialog
- If All Files is not available (activity to manage not present, or permission not in manifest), request READ_EXTERNAL_STORAGE instead
- Misc improvements to permission request in UploadFilesActivity and SyncedFoldersActivity

Signed-off-by: Álvaro Brey Vilas <alvaro.brey@nextcloud.com>
Álvaro Brey Vilas 3 سال پیش
والد
کامیت
ad5b443489

+ 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" />

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

@@ -21,7 +21,6 @@
 
 package com.owncloud.android.datamodel;
 
-import android.app.Activity;
 import android.content.ContentResolver;
 import android.database.Cursor;
 import android.net.Uri;
@@ -39,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.
  */
@@ -69,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);
 
@@ -168,15 +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())) {
-           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);
 

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

@@ -263,12 +263,6 @@ public class UploadFilesActivity extends DrawerActivity implements LocalFileList
         Log_OC.d(TAG, "onCreate() end");
     }
 
-    @Override
-    protected void onPostCreate(Bundle savedInstanceState) {
-        super.onPostCreate(savedInstanceState);
-        requestPermissions();
-    }
-
     private void requestPermissions() {
         PermissionUtil.requestExternalStoragePermission(this, true);
     }
@@ -277,6 +271,13 @@ public class UploadFilesActivity extends DrawerActivity implements LocalFileList
         mToolbarSpinner.setVisibility(View.VISIBLE);
     }
 
+
+    @Override
+    protected void onResume() {
+        super.onResume();
+        requestPermissions();
+    }
+
     private void fillDirectoryDropdown() {
         File currentDir = mCurrentDir;
         while (currentDir != null && currentDir.getParentFile() != null) {
@@ -363,18 +364,6 @@ public class UploadFilesActivity extends DrawerActivity implements LocalFileList
         }
     }
 
-    @Override
-    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
-        super.onActivityResult(requestCode, resultCode, data);
-        if (requestCode == PermissionUtil.REQUEST_CODE_MANAGE_ALL_FILES) {
-            if (resultCode == Activity.RESULT_OK) {
-                showLocalStoragePathPickerDialog();
-            } else {
-                DisplayUtils.showSnackMessage(this, R.string.permission_storage_access);
-            }
-        }
-    }
-
     @Override
     public void onSortingOrderChosen(FileSortOrder selection) {
         preferences.setSortOrder(FileSortOrder.Type.localFileListView, selection);
@@ -647,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();
             }
         }
     }

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

@@ -0,0 +1,101 @@
+/*
+ * 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.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
+        binding.btnFullAccess.setOnClickListener {
+            listener.onClickFullAccess()
+            dismiss()
+        }
+        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 = AlertDialog.Builder(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()
+    }
+}

+ 69 - 39
app/src/main/java/com/owncloud/android/utils/PermissionUtil.kt

@@ -28,19 +28,20 @@ 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 {
@@ -82,14 +83,19 @@ object PermissionUtil {
     /**
      * Determine whether the app has been granted external storage permissions depending on SDK.
      *
-     * For sdk >= 30 we use the storage manager special permissin
+     * 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()
+        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)
     }
 
@@ -97,36 +103,43 @@ object PermissionUtil {
      * Request relevant external storage permission depending on SDK, if needed.
      *
      * Activities should implement [Activity.onRequestPermissionsResult]
-     * and handle the [PERMISSIONS_EXTERNAL_STORAGE] code, as well ass [Activity.onActivityResult]
+     * 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 force for MANAGE_ALL_FILES specifically, show again even if already denied in the past
+     * @param permissionRequired for SDK >=30 specifically, show again even if already denied in the past
      */
     @JvmStatic
     @JvmOverloads
-    fun requestExternalStoragePermission(activity: Activity, force: Boolean = false) {
+    fun requestExternalStoragePermission(activity: AppCompatActivity, permissionRequired: Boolean = false) {
         if (!checkExternalStoragePermission(activity)) {
             when {
-                Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> requestManageFilesPermission(activity, force)
-                else -> requestWriteExternalStoragePermission(activity)
+                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)
             }
         }
     }
-
     /**
-     * For sdk < 30: request WRITE_EXTERNAL_STORAGE
+     * Request a storage permission
      */
-    private fun requestWriteExternalStoragePermission(activity: Activity) {
+    private fun requestStoragePermission(activity: Activity, permission: String) {
         fun doRequest() {
             ActivityCompat.requestPermissions(
-                activity, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
+                activity, arrayOf(permission),
                 PERMISSIONS_EXTERNAL_STORAGE
             )
         }
 
         // Check if we should show an explanation
-        if (shouldShowRequestPermissionRationale(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+        if (shouldShowRequestPermissionRationale(activity, permission)) {
             // Show explanation to the user and then request permission
             Snackbar
                 .make(
@@ -147,45 +160,62 @@ object PermissionUtil {
         }
     }
 
+    @RequiresApi(Build.VERSION_CODES.R)
+    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
+    }
+
     /**
-     * For sdk < 30: request MANAGE_EXTERNAL_STORAGE through system preferences
+     * sdk >= 30: Choice between All Files access or read_external_storage
      */
     @RequiresApi(Build.VERSION_CODES.R)
-    private fun requestManageFilesPermission(activity: Activity, force: Boolean) {
-
+    private fun showPermissionChoiceDialog(activity: AppCompatActivity, permissionRequired: Boolean) {
         val preferences: AppPreferences = AppPreferencesImpl.fromContext(activity)
 
-        if (!preferences.isStoragePermissionRequested || force) {
+        if (!preferences.isStoragePermissionRequested || permissionRequired) {
+            val listener = object : StoragePermissionDialogFragment.Listener {
+                override fun onCancel() {
+                    preferences.isStoragePermissionRequested = true
+                }
 
-            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_optional_text),
-                        activity.getString(R.string.app_name)
-                    )
-                )
-                .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}")
-                    }
+                override fun onClickFullAccess() {
+                    preferences.isStoragePermissionRequested = true
+                    val intent = getManageAllFilesIntent(activity)
                     activity.startActivityForResult(intent, REQUEST_CODE_MANAGE_ALL_FILES)
                     preferences.isStoragePermissionRequested = true
-                    dialog.dismiss()
                 }
-                .setNegativeButton(R.string.common_cancel) { dialog, _ ->
+
+                override fun onClickMediaReadOnly() {
                     preferences.isStoragePermissionRequested = true
-                    dialog.dismiss()
+                    requestStoragePermission(activity, Manifest.permission.READ_EXTERNAL_STORAGE)
                 }
-                .create()
-
-            alertDialog.show()
-            ThemeButtonUtils.themeBorderlessButton(alertDialog.getButton(AlertDialog.BUTTON_POSITIVE))
-            ThemeButtonUtils.themeBorderlessButton(alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE))
+            }
+            val dialogFragment = StoragePermissionDialogFragment(listener, permissionRequired)
+            dialogFragment.show(activity.supportFragmentManager, "")
         }
     }
 
+    @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 - 2
app/src/main/res/values/strings.xml

@@ -998,11 +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_text">%1$s needs file management permissions to upload files from this device. Please enable it in the following screen if appropriate.</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 external 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>