Kotlin migration (WIP)

This commit is contained in:
Thibault Deckers 2020-10-11 20:54:38 +09:00
parent 5c93abd928
commit 9a9805d31c
7 changed files with 481 additions and 540 deletions

View file

@ -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;
} }
} }

View file

@ -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);
}
}
}
}

View file

@ -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,22 +320,14 @@ 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) // keep `0.0` as `0.0`, not `0`
val longitudeString = locationMatcher.group(2) val latitude = matcher.group(1)?.toDoubleOrNull() ?: 0.0
if (latitudeString != null && longitudeString != null) { val longitude = matcher.group(2)?.toDoubleOrNull() ?: 0.0
try { if (latitude != 0.0 || longitude != 0.0) {
val latitude = latitudeString.toDoubleOrNull() ?: 0 metadataMap[KEY_LATITUDE] = latitude
val longitude = longitudeString.toDoubleOrNull() ?: 0 metadataMap[KEY_LONGITUDE] = longitude
// 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
}
} }
} }
} }
@ -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

View file

@ -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)

View file

@ -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 ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val dirSet = dirsPerVolume.getOrDefault(volumePath, HashSet())
// request primary directory on volume from Android R if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
segments.relativeDir?.let { relativeDir -> // request primary directory on volume from Android R
relativeDir.split(File.separatorChar).firstOrNull { it.isNotEmpty() }?.let { dirSet.add(it) } 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 { dirsPerVolume[volumePath] = dirSet
// request volume root until Android Q
dirSet.add("")
} }
dirsPerVolume[segments.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

View file

@ -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)
}
}
}
}
}

View file

@ -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) {
if (this.doesPropertyExist(DC_SCHEMA_NS, propName)) { try {
val item = this.getLocalizedText(DC_SCHEMA_NS, propName, GENERIC_LANG, SPECIFIC_LANG) if (this.doesPropertyExist(DC_SCHEMA_NS, propName)) {
// double check retrieved items as the property sometimes is reported to exist but it is actually null val item = this.getLocalizedText(DC_SCHEMA_NS, propName, GENERIC_LANG, SPECIFIC_LANG)
if (item != null) save(item.value) // 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)
} }
} }
} }