Kotlin migration (WIP)
This commit is contained in:
parent
5c93abd928
commit
9a9805d31c
7 changed files with 481 additions and 540 deletions
|
@ -437,7 +437,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
MediaStoreMoveDestination(@NonNull Context context, @NonNull String destinationDir) {
|
||||
fullPath = destinationDir;
|
||||
volumeNameForMediaStore = getVolumeNameForMediaStore(context, destinationDir);
|
||||
volumePath = StorageUtils.getVolumePath(context, destinationDir).orElse(null);
|
||||
volumePath = StorageUtils.getVolumePath(context, destinationDir);
|
||||
relativePath = volumePath != null ? destinationDir.replaceFirst(volumePath, "") : null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,488 +0,0 @@
|
|||
package deckers.thibault.aves.utils;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.media.MediaMetadataRetriever;
|
||||
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.DocumentsContract;
|
||||
import android.provider.MediaStore;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
import android.webkit.MimeTypeMap;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.commonsware.cwac.document.DocumentFileCompat;
|
||||
import com.google.common.base.Splitter;
|
||||
import com.google.common.collect.Lists;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class StorageUtils {
|
||||
private static final String LOG_TAG = Utils.createLogTag(StorageUtils.class);
|
||||
|
||||
/**
|
||||
* Volume paths
|
||||
*/
|
||||
|
||||
// volume paths, with trailing "/"
|
||||
private static String[] mStorageVolumePaths;
|
||||
|
||||
// primary volume path, with trailing "/"
|
||||
private static String mPrimaryVolumePath;
|
||||
|
||||
public static String getPrimaryVolumePath() {
|
||||
if (mPrimaryVolumePath == null) {
|
||||
mPrimaryVolumePath = findPrimaryVolumePath();
|
||||
}
|
||||
return mPrimaryVolumePath;
|
||||
}
|
||||
|
||||
public static String[] getVolumePaths(@NonNull Context context) {
|
||||
if (mStorageVolumePaths == null) {
|
||||
mStorageVolumePaths = findVolumePaths(context);
|
||||
}
|
||||
return mStorageVolumePaths;
|
||||
}
|
||||
|
||||
public static Optional<String> getVolumePath(@NonNull Context context, @NonNull String anyPath) {
|
||||
return Arrays.stream(getVolumePaths(context)).filter(anyPath::startsWith).findFirst();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static Iterator<String> getPathStepIterator(@NonNull Context context, @NonNull String anyPath, @Nullable String root) {
|
||||
if (root == null) {
|
||||
root = getVolumePath(context, anyPath).orElse(null);
|
||||
if (root == null) return null;
|
||||
}
|
||||
|
||||
String relativePath = null, filename = null;
|
||||
int lastSeparatorIndex = anyPath.lastIndexOf(File.separator) + 1;
|
||||
int rootLength = root.length();
|
||||
if (lastSeparatorIndex > rootLength) {
|
||||
filename = anyPath.substring(lastSeparatorIndex);
|
||||
relativePath = anyPath.substring(rootLength, lastSeparatorIndex);
|
||||
}
|
||||
if (relativePath == null) return null;
|
||||
|
||||
ArrayList<String> pathSteps = Lists.newArrayList(Splitter.on(File.separatorChar)
|
||||
.trimResults().omitEmptyStrings().split(relativePath));
|
||||
if (filename.length() > 0) {
|
||||
pathSteps.add(filename);
|
||||
}
|
||||
return pathSteps.iterator();
|
||||
}
|
||||
|
||||
private static String findPrimaryVolumePath() {
|
||||
String primaryVolumePath = Environment.getExternalStorageDirectory().getAbsolutePath();
|
||||
if (!primaryVolumePath.endsWith(File.separator)) {
|
||||
primaryVolumePath += File.separator;
|
||||
}
|
||||
return primaryVolumePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all available SD-Cards in the system (include emulated)
|
||||
* <p/>
|
||||
* Warning: Hack! Based on Android source code of version 4.3 (API 18)
|
||||
* Because there is no standard way to get it.
|
||||
* Edited by hendrawd
|
||||
*
|
||||
* @return paths to all available SD-Cards in the system (include emulated)
|
||||
*/
|
||||
@SuppressLint("ObsoleteSdkInt")
|
||||
private static String[] findVolumePaths(Context context) {
|
||||
// Final set of paths
|
||||
final Set<String> rv = new HashSet<>();
|
||||
|
||||
// Primary emulated SD-CARD
|
||||
final String rawEmulatedStorageTarget = System.getenv("EMULATED_STORAGE_TARGET");
|
||||
|
||||
if (TextUtils.isEmpty(rawEmulatedStorageTarget)) {
|
||||
// fix of empty raw emulated storage on marshmallow
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
List<File> files;
|
||||
boolean validFiles;
|
||||
do {
|
||||
// `getExternalFilesDirs` sometimes include `null` when called right after getting read access
|
||||
// (e.g. on API 30 emulator) so we retry until the file system is ready
|
||||
files = Arrays.asList(context.getExternalFilesDirs(null));
|
||||
validFiles = !files.contains(null);
|
||||
if (!validFiles) {
|
||||
try {
|
||||
Thread.sleep(100);
|
||||
} catch (InterruptedException e) {
|
||||
Log.e(LOG_TAG, "insomnia", e);
|
||||
}
|
||||
}
|
||||
} while (!validFiles);
|
||||
for (File file : files) {
|
||||
String applicationSpecificAbsolutePath = file.getAbsolutePath();
|
||||
String emulatedRootPath = applicationSpecificAbsolutePath.substring(0, applicationSpecificAbsolutePath.indexOf("Android/data"));
|
||||
rv.add(emulatedRootPath);
|
||||
}
|
||||
} else {
|
||||
// Primary physical SD-CARD (not emulated)
|
||||
final String rawExternalStorage = System.getenv("EXTERNAL_STORAGE");
|
||||
|
||||
// Device has physical external storage; use plain paths.
|
||||
if (TextUtils.isEmpty(rawExternalStorage)) {
|
||||
// EXTERNAL_STORAGE undefined; falling back to default.
|
||||
rv.addAll(Arrays.asList(getPhysicalPaths()));
|
||||
} else {
|
||||
rv.add(rawExternalStorage);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Device has emulated storage; external storage paths should have userId burned into them.
|
||||
final String rawUserId;
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||
rawUserId = "";
|
||||
} else {
|
||||
final String path = Environment.getExternalStorageDirectory().getAbsolutePath();
|
||||
final String[] folders = path.split(File.separator);
|
||||
final String lastFolder = folders[folders.length - 1];
|
||||
boolean isDigit = TextUtils.isDigitsOnly(lastFolder);
|
||||
rawUserId = isDigit ? lastFolder : "";
|
||||
}
|
||||
// /storage/emulated/0[1,2,...]
|
||||
if (TextUtils.isEmpty(rawUserId)) {
|
||||
rv.add(rawEmulatedStorageTarget);
|
||||
} else {
|
||||
rv.add(rawEmulatedStorageTarget + File.separator + rawUserId);
|
||||
}
|
||||
}
|
||||
|
||||
// All Secondary SD-CARDs (all exclude primary) separated by ":"
|
||||
final String rawSecondaryStoragesStr = System.getenv("SECONDARY_STORAGE");
|
||||
|
||||
// Add all secondary storages
|
||||
if (!TextUtils.isEmpty(rawSecondaryStoragesStr)) {
|
||||
// All Secondary SD-CARDs split into array
|
||||
final String[] rawSecondaryStorages = rawSecondaryStoragesStr.split(File.pathSeparator);
|
||||
Collections.addAll(rv, rawSecondaryStorages);
|
||||
}
|
||||
|
||||
String[] paths = rv.toArray(new String[0]);
|
||||
for (int i = 0; i < paths.length; i++) {
|
||||
String path = paths[i];
|
||||
if (!path.endsWith(File.separator)) {
|
||||
paths[i] = path + File.separator;
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return physicalPaths based on phone model
|
||||
*/
|
||||
@SuppressLint("SdCardPath")
|
||||
private static String[] getPhysicalPaths() {
|
||||
return new String[]{
|
||||
"/storage/sdcard0",
|
||||
"/storage/sdcard1", //Motorola Xoom
|
||||
"/storage/extsdcard", //Samsung SGS3
|
||||
"/storage/sdcard0/external_sdcard", //User request
|
||||
"/mnt/extsdcard",
|
||||
"/mnt/sdcard/external_sd", //Samsung galaxy family
|
||||
"/mnt/external_sd",
|
||||
"/mnt/media_rw/sdcard1", //4.4.2 on CyanogenMod S3
|
||||
"/removable/microsd", //Asus transformer prime
|
||||
"/mnt/emmc",
|
||||
"/storage/external_SD", //LG
|
||||
"/storage/ext_sd", //HTC One Max
|
||||
"/storage/removable/sdcard1", //Sony Xperia Z1
|
||||
"/data/sdext",
|
||||
"/data/sdext2",
|
||||
"/data/sdext3",
|
||||
"/data/sdext4",
|
||||
"/sdcard1", //Sony Xperia Z
|
||||
"/sdcard2", //HTC One M8s
|
||||
"/storage/microsd" //ASUS ZenFone 2
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Volume tree URIs
|
||||
*/
|
||||
|
||||
private static Optional<String> getVolumeUuidForTreeUri(@NonNull Context context, @NonNull String anyPath) {
|
||||
StorageManager sm = context.getSystemService(StorageManager.class);
|
||||
if (sm != null) {
|
||||
StorageVolume volume = sm.getStorageVolume(new File(anyPath));
|
||||
if (volume != null) {
|
||||
if (volume.isPrimary()) {
|
||||
return Optional.of("primary");
|
||||
}
|
||||
String uuid = volume.getUuid();
|
||||
if (uuid != null) {
|
||||
return Optional.of(uuid.toUpperCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.e(LOG_TAG, "failed to find volume UUID for anyPath=" + anyPath);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private static Optional<String> getVolumePathFromTreeUriUuid(@NonNull Context context, @NonNull String uuid) {
|
||||
if (uuid.equals("primary")) {
|
||||
return Optional.of(getPrimaryVolumePath());
|
||||
}
|
||||
StorageManager sm = context.getSystemService(StorageManager.class);
|
||||
if (sm != null) {
|
||||
for (String volumePath : StorageUtils.getVolumePaths(context)) {
|
||||
try {
|
||||
StorageVolume volume = sm.getStorageVolume(new File(volumePath));
|
||||
if (volume != null && uuid.equalsIgnoreCase(volume.getUuid())) {
|
||||
return Optional.of(volumePath);
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.e(LOG_TAG, "failed to find volume path for UUID=" + uuid);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// e.g.
|
||||
// /storage/emulated/0/ -> content://com.android.externalstorage.documents/tree/primary%3A
|
||||
// /storage/10F9-3F13/Pictures/ -> content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures
|
||||
static Optional<Uri> convertDirPathToTreeUri(@NonNull Context context, @NonNull String dirPath) {
|
||||
Optional<String> uuid = getVolumeUuidForTreeUri(context, dirPath);
|
||||
if (uuid.isPresent()) {
|
||||
String relativeDir = new PathSegments(context, dirPath).relativeDir;
|
||||
if (relativeDir == null) {
|
||||
relativeDir = "";
|
||||
} else if (relativeDir.endsWith(File.separator)) {
|
||||
relativeDir = relativeDir.substring(0, relativeDir.length() - 1);
|
||||
}
|
||||
Uri treeUri = DocumentsContract.buildTreeDocumentUri("com.android.externalstorage.documents", uuid.get() + ":" + relativeDir);
|
||||
return Optional.of(treeUri);
|
||||
}
|
||||
Log.e(LOG_TAG, "failed to convert dirPath=" + dirPath + " to tree URI");
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// e.g.
|
||||
// content://com.android.externalstorage.documents/tree/primary%3A -> /storage/emulated/0/
|
||||
// content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures -> /storage/10F9-3F13/Pictures/
|
||||
static Optional<String> convertTreeUriToDirPath(@NonNull Context context, @NonNull Uri treeUri) {
|
||||
String encoded = treeUri.toString().substring("content://com.android.externalstorage.documents/tree/".length());
|
||||
Matcher matcher = Pattern.compile("(.*?):(.*)").matcher(Uri.decode(encoded));
|
||||
if (matcher.find()) {
|
||||
String uuid = matcher.group(1);
|
||||
String relativePath = matcher.group(2);
|
||||
if (uuid != null && relativePath != null) {
|
||||
Optional<String> volumePath = getVolumePathFromTreeUriUuid(context, uuid);
|
||||
if (volumePath.isPresent()) {
|
||||
String dirPath = volumePath.get() + relativePath;
|
||||
if (!dirPath.endsWith(File.separator)) {
|
||||
dirPath += File.separator;
|
||||
}
|
||||
return Optional.of(dirPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.e(LOG_TAG, "failed to convert treeUri=" + treeUri + " to path");
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Document files
|
||||
*/
|
||||
|
||||
@Nullable
|
||||
public static DocumentFileCompat getDocumentFile(@NonNull Context context, @NonNull String anyPath, @NonNull Uri mediaUri) {
|
||||
if (requireAccessPermission(anyPath)) {
|
||||
// need a document URI (not a media content URI) to open a `DocumentFile` output stream
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
// cleanest API to get it
|
||||
Uri docUri = MediaStore.getDocumentUri(context, mediaUri);
|
||||
if (docUri != null) {
|
||||
return DocumentFileCompat.fromSingleUri(context, docUri);
|
||||
}
|
||||
}
|
||||
// fallback for older APIs
|
||||
return getVolumePath(context, anyPath)
|
||||
.flatMap(volumePath -> convertDirPathToTreeUri(context, volumePath)
|
||||
.flatMap(treeUri -> getDocumentFileFromVolumeTree(context, treeUri, anyPath)))
|
||||
.orElse(null);
|
||||
|
||||
}
|
||||
// good old `File`
|
||||
return DocumentFileCompat.fromFile(new File(anyPath));
|
||||
}
|
||||
|
||||
// returns the directory `DocumentFile` (from tree URI when scoped storage is required, `File` otherwise)
|
||||
// returns null if directory does not exist and could not be created
|
||||
public static DocumentFileCompat createDirectoryIfAbsent(@NonNull Context context, @NonNull String dirPath) {
|
||||
if (!dirPath.endsWith(File.separator)) {
|
||||
dirPath += File.separator;
|
||||
}
|
||||
if (requireAccessPermission(dirPath)) {
|
||||
String grantedDir = PermissionManager.getGrantedDirForPath(context, dirPath);
|
||||
if (grantedDir == null) return null;
|
||||
|
||||
Uri rootTreeUri = convertDirPathToTreeUri(context, grantedDir).orElse(null);
|
||||
if (rootTreeUri == null) return null;
|
||||
|
||||
DocumentFileCompat parentFile = DocumentFileCompat.fromTreeUri(context, rootTreeUri);
|
||||
if (parentFile == null) return null;
|
||||
|
||||
Iterator<String> pathIterator = getPathStepIterator(context, dirPath, grantedDir);
|
||||
while (pathIterator != null && pathIterator.hasNext()) {
|
||||
String dirName = pathIterator.next();
|
||||
DocumentFileCompat dirFile = findDocumentFileIgnoreCase(parentFile, dirName);
|
||||
if (dirFile == null || !dirFile.exists()) {
|
||||
try {
|
||||
dirFile = parentFile.createDirectory(dirName);
|
||||
if (dirFile == null) {
|
||||
Log.e(LOG_TAG, "failed to create directory with name=" + dirName + " from parent=" + parentFile);
|
||||
return null;
|
||||
}
|
||||
} catch (FileNotFoundException e) {
|
||||
Log.e(LOG_TAG, "failed to create directory with name=" + dirName + " from parent=" + parentFile, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
parentFile = dirFile;
|
||||
}
|
||||
return parentFile;
|
||||
} else {
|
||||
File directory = new File(dirPath);
|
||||
if (!directory.exists()) {
|
||||
if (!directory.mkdirs()) {
|
||||
Log.e(LOG_TAG, "failed to create directories at path=" + dirPath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return DocumentFileCompat.fromFile(directory);
|
||||
}
|
||||
}
|
||||
|
||||
public static String copyFileToTemp(@NonNull DocumentFileCompat documentFile, @NonNull String path) {
|
||||
String extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(new File(path)).toString());
|
||||
try {
|
||||
File temp = File.createTempFile("aves", '.' + extension);
|
||||
documentFile.copyTo(DocumentFileCompat.fromFile(temp));
|
||||
temp.deleteOnExit();
|
||||
return temp.getPath();
|
||||
} catch (IOException e) {
|
||||
Log.e(LOG_TAG, "failed to copy file from path=" + path);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Optional<DocumentFileCompat> getDocumentFileFromVolumeTree(Context context, @NonNull Uri rootTreeUri, @NonNull String anyPath) {
|
||||
DocumentFileCompat documentFile = DocumentFileCompat.fromTreeUri(context, rootTreeUri);
|
||||
if (documentFile == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// follow the entry path down the document tree
|
||||
Iterator<String> pathIterator = getPathStepIterator(context, anyPath, null);
|
||||
while (pathIterator != null && pathIterator.hasNext()) {
|
||||
documentFile = findDocumentFileIgnoreCase(documentFile, pathIterator.next());
|
||||
if (documentFile == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
return Optional.of(documentFile);
|
||||
}
|
||||
|
||||
// variation on `DocumentFileCompat.findFile()` to allow case insensitive search
|
||||
private static DocumentFileCompat findDocumentFileIgnoreCase(DocumentFileCompat documentFile, String displayName) {
|
||||
for (DocumentFileCompat doc : documentFile.listFiles()) {
|
||||
if (displayName.equalsIgnoreCase(doc.getName())) {
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Misc
|
||||
*/
|
||||
|
||||
public static boolean requireAccessPermission(@NonNull String anyPath) {
|
||||
// on Android R, we should always require access permission, even on primary volume
|
||||
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
|
||||
return true;
|
||||
}
|
||||
boolean onPrimaryVolume = anyPath.startsWith(getPrimaryVolumePath());
|
||||
return !onPrimaryVolume;
|
||||
}
|
||||
|
||||
private static boolean isMediaStoreContentUri(Uri uri) {
|
||||
// a URI's authority is [userinfo@]host[:port]
|
||||
// but we only want the host when comparing to Media Store's "authority"
|
||||
return uri != null && ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(uri.getScheme()) && MediaStore.AUTHORITY.equalsIgnoreCase(uri.getHost());
|
||||
}
|
||||
|
||||
public static InputStream openInputStream(@NonNull Context context, @NonNull Uri uri) throws FileNotFoundException {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// we get a permission denial if we require original from a provider other than the media store
|
||||
if (isMediaStoreContentUri(uri)) {
|
||||
uri = MediaStore.setRequireOriginal(uri);
|
||||
}
|
||||
}
|
||||
return context.getContentResolver().openInputStream(uri);
|
||||
}
|
||||
|
||||
public static MediaMetadataRetriever openMetadataRetriever(@NonNull Context context, @NonNull Uri uri) {
|
||||
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// we get a permission denial if we require original from a provider other than the media store
|
||||
if (isMediaStoreContentUri(uri)) {
|
||||
uri = MediaStore.setRequireOriginal(uri);
|
||||
}
|
||||
}
|
||||
retriever.setDataSource(context, uri);
|
||||
} catch (Exception e) {
|
||||
// unsupported format
|
||||
return null;
|
||||
}
|
||||
return retriever;
|
||||
}
|
||||
|
||||
public static class PathSegments {
|
||||
String fullPath; // should match "volumePath + relativeDir + filename"
|
||||
String volumePath; // with trailing "/"
|
||||
String relativeDir; // with trailing "/"
|
||||
String filename; // null for directories
|
||||
|
||||
PathSegments(@NonNull Context context, @NonNull String fullPath) {
|
||||
this.fullPath = fullPath;
|
||||
volumePath = StorageUtils.getVolumePath(context, fullPath).orElse(null);
|
||||
if (volumePath == null) return;
|
||||
|
||||
int lastSeparatorIndex = fullPath.lastIndexOf(File.separator) + 1;
|
||||
int volumePathLength = volumePath.length();
|
||||
if (lastSeparatorIndex > volumePathLength) {
|
||||
filename = fullPath.substring(lastSeparatorIndex);
|
||||
relativeDir = fullPath.substring(volumePathLength, lastSeparatorIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -79,7 +79,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
if (isSupportedByMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri).use { input ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
|
||||
foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java)
|
||||
|
@ -120,7 +120,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
if (!foundExif) {
|
||||
// fallback to read EXIF via ExifInterface
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri).use { input ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val exif = ExifInterface(input)
|
||||
val allTags = describeAll(exif).toMutableMap()
|
||||
if (foundXmp) {
|
||||
|
@ -190,7 +190,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
if (isSupportedByMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri).use { input ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
|
||||
|
||||
|
@ -278,7 +278,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
if (!foundExif) {
|
||||
// fallback to read EXIF via ExifInterface
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri).use { input ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val exif = ExifInterface(input)
|
||||
exif.getSafeDateMillis(ExifInterface.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it }
|
||||
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
|
||||
|
@ -289,7 +289,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
metadataMap[KEY_ROTATION_DEGREES] = exif.rotationDegrees
|
||||
}
|
||||
val latLong = exif.latLong
|
||||
if (latLong != null && latLong.size == 2) {
|
||||
if (latLong?.size == 2) {
|
||||
metadataMap[KEY_LATITUDE] = latLong[0]
|
||||
metadataMap[KEY_LONGITUDE] = latLong[1]
|
||||
}
|
||||
|
@ -320,22 +320,14 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
|
||||
if (locationString != null) {
|
||||
val locationMatcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
|
||||
if (locationMatcher.find() && locationMatcher.groupCount() >= 2) {
|
||||
val latitudeString = locationMatcher.group(1)
|
||||
val longitudeString = locationMatcher.group(2)
|
||||
if (latitudeString != null && longitudeString != null) {
|
||||
try {
|
||||
val latitude = latitudeString.toDoubleOrNull() ?: 0
|
||||
val longitude = longitudeString.toDoubleOrNull() ?: 0
|
||||
// keep `0.0` as `0.0`, not `0`
|
||||
if (latitude != 0.0 || longitude != 0.0) {
|
||||
metadataMap[KEY_LATITUDE] = latitude
|
||||
metadataMap[KEY_LONGITUDE] = longitude
|
||||
}
|
||||
} catch (e: NumberFormatException) {
|
||||
// ignore
|
||||
}
|
||||
val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
|
||||
if (matcher.find() && matcher.groupCount() >= 2) {
|
||||
// keep `0.0` as `0.0`, not `0`
|
||||
val latitude = matcher.group(1)?.toDoubleOrNull() ?: 0.0
|
||||
val longitude = matcher.group(2)?.toDoubleOrNull() ?: 0.0
|
||||
if (latitude != 0.0 || longitude != 0.0) {
|
||||
metadataMap[KEY_LATITUDE] = latitude
|
||||
metadataMap[KEY_LONGITUDE] = longitude
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -362,7 +354,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri).use { input ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
|
||||
dir.getSafeDescription(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it }
|
||||
|
@ -382,7 +374,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
result.success(metadataMap)
|
||||
}
|
||||
} ?: result.error("getOverlayMetadata-noinput", "failed to get metadata for uri=$uri", null)
|
||||
} catch (e: Exception) {
|
||||
result.error("getOverlayMetadata-exception", "failed to get metadata for uri=$uri", e.message)
|
||||
} catch (e: NoClassDefFoundError) {
|
||||
|
@ -443,14 +435,14 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri).use { input ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val exif = ExifInterface(input)
|
||||
val metadataMap = HashMap<String, String?>()
|
||||
for (tag in ExifInterfaceHelper.allTags.keys.filter { exif.hasAttribute(it) }) {
|
||||
metadataMap[tag] = exif.getAttribute(tag)
|
||||
}
|
||||
result.success(metadataMap)
|
||||
}
|
||||
} ?: result.error("getExifInterfaceMetadata-noinput", "failed to get exif for uri=$uri", null)
|
||||
} catch (e: Exception) {
|
||||
// ExifInterface initialization can fail with a RuntimeException
|
||||
// caused by an internal MediaMetadataRetriever failure
|
||||
|
@ -513,7 +505,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
val thumbnails = ArrayList<ByteArray>()
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri).use { input ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val exif = ExifInterface(input)
|
||||
exif.thumbnailBytes?.let { thumbnails.add(it) }
|
||||
}
|
||||
|
@ -535,7 +527,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
val thumbnails = ArrayList<ByteArray>()
|
||||
if (isSupportedByMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri).use { input ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
|
||||
val xmpMeta = dir.xmpMeta
|
||||
|
|
|
@ -20,10 +20,10 @@ import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeDateMilli
|
|||
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeInt
|
||||
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeLong
|
||||
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeString
|
||||
import deckers.thibault.aves.utils.Metadata.getRotationDegreesForExifCode
|
||||
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeDateMillis
|
||||
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeInt
|
||||
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeLong
|
||||
import deckers.thibault.aves.utils.Metadata.getRotationDegreesForExifCode
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import java.io.IOException
|
||||
|
@ -158,7 +158,7 @@ class SourceImageEntry {
|
|||
// finds: width, height, orientation, date, duration
|
||||
private fun fillByMetadataExtractor(context: Context) {
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri).use { input ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
|
||||
// do not switch on specific mime types, as the reported mime type could be wrong
|
||||
|
@ -209,7 +209,7 @@ class SourceImageEntry {
|
|||
// finds: width, height, orientation, date
|
||||
private fun fillByExifInterface(context: Context) {
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri).use { input ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val exif = ExifInterface(input)
|
||||
foundExif = true
|
||||
exif.getSafeInt(ExifInterface.TAG_IMAGE_WIDTH, acceptZero = false) { width = it }
|
||||
|
@ -226,7 +226,7 @@ class SourceImageEntry {
|
|||
// finds: width, height
|
||||
private fun fillByBitmapDecode(context: Context) {
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri).use { input ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val options = BitmapFactory.Options()
|
||||
options.inJustDecodeBounds = true
|
||||
BitmapFactory.decodeStream(input, null, options)
|
||||
|
|
|
@ -62,23 +62,27 @@ object PermissionManager {
|
|||
if (accessibleDirs.none { dirPath.startsWith(it) }) {
|
||||
// inaccessible dirs
|
||||
val segments = PathSegments(context, dirPath)
|
||||
val dirSet = dirsPerVolume.getOrDefault(segments.volumePath, HashSet())
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
// request primary directory on volume from Android R
|
||||
segments.relativeDir?.let { relativeDir ->
|
||||
relativeDir.split(File.separatorChar).firstOrNull { it.isNotEmpty() }?.let { dirSet.add(it) }
|
||||
segments.volumePath?.let { volumePath ->
|
||||
val dirSet = dirsPerVolume.getOrDefault(volumePath, HashSet())
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
// request primary directory on volume from Android R
|
||||
segments.relativeDir?.apply {
|
||||
val primaryDir = split(File.separator).firstOrNull { it.isNotEmpty() }
|
||||
primaryDir?.let { dirSet.add(it) }
|
||||
}
|
||||
} else {
|
||||
// request volume root until Android Q
|
||||
dirSet.add("")
|
||||
}
|
||||
} else {
|
||||
// request volume root until Android Q
|
||||
dirSet.add("")
|
||||
dirsPerVolume[volumePath] = dirSet
|
||||
}
|
||||
dirsPerVolume[segments.volumePath] = dirSet
|
||||
}
|
||||
}
|
||||
|
||||
// format for easier handling on Flutter
|
||||
val inaccessibleDirs = ArrayList<Map<String, String>>()
|
||||
context.getSystemService(StorageManager::class.java)?.let { sm ->
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
if (sm != null) {
|
||||
for ((volumePath, relativeDirs) in dirsPerVolume) {
|
||||
var volumeDescription: String? = null
|
||||
try {
|
||||
|
@ -101,10 +105,9 @@ object PermissionManager {
|
|||
|
||||
@JvmStatic
|
||||
fun revokeDirectoryAccess(context: Context, path: String) {
|
||||
val uri = StorageUtils.convertDirPathToTreeUri(context, path)
|
||||
if (uri.isPresent) {
|
||||
StorageUtils.convertDirPathToTreeUri(context, path)?.let {
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
context.contentResolver.releasePersistableUriPermission(uri.get(), flags)
|
||||
context.contentResolver.releasePersistableUriPermission(it, flags)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -114,7 +117,7 @@ object PermissionManager {
|
|||
val grantedDirs = HashSet<String>()
|
||||
for (uriPermission in context.contentResolver.persistedUriPermissions) {
|
||||
val dirPath = StorageUtils.convertTreeUriToDirPath(context, uriPermission.uri)
|
||||
dirPath.ifPresent { grantedDirs.add(it) }
|
||||
dirPath?.let { grantedDirs.add(it) }
|
||||
}
|
||||
return grantedDirs
|
||||
}
|
||||
|
@ -124,7 +127,7 @@ object PermissionManager {
|
|||
val accessibleDirs = HashSet(getGrantedDirs(context))
|
||||
// from Android R, we no longer have access permission by default on primary volume
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
|
||||
accessibleDirs.add(StorageUtils.getPrimaryVolumePath())
|
||||
accessibleDirs.add(StorageUtils.primaryVolumePath)
|
||||
}
|
||||
Log.d(LOG_TAG, "getAccessibleDirs accessibleDirs=$accessibleDirs")
|
||||
return accessibleDirs
|
||||
|
|
|
@ -0,0 +1,428 @@
|
|||
package deckers.thibault.aves.utils
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.storage.StorageManager
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.MediaStore
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import com.commonsware.cwac.document.DocumentFileCompat
|
||||
import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath
|
||||
import deckers.thibault.aves.utils.Utils.createLogTag
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.util.*
|
||||
import java.util.regex.Pattern
|
||||
|
||||
object StorageUtils {
|
||||
private val LOG_TAG = createLogTag(StorageUtils::class.java)
|
||||
|
||||
/**
|
||||
* Volume paths
|
||||
*/
|
||||
|
||||
// volume paths, with trailing "/"
|
||||
private var mStorageVolumePaths: Array<String>? = null
|
||||
|
||||
// primary volume path, with trailing "/"
|
||||
private var mPrimaryVolumePath: String? = null
|
||||
|
||||
val primaryVolumePath: String
|
||||
get() {
|
||||
if (mPrimaryVolumePath == null) {
|
||||
mPrimaryVolumePath = findPrimaryVolumePath()
|
||||
}
|
||||
return mPrimaryVolumePath!!
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getVolumePaths(context: Context): Array<String> {
|
||||
if (mStorageVolumePaths == null) {
|
||||
mStorageVolumePaths = findVolumePaths(context)
|
||||
}
|
||||
return mStorageVolumePaths!!
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getVolumePath(context: Context, anyPath: String): String? {
|
||||
return getVolumePaths(context).firstOrNull { anyPath.startsWith(it) }
|
||||
}
|
||||
|
||||
private fun getPathStepIterator(context: Context, anyPath: String, root: String?): Iterator<String?>? {
|
||||
val rootLength = (root ?: getVolumePath(context, anyPath))?.length ?: return null
|
||||
|
||||
var filename: String? = null
|
||||
var relativePath: String? = null
|
||||
val lastSeparatorIndex = anyPath.lastIndexOf(File.separator) + 1
|
||||
if (lastSeparatorIndex > rootLength) {
|
||||
filename = anyPath.substring(lastSeparatorIndex)
|
||||
relativePath = anyPath.substring(rootLength, lastSeparatorIndex)
|
||||
}
|
||||
relativePath ?: return null
|
||||
|
||||
val pathSteps = relativePath.split(File.separator).filter { it.isNotEmpty() }.toMutableList()
|
||||
if (filename?.isNotEmpty() == true) {
|
||||
pathSteps.add(filename)
|
||||
}
|
||||
return pathSteps.iterator()
|
||||
}
|
||||
|
||||
private fun findPrimaryVolumePath(): String {
|
||||
return ensureTrailingSeparator(Environment.getExternalStorageDirectory().absolutePath)
|
||||
}
|
||||
|
||||
@SuppressLint("ObsoleteSdkInt")
|
||||
private fun findVolumePaths(context: Context): Array<String> {
|
||||
// Final set of paths
|
||||
val paths = HashSet<String>()
|
||||
|
||||
// Primary emulated SD-CARD
|
||||
val rawEmulatedStorageTarget = System.getenv("EMULATED_STORAGE_TARGET") ?: ""
|
||||
if (TextUtils.isEmpty(rawEmulatedStorageTarget)) {
|
||||
// fix of empty raw emulated storage on marshmallow
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
lateinit var files: List<File>
|
||||
var validFiles: Boolean
|
||||
do {
|
||||
// `getExternalFilesDirs` sometimes include `null` when called right after getting read access
|
||||
// (e.g. on API 30 emulator) so we retry until the file system is ready
|
||||
val externalFilesDirs = context.getExternalFilesDirs(null)
|
||||
validFiles = !externalFilesDirs.contains(null)
|
||||
if (validFiles) {
|
||||
files = externalFilesDirs.filterNotNull()
|
||||
} else {
|
||||
try {
|
||||
Thread.sleep(100)
|
||||
} catch (e: InterruptedException) {
|
||||
Log.e(LOG_TAG, "insomnia", e)
|
||||
}
|
||||
}
|
||||
} while (!validFiles)
|
||||
for (file in files) {
|
||||
val appSpecificAbsolutePath = file.absolutePath
|
||||
val emulatedRootPath = appSpecificAbsolutePath.substring(0, appSpecificAbsolutePath.indexOf("Android/data"))
|
||||
paths.add(emulatedRootPath)
|
||||
}
|
||||
} else {
|
||||
// Primary physical SD-CARD (not emulated)
|
||||
val rawExternalStorage = System.getenv("EXTERNAL_STORAGE") ?: ""
|
||||
|
||||
// Device has physical external storage; use plain paths.
|
||||
if (TextUtils.isEmpty(rawExternalStorage)) {
|
||||
// EXTERNAL_STORAGE undefined; falling back to default.
|
||||
paths.addAll(physicalPaths)
|
||||
} else {
|
||||
paths.add(rawExternalStorage)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Device has emulated storage; external storage paths should have userId burned into them.
|
||||
val path = Environment.getExternalStorageDirectory().absolutePath
|
||||
val rawUserId = path.split(File.separator).lastOrNull()?.takeIf { TextUtils.isDigitsOnly(it) } ?: ""
|
||||
// /storage/emulated/0[1,2,...]
|
||||
if (TextUtils.isEmpty(rawUserId)) {
|
||||
paths.add(rawEmulatedStorageTarget)
|
||||
} else {
|
||||
paths.add(rawEmulatedStorageTarget + File.separator + rawUserId)
|
||||
}
|
||||
}
|
||||
|
||||
// All Secondary SD-CARDs (all exclude primary) separated by ":"
|
||||
System.getenv("SECONDARY_STORAGE")?.let { secondaryStorages ->
|
||||
paths.addAll(secondaryStorages.split(File.pathSeparator).filter { it.isNotEmpty() })
|
||||
}
|
||||
|
||||
return paths.map { ensureTrailingSeparator(it) }.toTypedArray()
|
||||
}
|
||||
|
||||
// return physicalPaths based on phone model
|
||||
private val physicalPaths: Array<String>
|
||||
@SuppressLint("SdCardPath")
|
||||
get() = arrayOf(
|
||||
"/storage/sdcard0",
|
||||
"/storage/sdcard1", //Motorola Xoom
|
||||
"/storage/extsdcard", //Samsung SGS3
|
||||
"/storage/sdcard0/external_sdcard", //User request
|
||||
"/mnt/extsdcard",
|
||||
"/mnt/sdcard/external_sd", //Samsung galaxy family
|
||||
"/mnt/external_sd",
|
||||
"/mnt/media_rw/sdcard1", //4.4.2 on CyanogenMod S3
|
||||
"/removable/microsd", //Asus transformer prime
|
||||
"/mnt/emmc",
|
||||
"/storage/external_SD", //LG
|
||||
"/storage/ext_sd", //HTC One Max
|
||||
"/storage/removable/sdcard1", //Sony Xperia Z1
|
||||
"/data/sdext",
|
||||
"/data/sdext2",
|
||||
"/data/sdext3",
|
||||
"/data/sdext4",
|
||||
"/sdcard1", //Sony Xperia Z
|
||||
"/sdcard2", //HTC One M8s
|
||||
"/storage/microsd" //ASUS ZenFone 2
|
||||
)
|
||||
|
||||
/**
|
||||
* Volume tree URIs
|
||||
*/
|
||||
|
||||
private fun getVolumeUuidForTreeUri(context: Context, anyPath: String): String? {
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
if (sm != null) {
|
||||
val volume = sm.getStorageVolume(File(anyPath))
|
||||
if (volume != null) {
|
||||
if (volume.isPrimary) {
|
||||
return "primary"
|
||||
}
|
||||
val uuid = volume.uuid
|
||||
if (uuid != null) {
|
||||
return uuid.toUpperCase(Locale.ROOT)
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.e(LOG_TAG, "failed to find volume UUID for anyPath=$anyPath")
|
||||
return null
|
||||
}
|
||||
|
||||
private fun getVolumePathFromTreeUriUuid(context: Context, uuid: String): String? {
|
||||
if (uuid == "primary") {
|
||||
return primaryVolumePath
|
||||
}
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
if (sm != null) {
|
||||
for (volumePath in getVolumePaths(context)) {
|
||||
try {
|
||||
val volume = sm.getStorageVolume(File(volumePath))
|
||||
if (volume != null && uuid.equals(volume.uuid, ignoreCase = true)) {
|
||||
return volumePath
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.e(LOG_TAG, "failed to find volume path for UUID=$uuid")
|
||||
return null
|
||||
}
|
||||
|
||||
// e.g.
|
||||
// /storage/emulated/0/ -> content://com.android.externalstorage.documents/tree/primary%3A
|
||||
// /storage/10F9-3F13/Pictures/ -> content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures
|
||||
fun convertDirPathToTreeUri(context: Context, dirPath: String): Uri? {
|
||||
val uuid = getVolumeUuidForTreeUri(context, dirPath)
|
||||
if (uuid != null) {
|
||||
var relativeDir = PathSegments(context, dirPath).relativeDir ?: ""
|
||||
if (relativeDir.endsWith(File.separator)) {
|
||||
relativeDir = relativeDir.substring(0, relativeDir.length - 1)
|
||||
}
|
||||
return DocumentsContract.buildTreeDocumentUri("com.android.externalstorage.documents", "$uuid:$relativeDir")
|
||||
}
|
||||
Log.e(LOG_TAG, "failed to convert dirPath=$dirPath to tree URI")
|
||||
return null
|
||||
}
|
||||
|
||||
// e.g.
|
||||
// content://com.android.externalstorage.documents/tree/primary%3A -> /storage/emulated/0/
|
||||
// content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures -> /storage/10F9-3F13/Pictures/
|
||||
fun convertTreeUriToDirPath(context: Context, treeUri: Uri): String? {
|
||||
val encoded = treeUri.toString().substring("content://com.android.externalstorage.documents/tree/".length)
|
||||
val matcher = Pattern.compile("(.*?):(.*)").matcher(Uri.decode(encoded))
|
||||
with(matcher) {
|
||||
if (find()) {
|
||||
val uuid = group(1)
|
||||
val relativePath = group(2)
|
||||
if (uuid != null && relativePath != null) {
|
||||
val volumePath = getVolumePathFromTreeUriUuid(context, uuid)
|
||||
if (volumePath != null) {
|
||||
return ensureTrailingSeparator(volumePath + relativePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.e(LOG_TAG, "failed to convert treeUri=$treeUri to path")
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Document files
|
||||
*/
|
||||
|
||||
@JvmStatic
|
||||
fun getDocumentFile(context: Context, anyPath: String, mediaUri: Uri): DocumentFileCompat? {
|
||||
if (requireAccessPermission(anyPath)) {
|
||||
// need a document URI (not a media content URI) to open a `DocumentFile` output stream
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
// cleanest API to get it
|
||||
val docUri = MediaStore.getDocumentUri(context, mediaUri)
|
||||
if (docUri != null) {
|
||||
return DocumentFileCompat.fromSingleUri(context, docUri)
|
||||
}
|
||||
}
|
||||
// fallback for older APIs
|
||||
return getVolumePath(context, anyPath)?.let { convertDirPathToTreeUri(context, it) }?.let { getDocumentFileFromVolumeTree(context, it, anyPath) }
|
||||
}
|
||||
// good old `File`
|
||||
return DocumentFileCompat.fromFile(File(anyPath))
|
||||
}
|
||||
|
||||
// returns the directory `DocumentFile` (from tree URI when scoped storage is required, `File` otherwise)
|
||||
// returns null if directory does not exist and could not be created
|
||||
@JvmStatic
|
||||
fun createDirectoryIfAbsent(context: Context, dirPath: String): DocumentFileCompat? {
|
||||
val cleanDirPath = ensureTrailingSeparator(dirPath)
|
||||
return if (requireAccessPermission(cleanDirPath)) {
|
||||
val grantedDir = getGrantedDirForPath(context, cleanDirPath) ?: return null
|
||||
val rootTreeUri = convertDirPathToTreeUri(context, grantedDir) ?: return null
|
||||
var parentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: return null
|
||||
val pathIterator = getPathStepIterator(context, cleanDirPath, grantedDir)
|
||||
while (pathIterator?.hasNext() == true) {
|
||||
val dirName = pathIterator.next()
|
||||
var dirFile = findDocumentFileIgnoreCase(parentFile, dirName)
|
||||
if (dirFile == null || !dirFile.exists()) {
|
||||
try {
|
||||
dirFile = parentFile?.createDirectory(dirName)
|
||||
if (dirFile == null) {
|
||||
Log.e(LOG_TAG, "failed to create directory with name=$dirName from parent=$parentFile")
|
||||
return null
|
||||
}
|
||||
} catch (e: FileNotFoundException) {
|
||||
Log.e(LOG_TAG, "failed to create directory with name=$dirName from parent=$parentFile", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
parentFile = dirFile
|
||||
}
|
||||
parentFile
|
||||
} else {
|
||||
val directory = File(cleanDirPath)
|
||||
if (!directory.exists() && !directory.mkdirs()) {
|
||||
Log.e(LOG_TAG, "failed to create directories at path=$cleanDirPath")
|
||||
return null
|
||||
}
|
||||
DocumentFileCompat.fromFile(directory)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun copyFileToTemp(documentFile: DocumentFileCompat, path: String): String? {
|
||||
val extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(File(path)).toString())
|
||||
try {
|
||||
val temp = File.createTempFile("aves", ".$extension")
|
||||
documentFile.copyTo(DocumentFileCompat.fromFile(temp))
|
||||
temp.deleteOnExit()
|
||||
return temp.path
|
||||
} catch (e: IOException) {
|
||||
Log.e(LOG_TAG, "failed to copy file from path=$path")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun getDocumentFileFromVolumeTree(context: Context, rootTreeUri: Uri, anyPath: String): DocumentFileCompat? {
|
||||
var documentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: return null
|
||||
|
||||
// follow the entry path down the document tree
|
||||
val pathIterator = getPathStepIterator(context, anyPath, null)
|
||||
while (pathIterator?.hasNext() == true) {
|
||||
documentFile = findDocumentFileIgnoreCase(documentFile, pathIterator.next()) ?: return null
|
||||
}
|
||||
return documentFile
|
||||
}
|
||||
|
||||
// variation on `DocumentFileCompat.findFile()` to allow case insensitive search
|
||||
private fun findDocumentFileIgnoreCase(documentFile: DocumentFileCompat?, displayName: String?): DocumentFileCompat? {
|
||||
documentFile ?: return null
|
||||
for (doc in documentFile.listFiles()) {
|
||||
if (displayName.equals(doc.name, ignoreCase = true)) {
|
||||
return doc
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Misc
|
||||
*/
|
||||
|
||||
@JvmStatic
|
||||
fun requireAccessPermission(anyPath: String): Boolean {
|
||||
// on Android R, we should always require access permission, even on primary volume
|
||||
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
|
||||
return true
|
||||
}
|
||||
val onPrimaryVolume = anyPath.startsWith(primaryVolumePath)
|
||||
return !onPrimaryVolume
|
||||
}
|
||||
|
||||
private fun isMediaStoreContentUri(uri: Uri?): Boolean {
|
||||
uri ?: return false
|
||||
// a URI's authority is [userinfo@]host[:port]
|
||||
// but we only want the host when comparing to Media Store's "authority"
|
||||
return ContentResolver.SCHEME_CONTENT.equals(uri.scheme, ignoreCase = true) && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)
|
||||
}
|
||||
|
||||
fun openInputStream(context: Context, uri: Uri): InputStream? {
|
||||
var effectiveUri = uri
|
||||
// we get a permission denial if we require original from a provider other than the media store
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
|
||||
effectiveUri = MediaStore.setRequireOriginal(uri)
|
||||
}
|
||||
|
||||
return try {
|
||||
context.contentResolver.openInputStream(effectiveUri)
|
||||
} catch (e: FileNotFoundException) {
|
||||
Log.w(LOG_TAG, "failed to find file at uri=$effectiveUri")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun openMetadataRetriever(context: Context, uri: Uri): MediaMetadataRetriever? {
|
||||
var effectiveUri = uri
|
||||
// we get a permission denial if we require original from a provider other than the media store
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
|
||||
effectiveUri = MediaStore.setRequireOriginal(uri)
|
||||
}
|
||||
|
||||
return try {
|
||||
MediaMetadataRetriever().apply {
|
||||
setDataSource(context, effectiveUri)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// unsupported format
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// convenience methods
|
||||
|
||||
private fun ensureTrailingSeparator(dirPath: String): String {
|
||||
return if (dirPath.endsWith(File.separator)) dirPath else dirPath + File.separator
|
||||
}
|
||||
|
||||
// `fullPath` should match "volumePath + relativeDir + filename"
|
||||
class PathSegments(context: Context, fullPath: String) {
|
||||
var volumePath: String? = null // `volumePath` with trailing "/"
|
||||
var relativeDir: String? = null // `relativeDir` with trailing "/"
|
||||
var filename: String? = null // null for directories
|
||||
|
||||
init {
|
||||
volumePath = getVolumePath(context, fullPath)
|
||||
if (volumePath != null) {
|
||||
val lastSeparatorIndex = fullPath.lastIndexOf(File.separator) + 1
|
||||
val volumePathLength = volumePath!!.length
|
||||
if (lastSeparatorIndex > volumePathLength) {
|
||||
filename = fullPath.substring(lastSeparatorIndex)
|
||||
relativeDir = fullPath.substring(volumePathLength, lastSeparatorIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,9 +1,12 @@
|
|||
package deckers.thibault.aves.utils
|
||||
|
||||
import android.util.Log
|
||||
import com.adobe.internal.xmp.XMPException
|
||||
import com.adobe.internal.xmp.XMPMeta
|
||||
|
||||
object XMP {
|
||||
private val LOG_TAG = Utils.createLogTag(XMP::class.java)
|
||||
|
||||
const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
|
||||
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/"
|
||||
const val IMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/"
|
||||
|
@ -15,12 +18,15 @@ object XMP {
|
|||
private const val GENERIC_LANG = ""
|
||||
private const val SPECIFIC_LANG = "en-US"
|
||||
|
||||
@Throws(XMPException::class)
|
||||
fun XMPMeta.getSafeLocalizedText(propName: String, save: (value: String) -> Unit) {
|
||||
if (this.doesPropertyExist(DC_SCHEMA_NS, propName)) {
|
||||
val item = this.getLocalizedText(DC_SCHEMA_NS, propName, GENERIC_LANG, SPECIFIC_LANG)
|
||||
// double check retrieved items as the property sometimes is reported to exist but it is actually null
|
||||
if (item != null) save(item.value)
|
||||
try {
|
||||
if (this.doesPropertyExist(DC_SCHEMA_NS, propName)) {
|
||||
val item = this.getLocalizedText(DC_SCHEMA_NS, propName, GENERIC_LANG, SPECIFIC_LANG)
|
||||
// double check retrieved items as the property sometimes is reported to exist but it is actually null
|
||||
if (item != null) save(item.value)
|
||||
}
|
||||
} catch (e: XMPException) {
|
||||
Log.w(LOG_TAG, "failed to get text for XMP propName=$propName", e)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue