Import from an SD card or card reader on Android
Import was switched off on Android: `imports_supported` was true only for `target_os = "linux"`, and its comment said Android has no path to read a card by and nowhere to write the copies. Neither holds. With "all files access" (MANAGE_EXTERNAL_STORAGE, API 30) an app reads the root of an SD card or a USB card reader by path, `/storage/9C33-6BBD`, and the importer only ever writes into its own staging directory, which is a plain directory on Android too. So the engine runs unchanged; what was missing was finding the card and the permission. - The manifest declares MANAGE_EXTERNAL_STORAGE, and READ_EXTERNAL_STORAGE up to API 29 with requestLegacyExternalStorage, which is the same access on 28 and 29. - Cards.java lists the mounted non-primary volumes through StorageManager and opens the system "All files access" page for this app. dr_ui::cards is the JNI bridge, through saf's helpers. - The import page on Android asks for the permission with an "Allow access" button until it has it, rather than showing an empty list that reads as "no card", and watches for the grant so the list fills in when the user comes back from settings. Google Play restricts this permission to file managers and the like; DarkRoom is sideloaded, so that does not apply.
This commit is contained in:
@@ -4,9 +4,10 @@
|
||||
|
||||
Deliberately minimal: this packages the viewer for on-device testing (spike
|
||||
S2 needs Adreno and Mali hardware, which no emulator represents). Nothing
|
||||
here is a distribution manifest yet. Only network access is declared: file
|
||||
access needs no manifest permission because the library grid reads through
|
||||
SAF, which grants per-tree at runtime (ARCH §6.9).
|
||||
here is a distribution manifest yet. The library grid needs no storage
|
||||
permission, because it reads through SAF, which grants per-tree at runtime
|
||||
(ARCH §6.9); the one storage permission declared is for importing from a
|
||||
camera card, which is read by path.
|
||||
|
||||
Minimal is not the same as empty, and the entries below that are not the
|
||||
activity are the difference. A manifest is the only place a component can be
|
||||
@@ -21,13 +22,29 @@
|
||||
WebDAV listing, thumbnail and image fetches. Without it Android refuses
|
||||
socket creation outright, and the failure is invisible — no panic to
|
||||
catch, no log line, just a worker thread that stops. Storage is the
|
||||
separate case that genuinely needs no permission here, because SAF
|
||||
grants per-tree at runtime (ARCH §6.9). -->
|
||||
separate case: the library and album folders need no permission
|
||||
here, because SAF grants per-tree at runtime (ARCH §6.9). -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Read before deciding whether a sync may run: FR-NC-6 gates background
|
||||
work on unmetered-and-charging, which means knowing the network type. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- FR-CAT-10: importing from a camera card. The importer reads the card
|
||||
as files, and "all files access" is what makes an SD card or a USB
|
||||
card reader readable by path on API 30 and up (see Cards.java). It is
|
||||
granted on a system settings page, not a dialog; the import page
|
||||
sends the user there when it is missing. READ_EXTERNAL_STORAGE is the
|
||||
same thing for API 28 and 29, and means nothing above them; on 29 it
|
||||
reads by path only with requestLegacyExternalStorage, which is why
|
||||
<application> carries that flag.
|
||||
|
||||
Google Play limits MANAGE_EXTERNAL_STORAGE to a short list of app
|
||||
kinds. DarkRoom is not distributed through Play. -->
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29" />
|
||||
|
||||
<!-- Vulkan 1.1 is what wgpu needs; the API 28 floor is where support is
|
||||
dependable (NFR-COMPAT-1). Marked required so an unsupported device
|
||||
fails at install rather than at first frame. -->
|
||||
@@ -53,6 +70,7 @@
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:hasCode="true"
|
||||
android:allowBackup="false"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:supportsRtl="true">
|
||||
|
||||
<!-- NativeActivity rather than a Kotlin Activity: android-activity's
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
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).
|
||||
*
|
||||
* <p>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}.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<String> out = new ArrayList<String>();
|
||||
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.
|
||||
*
|
||||
* <p>{@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user