package paris.tourolle.darkroom; import android.Manifest; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.net.Uri; import android.os.Build; import android.os.Environment; import android.os.storage.StorageManager; import android.os.storage.StorageVolume; import android.provider.Settings; import android.util.Log; import java.io.File; import java.util.ArrayList; import java.util.List; /** * Finding a camera card, and the permission that makes it readable (FR-CAT-10). * *

An import reads the card as files: the survey walks it, the probe reads * each header and the copy streams each original, all through the same * {@code std::fs} code the desktop uses. Android hands out such paths — * {@code /storage/9C33-6BBD/DCIM} — to an app holding "all files access" * ({@code MANAGE_EXTERNAL_STORAGE}, API 30), which covers the root of an SD * card and of a USB card reader. Below API 30 the same paths are readable * with {@code READ_EXTERNAL_STORAGE}. * *

Not the folder picker {@link FolderPicker} uses for albums. A tree * granted through SAF is {@code content://} URIs, not paths, and since API 30 * the picker refuses the root of a card outright; reading a card through it * would mean a second storage implementation under the importer, where this * needs none. * *

Google Play restricts this permission to file managers and the like. * DarkRoom is not distributed through Play, so the restriction does not * apply; it would need revisiting if that changed. */ public final class Cards { private static final String TAG = "DarkRoom"; private Cards() { } /** Whether this app may read a card's files by path. */ public static boolean hasAccess(Context context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { return Environment.isExternalStorageManager(); } return context.checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED; } /** * Open the system page where the user grants it. * *

A settings page rather than a permission dialog because there is no * dialog for this one on API 30 and up: the user flips "Allow access to * manage all files" for this app. Below 30 the context is the application * context, which cannot raise a runtime permission request (that needs an * Activity's result), so the app's own settings page is the route there * too. Either way the app learns of the grant by asking * {@link #hasAccess} again. */ public static void requestAccess(Context context) { Uri self = Uri.parse("package:" + context.getPackageName()); Intent intent; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, self); } else { intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, self); } // The context is not an Activity; see FolderPicker.start. intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); try { context.startActivity(intent); } catch (RuntimeException e) { // Some builds ship without the per-app page; the list of every // app holding the permission is the fallback that always exists. Log.w(TAG, "no per-app all-files page; opening the list", e); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { Intent list = new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION); list.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(list); } } } /** * Every mounted volume other than the device's own storage. * *

One string per volume, {@code path \t description \t removable}, * where removable is {@code 1} or {@code 0}: the reason {@link Intents} * gives for keeping the JNI surface to strings. The primary volume is left * out — it is the device's internal storage, never a card — and so is * anything not mounted, which is a card being ejected or one the system * could not read. */ public static String[] volumes(Context context) { List out = new ArrayList(); StorageManager manager = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE); if (manager == null) { return new String[0]; } for (StorageVolume volume : manager.getStorageVolumes()) { if (volume.isPrimary()) { continue; } String state = volume.getState(); if (!Environment.MEDIA_MOUNTED.equals(state) && !Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) { continue; } String path = path(volume); if (path == null) { Log.w(TAG, "a mounted volume with no path: " + volume); continue; } String description = volume.getDescription(context); if (description == null) { description = new File(path).getName(); } out.add(path + "\t" + description.replace('\t', ' ') + "\t" + (volume.isRemovable() ? "1" : "0")); } return out.toArray(new String[0]); } /** * Where the volume is mounted. * *

{@code getDirectory} is API 30. Below it the same answer is the * hidden {@code getPath}, which every release from 24 to 29 has, reached by * reflection because android.jar does not declare it. */ private static String path(StorageVolume volume) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { File dir = volume.getDirectory(); return dir == null ? null : dir.getPath(); } try { Object path = StorageVolume.class.getMethod("getPath").invoke(volume); return path == null ? null : path.toString(); } catch (ReflectiveOperationException e) { Log.w(TAG, "StorageVolume.getPath", e); return null; } } }