diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java index 156f28f81..41f212cb2 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java @@ -437,7 +437,7 @@ public class MediaStoreImageProvider extends ImageProvider { MediaStoreMoveDestination(@NonNull Context context, @NonNull String destinationDir) { fullPath = destinationDir; volumeNameForMediaStore = getVolumeNameForMediaStore(context, destinationDir); - volumePath = StorageUtils.getVolumePath(context, destinationDir).orElse(null); + volumePath = StorageUtils.getVolumePath(context, destinationDir); relativePath = volumePath != null ? destinationDir.replaceFirst(volumePath, "") : null; } } diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java b/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java deleted file mode 100644 index 48fc35bcb..000000000 --- a/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java +++ /dev/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 getVolumePath(@NonNull Context context, @NonNull String anyPath) { - return Arrays.stream(getVolumePaths(context)).filter(anyPath::startsWith).findFirst(); - } - - @Nullable - private static Iterator 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 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) - *

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