diff --git a/app/src/main/java/com/nextcloud/client/jobs/MediaFoldersDetectionWork.kt b/app/src/main/java/com/nextcloud/client/jobs/MediaFoldersDetectionWork.kt index 354b8a1d4f08..f7c7694ba662 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/MediaFoldersDetectionWork.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/MediaFoldersDetectionWork.kt @@ -82,15 +82,13 @@ class MediaFoldersDetectionWork constructor( contentResolver, 1, null, - true, - viewThemeUtils + true ) val videoMediaFolders = MediaProvider.getVideoFolders( contentResolver, 1, null, - true, - viewThemeUtils + true ) val imageMediaFolderPaths: MutableList = ArrayList() val videoMediaFolderPaths: MutableList = ArrayList() diff --git a/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java b/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java index f6ebc7df2a18..e8e09296344a 100644 --- a/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java +++ b/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java @@ -100,7 +100,6 @@ public final class AppPreferencesImpl implements AppPreferences { private static final String PREF__PDF_ZOOM_TIP_SHOWN = "pdf_zoom_tip_shown"; private static final String PREF__MEDIA_FOLDER_LAST_PATH = "media_folder_last_path"; - private static final String PREF__STORAGE_PERMISSION_REQUESTED = "storage_permission_requested"; private static final String PREF__IN_APP_REVIEW_DATA = "in_app_review_data"; diff --git a/app/src/main/java/com/nextcloud/utils/extensions/ContextExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/ContextExtensions.kt index 6a2441bb29ea..53b3c35495b6 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/ContextExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/ContextExtensions.kt @@ -14,12 +14,15 @@ import android.content.Context import android.content.ContextWrapper import android.content.Intent import android.content.IntentFilter +import android.net.Uri import android.os.Build import android.os.Handler import android.os.Looper +import android.provider.Settings import android.view.WindowInsets import android.view.WindowManager import android.widget.Toast +import androidx.core.net.toUri import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import com.owncloud.android.R @@ -69,3 +72,22 @@ fun Context.getActivity(): Activity? = when (this) { is ContextWrapper -> baseContext.getActivity() else -> null } + +fun Activity.openMediaPermissions(requestCode: Int) { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", packageName, null) + } + startActivityForResult(intent, requestCode) +} + +fun Activity.openAllFilesAccessSettings(requestCode: Int) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return + } + + val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply { + data = "package:$packageName".toUri() + } + + startActivityForResult(intent, requestCode) +} diff --git a/app/src/main/java/com/owncloud/android/MainApp.java b/app/src/main/java/com/owncloud/android/MainApp.java index 64c8f485a3c8..02ea98ece021 100644 --- a/app/src/main/java/com/owncloud/android/MainApp.java +++ b/app/src/main/java/com/owncloud/android/MainApp.java @@ -628,7 +628,7 @@ public static void initSyncOperations( updateAutoUploadEntries(clock); if (getAppContext() != null) { - if (PermissionUtil.checkExternalStoragePermission(getAppContext())) { + if (PermissionUtil.checkStoragePermission(getAppContext())) { splitOutAutoUploadEntries(clock, viewThemeUtils); } else { preferences.setAutoUploadSplitEntriesEnabled(true); @@ -904,13 +904,11 @@ private static void splitOutAutoUploadEntries(Clock clock, final List imageMediaFolders = MediaProvider.getImageFolders(contentResolver, 1, null, - true, - viewThemeUtils); + true); final List videoMediaFolders = MediaProvider.getVideoFolders(contentResolver, 1, null, - true, - viewThemeUtils); + true); ArrayList idsToDelete = new ArrayList<>(); List syncedFolders = syncedFolderProvider.getSyncedFolders(); diff --git a/app/src/main/java/com/owncloud/android/datamodel/MediaProvider.java b/app/src/main/java/com/owncloud/android/datamodel/MediaProvider.java index c3dd31c29290..b4833d456d24 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/MediaProvider.java +++ b/app/src/main/java/com/owncloud/android/datamodel/MediaProvider.java @@ -59,14 +59,13 @@ private MediaProvider() { public static List getImageFolders(ContentResolver contentResolver, int itemLimit, @Nullable final AppCompatActivity activity, - boolean getWithoutActivity, - final ViewThemeUtils viewThemeUtils) { + boolean getWithoutActivity) { // check permissions - checkPermissions(activity, viewThemeUtils); + checkPermissions(activity); // query media/image folders Cursor cursorFolders = null; - if (activity != null && PermissionUtil.checkExternalStoragePermission(activity.getApplicationContext()) + if (activity != null && PermissionUtil.checkStoragePermission(activity.getApplicationContext()) || getWithoutActivity) { cursorFolders = ContentResolverHelper.queryResolver(contentResolver, IMAGES_MEDIA_URI, IMAGES_FOLDER_PROJECTION, null, null, @@ -159,25 +158,23 @@ private static boolean isValidAndExistingFilePath(String filePath) { return filePath != null && filePath.lastIndexOf('/') > 0 && new File(filePath).exists(); } - private static void checkPermissions(@Nullable AppCompatActivity activity, - final ViewThemeUtils viewThemeUtils) { + private static void checkPermissions(@Nullable AppCompatActivity activity) { if (activity != null && - !PermissionUtil.checkExternalStoragePermission(activity.getApplicationContext())) { - PermissionUtil.requestExternalStoragePermission(activity, viewThemeUtils, true); + !PermissionUtil.checkStoragePermission(activity.getApplicationContext())) { + PermissionUtil.requestStoragePermissionIfNeeded(activity); } } public static List getVideoFolders(ContentResolver contentResolver, int itemLimit, @Nullable final AppCompatActivity activity, - boolean getWithoutActivity, - final ViewThemeUtils viewThemeUtils) { + boolean getWithoutActivity) { // check permissions - checkPermissions(activity, viewThemeUtils); + checkPermissions(activity); // query media/image folders Cursor cursorFolders = null; - if ((activity != null && PermissionUtil.checkExternalStoragePermission(activity.getApplicationContext())) + if ((activity != null && PermissionUtil.checkStoragePermission(activity.getApplicationContext())) || getWithoutActivity) { cursorFolders = contentResolver.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, VIDEOS_FOLDER_PROJECTION, null, null, null); diff --git a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java index 2b0d1c0d2c2e..c2e16c0630b7 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java @@ -15,6 +15,7 @@ import android.accounts.AuthenticatorException; import android.accounts.OperationCanceledException; import android.app.Activity; +import android.app.ComponentCaller; import android.content.Context; import android.content.Intent; import android.content.res.ColorStateList; @@ -52,7 +53,6 @@ import com.nextcloud.client.account.User; import com.nextcloud.client.di.Injectable; import com.nextcloud.client.files.DeepLinkConstants; -import com.nextcloud.client.jobs.upload.FileUploadWorker; import com.nextcloud.client.network.ClientFactory; import com.nextcloud.client.onboarding.FirstRunActivity; import com.nextcloud.client.preferences.AppPreferences; @@ -145,6 +145,8 @@ public abstract class DrawerActivity extends ToolbarActivity private static final int MENU_ITEM_EXTERNAL_LINK = 111; private static final int MAX_LOGO_SIZE_PX = 1000; private static final int RELATIVE_THRESHOLD_WARNING = 80; + public static final int REQ_ALL_FILES_ACCESS = 3001; + public static final int REQ_MEDIA_ACCESS = 3000; /** * Reference to the drawer layout. @@ -1432,4 +1434,21 @@ public void showBottomNavigationBar(boolean show) { public BottomNavigationView getBottomNavigationView() { return bottomNavigationView; } + + @Override + public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data, @NonNull ComponentCaller caller) { + super.onActivityResult(requestCode, resultCode, data, caller); + + if (requestCode == REQ_ALL_FILES_ACCESS || requestCode == REQ_MEDIA_ACCESS) { + checkStoragePermissionWarningBannerVisibility(); + } + } + + private void checkStoragePermissionWarningBannerVisibility() { + if (this instanceof SyncedFoldersActivity syncedFoldersActivity) { + syncedFoldersActivity.setupStoragePermissionWarningBanner(); + } else if (this instanceof UploadFilesActivity uploadFilesActivity) { + uploadFilesActivity.setupStoragePermissionWarningBanner(); + } + } } diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index 32aad45c9bb0..3dc140d612e1 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -153,7 +153,7 @@ import com.owncloud.android.utils.ErrorMessageAdapter import com.owncloud.android.utils.FileSortOrder import com.owncloud.android.utils.MimeTypeUtil import com.owncloud.android.utils.PermissionUtil -import com.owncloud.android.utils.PermissionUtil.requestExternalStoragePermission +import com.owncloud.android.utils.PermissionUtil.requestStoragePermissionIfNeeded import com.owncloud.android.utils.PermissionUtil.requestNotificationPermission import com.owncloud.android.utils.PushUtils import com.owncloud.android.utils.StringUtils @@ -364,7 +364,7 @@ class FileDisplayActivity : if (dialog != null && dialog.isShowing) { dialog.dismiss() supportFragmentManager.beginTransaction().remove(fragment).commitNowAllowingStateLoss() - requestExternalStoragePermission(this, viewThemeUtils) + requestStoragePermissionIfNeeded(this) } } } @@ -379,7 +379,7 @@ class FileDisplayActivity : // storage permissions handled in onRequestPermissionsResult requestNotificationPermission(this) } else { - requestExternalStoragePermission(this, viewThemeUtils) + requestStoragePermissionIfNeeded(this) } if (intent.getParcelableArgument( @@ -462,7 +462,7 @@ class FileDisplayActivity : // handle notification permission on API level >= 33 PermissionUtil.PERMISSIONS_POST_NOTIFICATIONS -> // dialogue was dismissed -> prompt for storage permissions - requestExternalStoragePermission(this, viewThemeUtils) + requestStoragePermissionIfNeeded(this) // If request is cancelled, result arrays are empty. PermissionUtil.PERMISSIONS_EXTERNAL_STORAGE -> diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java index 5cf6399b7afe..aded332ec1a5 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java @@ -54,6 +54,7 @@ import com.nextcloud.client.preferences.AppPreferences; import com.nextcloud.client.preferences.AppPreferencesImpl; import com.nextcloud.client.preferences.DarkMode; +import com.nextcloud.utils.extensions.ContextExtensionsKt; import com.nextcloud.utils.extensions.ViewExtensionsKt; import com.nextcloud.utils.mdm.MDMConfig; import com.owncloud.android.MainApp; @@ -76,6 +77,7 @@ import com.owncloud.android.utils.DisplayUtils; import com.owncloud.android.utils.EncryptionUtils; import com.owncloud.android.utils.MimeTypeUtil; +import com.owncloud.android.utils.PermissionUtil; import com.owncloud.android.utils.theme.CapabilityUtils; import com.owncloud.android.utils.theme.ViewThemeUtils; @@ -93,6 +95,8 @@ import androidx.core.content.ContextCompat; import androidx.core.content.res.ResourcesCompat; +import static com.owncloud.android.ui.activity.DrawerActivity.REQ_ALL_FILES_ACCESS; + /** * An Activity that allows the user to change the application's settings. * It proxies the necessary calls via {@link androidx.appcompat.app.AppCompatDelegate} to be used with AppCompat. @@ -374,6 +378,7 @@ private void setupSyncCategory() { setupAutoUploadPreference(preferenceCategorySync); setupInternalTwoWaySyncPreference(); + setupAllFilesAccessPreference(preferenceCategorySync); } private void setupMoreCategory() { @@ -620,6 +625,23 @@ private void setupInternalTwoWaySyncPreference() { }); } + private void setupAllFilesAccessPreference(PreferenceCategory category) { + Preference allFilesAccess = findPreference("allFilesAccess"); + + if (PermissionUtil.checkAllFilesAccess()) { + category.removePreference(allFilesAccess); + } else { + if (allFilesAccess.getParent() == null) { + category.addPreference(allFilesAccess); + } + } + + allFilesAccess.setOnPreferenceClickListener(preference -> { + ContextExtensionsKt.openAllFilesAccessSettings(this, REQ_ALL_FILES_ACCESS); + return true; + }); + } + private void setupBackupPreference() { Preference pContactsBackup = findPreference("backup"); if (pContactsBackup != null) { @@ -1067,6 +1089,9 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) { storageMigration.setStorageMigrationProgressListener(this); storageMigration.migrate(); } + } else if (requestCode == REQ_ALL_FILES_ACCESS) { + final PreferenceCategory preferenceCategorySync = (PreferenceCategory) findPreference("sync"); + setupAllFilesAccessPreference(preferenceCategorySync); } } diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SyncedFoldersActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/SyncedFoldersActivity.kt index ad899f667298..96a853af2a40 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/SyncedFoldersActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/SyncedFoldersActivity.kt @@ -38,9 +38,11 @@ import com.nextcloud.client.jobs.upload.FileUploadWorker import com.nextcloud.client.preferences.SubFolderRule import com.nextcloud.utils.extensions.getParcelableArgument import com.nextcloud.utils.extensions.isDialogFragmentReady +import com.nextcloud.utils.extensions.setVisibleIf import com.owncloud.android.BuildConfig import com.owncloud.android.MainApp import com.owncloud.android.R +import com.owncloud.android.databinding.StoragePermissionWarningBannerBinding import com.owncloud.android.databinding.SyncedFoldersLayoutBinding import com.owncloud.android.datamodel.ArbitraryDataProviderImpl import com.owncloud.android.datamodel.MediaFolder @@ -53,6 +55,7 @@ import com.owncloud.android.datamodel.SyncedFolderProvider import com.owncloud.android.files.services.NameCollisionPolicy import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.ui.adapter.SyncedFolderAdapter +import com.owncloud.android.ui.adapter.storagePermissionBanner.setup import com.owncloud.android.ui.decoration.MediaGridItemDecoration import com.owncloud.android.ui.dialog.ConfirmationDialogFragment import com.owncloud.android.ui.dialog.SyncedFolderPreferencesDialogFragment @@ -197,7 +200,15 @@ class SyncedFoldersActivity : setTheme(R.style.FallbackThemingTheme) } binding.emptyList.emptyListViewAction.setOnClickListener { showHiddenItems() } - PermissionUtil.requestExternalStoragePermission(this, viewThemeUtils, true) + setupStoragePermissionWarningBanner() + } + + fun setupStoragePermissionWarningBanner() { + val storagePermissionWarningBanner = binding.storagePermissionWarningBanner.root + StoragePermissionWarningBannerBinding.bind(storagePermissionWarningBanner).apply { + setup(this@SyncedFoldersActivity, R.string.storage_permission_banner_auto_upload_text) + } + storagePermissionWarningBanner.setVisibleIf(!PermissionUtil.checkStoragePermission(this)) } override fun onCreateOptionsMenu(menu: Menu): Boolean { @@ -274,16 +285,14 @@ class SyncedFoldersActivity : contentResolver, perFolderMediaItemLimit, this@SyncedFoldersActivity, - false, - viewThemeUtils + false ) mediaFolders.addAll( MediaProvider.getVideoFolders( contentResolver, perFolderMediaItemLimit, this@SyncedFoldersActivity, - false, - viewThemeUtils + false ) ) @@ -519,7 +528,7 @@ class SyncedFoldersActivity : android.R.id.home -> finish() R.id.action_create_custom_folder -> { Log_OC.d(TAG, "Show custom folder dialog") - if (PermissionUtil.checkExternalStoragePermission(this)) { + if (PermissionUtil.checkStoragePermission(this)) { val emptyCustomFolder = SyncedFolderDisplayItem( SyncedFolder.UNPERSISTED_ID, null, @@ -542,7 +551,7 @@ class SyncedFoldersActivity : ) onSyncFolderSettingsClick(0, emptyCustomFolder) } else { - PermissionUtil.requestExternalStoragePermission(this, viewThemeUtils, true) + PermissionUtil.requestStoragePermissionIfNeeded(this) } result = super.onOptionsItemSelected(item) } diff --git a/app/src/main/java/com/owncloud/android/ui/activity/UploadFilesActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/UploadFilesActivity.java index 4cd80f500f91..01cd186d83f6 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/UploadFilesActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/UploadFilesActivity.java @@ -17,7 +17,6 @@ import android.annotation.SuppressLint; import android.app.Activity; import android.content.Intent; -import android.content.pm.PackageManager; import android.os.Bundle; import android.os.Environment; import android.view.Menu; @@ -28,6 +27,7 @@ import android.widget.ArrayAdapter; import android.widget.TextView; +import com.nextcloud.android.common.ui.theme.utils.ColorRole; import com.nextcloud.client.account.User; import com.nextcloud.client.core.Clock; import com.nextcloud.client.di.Injectable; @@ -50,7 +50,6 @@ import com.owncloud.android.ui.dialog.SortingOrderDialogFragment; import com.owncloud.android.ui.fragment.ExtendedListFragment; import com.owncloud.android.ui.fragment.LocalFileListFragment; -import com.owncloud.android.utils.DisplayUtils; import com.owncloud.android.utils.FileSortOrder; import com.owncloud.android.utils.FileStorageUtils; import com.owncloud.android.utils.PermissionUtil; @@ -267,10 +266,6 @@ public void onNothingSelected(AdapterView parent) { Log_OC.d(TAG, "onCreate() end"); } - private void requestPermissions() { - PermissionUtil.requestExternalStoragePermission(this, viewThemeUtils, true); - } - public void showToolbarSpinner() { mToolbarSpinner.setVisibility(View.VISIBLE); } @@ -297,45 +292,88 @@ public boolean onCreateOptionsMenu(Menu menu) { final MenuItem item = menu.findItem(R.id.action_search); mSearchView = (SearchView) MenuItemCompat.getActionView(item); viewThemeUtils.androidx.themeToolbarSearchView(mSearchView); - viewThemeUtils.platform.tintTextDrawable(this, menu.findItem(R.id.action_choose_storage_path).getIcon()); mSearchView.setOnSearchClickListener(v -> mToolbarSpinner.setVisibility(View.GONE)); + MenuItem chooseStoragePathItem = menu.findItem(R.id.action_choose_storage_path); + if (chooseStoragePathItem != null) { + chooseStoragePathItem.setVisible(PermissionUtil.checkStoragePermission(this)); + + final var chooseStoragePathDrawable = chooseStoragePathItem.getIcon(); + if (chooseStoragePathDrawable != null) { + viewThemeUtils.platform.tintDrawable(this, chooseStoragePathDrawable, ColorRole.ON_SURFACE); + } + } + return super.onCreateOptionsMenu(menu); } + private static final String rootDir = "/storage/emulated/0"; + + private boolean isRoot() { + if (mCurrentDir == null) { + return false; + } + + return mCurrentDir.getAbsolutePath().equals(rootDir); + } + @Override public boolean onOptionsItemSelected(MenuItem item) { - boolean retval = true; int itemId = item.getItemId(); if (itemId == android.R.id.home) { - if (mCurrentDir != null && mCurrentDir.getParentFile() != null) { - getOnBackPressedDispatcher().onBackPressed(); - } - } else if (itemId == R.id.action_select_all) { + handleHomePressed(); + return true; + } + + if (itemId == R.id.action_select_all) { mSelectAll = !item.isChecked(); item.setChecked(mSelectAll); mFileListFragment.selectAllFiles(mSelectAll); setSelectAllMenuItem(item, mSelectAll); - } else if (itemId == R.id.action_choose_storage_path) { - checkLocalStoragePathPickerPermission(); - } else { - retval = super.onOptionsItemSelected(item); + return true; } - return retval; + if (itemId == R.id.action_choose_storage_path) { + showLocalStoragePathPickerDialog(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + private void cancelAndFinish() { + setResult(RESULT_CANCELED); + finish(); } - private void checkLocalStoragePathPickerPermission() { - if (!PermissionUtil.checkExternalStoragePermission(this)) { - requestPermissions(); + private void handleHomePressed() { + boolean root = isRoot(); + boolean hasPermission = PermissionUtil.checkStoragePermission(this); + + if (root && !hasPermission) { + cancelAndFinish(); + return; + } + + if (mCurrentDir == null || mCurrentDir.getParentFile() == null) { + return; + } + + if (root) { + cancelAndFinish(); } else { - showLocalStoragePathPickerDialog(); + getOnBackPressedDispatcher().onBackPressed(); } } private void showLocalStoragePathPickerDialog() { + if (!PermissionUtil.checkStoragePermission(this)) { + cancelAndFinish(); + return; + } + FragmentManager fm = getSupportFragmentManager(); FragmentTransaction ft = fm.beginTransaction(); ft.addToBackStack(null); @@ -343,23 +381,6 @@ private void showLocalStoragePathPickerDialog() { dialog.show(ft, LocalStoragePathPickerDialogFragment.LOCAL_STORAGE_PATH_PICKER_FRAGMENT); } - @Override - public void onRequestPermissionsResult(int requestCode, - @NonNull String[] permissions, - @NonNull int[] grantResults) { - - if (requestCode == PermissionUtil.PERMISSIONS_EXTERNAL_STORAGE) { - if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - // permission was granted - showLocalStoragePathPickerDialog(); - } else { - DisplayUtils.showSnackMessage(this, R.string.permission_storage_access); - } - } else { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - } - } - @Override public void onSortingOrderChosen(FileSortOrder selection) { preferences.setSortOrder(FileSortOrder.Type.localFileListView, selection); @@ -390,8 +411,7 @@ public void handleOnBackPressed() { } File parentFolder = mCurrentDir.getParentFile(); - if (!parentFolder.canRead()) { - checkLocalStoragePathPickerPermission(); + if (parentFolder != null && !parentFolder.canRead()) { return; } @@ -647,37 +667,31 @@ private boolean isGivenLocalPathHasEnabledParent() { @Override public void onClick(View v) { if (v.getId() == R.id.upload_files_btn_cancel) { - setResult(RESULT_CANCELED); - finish(); - - } else if (v.getId() == R.id.upload_files_btn_upload) { - if (PermissionUtil.checkExternalStoragePermission(this)) { + cancelAndFinish(); + } else if (v.getId() == R.id.upload_files_btn_upload && PermissionUtil.checkStoragePermission(this)) { + if (mCurrentDir != null) { + preferences.setUploadFromLocalLastPath(mCurrentDir.getAbsolutePath()); + } + if (mLocalFolderPickerMode) { + Intent data = new Intent(); if (mCurrentDir != null) { - preferences.setUploadFromLocalLastPath(mCurrentDir.getAbsolutePath()); + data.putExtra(EXTRA_CHOSEN_FILES, mCurrentDir.getAbsolutePath()); } - if (mLocalFolderPickerMode) { - Intent data = new Intent(); - if (mCurrentDir != null) { - data.putExtra(EXTRA_CHOSEN_FILES, mCurrentDir.getAbsolutePath()); - } - setResult(RESULT_OK, data); + setResult(RESULT_OK, data); - if (isGivenLocalPathHasEnabledParent()) { - showSubFolderWarningDialog(); - } else { - finish(); - } + if (isGivenLocalPathHasEnabledParent()) { + showSubFolderWarningDialog(); } else { - final var chosenFiles = mFileListFragment.getCheckedFilePaths(); - if (chosenFiles.length > FileUploadHelper.MAX_FILE_COUNT) { - FileUploadHelper.Companion.instance().showFileUploadLimitMessage(this); - return; - } - boolean isPositionZero = (binding.uploadFilesSpinnerBehaviour.getSelectedItemPosition() == 0); - new CheckAvailableSpaceTask(this, chosenFiles).execute(isPositionZero); + finish(); } } else { - requestPermissions(); + final var chosenFiles = mFileListFragment.getCheckedFilePaths(); + if (chosenFiles.length > FileUploadHelper.MAX_FILE_COUNT) { + FileUploadHelper.Companion.instance().showFileUploadLimitMessage(this); + return; + } + boolean isPositionZero = (binding.uploadFilesSpinnerBehaviour.getSelectedItemPosition() == 0); + new CheckAvailableSpaceTask(this, chosenFiles).execute(isPositionZero); } } } @@ -750,11 +764,8 @@ public void onCancel(String callerTag) { protected void onStart() { super.onStart(); final Account account = getAccount(); - if (mAccountOnCreation != null && mAccountOnCreation.equals(account)) { - requestPermissions(); - } else { - setResult(RESULT_CANCELED); - finish(); + if (mAccountOnCreation == null || !mAccountOnCreation.equals(account)) { + cancelAndFinish(); } } @@ -778,4 +789,10 @@ protected void onStop() { super.onStop(); } + + public void setupStoragePermissionWarningBanner() { + if (getListOfFilesFragment() instanceof LocalFileListFragment fragment) { + fragment.setupStoragePermissionWarningBanner(); + } + } } diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/LocalFileListAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/LocalFileListAdapter.java index 867c8a351f32..b8c8423226b4 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/LocalFileListAdapter.java +++ b/app/src/main/java/com/owncloud/android/ui/adapter/LocalFileListAdapter.java @@ -10,6 +10,7 @@ package com.owncloud.android.ui.adapter; import android.annotation.SuppressLint; +import android.app.Activity; import android.content.Context; import android.content.res.Resources; import android.graphics.Bitmap; @@ -28,10 +29,12 @@ import com.owncloud.android.R; import com.owncloud.android.datamodel.ThumbnailsCacheManager; import com.owncloud.android.lib.common.utils.Log_OC; +import com.owncloud.android.ui.adapter.storagePermissionBanner.StoragePermissionBannerViewHolder; import com.owncloud.android.ui.interfaces.LocalFileListFragmentInterface; import com.owncloud.android.utils.DisplayUtils; import com.owncloud.android.utils.FileSortOrder; import com.owncloud.android.utils.MimeTypeUtil; +import com.owncloud.android.utils.PermissionUtil; import com.owncloud.android.utils.theme.ViewThemeUtils; import java.io.File; @@ -54,8 +57,8 @@ public class LocalFileListAdapter extends RecyclerView.Adapter mFiles = new ArrayList<>(); private List mFilesAll = new ArrayList<>(); private boolean mLocalFolderPicker; @@ -68,6 +71,7 @@ public class LocalFileListAdapter extends RecyclerView.Adapter= mFiles.size()) { + return; + } + + File file = mFiles.get(fileIndex); + if (file == null) { + return; + } + + LocalFileListGridItemViewHolder grid = (LocalFileListGridItemViewHolder) holder; + + // Background + checkbox logic + if (mLocalFolderPicker) { + grid.itemLayout.setBackgroundColor(mContext.getResources().getColor(R.color.bg_default)); + grid.checkbox.setVisibility(View.GONE); } else { - File file = null; - if (mFiles.size() > position && mFiles.get(position) != null) { - file = mFiles.get(position); + grid.checkbox.setVisibility(View.VISIBLE); + + if (isCheckedFile(file)) { + grid.itemLayout.setBackgroundColor( + ContextCompat.getColor(mContext, R.color.selected_item_background) + ); + grid.checkbox.setImageDrawable( + viewThemeUtils.platform.tintDrawable(mContext, R.drawable.ic_checkbox_marked, ColorRole.PRIMARY) + ); + } else { + grid.itemLayout.setBackgroundColor( + mContext.getResources().getColor(R.color.bg_default) + ); + grid.checkbox.setImageResource(R.drawable.ic_checkbox_blank_outline); } - if (file != null) { - File finalFile = file; - - LocalFileListGridItemViewHolder gridViewHolder = (LocalFileListGridItemViewHolder) holder; + grid.checkbox.setOnClickListener(v -> + localFileListFragmentInterface.onItemCheckboxClicked(file) + ); + } - if (mLocalFolderPicker) { - gridViewHolder.itemLayout.setBackgroundColor(mContext.getResources().getColor(R.color.bg_default)); - gridViewHolder.checkbox.setVisibility(View.GONE); - } else { - gridViewHolder.checkbox.setVisibility(View.VISIBLE); - if (isCheckedFile(file)) { - gridViewHolder.itemLayout.setBackgroundColor(ContextCompat.getColor(mContext, R.color.selected_item_background)); - - gridViewHolder.checkbox.setImageDrawable( - viewThemeUtils.platform.tintDrawable(mContext, R.drawable.ic_checkbox_marked, ColorRole.PRIMARY)); - } else { - gridViewHolder.itemLayout.setBackgroundColor(mContext.getResources().getColor(R.color.bg_default)); - gridViewHolder.checkbox.setImageResource(R.drawable.ic_checkbox_blank_outline); - } - gridViewHolder.checkbox.setOnClickListener(v -> localFileListFragmentInterface - .onItemCheckboxClicked(finalFile)); - } + // Thumbnail + grid.thumbnail.setTag(file.hashCode()); + setThumbnail(file, grid.thumbnail, mContext, viewThemeUtils); - gridViewHolder.thumbnail.setTag(file.hashCode()); - setThumbnail(file, gridViewHolder.thumbnail, mContext, viewThemeUtils); + grid.itemLayout.setOnClickListener(v -> + localFileListFragmentInterface.onItemClicked(file) + ); - gridViewHolder.itemLayout.setOnClickListener(v -> localFileListFragmentInterface - .onItemClicked(finalFile)); + if (holder instanceof LocalFileListItemViewHolder item) { + if (file.isDirectory()) { + item.fileSize.setVisibility(View.GONE); + item.fileSeparator.setVisibility(View.GONE); - if (holder instanceof LocalFileListItemViewHolder itemViewHolder) { - if (file.isDirectory()) { - itemViewHolder.fileSize.setVisibility(View.GONE); - itemViewHolder.fileSeparator.setVisibility(View.GONE); - if (isWithinEncryptedFolder) { - itemViewHolder.checkbox.setVisibility(View.GONE); - } - } else { - itemViewHolder.fileSize.setVisibility(View.VISIBLE); - itemViewHolder.fileSeparator.setVisibility(View.VISIBLE); - itemViewHolder.fileSize.setText(DisplayUtils.bytesToHumanReadable(file.length())); - } - itemViewHolder.lastModification.setText(DisplayUtils.getRelativeTimestamp(mContext, - file.lastModified())); + if (isWithinEncryptedFolder) { + item.checkbox.setVisibility(View.GONE); } - gridViewHolder.fileName.setText(file.getName()); + } else { + item.fileSize.setVisibility(View.VISIBLE); + item.fileSeparator.setVisibility(View.VISIBLE); + item.fileSize.setText(DisplayUtils.bytesToHumanReadable(file.length())); } + + item.lastModification.setText( + DisplayUtils.getRelativeTimestamp(mContext, file.lastModified()) + ); } + + // Filename + grid.fileName.setText(file.getName()); } public static void setThumbnail(File file, @@ -297,6 +353,9 @@ public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int View itemView = LayoutInflater.from(mContext).inflate(R.layout.list_footer, parent, false); return new LocalFileListFooterViewHolder(itemView); + case VIEWTYPE_HEADER: + View headerItemView = LayoutInflater.from(mContext).inflate(R.layout.storage_permission_warning_banner, parent, false); + return new StoragePermissionBannerViewHolder(mContext, headerItemView); default: throw new IllegalArgumentException("Invalid viewType: " + viewType); } diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/storagePermissionBanner/StoragePermissionBannerActionHandler.kt b/app/src/main/java/com/owncloud/android/ui/adapter/storagePermissionBanner/StoragePermissionBannerActionHandler.kt new file mode 100644 index 000000000000..0d336bfaabcc --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/storagePermissionBanner/StoragePermissionBannerActionHandler.kt @@ -0,0 +1,36 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.ui.adapter.storagePermissionBanner + +import android.app.Activity +import android.view.View +import com.nextcloud.utils.BuildHelper.isFlavourGPlay +import com.nextcloud.utils.extensions.openAllFilesAccessSettings +import com.nextcloud.utils.extensions.openMediaPermissions +import com.nextcloud.utils.extensions.setVisibleIf +import com.owncloud.android.MainApp +import com.owncloud.android.databinding.StoragePermissionWarningBannerBinding +import com.owncloud.android.ui.activity.DrawerActivity.REQ_ALL_FILES_ACCESS +import com.owncloud.android.ui.activity.DrawerActivity.REQ_MEDIA_ACCESS +import com.owncloud.android.utils.PermissionUtil + +fun StoragePermissionWarningBannerBinding.setup(activity: Activity, descriptionId: Int) { + description.text = activity.getString(descriptionId) + + val isBrandedAndFlavourGplay = (MainApp.isClientBranded() && isFlavourGPlay()) + allFilesAccess.setVisibleIf(!PermissionUtil.checkAllFilesAccess() && !isBrandedAndFlavourGplay) + allFilesAccess.setOnClickListener { activity.openAllFilesAccessSettings(REQ_ALL_FILES_ACCESS) } + + mediaReadOnly.setVisibleIf(!PermissionUtil.checkMediaAccess(activity)) + mediaReadOnly.setOnClickListener { activity.openMediaPermissions(REQ_MEDIA_ACCESS) } + + root.visibility = if (PermissionUtil.checkAllFilesAccess() || PermissionUtil.checkMediaAccess(activity)) { + View.GONE + } else { + View.VISIBLE + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/storagePermissionBanner/StoragePermissionBannerViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/storagePermissionBanner/StoragePermissionBannerViewHolder.kt new file mode 100644 index 000000000000..5b8ea0946dc0 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/storagePermissionBanner/StoragePermissionBannerViewHolder.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.adapter.storagePermissionBanner + +import android.app.Activity +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import com.owncloud.android.R +import com.owncloud.android.databinding.StoragePermissionWarningBannerBinding + +class StoragePermissionBannerViewHolder(activity: Activity, itemView: View) : RecyclerView.ViewHolder(itemView) { + init { + StoragePermissionWarningBannerBinding.bind(itemView).apply { + setup(activity, R.string.storage_permission_banner_upload_text) + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/StoragePermissionDialogFragment.kt b/app/src/main/java/com/owncloud/android/ui/dialog/StoragePermissionDialogFragment.kt index b37d247450f2..32424b7e1e8c 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/StoragePermissionDialogFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/StoragePermissionDialogFragment.kt @@ -1,6 +1,7 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2025 Alper Ozturk * SPDX-FileCopyrightText: 2022 Álvaro Brey * SPDX-FileCopyrightText: 2022 Nextcloud GmbH * SPDX-License-Identifier: AGPL-3.0-or-later @@ -10,17 +11,16 @@ package com.owncloud.android.ui.dialog import android.app.Dialog import android.os.Build import android.os.Bundle -import android.os.Parcelable import androidx.annotation.RequiresApi import androidx.appcompat.app.AlertDialog -import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment import com.google.android.material.button.MaterialButton import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.nextcloud.client.di.Injectable +import com.nextcloud.client.preferences.AppPreferences import com.owncloud.android.R +import com.owncloud.android.utils.PermissionUtil import com.owncloud.android.utils.theme.ViewThemeUtils -import kotlinx.parcelize.Parcelize import javax.inject.Inject /** @@ -33,20 +33,20 @@ class StoragePermissionDialogFragment : DialogFragment(), Injectable { - private var permissionRequired = false - @Inject lateinit var viewThemeUtils: ViewThemeUtils + @Inject + lateinit var preferences: AppPreferences + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - arguments?.let { - permissionRequired = it.getBoolean(ARG_PERMISSION_REQUIRED, false) - } + isCancelable = false } override fun onStart() { super.onStart() + dialog?.setCanceledOnTouchOutside(false) dialog?.let { val alertDialog = it as AlertDialog @@ -62,29 +62,24 @@ class StoragePermissionDialogFragment : } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val title = when { - permissionRequired -> R.string.file_management_permission - else -> R.string.file_management_permission_optional - } - val explanationResource = when { - permissionRequired -> R.string.file_management_permission_text - else -> R.string.file_management_permission_optional_text - } + val title = R.string.file_management_permission_optional + val explanationResource = R.string.file_management_permission_optional_text val message = getString(explanationResource, getString(R.string.app_name)) val dialogBuilder = MaterialAlertDialogBuilder(requireContext()) .setTitle(title) .setMessage(message) - .setPositiveButton(R.string.storage_permission_full_access) { _, _ -> - setResult(Result.FULL_ACCESS) + .setPositiveButton(R.string.storage_permission_all_files_access) { _, _ -> + val intent = PermissionUtil.getManageAllFilesIntent(requireActivity()) + activity?.startActivity(intent) dismiss() } .setNegativeButton(R.string.storage_permission_media_read_only) { _, _ -> - setResult(Result.MEDIA_READ_ONLY) + PermissionUtil.requestRequiredStoragePermissions(requireActivity()) dismiss() } - .setNeutralButton(R.string.common_cancel) { _, _ -> - setResult(Result.CANCEL) + .setNeutralButton(R.string.storage_permission_dont_ask) { _, _ -> + preferences.isStoragePermissionRequested = true dismiss() } @@ -92,30 +87,4 @@ class StoragePermissionDialogFragment : return dialogBuilder.create() } - - private fun setResult(result: Result) { - parentFragmentManager.setFragmentResult(REQUEST_KEY, bundleOf(RESULT_KEY to result)) - } - - @Parcelize - enum class Result : Parcelable { - CANCEL, - FULL_ACCESS, - MEDIA_READ_ONLY - } - - companion object { - private const val ARG_PERMISSION_REQUIRED = "ARG_PERMISSION_REQUIRED" - const val REQUEST_KEY = "REQUEST_KEY_STORAGE_PERMISSION" - const val RESULT_KEY = "RESULT" - - /** - * @param permissionRequired Whether the permission is absolutely required by the calling component. - * This changes the texts to a more strict version. - */ - fun newInstance(permissionRequired: Boolean): StoragePermissionDialogFragment = - StoragePermissionDialogFragment().apply { - arguments = bundleOf(ARG_PERMISSION_REQUIRED to permissionRequired) - } - } } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/LocalFileListFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/LocalFileListFragment.java index f5757c80e9c6..ba732d1b788a 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/LocalFileListFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/LocalFileListFragment.java @@ -9,6 +9,7 @@ */ package com.owncloud.android.ui.fragment; +import android.annotation.SuppressLint; import android.app.Activity; import android.content.Context; import android.os.Bundle; @@ -420,5 +421,8 @@ public interface ContainerActivity { boolean isWithinEncryptedFolder(); } - + @SuppressLint("NotifyDataSetChanged") + public void setupStoragePermissionWarningBanner() { + mAdapter.notifyDataSetChanged(); + } } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java index 2489b4d555d0..524594e90ee8 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java @@ -19,7 +19,6 @@ import android.app.Activity; import android.content.Context; import android.content.Intent; -import android.os.AsyncTask; import android.os.Bundle; import android.os.Handler; import android.os.Looper; @@ -505,8 +504,6 @@ public void registerFabListener() { // is not available in FolderPickerActivity viewThemeUtils.material.themeFAB(mFabMain); mFabMain.setOnClickListener(v -> { - PermissionUtil.requestMediaLocationPermission(fileActivity); - var currentDir = getCurrentFile(); if (currentDir == null) { Log_OC.w(TAG, "currentDir is null cannot open bottom sheet dialog"); diff --git a/app/src/main/java/com/owncloud/android/utils/PermissionUtil.kt b/app/src/main/java/com/owncloud/android/utils/PermissionUtil.kt index 5b1767a9cf30..b69e94d5a1ff 100644 --- a/app/src/main/java/com/owncloud/android/utils/PermissionUtil.kt +++ b/app/src/main/java/com/owncloud/android/utils/PermissionUtil.kt @@ -1,6 +1,7 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2025 Alper Ozturk * SPDX-FileCopyrightText: 2021 Álvaro Brey * SPDX-FileCopyrightText: 2021 Nextcloud GmbH * SPDX-FileCopyrightText: 2015 Andy Scherzinger @@ -15,7 +16,6 @@ import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ResolveInfo import android.os.Build -import android.os.Bundle import android.os.Environment import android.provider.Settings import androidx.annotation.RequiresApi @@ -23,17 +23,15 @@ import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.net.toUri -import com.google.android.material.snackbar.Snackbar import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.client.preferences.AppPreferencesImpl -import com.nextcloud.utils.extensions.getParcelableArgument -import com.owncloud.android.R +import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.ui.dialog.StoragePermissionDialogFragment -import com.owncloud.android.utils.PermissionUtil.PERMISSIONS_EXTERNAL_STORAGE -import com.owncloud.android.utils.PermissionUtil.REQUEST_CODE_MANAGE_ALL_FILES -import com.owncloud.android.utils.theme.ViewThemeUtils +@Suppress("TooManyFunctions") object PermissionUtil { + private const val TAG = "PermissionUtil" + const val PERMISSIONS_EXTERNAL_STORAGE = 1 const val PERMISSIONS_READ_CONTACTS_AUTOMATIC = 2 const val PERMISSIONS_WRITE_CONTACTS = 4 @@ -41,162 +39,50 @@ object PermissionUtil { const val PERMISSIONS_READ_CALENDAR_AUTOMATIC = 6 const val PERMISSIONS_WRITE_CALENDAR = 7 const val PERMISSIONS_POST_NOTIFICATIONS = 8 - const val PERMISSIONS_MEDIA_LOCATION = 9 - - 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. - * - * @param permission The name of the permission being checked. - * @return `true` if app has the permission, or `false` if not. - */ + // region Permission Check Helpers @JvmStatic fun checkSelfPermission(context: Context, permission: String): Boolean = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED - /** - * Wrapper method for ActivityCompat.shouldShowRequestPermissionRationale(). - * Gets whether you should show UI with rationale for requesting a permission. - * You should do this only if you do not have the permission and the context in - * which the permission is requested does not clearly communicate to the user - * what would be the benefit from granting this permission. - * - * @param activity The target activity. - * @param permission A permission to be requested. - * @return Whether to show permission rationale UI. - */ - @JvmStatic - fun shouldShowRequestPermissionRationale(activity: Activity, permission: String): Boolean = - ActivityCompat.shouldShowRequestPermissionRationale(activity, permission) + fun checkPermissions(context: Context, permissions: Array): Boolean = + permissions.all { checkSelfPermission(context, it) } + // endregion /** - * 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. + * Request storage permission as needed. + * Will handle: + * - All files access (Android 11+) + * - Media permissions (Android 13+) + * - Legacy storage (Android < 11) */ @JvmStatic - fun checkExternalStoragePermission(context: Context): Boolean = when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Environment.isExternalStorageManager() || - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - checkSelfPermission(context, Manifest.permission.READ_MEDIA_IMAGES) || - checkSelfPermission( - context, - Manifest.permission.READ_MEDIA_VIDEO - ) - } else { - checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) - } + fun requestStoragePermissionIfNeeded(activity: AppCompatActivity) { + if (checkStoragePermission(activity)) { + Log_OC.d(TAG, "Storage permissions are already granted") + return + } - else -> checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && canRequestAllFilesPermission(activity)) { + showStoragePermissionDialogFragment(activity) + return + } - fun checkPermissions(context: Context, permissions: Array): Boolean = permissions.all { - ActivityCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED + requestRequiredStoragePermissions(activity) } - /** - * 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 - @JvmOverloads - fun requestExternalStoragePermission( - activity: AppCompatActivity, - viewThemeUtils: ViewThemeUtils, - permissionRequired: Boolean = false - ) { - if (!checkExternalStoragePermission(activity)) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && canRequestAllFilesPermission(activity)) { - // can request All Files, show choice - showPermissionChoiceDialog(activity, permissionRequired, viewThemeUtils) - } else { - // can not request all files, request read-only access - requestStoragePermission( - activity, - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R, - permissionRequired, - viewThemeUtils - ) - } + fun requestRequiredStoragePermissions(activity: Activity) { + val permissions = getRequiredStoragePermissions() + if (checkPermissions(activity, permissions)) { + return } - } - - /** - * Request a storage permission - */ - // TODO inject this class to avoid passing ViewThemeUtils around - @Suppress("NestedBlockDepth") - private fun requestStoragePermission( - activity: Activity, - readOnly: Boolean, - permissionRequired: Boolean, - viewThemeUtils: ViewThemeUtils - ) { - val preferences: AppPreferences = AppPreferencesImpl.fromContext(activity) - if (permissionRequired || !preferences.isStoragePermissionRequested) { - // determine required permissions - val permissions = if (readOnly && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - // use granular media permissions - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - arrayOf( - Manifest.permission.READ_MEDIA_IMAGES, - Manifest.permission.READ_MEDIA_VIDEO, - Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED - ) - } else { - arrayOf( - Manifest.permission.READ_MEDIA_IMAGES, - Manifest.permission.READ_MEDIA_VIDEO - ) - } - } else { - arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) - } - } else { - arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE) - } - - fun doRequest() { - ActivityCompat.requestPermissions( - activity, - permissions, - PERMISSIONS_EXTERNAL_STORAGE - ) - preferences.isStoragePermissionRequested = true - } - - // Check if we should show an explanation - if (permissions.any { shouldShowRequestPermissionRationale(activity, it) }) { - // 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 { viewThemeUtils.material.themeSnackbar(it) }.show() - } else { - // No explanation needed, request the permission. - doRequest() - } - } + ActivityCompat.requestPermissions( + activity, + permissions, + PERMISSIONS_EXTERNAL_STORAGE + ) } @RequiresApi(Build.VERSION_CODES.R) @@ -222,63 +108,21 @@ object PermissionUtil { * sdk >= 30: Choice between All Files access or read_external_storage */ @RequiresApi(Build.VERSION_CODES.R) - private fun showPermissionChoiceDialog( - activity: AppCompatActivity, - permissionRequired: Boolean, - viewThemeUtils: ViewThemeUtils - ) { + private fun showStoragePermissionDialogFragment(activity: AppCompatActivity) { val preferences: AppPreferences = AppPreferencesImpl.fromContext(activity) - val shouldRequestPermission = !preferences.isStoragePermissionRequested || permissionRequired - if (shouldRequestPermission && - activity.supportFragmentManager.findFragmentByTag(PERMISSION_CHOICE_DIALOG_TAG) == null - ) { - val listener: (requestKey: String, result: Bundle) -> Unit = { _, resultBundle -> - val result: StoragePermissionDialogFragment.Result? = - resultBundle.getParcelableArgument( - StoragePermissionDialogFragment.RESULT_KEY, - StoragePermissionDialogFragment.Result::class.java - ) - if (result != null) { - preferences.isStoragePermissionRequested = true - when (result) { - StoragePermissionDialogFragment.Result.FULL_ACCESS -> { - val intent = getManageAllFilesIntent(activity) - activity.startActivityForResult(intent, REQUEST_CODE_MANAGE_ALL_FILES) - } - - StoragePermissionDialogFragment.Result.MEDIA_READ_ONLY -> requestStoragePermission( - activity = activity, - readOnly = true, - permissionRequired = true, - viewThemeUtils = viewThemeUtils - ) - - else -> {} - } - } - } - - activity.runOnUiThread { - activity.supportFragmentManager.setFragmentResultListener( - StoragePermissionDialogFragment.REQUEST_KEY, - activity, - listener - ) - - // Check if the dialog is already added to the FragmentManager. - val existingDialog = activity.supportFragmentManager.findFragmentByTag(PERMISSION_CHOICE_DIALOG_TAG) + val existingDialog = activity.supportFragmentManager.findFragmentByTag(PERMISSION_CHOICE_DIALOG_TAG) + if (preferences.isStoragePermissionRequested || existingDialog != null) { + return + } - // Only show the dialog if it's not already shown. - if (existingDialog == null) { - val dialogFragment = StoragePermissionDialogFragment.newInstance(permissionRequired) - dialogFragment.show(activity.supportFragmentManager, PERMISSION_CHOICE_DIALOG_TAG) - } - } + activity.runOnUiThread { + val dialogFragment = StoragePermissionDialogFragment() + dialogFragment.show(activity.supportFragmentManager, PERMISSION_CHOICE_DIALOG_TAG) } } @RequiresApi(Build.VERSION_CODES.R) - private fun getManageAllFilesIntent(context: Context) = Intent().apply { + fun getManageAllFilesIntent(context: Context) = Intent().apply { action = Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION data = "package:${context.applicationContext.packageName}".toUri() } @@ -316,31 +160,51 @@ object PermissionUtil { } } + // region Storage permission checks /** - * Request media location permission. Required on API level >= 34. - * Does not have any effect on API level < 34. + * Checks if the application has storage/media access permissions. * - * @param activity target activity + * - Android 11+ (API 30+): Checks for MANAGE_EXTERNAL_STORAGE (all files system access) + * - Android 13+ (API 33+): Checks for granular media permissions (READ_MEDIA_IMAGES, READ_MEDIA_VIDEO) + * - Android 14+ (API 34+): Also checks for limited/partial media access (READ_MEDIA_VISUAL_USER_SELECTED) + * - Below Android 11: Uses legacy WRITE_EXTERNAL_STORAGE, READ_EXTERNAL_STORAGE permission */ - @Suppress("ReturnCount") @JvmStatic - fun requestMediaLocationPermission(activity: Activity) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - return - } + fun checkStoragePermission(context: Context): Boolean = checkAllFilesAccess() || checkMediaAccess(context) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && Environment.isExternalStorageManager()) { - return - } + @JvmStatic + fun checkAllFilesAccess(): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && Environment.isExternalStorageManager() - if (checkSelfPermission(activity, Manifest.permission.ACCESS_MEDIA_LOCATION)) { - return - } + fun checkMediaAccess(context: Context): Boolean = checkPermissions(context, getRequiredStoragePermissions()) - ActivityCompat.requestPermissions( - activity, - arrayOf(Manifest.permission.ACCESS_MEDIA_LOCATION), - PERMISSIONS_MEDIA_LOCATION - ) + private fun getRequiredStoragePermissions() = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> getApiLevel34StoragePermissions() + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getApiLevel33StoragePermissions() + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> getApiLevel29StoragePermissions() + else -> getLegacyStoragePermissions() } + + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + private fun getApiLevel34StoragePermissions(): Array = listOf( + Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED + ).plus(getApiLevel33StoragePermissions()).toTypedArray() + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private fun getApiLevel33StoragePermissions(): Array = listOf( + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VIDEO, + Manifest.permission.ACCESS_MEDIA_LOCATION + ).toTypedArray() + + @RequiresApi(Build.VERSION_CODES.Q) + private fun getApiLevel29StoragePermissions(): Array = listOf( + Manifest.permission.ACCESS_MEDIA_LOCATION + ).plus(getLegacyStoragePermissions()).toTypedArray() + + private fun getLegacyStoragePermissions(): Array = listOf( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ).toTypedArray() + // endregion } diff --git a/app/src/main/res/layout/auto_upload_battery_saver_warning_card.xml b/app/src/main/res/layout/auto_upload_battery_saver_warning_banner.xml similarity index 97% rename from app/src/main/res/layout/auto_upload_battery_saver_warning_card.xml rename to app/src/main/res/layout/auto_upload_battery_saver_warning_banner.xml index c08ca9794359..83274c0eda75 100644 --- a/app/src/main/res/layout/auto_upload_battery_saver_warning_card.xml +++ b/app/src/main/res/layout/auto_upload_battery_saver_warning_banner.xml @@ -12,7 +12,7 @@ android:background="@drawable/rounded_rect_8dp" android:layout_marginTop="@dimen/standard_half_margin" android:layout_marginHorizontal="@dimen/standard_half_margin" - android:backgroundTint="@color/grey_200" + android:backgroundTint="@color/task_container" android:elevation="4dp" android:padding="@dimen/standard_double_margin" xmlns:android="http://schemas.android.com/apk/res/android" diff --git a/app/src/main/res/layout/storage_permission_warning_banner.xml b/app/src/main/res/layout/storage_permission_warning_banner.xml new file mode 100644 index 000000000000..993114db9a0e --- /dev/null +++ b/app/src/main/res/layout/storage_permission_warning_banner.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/synced_folders_item_header.xml b/app/src/main/res/layout/synced_folders_item_header.xml index d4b9a81e160e..983c109f1f24 100644 --- a/app/src/main/res/layout/synced_folders_item_header.xml +++ b/app/src/main/res/layout/synced_folders_item_header.xml @@ -22,7 +22,7 @@ - + + + - - + android:layout_height="wrap_content" + android:visibility="gone" + android:layout_marginTop="@dimen/standard_margin" + android:layout_marginHorizontal="@dimen/standard_margin" + tools:visibility="visible" /> - + android:layout_height="0dp" + android:layout_weight="1"> - - + - + - + + + + + + + + - + @@ -63,6 +85,6 @@ layout="@layout/drawer" android:layout_width="@dimen/drawer_width" android:layout_height="match_parent" - android:layout_gravity="start"/> + android:layout_gravity="start" /> diff --git a/app/src/main/res/layout/upload_list_header.xml b/app/src/main/res/layout/upload_list_header.xml index 5321c9a05218..52f3d76065e8 100755 --- a/app/src/main/res/layout/upload_list_header.xml +++ b/app/src/main/res/layout/upload_list_header.xml @@ -68,5 +68,5 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_below="@+id/upload_list_title" - layout="@layout/auto_upload_battery_saver_warning_card" /> + layout="@layout/auto_upload_battery_saver_warning_banner" /> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 97ba5238494a..2d73987289a7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -120,6 +120,10 @@ Daily backup of your calendar and contacts Daily backup of your contacts Manage folders for auto upload + + All files access + Allow the app to access and manage all files on your device + Help Recommend to a friend Imprint @@ -465,6 +469,7 @@ The certificate could not be shown. - No information about the error + Storage permission is required for Auto-upload and file uploads. 2012/05/18 12:23 PM 12:23:45 This is a placeholder @@ -1290,9 +1295,9 @@ No actions for this user Error getting search results Load more results - Permissions needed + Storage permission is required for Auto-upload. + Storage permission is required for file uploads. Storage permissions - %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. %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. No results found for your query Start your search @@ -1302,8 +1307,9 @@ Error creating file from template No app available for sending the selected files Tap on a page to zoom in - Full access + All files access Media read-only + Don\'t ask Photos and videos Show photos Photos only diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 8ab1f37e7090..adada5180474 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -79,6 +79,11 @@ android:title="@string/internal_two_way_sync" android:key="internal_two_way_sync" android:summary="@string/prefs_two_way_sync_summary" /> + +