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