diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d43f17c3..988c0f47 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,7 +22,9 @@ + + @@ -41,6 +43,7 @@ android:extractNativeLibs="true" android:icon="@mipmap/ic_launcher" android:label="@string/application_name" + android:requestLegacyExternalStorage="true" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="false" android:theme="@style/Theme.Termux"> diff --git a/app/src/main/java/com/termux/app/TermuxActivity.java b/app/src/main/java/com/termux/app/TermuxActivity.java index 524cc931..2c0797ec 100644 --- a/app/src/main/java/com/termux/app/TermuxActivity.java +++ b/app/src/main/java/com/termux/app/TermuxActivity.java @@ -1,6 +1,5 @@ package com.termux.app; -import android.Manifest; import android.annotation.SuppressLint; import android.app.Activity; import android.app.AlertDialog; @@ -11,7 +10,6 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.ServiceConnection; -import android.content.pm.PackageManager; import android.graphics.Color; import android.net.Uri; import android.os.Build; @@ -35,6 +33,7 @@ import android.widget.Toast; import com.termux.R; import com.termux.app.terminal.TermuxActivityRootView; import com.termux.shared.activities.ReportActivity; +import com.termux.shared.data.IntentUtils; import com.termux.shared.packages.PermissionUtils; import com.termux.shared.termux.TermuxConstants; import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY; @@ -63,6 +62,8 @@ import androidx.core.content.ContextCompat; import androidx.drawerlayout.widget.DrawerLayout; import androidx.viewpager.widget.ViewPager; +import java.util.Arrays; + /** * A terminal emulator activity. *

@@ -712,25 +713,49 @@ public final class TermuxActivity extends Activity implements ServiceConnection /** - * For processes to access shared internal storage (/sdcard) we need this permission. + * For processes to access primary external storage (/sdcard, /storage/emulated/0, ~/storage/shared), + * termux needs to be granted legacy WRITE_EXTERNAL_STORAGE or MANAGE_EXTERNAL_STORAGE permissions + * if targeting targetSdkVersion 30 (android 11) and running on sdk 30 (android 11) and higher. */ - public boolean ensureStoragePermissionGranted() { - if (PermissionUtils.checkPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)) { - return true; - } else { - Logger.logInfo(LOG_TAG, "Storage permission not granted, requesting permission."); - PermissionUtils.requestPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE, PermissionUtils.REQUEST_GRANT_STORAGE_PERMISSION); - return false; + public void requestStoragePermission(boolean isPermissionCallback) { + new Thread() { + @Override + public void run() { + // Do not ask for permission again + int requestCode = isPermissionCallback ? -1 : PermissionUtils.REQUEST_GRANT_STORAGE_PERMISSION; + + // If permission is granted, then also setup storage symlinks. + if(PermissionUtils.checkAndRequestLegacyOrManageExternalStoragePermission( + TermuxActivity.this, requestCode, !isPermissionCallback)) { + if (isPermissionCallback) + Logger.logInfoAndShowToast(TermuxActivity.this, LOG_TAG, + getString(com.termux.shared.R.string.msg_storage_permission_granted_on_request)); + + TermuxInstaller.setupStorageSymlinks(TermuxActivity.this); + } else { + if (isPermissionCallback) + Logger.logInfoAndShowToast(TermuxActivity.this, LOG_TAG, + getString(com.termux.shared.R.string.msg_storage_permission_not_granted_on_request)); + } + } + }.start(); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + super.onActivityResult(requestCode, resultCode, data); + Logger.logVerbose(LOG_TAG, "onActivityResult: requestCode: " + requestCode + ", resultCode: " + resultCode + ", data: " + IntentUtils.getIntentString(data)); + if (requestCode == PermissionUtils.REQUEST_GRANT_STORAGE_PERMISSION) { + requestStoragePermission(true); } } @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - if (requestCode == PermissionUtils.REQUEST_GRANT_STORAGE_PERMISSION && grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - Logger.logInfo(LOG_TAG, "Storage permission granted by user on request."); - TermuxInstaller.setupStorageSymlinks(this); - } else { - Logger.logInfo(LOG_TAG, "Storage permission denied by user on request."); + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + Logger.logVerbose(LOG_TAG, "onRequestPermissionsResult: requestCode: " + requestCode + ", permissions: " + Arrays.toString(permissions) + ", grantResults: " + Arrays.toString(grantResults)); + if (requestCode == PermissionUtils.REQUEST_GRANT_STORAGE_PERMISSION) { + requestStoragePermission(true); } } @@ -862,8 +887,7 @@ public final class TermuxActivity extends Activity implements ServiceConnection switch (intent.getAction()) { case TERMUX_ACTIVITY.ACTION_REQUEST_PERMISSIONS: Logger.logDebug(LOG_TAG, "Received intent to request storage permissions"); - if (ensureStoragePermissionGranted()) - TermuxInstaller.setupStorageSymlinks(TermuxActivity.this); + requestStoragePermission(false); return; case TERMUX_ACTIVITY.ACTION_RELOAD_STYLE: Logger.logDebug(LOG_TAG, "Received intent to reload styling"); diff --git a/termux-shared/src/main/java/com/termux/shared/packages/PackageUtils.java b/termux-shared/src/main/java/com/termux/shared/packages/PackageUtils.java index 20c888aa..e827bb09 100644 --- a/termux-shared/src/main/java/com/termux/shared/packages/PackageUtils.java +++ b/termux-shared/src/main/java/com/termux/shared/packages/PackageUtils.java @@ -18,8 +18,10 @@ import com.termux.shared.R; import com.termux.shared.data.DataUtils; import com.termux.shared.interact.MessageDialogUtils; import com.termux.shared.logger.Logger; +import com.termux.shared.reflection.ReflectionUtils; import com.termux.shared.termux.TermuxConstants; +import java.lang.reflect.Field; import java.security.MessageDigest; import java.util.List; @@ -158,6 +160,63 @@ public class PackageUtils { } } + /** + * Get the {@code privateFlags} {@link Field} of the {@link ApplicationInfo} class. + * + * @param applicationInfo The {@link ApplicationInfo} for the package. + * @return Returns the private flags or {@code null} if an exception was raised. + */ + @Nullable + public static Integer getApplicationInfoPrivateFlagsForPackage(@NonNull final ApplicationInfo applicationInfo) { + ReflectionUtils.bypassHiddenAPIReflectionRestrictions(); + try { + return (Integer) ReflectionUtils.invokeField(ApplicationInfo.class, "privateFlags", applicationInfo).value; + } catch (Exception e) { + // ClassCastException may be thrown + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get privateFlags field value for ApplicationInfo class", e); + return null; + } + } + + /** + * Get the {@code privateFlags} {@link Field} of the {@link ApplicationInfo} class. + * + * @param fieldName The name of the field to get. + * @return Returns the field value or {@code null} if an exception was raised. + */ + @Nullable + public static Integer getApplicationInfoStaticIntFieldValue(@NonNull String fieldName) { + ReflectionUtils.bypassHiddenAPIReflectionRestrictions(); + try { + return (Integer) ReflectionUtils.invokeField(ApplicationInfo.class, fieldName, null).value; + } catch (Exception e) { + // ClassCastException may be thrown + Logger.logStackTraceWithMessage(LOG_TAG, "Failed to get \"" + fieldName + "\" field value for ApplicationInfo class", e); + return null; + } + } + + /** + * Check if the app associated with the {@code applicationInfo} has a specific flag set. + * + * @param flagToCheckName The name of the field for the flag to check. + * @param applicationInfo The {@link ApplicationInfo} for the package. + * @return Returns {@code true} if app has flag is set, otherwise {@code false}. This will be + * {@code null} if an exception is raised. + */ + @Nullable + public static Boolean isApplicationInfoPrivateFlagSetForPackage(@NonNull String flagToCheckName, @NonNull final ApplicationInfo applicationInfo) { + Integer privateFlags = getApplicationInfoPrivateFlagsForPackage(applicationInfo); + if (privateFlags == null) return null; + + Integer flagToCheck = getApplicationInfoStaticIntFieldValue(flagToCheckName); + if (flagToCheck == null) return null; + + return ( 0 != ( privateFlags & flagToCheck ) ); + } + + + /** @@ -297,6 +356,36 @@ public class PackageUtils { + /** + * Check if the app associated with the {@code context} has + * ApplicationInfo.PRIVATE_FLAG_REQUEST_LEGACY_EXTERNAL_STORAGE (requestLegacyExternalStorage) + * set to {@code true} in app manifest. + * + * @param context The {@link Context} for the package. + * @return Returns {@code true} if app has requested legacy external storage, otherwise + * {@code false}. This will be {@code null} if an exception is raised. + */ + @Nullable + public static Boolean hasRequestedLegacyExternalStorage(@NonNull final Context context) { + return hasRequestedLegacyExternalStorage(context.getApplicationInfo()); + } + + /** + * Check if the app associated with the {@code applicationInfo} has + * ApplicationInfo.PRIVATE_FLAG_REQUEST_LEGACY_EXTERNAL_STORAGE (requestLegacyExternalStorage) + * set to {@code true} in app manifest. + * + * @param applicationInfo The {@link ApplicationInfo} for the package. + * @return Returns {@code true} if app has requested legacy external storage, otherwise + * {@code false}. This will be {@code null} if an exception is raised. + */ + @Nullable + public static Boolean hasRequestedLegacyExternalStorage(@NonNull final ApplicationInfo applicationInfo) { + return isApplicationInfoPrivateFlagSetForPackage("PRIVATE_FLAG_REQUEST_LEGACY_EXTERNAL_STORAGE", applicationInfo); + } + + + /** * Get the {@code versionCode} for the package associated with the {@code context}. * @@ -545,8 +634,8 @@ public class PackageUtils { */ @Nullable public static String setComponentState(@NonNull final Context context, @NonNull String packageName, - @NonNull String className, boolean state, String toastString, - boolean showErrorMessage) { + @NonNull String className, boolean state, String toastString, + boolean showErrorMessage) { try { PackageManager packageManager = context.getPackageManager(); if (packageManager != null) { @@ -579,7 +668,7 @@ public class PackageUtils { * get the state. */ public static Boolean isComponentDisabled(@NonNull final Context context, @NonNull String packageName, - @NonNull String className, boolean logErrorMessage) { + @NonNull String className, boolean logErrorMessage) { try { PackageManager packageManager = context.getPackageManager(); if (packageManager != null) { @@ -607,7 +696,7 @@ public class PackageUtils { * @return Returns {@code true} if it exists, otherwise {@code false}. */ public static boolean doesActivityComponentExist(@NonNull final Context context, @NonNull String packageName, - @NonNull String className, int flags) { + @NonNull String className, int flags) { try { PackageManager packageManager = context.getPackageManager(); if (packageManager != null) { diff --git a/termux-shared/src/main/java/com/termux/shared/packages/PermissionUtils.java b/termux-shared/src/main/java/com/termux/shared/packages/PermissionUtils.java index e422ff42..0ab49d64 100644 --- a/termux-shared/src/main/java/com/termux/shared/packages/PermissionUtils.java +++ b/termux-shared/src/main/java/com/termux/shared/packages/PermissionUtils.java @@ -9,6 +9,7 @@ import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.net.Uri; import android.os.Build; +import android.os.Environment; import android.os.PowerManager; import android.provider.Settings; @@ -18,6 +19,7 @@ import androidx.core.content.ContextCompat; import com.google.common.base.Joiner; import com.termux.shared.R; +import com.termux.shared.file.FileUtils; import com.termux.shared.logger.Logger; import com.termux.shared.models.errors.Error; import com.termux.shared.models.errors.FunctionErrno; @@ -199,6 +201,215 @@ public class PermissionUtils { + /** If path is under primary external storage directory and storage permission is missing, + * then legacy or manage external storage permission will be requested from the user via a call + * to {@link #checkAndRequestLegacyOrManageExternalStoragePermission(Context, int, boolean)}. + * + * @param context The context for operations. + * @param filePath The path to check. + * @param requestCode The request code to use while asking for permission. + * @param showErrorMessage If an error message toast should be shown if permission is not granted. + * @return Returns {@code true} if permission is granted, otherwise {@code false}. + */ + @SuppressLint("SdCardPath") + public static boolean checkAndRequestLegacyOrManageExternalStoragePermissionIfPathOnPrimaryExternalStorage( + @NonNull Context context, String filePath, int requestCode, boolean showErrorMessage) { + // If path is under primary external storage directory, then check for missing permissions. + if (!FileUtils.isPathInDirPaths(filePath, + Arrays.asList(Environment.getExternalStorageDirectory().getAbsolutePath(), "/sdcard"), true)) + return true; + + return checkAndRequestLegacyOrManageExternalStoragePermission(context, requestCode, showErrorMessage); + } + + /** + * Check if legacy or manage external storage permissions has been granted. If + * {@link #isLegacyExternalStoragePossible(Context)} returns {@code true}, them it will be + * checked if app has has been granted {@link Manifest.permission#READ_EXTERNAL_STORAGE} and + * {@link Manifest.permission#WRITE_EXTERNAL_STORAGE} permissions, otherwise it will be checked + * if app has been granted the {@link Manifest.permission#MANAGE_EXTERNAL_STORAGE} permission. + * + * If storage permission is missing, it will be requested from the user if {@code context} is an + * instance of {@link Activity} or {@link AppCompatActivity} and {@code requestCode} + * is `>=0` and the function will automatically return. The caller should register for + * Activity.onActivityResult() and Activity.onRequestPermissionsResult() and call this function + * again but set {@code requestCode} to `-1` to check if permission was granted or not. + * + * Caller must add following to AndroidManifest.xml of the app, otherwise errors will be thrown. + * {@code + * + * + * + * + * + * + *} + * @param context The context for operations. + * @param requestCode The request code to use while asking for permission. + * @param showErrorMessage If an error message toast should be shown if permission is not granted. + * @return Returns {@code true} if permission is granted, otherwise {@code false}. + */ + public static boolean checkAndRequestLegacyOrManageExternalStoragePermission(@NonNull Context context, + int requestCode, + boolean showErrorMessage) { + String errmsg; + boolean requestLegacyStoragePermission = isLegacyExternalStoragePossible(context); + boolean checkIfHasRequestedLegacyExternalStorage = checkIfHasRequestedLegacyExternalStorage(context); + + if (requestLegacyStoragePermission && checkIfHasRequestedLegacyExternalStorage) { + // Check if requestLegacyExternalStorage is set to true in app manifest + if (!hasRequestedLegacyExternalStorage(context, showErrorMessage)) + return false; + } + + if (checkStoragePermission(context, requestLegacyStoragePermission)) { + return true; + } + + errmsg = context.getString(R.string.msg_storage_permission_not_granted); + Logger.logError(LOG_TAG, errmsg); + if (showErrorMessage) + Logger.showToast(context, errmsg, false); + + if (requestCode < 0) + return false; + + if (requestLegacyStoragePermission) { + requestLegacyStorageExternalPermission(context, requestCode); + } else { + requestManageStorageExternalPermission(context, requestCode); + } + + return false; + } + + /** + * Check if app has been granted storage permission. + * + * @param context The context for operations. + * @param checkLegacyStoragePermission If set to {@code true}, then it will be checked if app + * has been granted {@link Manifest.permission#READ_EXTERNAL_STORAGE} + * and {@link Manifest.permission#WRITE_EXTERNAL_STORAGE} + * permissions, otherwise it will be checked if app has been + * granted the {@link Manifest.permission#MANAGE_EXTERNAL_STORAGE} + * permission. + * @return Returns {@code true} if permission is granted, otherwise {@code false}. + */ + public static boolean checkStoragePermission(@NonNull Context context, boolean checkLegacyStoragePermission) { + if (checkLegacyStoragePermission) { + return checkPermissions(context, + new String[]{Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE}); + } else { + return Environment.isExternalStorageManager(); + } + } + + /** + * Request user to grant {@link Manifest.permission#READ_EXTERNAL_STORAGE} and + * {@link Manifest.permission#WRITE_EXTERNAL_STORAGE} permissions to the app. + * + * @param context The context for operations. It must be an instance of {@link Activity} or + * {@link AppCompatActivity}. + * @param requestCode The request code to use while asking for permission. It must be `>=0` or + * will fail silently and will log an exception. + * @return Returns {@code true} if requesting the permission was successful, otherwise {@code false}. + */ + public static boolean requestLegacyStorageExternalPermission(@NonNull Context context, int requestCode) { + Logger.logInfo(LOG_TAG, "Requesting legacy external storage permission"); + return requestPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE, requestCode); + } + + /** + * Request user to grant {@link Manifest.permission#MANAGE_EXTERNAL_STORAGE} permission to the app. + * + * @param context The context for operations. It must be an instance of {@link Activity} or + * {@link AppCompatActivity}. + * @param requestCode The request code to use while asking for permission. It must be `>=0` or + * will fail silently and will log an exception. + * @return Returns {@code true} if requesting the permission was successful, otherwise {@code false}. + */ + public static boolean requestManageStorageExternalPermission(@NonNull Context context, int requestCode) { + Logger.logInfo(LOG_TAG, "Requesting manage external storage permission"); + + Intent intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION); + intent.addCategory("android.intent.category.DEFAULT"); + intent.setData(Uri.parse("package:" + context.getPackageName())); + boolean result = ActivityUtils.startActivityForResult(context, requestCode, intent); + + // Use fallback if matching Activity did not exist for ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION. + if (!result) { + intent = new Intent(); + intent.setAction(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION); + return ActivityUtils.startActivityForResult(context, requestCode, intent); + } + + return true; + } + + /** + * If app is targeting targetSdkVersion 30 (android 11) and running on sdk 30 (android 11) or + * higher, then {@link android.R.attr#requestLegacyExternalStorage} attribute is ignored. + * https://developer.android.com/training/data-storage/use-cases#opt-out-scoped-storage + */ + public static boolean isLegacyExternalStoragePossible(@NonNull Context context) { + return !(PackageUtils.getTargetSDKForPackage(context) >= Build.VERSION_CODES.R && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R); + } + + /** + * Return whether it should be checked if app has set + * {@link android.R.attr#requestLegacyExternalStorage} attribute to {@code true}, if storage + * permissions are to be requested based on if {@link #isLegacyExternalStoragePossible(Context)} + * return {@code true}. + * + * If app is targeting targetSdkVersion 30 (android 11), then legacy storage can only be + * requested if running on sdk 29 (android 10). + * If app is targeting targetSdkVersion 29 (android 10), then legacy storage can only be + * requested if running on sdk 29 (android 10) and higher. + */ + public static boolean checkIfHasRequestedLegacyExternalStorage(@NonNull Context context) { + int targetSdkVersion = PackageUtils.getTargetSDKForPackage(context); + + if (targetSdkVersion >= Build.VERSION_CODES.R) { + return Build.VERSION.SDK_INT == Build.VERSION_CODES.Q; + } else if (targetSdkVersion == Build.VERSION_CODES.Q) { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q; + } else { + return false; + } + } + + /** + * Call to {@link Environment#isExternalStorageLegacy()} will not return the actual value defined + * in app manifest for {@link android.R.attr#requestLegacyExternalStorage} attribute, + * since an app may inherit its legacy state based on when it was first installed, target sdk and + * other factors. To provide consistent experience for all users regardless of current legacy + * state on a specific device, we directly use the value defined in app` manifest. + */ + public static boolean hasRequestedLegacyExternalStorage(@NonNull Context context, + boolean showErrorMessage) { + String errmsg; + Boolean hasRequestedLegacyExternalStorage = PackageUtils.hasRequestedLegacyExternalStorage(context); + if (hasRequestedLegacyExternalStorage != null && !hasRequestedLegacyExternalStorage) { + errmsg = context.getString(R.string.error_has_not_requested_legacy_external_storage, + context.getPackageName(), PackageUtils.getTargetSDKForPackage(context), Build.VERSION.SDK_INT); + Logger.logError(LOG_TAG, errmsg); + if (showErrorMessage) + Logger.showToast(context, errmsg, true); + return false; + } + + return true; + } + + + + /** * Check if {@link Manifest.permission#SYSTEM_ALERT_WINDOW} permission has been granted. diff --git a/termux-shared/src/main/res/values/strings.xml b/termux-shared/src/main/res/values/strings.xml index 0c351a5d..b67468aa 100644 --- a/termux-shared/src/main/res/values/strings.xml +++ b/termux-shared/src/main/res/values/strings.xml @@ -41,6 +41,7 @@ Failed to request permissions with request code %1$d: %2$s Attempted to check for permissions that have not been requested in app manifest: %1$s Attempted to ask for permissions that have not been requested in app manifest: %1$s + The \"%1$s\" package is targeting targetSdkVersion %2$d and is running on android sdk %3$d but has not set requestLegacyExternalStorage to true in app manifest" @@ -101,6 +102,9 @@ Cancel Save To File + "The storage permission granted by user on request" + "The storage permission not granted by user on request" +