diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/streams/IntentStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/streams/IntentStreamHandler.java deleted file mode 100644 index a02446d29..000000000 --- a/android/app/src/main/java/deckers/thibault/aves/channel/streams/IntentStreamHandler.java +++ /dev/null @@ -1,20 +0,0 @@ -package deckers.thibault.aves.channel.streams; - -import io.flutter.plugin.common.EventChannel; - -public class IntentStreamHandler implements EventChannel.StreamHandler { - private EventChannel.EventSink eventSink; - - @Override - public void onListen(Object args, EventChannel.EventSink eventSink) { - this.eventSink = eventSink; - } - - @Override - public void onCancel(Object arguments) { - } - - public void notifyNewIntent() { - eventSink.success(true); - } -} \ No newline at end of file diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.java deleted file mode 100644 index f837895d5..000000000 --- a/android/app/src/main/java/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.java +++ /dev/null @@ -1,53 +0,0 @@ -package deckers.thibault.aves.channel.streams; - -import android.content.Context; -import android.os.Handler; -import android.os.Looper; - -import java.util.Map; - -import deckers.thibault.aves.model.provider.MediaStoreImageProvider; -import io.flutter.plugin.common.EventChannel; - -public class MediaStoreStreamHandler implements EventChannel.StreamHandler { - public static final String CHANNEL = "deckers.thibault/aves/mediastorestream"; - - private Context context; - private EventChannel.EventSink eventSink; - private Handler handler; - private Map knownEntries; - - @SuppressWarnings("unchecked") - public MediaStoreStreamHandler(Context context, Object arguments) { - this.context = context; - if (arguments instanceof Map) { - Map argMap = (Map) arguments; - this.knownEntries = (Map) argMap.get("knownEntries"); - } - } - - @Override - public void onListen(Object args, EventChannel.EventSink eventSink) { - this.eventSink = eventSink; - this.handler = new Handler(Looper.getMainLooper()); - new Thread(this::fetchAll).start(); - } - - @Override - public void onCancel(Object args) { - // nothing - } - - private void success(final Map result) { - handler.post(() -> eventSink.success(result)); - } - - private void endOfStream() { - handler.post(() -> eventSink.endOfStream()); - } - - void fetchAll() { - new MediaStoreImageProvider().fetchAll(context, knownEntries, this::success); // 350ms - endOfStream(); - } -} \ No newline at end of file diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.java deleted file mode 100644 index 88c630c2d..000000000 --- a/android/app/src/main/java/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.java +++ /dev/null @@ -1,52 +0,0 @@ -package deckers.thibault.aves.channel.streams; - -import android.app.Activity; -import android.os.Handler; -import android.os.Looper; - -import java.util.Map; - -import deckers.thibault.aves.utils.PermissionManager; -import io.flutter.plugin.common.EventChannel; - -// starting activity to give access with the native dialog -// breaks the regular `MethodChannel` so we use a stream channel instead -public class StorageAccessStreamHandler implements EventChannel.StreamHandler { - public static final String CHANNEL = "deckers.thibault/aves/storageaccessstream"; - - private Activity activity; - private EventChannel.EventSink eventSink; - private Handler handler; - private String path; - - @SuppressWarnings("unchecked") - public StorageAccessStreamHandler(Activity activity, Object arguments) { - this.activity = activity; - if (arguments instanceof Map) { - Map argMap = (Map) arguments; - this.path = (String) argMap.get("path"); - } - } - - @Override - public void onListen(Object args, EventChannel.EventSink eventSink) { - this.eventSink = eventSink; - this.handler = new Handler(Looper.getMainLooper()); - Runnable onGranted = () -> success(true); // user gave access to a directory, with no guarantee that it matches the specified `path` - Runnable onDenied = () -> success(false); // user cancelled - PermissionManager.requestVolumeAccess(activity, path, onGranted, onDenied); - } - - @Override - public void onCancel(Object o) { - } - - private void success(final boolean result) { - handler.post(() -> eventSink.success(result)); - endOfStream(); - } - - private void endOfStream() { - handler.post(() -> eventSink.endOfStream()); - } -} diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/FileImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/FileImageProvider.java index a00e780dc..b4b1de9f4 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/FileImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/FileImageProvider.java @@ -8,14 +8,13 @@ import androidx.annotation.NonNull; import java.io.File; import deckers.thibault.aves.model.SourceImageEntry; -import deckers.thibault.aves.utils.FileUtils; class FileImageProvider extends ImageProvider { @Override public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) { SourceImageEntry entry = new SourceImageEntry(uri, mimeType); - String path = FileUtils.getPathFromUri(context, uri); + String path = uri.getPath(); if (path != null) { try { File file = new File(path); diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/FileUtils.java b/android/app/src/main/java/deckers/thibault/aves/utils/FileUtils.java deleted file mode 100644 index 174f88624..000000000 --- a/android/app/src/main/java/deckers/thibault/aves/utils/FileUtils.java +++ /dev/null @@ -1,218 +0,0 @@ -/* - * Copyright (C) 2007-2008 OpenIntents.org - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - * This file was modified by the Flutter authors from the following original file: - * https://raw.githubusercontent.com/iPaulPro/aFileChooser/master/aFileChooser/src/com/ipaulpro/afilechooser/utils/FileUtils.java - */ - -// TLAD: formatted code copied from: -// https://raw.githubusercontent.com/flutter/plugins/master/packages/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/FileUtils.java -// do not add code to this file! - -package deckers.thibault.aves.utils; - -import android.annotation.SuppressLint; -import android.content.ContentResolver; -import android.content.ContentUris; -import android.content.Context; -import android.database.Cursor; -import android.net.Uri; -import android.os.Build; -import android.os.Environment; -import android.provider.DocumentsContract; -import android.provider.MediaStore; -import android.text.TextUtils; -import android.webkit.MimeTypeMap; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; - -public class FileUtils { - // useful (for Download, File, etc.) but slower - // than directly using `MediaStore.MediaColumns.DATA` from the MediaStore query - public static String getPathFromUri(final Context context, final Uri uri) { - String path = getPathFromLocalUri(context, uri); - if (path == null) { - path = getPathFromRemoteUri(context, uri); - } - return path; - } - - @SuppressLint("NewApi") - private static String getPathFromLocalUri(final Context context, final Uri uri) { - final boolean isKitKat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; - - if (isKitKat && DocumentsContract.isDocumentUri(context, uri)) { - if (isExternalStorageDocument(uri)) { - final String docId = DocumentsContract.getDocumentId(uri); - final String[] split = docId.split(":"); - final String type = split[0]; - - if ("primary".equalsIgnoreCase(type)) { - return Environment.getExternalStorageDirectory() + "/" + split[1]; - } - } else if (isDownloadsDocument(uri)) { - final String id = DocumentsContract.getDocumentId(uri); - - if (!TextUtils.isEmpty(id)) { - try { - final Uri contentUri = - ContentUris.withAppendedId( - Uri.parse(Environment.DIRECTORY_DOWNLOADS), Long.valueOf(id)); - return getDataColumn(context, contentUri, null, null); - } catch (NumberFormatException e) { - return null; - } - } - - } else if (isMediaDocument(uri)) { - final String docId = DocumentsContract.getDocumentId(uri); - final String[] split = docId.split(":"); - final String type = split[0]; - - Uri contentUri = null; - if ("image".equals(type)) { - contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; - } else if ("video".equals(type)) { - contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; - } else if ("audio".equals(type)) { - contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; - } - - final String selection = "_id=?"; - final String[] selectionArgs = new String[]{split[1]}; - - return getDataColumn(context, contentUri, selection, selectionArgs); - } - } else if (ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(uri.getScheme())) { - - // Return the remote address - if (isGooglePhotosUri(uri)) { - return null; - } - - return getDataColumn(context, uri, null, null); - } else if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(uri.getScheme())) { - return uri.getPath(); - } - - return null; - } - - private static String getDataColumn( - Context context, Uri uri, String selection, String[] selectionArgs) { - - final String column = "_data"; - final String[] projection = {column}; - try (Cursor cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs, null)) { - if (cursor != null && cursor.moveToFirst()) { - final int column_index = cursor.getColumnIndex(column); - - //yandex.disk and dropbox do not have _data column - if (column_index == -1) { - return null; - } - - return cursor.getString(column_index); - } - } - return null; - } - - private static String getPathFromRemoteUri(final Context context, final Uri uri) { - // The code below is why Java now has try-with-resources and the Files utility. - File file = null; - InputStream inputStream = null; - OutputStream outputStream = null; - boolean success = false; - try { - String extension = getImageExtension(context, uri); - inputStream = context.getContentResolver().openInputStream(uri); - file = File.createTempFile("image_picker", extension, context.getCacheDir()); - outputStream = new FileOutputStream(file); - if (inputStream != null) { - copy(inputStream, outputStream); - success = true; - } - } catch (IOException ignored) { - } finally { - try { - if (inputStream != null) inputStream.close(); - } catch (IOException ignored) { - } - try { - if (outputStream != null) outputStream.close(); - } catch (IOException ignored) { - // If closing the output stream fails, we cannot be sure that the - // target file was written in full. Flushing the stream merely moves - // the bytes into the OS, not necessarily to the file. - success = false; - } - } - return success ? file.getPath() : null; - } - - /** - * @return extension of image with dot, or default .jpg if it none. - */ - private static String getImageExtension(Context context, Uri uriImage) { - String extension = null; - - try (Cursor cursor = context - .getContentResolver() - .query(uriImage, new String[]{MediaStore.MediaColumns.MIME_TYPE}, null, null, null)) { - - if (cursor != null && cursor.moveToNext()) { - String mimeType = cursor.getString(0); - - extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType); - } - } - - if (extension == null) { - //default extension for matches the previous behavior of the plugin - extension = "jpg"; - } - return "." + extension; - } - - private static void copy(InputStream in, OutputStream out) throws IOException { - final byte[] buffer = new byte[4 * 1024]; - int bytesRead; - while ((bytesRead = in.read(buffer)) != -1) { - out.write(buffer, 0, bytesRead); - } - out.flush(); - } - - private static boolean isExternalStorageDocument(Uri uri) { - return "com.android.externalstorage.documents".equals(uri.getAuthority()); - } - - private static boolean isDownloadsDocument(Uri uri) { - return "com.android.providers.downloads.documents".equals(uri.getAuthority()); - } - - private static boolean isMediaDocument(Uri uri) { - return "com.android.providers.media.documents".equals(uri.getAuthority()); - } - - private static boolean isGooglePhotosUri(Uri uri) { - return "com.google.android.apps.photos.contentprovider".equals(uri.getAuthority()); - } -} diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java b/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java deleted file mode 100644 index 4692a2add..000000000 --- a/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java +++ /dev/null @@ -1,180 +0,0 @@ -package deckers.thibault.aves.utils; - -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.content.UriPermission; -import android.net.Uri; -import android.os.Build; -import android.os.storage.StorageManager; -import android.os.storage.StorageVolume; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.ActivityCompat; - -import com.google.common.base.Splitter; - -import java.io.File; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; - -public class PermissionManager { - private static final String LOG_TAG = Utils.createLogTag(PermissionManager.class); - - public static final int VOLUME_ROOT_PERMISSION_REQUEST_CODE = 1; - - // permission request code to pending runnable - private static ConcurrentHashMap pendingPermissionMap = new ConcurrentHashMap<>(); - - public static void requestVolumeAccess(@NonNull Activity activity, @NonNull String path, @NonNull Runnable onGranted, @NonNull Runnable onDenied) { - Log.i(LOG_TAG, "request user to select and grant access permission to volume=" + path); - pendingPermissionMap.put(VOLUME_ROOT_PERMISSION_REQUEST_CODE, new PendingPermissionHandler(path, onGranted, onDenied)); - - Intent intent = null; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - StorageManager sm = activity.getSystemService(StorageManager.class); - if (sm != null) { - StorageVolume volume = sm.getStorageVolume(new File(path)); - if (volume != null) { - intent = volume.createOpenDocumentTreeIntent(); - } - } - } - - // fallback to basic open document tree intent - if (intent == null) { - intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); - } - - ActivityCompat.startActivityForResult(activity, intent, VOLUME_ROOT_PERMISSION_REQUEST_CODE, null); - } - - public static void onPermissionResult(int requestCode, @Nullable Uri treeUri) { - Log.d(LOG_TAG, "onPermissionResult with requestCode=" + requestCode + ", treeUri=" + treeUri); - boolean granted = treeUri != null; - - PendingPermissionHandler handler = pendingPermissionMap.remove(requestCode); - if (handler == null) return; - - Runnable runnable = granted ? handler.onGranted : handler.onDenied; - if (runnable == null) return; - runnable.run(); - } - - public static Optional getGrantedDirForPath(@NonNull Context context, @NonNull String anyPath) { - return getAccessibleDirs(context).stream().filter(anyPath::startsWith).findFirst(); - } - - public static List> getInaccessibleDirectories(@NonNull Context context, @NonNull List dirPaths) { - Set accessibleDirs = getAccessibleDirs(context); - - // find set of inaccessible directories for each volume - Map> dirsPerVolume = new HashMap<>(); - for (String dirPath : dirPaths) { - if (!dirPath.endsWith(File.separator)) { - dirPath += File.separator; - } - if (accessibleDirs.stream().noneMatch(dirPath::startsWith)) { - // inaccessible dirs - StorageUtils.PathSegments segments = new StorageUtils.PathSegments(context, dirPath); - Set dirSet = dirsPerVolume.getOrDefault(segments.volumePath, new HashSet<>()); - if (dirSet != null) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - // request primary directory on volume from Android R - String relativeDir = segments.relativeDir; - if (relativeDir != null) { - Iterator iterator = Splitter.on(File.separatorChar).omitEmptyStrings().split(relativeDir).iterator(); - if (iterator.hasNext()) { - // primary dir - dirSet.add(iterator.next()); - } - } - } else { - // request volume root until Android Q - dirSet.add(""); - } - } - dirsPerVolume.put(segments.volumePath, dirSet); - } - } - - // format for easier handling on Flutter - List> inaccessibleDirs = new ArrayList<>(); - StorageManager sm = context.getSystemService(StorageManager.class); - if (sm != null) { - for (Map.Entry> volumeEntry : dirsPerVolume.entrySet()) { - String volumePath = volumeEntry.getKey(); - String volumeDescription = ""; - try { - StorageVolume volume = sm.getStorageVolume(new File(volumePath)); - if (volume != null) { - volumeDescription = volume.getDescription(context); - } - } catch (IllegalArgumentException e) { - // ignore - } - for (String relativeDir : volumeEntry.getValue()) { - HashMap dirMap = new HashMap<>(); - dirMap.put("volumePath", volumePath); - dirMap.put("volumeDescription", volumeDescription); - dirMap.put("relativeDir", relativeDir); - inaccessibleDirs.add(dirMap); - } - } - } - Log.d(LOG_TAG, "getInaccessibleDirectories dirPaths=" + dirPaths + " -> inaccessibleDirs=" + inaccessibleDirs); - return inaccessibleDirs; - } - - - public static void revokeDirectoryAccess(Context context, String path) { - Optional uri = StorageUtils.convertDirPathToTreeUri(context, path); - if (uri.isPresent()) { - int flags = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; - context.getContentResolver().releasePersistableUriPermission(uri.get(), flags); - } - } - - // returns paths matching URIs granted by the user - public static Set getGrantedDirs(Context context) { - Set grantedDirs = new HashSet<>(); - for (UriPermission uriPermission : context.getContentResolver().getPersistedUriPermissions()) { - Optional dirPath = StorageUtils.convertTreeUriToDirPath(context, uriPermission.getUri()); - dirPath.ifPresent(grantedDirs::add); - } - return grantedDirs; - } - - // returns paths accessible to the app (granted by the user or by default) - private static Set getAccessibleDirs(Context context) { - Set accessibleDirs = new 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) { - String primaryPath = StorageUtils.getPrimaryVolumePath(); - accessibleDirs.add(primaryPath); - } - Log.d(LOG_TAG, "getAccessibleDirs accessibleDirs=" + accessibleDirs); - return accessibleDirs; - } - - static class PendingPermissionHandler { - final String path; - final Runnable onGranted; // user gave access to a directory, with no guarantee that it matches the specified `path` - final Runnable onDenied; // user cancelled - - PendingPermissionHandler(@NonNull String path, @NonNull Runnable onGranted, @NonNull Runnable onDenied) { - this.path = path; - this.onGranted = onGranted; - this.onDenied = onDenied; - } - } -} 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 index e2ec2986f..48fc35bcb 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java @@ -340,7 +340,7 @@ public class StorageUtils { dirPath += File.separator; } if (requireAccessPermission(dirPath)) { - String grantedDir = PermissionManager.getGrantedDirForPath(context, dirPath).orElse(null); + String grantedDir = PermissionManager.getGrantedDirForPath(context, dirPath); if (grantedDir == null) return null; Uri rootTreeUri = convertDirPathToTreeUri(context, grantedDir).orElse(null); diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index fdb0bba50..b9b387ceb 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -139,7 +139,7 @@ class MainActivity : FlutterActivity() { } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent) { - if (requestCode == PermissionManager.VOLUME_ROOT_PERMISSION_REQUEST_CODE) { + if (requestCode == PermissionManager.VOLUME_ACCESS_REQUEST_CODE) { val treeUri = data.data if (resultCode != RESULT_OK || treeUri == null) { PermissionManager.onPermissionResult(requestCode, null) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/IntentStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/IntentStreamHandler.kt new file mode 100644 index 000000000..caa69e352 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/IntentStreamHandler.kt @@ -0,0 +1,18 @@ +package deckers.thibault.aves.channel.streams + +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.EventChannel.EventSink + +class IntentStreamHandler : EventChannel.StreamHandler { + private lateinit var eventSink: EventSink + + override fun onListen(arguments: Any?, eventSink: EventSink) { + this.eventSink = eventSink + } + + override fun onCancel(arguments: Any?) {} + + fun notifyNewIntent() { + eventSink.success(true) + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt new file mode 100644 index 000000000..0f71b94d6 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt @@ -0,0 +1,46 @@ +package deckers.thibault.aves.channel.streams + +import android.content.Context +import android.os.Handler +import android.os.Looper +import deckers.thibault.aves.model.provider.MediaStoreImageProvider +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.EventChannel.EventSink + +class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : EventChannel.StreamHandler { + private lateinit var eventSink: EventSink + private lateinit var handler: Handler + private var knownEntries: Map? = null + + init { + if (arguments is Map<*, *>) { + @Suppress("UNCHECKED_CAST") + knownEntries = arguments["knownEntries"] as Map? + } + } + + override fun onListen(arguments: Any?, eventSink: EventSink) { + this.eventSink = eventSink + handler = Handler(Looper.getMainLooper()) + Thread { fetchAll() }.start() + } + + override fun onCancel(arguments: Any?) {} + + private fun success(result: Map) { + handler.post { eventSink.success(result) } + } + + private fun endOfStream() { + handler.post { eventSink.endOfStream() } + } + + private fun fetchAll() { + MediaStoreImageProvider().fetchAll(context, knownEntries) { success(it) } + endOfStream() + } + + companion object { + const val CHANNEL = "deckers.thibault/aves/mediastorestream" + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt new file mode 100644 index 000000000..98d66f4e1 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt @@ -0,0 +1,44 @@ +package deckers.thibault.aves.channel.streams + +import android.app.Activity +import android.os.Handler +import android.os.Looper +import deckers.thibault.aves.utils.PermissionManager.requestVolumeAccess +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.EventChannel.EventSink + +// starting activity to give access with the native dialog +// breaks the regular `MethodChannel` so we use a stream channel instead +class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?) : EventChannel.StreamHandler { + private lateinit var eventSink: EventSink + private lateinit var handler: Handler + private var path: String? = null + + init { + if (arguments is Map<*, *>) { + path = arguments["path"] as String? + } + } + + override fun onListen(arguments: Any?, eventSink: EventSink) { + this.eventSink = eventSink + handler = Handler(Looper.getMainLooper()) + + requestVolumeAccess(activity, path!!, { success(true) }, { success(false) }) + } + + override fun onCancel(arguments: Any?) {} + + private fun success(result: Boolean) { + handler.post { eventSink.success(result) } + endOfStream() + } + + private fun endOfStream() { + handler.post { eventSink.endOfStream() } + } + + companion object { + const val CHANNEL = "deckers.thibault/aves/storageaccessstream" + } +} \ No newline at end of file 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 new file mode 100644 index 000000000..c502809a8 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt @@ -0,0 +1,136 @@ +package deckers.thibault.aves.utils + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.storage.StorageManager +import android.util.Log +import androidx.core.app.ActivityCompat +import deckers.thibault.aves.utils.StorageUtils.PathSegments +import deckers.thibault.aves.utils.Utils.createLogTag +import java.io.File +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +object PermissionManager { + private val LOG_TAG = createLogTag(PermissionManager::class.java) + + const val VOLUME_ACCESS_REQUEST_CODE = 1 + + // permission request code to pending runnable + private val pendingPermissionMap = ConcurrentHashMap() + + @JvmStatic + fun requestVolumeAccess(activity: Activity, path: String, onGranted: () -> Unit, onDenied: () -> Unit) { + Log.i(LOG_TAG, "request user to select and grant access permission to volume=$path") + pendingPermissionMap[VOLUME_ACCESS_REQUEST_CODE] = PendingPermissionHandler(path, onGranted, onDenied) + + var intent: Intent? = null + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val sm = activity.getSystemService(StorageManager::class.java) + intent = sm?.getStorageVolume(File(path))?.createOpenDocumentTreeIntent() + } + + // fallback to basic open document tree intent + if (intent == null) { + intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + } + + ActivityCompat.startActivityForResult(activity, intent, VOLUME_ACCESS_REQUEST_CODE, null) + } + + fun onPermissionResult(requestCode: Int, treeUri: Uri?) { + Log.d(LOG_TAG, "onPermissionResult with requestCode=$requestCode, treeUri=$treeUri") + val handler = pendingPermissionMap.remove(requestCode) ?: return + (if (treeUri != null) handler.onGranted else handler.onDenied)() + } + + @JvmStatic + fun getGrantedDirForPath(context: Context, anyPath: String): String? { + return getAccessibleDirs(context).firstOrNull { anyPath.startsWith(it) } + } + + @JvmStatic + fun getInaccessibleDirectories(context: Context, dirPaths: List): List> { + val accessibleDirs = getAccessibleDirs(context) + + // find set of inaccessible directories for each volume + val dirsPerVolume = HashMap>() + for (dirPath in dirPaths.map { if (it.endsWith(File.separator)) it else it + File.separator }) { + 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) } + } + } else { + // request volume root until Android Q + dirSet.add("") + } + dirsPerVolume[segments.volumePath] = dirSet + } + } + + // format for easier handling on Flutter + val inaccessibleDirs = ArrayList>() + context.getSystemService(StorageManager::class.java)?.let { sm -> + for ((volumePath, relativeDirs) in dirsPerVolume) { + var volumeDescription: String? = null + try { + volumeDescription = sm.getStorageVolume(File(volumePath))?.getDescription(context) + } catch (e: IllegalArgumentException) { + // ignore + } + for (relativeDir in relativeDirs) { + val dirMap = HashMap() + dirMap["volumePath"] = volumePath + dirMap["volumeDescription"] = volumeDescription ?: "" + dirMap["relativeDir"] = relativeDir + inaccessibleDirs.add(dirMap) + } + } + } + Log.d(LOG_TAG, "getInaccessibleDirectories dirPaths=$dirPaths -> inaccessibleDirs=$inaccessibleDirs") + return inaccessibleDirs + } + + @JvmStatic + fun revokeDirectoryAccess(context: Context, path: String) { + val uri = StorageUtils.convertDirPathToTreeUri(context, path) + if (uri.isPresent) { + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + context.contentResolver.releasePersistableUriPermission(uri.get(), flags) + } + } + + // returns paths matching URIs granted by the user + @JvmStatic + fun getGrantedDirs(context: Context): Set { + val grantedDirs = HashSet() + for (uriPermission in context.contentResolver.persistedUriPermissions) { + val dirPath = StorageUtils.convertTreeUriToDirPath(context, uriPermission.uri) + dirPath.ifPresent { grantedDirs.add(it) } + } + return grantedDirs + } + + // returns paths accessible to the app (granted by the user or by default) + private fun getAccessibleDirs(context: Context): Set { + 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()) + } + Log.d(LOG_TAG, "getAccessibleDirs accessibleDirs=$accessibleDirs") + return accessibleDirs + } + + // onGranted: user gave access to a directory, with no guarantee that it matches the specified `path` + // onDenied: user cancelled + internal data class PendingPermissionHandler(val path: String, val onGranted: () -> Unit, val onDenied: () -> Unit) +} \ No newline at end of file