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) {
|
MediaStoreMoveDestination(@NonNull Context context, @NonNull String destinationDir) {
|
||||||
fullPath = destinationDir;
|
fullPath = destinationDir;
|
||||||
volumeNameForMediaStore = getVolumeNameForMediaStore(context, destinationDir);
|
volumeNameForMediaStore = getVolumeNameForMediaStore(context, destinationDir);
|
||||||
volumePath = StorageUtils.getVolumePath(context, destinationDir).orElse(null);
|
volumePath = StorageUtils.getVolumePath(context, destinationDir);
|
||||||
relativePath = volumePath != null ? destinationDir.replaceFirst(volumePath, "") : null;
|
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)) {
|
if (isSupportedByMetadataExtractor(mimeType)) {
|
||||||
try {
|
try {
|
||||||
StorageUtils.openInputStream(context, uri).use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
val metadata = ImageMetadataReader.readMetadata(input)
|
val metadata = ImageMetadataReader.readMetadata(input)
|
||||||
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
|
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
|
||||||
foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java)
|
foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java)
|
||||||
|
@ -120,7 +120,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
if (!foundExif) {
|
if (!foundExif) {
|
||||||
// fallback to read EXIF via ExifInterface
|
// fallback to read EXIF via ExifInterface
|
||||||
try {
|
try {
|
||||||
StorageUtils.openInputStream(context, uri).use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
val exif = ExifInterface(input)
|
val exif = ExifInterface(input)
|
||||||
val allTags = describeAll(exif).toMutableMap()
|
val allTags = describeAll(exif).toMutableMap()
|
||||||
if (foundXmp) {
|
if (foundXmp) {
|
||||||
|
@ -190,7 +190,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
if (isSupportedByMetadataExtractor(mimeType)) {
|
if (isSupportedByMetadataExtractor(mimeType)) {
|
||||||
try {
|
try {
|
||||||
StorageUtils.openInputStream(context, uri).use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
val metadata = ImageMetadataReader.readMetadata(input)
|
val metadata = ImageMetadataReader.readMetadata(input)
|
||||||
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
|
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
|
||||||
|
|
||||||
|
@ -278,7 +278,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
if (!foundExif) {
|
if (!foundExif) {
|
||||||
// fallback to read EXIF via ExifInterface
|
// fallback to read EXIF via ExifInterface
|
||||||
try {
|
try {
|
||||||
StorageUtils.openInputStream(context, uri).use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
val exif = ExifInterface(input)
|
val exif = ExifInterface(input)
|
||||||
exif.getSafeDateMillis(ExifInterface.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it }
|
exif.getSafeDateMillis(ExifInterface.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it }
|
||||||
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
|
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
|
||||||
|
@ -289,7 +289,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
metadataMap[KEY_ROTATION_DEGREES] = exif.rotationDegrees
|
metadataMap[KEY_ROTATION_DEGREES] = exif.rotationDegrees
|
||||||
}
|
}
|
||||||
val latLong = exif.latLong
|
val latLong = exif.latLong
|
||||||
if (latLong != null && latLong.size == 2) {
|
if (latLong?.size == 2) {
|
||||||
metadataMap[KEY_LATITUDE] = latLong[0]
|
metadataMap[KEY_LATITUDE] = latLong[0]
|
||||||
metadataMap[KEY_LONGITUDE] = latLong[1]
|
metadataMap[KEY_LONGITUDE] = latLong[1]
|
||||||
}
|
}
|
||||||
|
@ -320,23 +320,15 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
|
val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
|
||||||
if (locationString != null) {
|
if (locationString != null) {
|
||||||
val locationMatcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
|
val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
|
||||||
if (locationMatcher.find() && locationMatcher.groupCount() >= 2) {
|
if (matcher.find() && matcher.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`
|
// 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) {
|
if (latitude != 0.0 || longitude != 0.0) {
|
||||||
metadataMap[KEY_LATITUDE] = latitude
|
metadataMap[KEY_LATITUDE] = latitude
|
||||||
metadataMap[KEY_LONGITUDE] = longitude
|
metadataMap[KEY_LONGITUDE] = longitude
|
||||||
}
|
}
|
||||||
} catch (e: NumberFormatException) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
@ -362,7 +354,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
StorageUtils.openInputStream(context, uri).use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
val metadata = ImageMetadataReader.readMetadata(input)
|
val metadata = ImageMetadataReader.readMetadata(input)
|
||||||
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
|
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
|
||||||
dir.getSafeDescription(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it }
|
dir.getSafeDescription(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it }
|
||||||
|
@ -382,7 +374,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.success(metadataMap)
|
result.success(metadataMap)
|
||||||
}
|
} ?: result.error("getOverlayMetadata-noinput", "failed to get metadata for uri=$uri", null)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
result.error("getOverlayMetadata-exception", "failed to get metadata for uri=$uri", e.message)
|
result.error("getOverlayMetadata-exception", "failed to get metadata for uri=$uri", e.message)
|
||||||
} catch (e: NoClassDefFoundError) {
|
} catch (e: NoClassDefFoundError) {
|
||||||
|
@ -443,14 +435,14 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
StorageUtils.openInputStream(context, uri).use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
val exif = ExifInterface(input)
|
val exif = ExifInterface(input)
|
||||||
val metadataMap = HashMap<String, String?>()
|
val metadataMap = HashMap<String, String?>()
|
||||||
for (tag in ExifInterfaceHelper.allTags.keys.filter { exif.hasAttribute(it) }) {
|
for (tag in ExifInterfaceHelper.allTags.keys.filter { exif.hasAttribute(it) }) {
|
||||||
metadataMap[tag] = exif.getAttribute(tag)
|
metadataMap[tag] = exif.getAttribute(tag)
|
||||||
}
|
}
|
||||||
result.success(metadataMap)
|
result.success(metadataMap)
|
||||||
}
|
} ?: result.error("getExifInterfaceMetadata-noinput", "failed to get exif for uri=$uri", null)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// ExifInterface initialization can fail with a RuntimeException
|
// ExifInterface initialization can fail with a RuntimeException
|
||||||
// caused by an internal MediaMetadataRetriever failure
|
// caused by an internal MediaMetadataRetriever failure
|
||||||
|
@ -513,7 +505,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
val thumbnails = ArrayList<ByteArray>()
|
val thumbnails = ArrayList<ByteArray>()
|
||||||
try {
|
try {
|
||||||
StorageUtils.openInputStream(context, uri).use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
val exif = ExifInterface(input)
|
val exif = ExifInterface(input)
|
||||||
exif.thumbnailBytes?.let { thumbnails.add(it) }
|
exif.thumbnailBytes?.let { thumbnails.add(it) }
|
||||||
}
|
}
|
||||||
|
@ -535,7 +527,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
val thumbnails = ArrayList<ByteArray>()
|
val thumbnails = ArrayList<ByteArray>()
|
||||||
if (isSupportedByMetadataExtractor(mimeType)) {
|
if (isSupportedByMetadataExtractor(mimeType)) {
|
||||||
try {
|
try {
|
||||||
StorageUtils.openInputStream(context, uri).use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
val metadata = ImageMetadataReader.readMetadata(input)
|
val metadata = ImageMetadataReader.readMetadata(input)
|
||||||
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
|
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
|
||||||
val xmpMeta = dir.xmpMeta
|
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.getSafeInt
|
||||||
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeLong
|
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeLong
|
||||||
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeString
|
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.getSafeDateMillis
|
||||||
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeInt
|
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeInt
|
||||||
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeLong
|
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.MimeTypes
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
@ -158,7 +158,7 @@ class SourceImageEntry {
|
||||||
// finds: width, height, orientation, date, duration
|
// finds: width, height, orientation, date, duration
|
||||||
private fun fillByMetadataExtractor(context: Context) {
|
private fun fillByMetadataExtractor(context: Context) {
|
||||||
try {
|
try {
|
||||||
StorageUtils.openInputStream(context, uri).use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
val metadata = ImageMetadataReader.readMetadata(input)
|
val metadata = ImageMetadataReader.readMetadata(input)
|
||||||
|
|
||||||
// do not switch on specific mime types, as the reported mime type could be wrong
|
// 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
|
// finds: width, height, orientation, date
|
||||||
private fun fillByExifInterface(context: Context) {
|
private fun fillByExifInterface(context: Context) {
|
||||||
try {
|
try {
|
||||||
StorageUtils.openInputStream(context, uri).use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
val exif = ExifInterface(input)
|
val exif = ExifInterface(input)
|
||||||
foundExif = true
|
foundExif = true
|
||||||
exif.getSafeInt(ExifInterface.TAG_IMAGE_WIDTH, acceptZero = false) { width = it }
|
exif.getSafeInt(ExifInterface.TAG_IMAGE_WIDTH, acceptZero = false) { width = it }
|
||||||
|
@ -226,7 +226,7 @@ class SourceImageEntry {
|
||||||
// finds: width, height
|
// finds: width, height
|
||||||
private fun fillByBitmapDecode(context: Context) {
|
private fun fillByBitmapDecode(context: Context) {
|
||||||
try {
|
try {
|
||||||
StorageUtils.openInputStream(context, uri).use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
val options = BitmapFactory.Options()
|
val options = BitmapFactory.Options()
|
||||||
options.inJustDecodeBounds = true
|
options.inJustDecodeBounds = true
|
||||||
BitmapFactory.decodeStream(input, null, options)
|
BitmapFactory.decodeStream(input, null, options)
|
||||||
|
|
|
@ -62,23 +62,27 @@ object PermissionManager {
|
||||||
if (accessibleDirs.none { dirPath.startsWith(it) }) {
|
if (accessibleDirs.none { dirPath.startsWith(it) }) {
|
||||||
// inaccessible dirs
|
// inaccessible dirs
|
||||||
val segments = PathSegments(context, dirPath)
|
val segments = PathSegments(context, dirPath)
|
||||||
val dirSet = dirsPerVolume.getOrDefault(segments.volumePath, HashSet())
|
segments.volumePath?.let { volumePath ->
|
||||||
|
val dirSet = dirsPerVolume.getOrDefault(volumePath, HashSet())
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
// request primary directory on volume from Android R
|
// request primary directory on volume from Android R
|
||||||
segments.relativeDir?.let { relativeDir ->
|
segments.relativeDir?.apply {
|
||||||
relativeDir.split(File.separatorChar).firstOrNull { it.isNotEmpty() }?.let { dirSet.add(it) }
|
val primaryDir = split(File.separator).firstOrNull { it.isNotEmpty() }
|
||||||
|
primaryDir?.let { dirSet.add(it) }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// request volume root until Android Q
|
// request volume root until Android Q
|
||||||
dirSet.add("")
|
dirSet.add("")
|
||||||
}
|
}
|
||||||
dirsPerVolume[segments.volumePath] = dirSet
|
dirsPerVolume[volumePath] = dirSet
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// format for easier handling on Flutter
|
// format for easier handling on Flutter
|
||||||
val inaccessibleDirs = ArrayList<Map<String, String>>()
|
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) {
|
for ((volumePath, relativeDirs) in dirsPerVolume) {
|
||||||
var volumeDescription: String? = null
|
var volumeDescription: String? = null
|
||||||
try {
|
try {
|
||||||
|
@ -101,10 +105,9 @@ object PermissionManager {
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun revokeDirectoryAccess(context: Context, path: String) {
|
fun revokeDirectoryAccess(context: Context, path: String) {
|
||||||
val uri = StorageUtils.convertDirPathToTreeUri(context, path)
|
StorageUtils.convertDirPathToTreeUri(context, path)?.let {
|
||||||
if (uri.isPresent) {
|
|
||||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
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>()
|
val grantedDirs = HashSet<String>()
|
||||||
for (uriPermission in context.contentResolver.persistedUriPermissions) {
|
for (uriPermission in context.contentResolver.persistedUriPermissions) {
|
||||||
val dirPath = StorageUtils.convertTreeUriToDirPath(context, uriPermission.uri)
|
val dirPath = StorageUtils.convertTreeUriToDirPath(context, uriPermission.uri)
|
||||||
dirPath.ifPresent { grantedDirs.add(it) }
|
dirPath?.let { grantedDirs.add(it) }
|
||||||
}
|
}
|
||||||
return grantedDirs
|
return grantedDirs
|
||||||
}
|
}
|
||||||
|
@ -124,7 +127,7 @@ object PermissionManager {
|
||||||
val accessibleDirs = HashSet(getGrantedDirs(context))
|
val accessibleDirs = HashSet(getGrantedDirs(context))
|
||||||
// from Android R, we no longer have access permission by default on primary volume
|
// from Android R, we no longer have access permission by default on primary volume
|
||||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
|
||||||
accessibleDirs.add(StorageUtils.getPrimaryVolumePath())
|
accessibleDirs.add(StorageUtils.primaryVolumePath)
|
||||||
}
|
}
|
||||||
Log.d(LOG_TAG, "getAccessibleDirs accessibleDirs=$accessibleDirs")
|
Log.d(LOG_TAG, "getAccessibleDirs accessibleDirs=$accessibleDirs")
|
||||||
return 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
|
package deckers.thibault.aves.utils
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import com.adobe.internal.xmp.XMPException
|
import com.adobe.internal.xmp.XMPException
|
||||||
import com.adobe.internal.xmp.XMPMeta
|
import com.adobe.internal.xmp.XMPMeta
|
||||||
|
|
||||||
object XMP {
|
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 DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
|
||||||
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/"
|
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/"
|
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 GENERIC_LANG = ""
|
||||||
private const val SPECIFIC_LANG = "en-US"
|
private const val SPECIFIC_LANG = "en-US"
|
||||||
|
|
||||||
@Throws(XMPException::class)
|
|
||||||
fun XMPMeta.getSafeLocalizedText(propName: String, save: (value: String) -> Unit) {
|
fun XMPMeta.getSafeLocalizedText(propName: String, save: (value: String) -> Unit) {
|
||||||
|
try {
|
||||||
if (this.doesPropertyExist(DC_SCHEMA_NS, propName)) {
|
if (this.doesPropertyExist(DC_SCHEMA_NS, propName)) {
|
||||||
val item = this.getLocalizedText(DC_SCHEMA_NS, propName, GENERIC_LANG, SPECIFIC_LANG)
|
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
|
// double check retrieved items as the property sometimes is reported to exist but it is actually null
|
||||||
if (item != null) save(item.value)
|
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