storage access: misc fixes for Android R style storage
This commit is contained in:
parent
4f30f5427e
commit
e79ffbdb89
9 changed files with 102 additions and 97 deletions
|
@ -51,12 +51,12 @@ android {
|
||||||
manifestPlaceholders = [googleApiKey:keystoreProperties['googleApiKey']]
|
manifestPlaceholders = [googleApiKey:keystoreProperties['googleApiKey']]
|
||||||
}
|
}
|
||||||
|
|
||||||
// compileOptions {
|
compileOptions {
|
||||||
// // enable support for Java 8 language APIs (stream, optional, etc.)
|
// enable support for Java 8 language APIs (stream, optional, etc.)
|
||||||
// coreLibraryDesugaringEnabled true
|
coreLibraryDesugaringEnabled true
|
||||||
// sourceCompatibility JavaVersion.VERSION_1_8
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
// targetCompatibility JavaVersion.VERSION_1_8
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
// }
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
release {
|
release {
|
||||||
|
@ -105,7 +105,7 @@ repositories {
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// enable support for Java 8 language APIs (stream, optional, etc.)
|
// enable support for Java 8 language APIs (stream, optional, etc.)
|
||||||
// coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.5'
|
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.9'
|
||||||
|
|
||||||
implementation "androidx.exifinterface:exifinterface:1.2.0"
|
implementation "androidx.exifinterface:exifinterface:1.2.0"
|
||||||
implementation 'com.commonsware.cwac:document:0.4.1'
|
implementation 'com.commonsware.cwac:document:0.4.1'
|
||||||
|
|
|
@ -42,10 +42,13 @@ public class StorageHandler implements MethodChannel.MethodCallHandler {
|
||||||
result.success(volumes);
|
result.success(volumes);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "hasGrantedPermissionToVolumeRoot": {
|
case "requireVolumeAccessDialog": {
|
||||||
String path = call.argument("path");
|
String path = call.argument("path");
|
||||||
boolean granted = PermissionManager.hasGrantedPermissionToVolumeRoot(activity, path);
|
if (path == null) {
|
||||||
result.success(granted);
|
result.success(true);
|
||||||
|
} else {
|
||||||
|
result.success(PermissionManager.requireVolumeAccessDialog(activity, path));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|
|
@ -32,7 +32,7 @@ public class StorageAccessStreamHandler implements EventChannel.StreamHandler {
|
||||||
public void onListen(Object o, final EventChannel.EventSink eventSink) {
|
public void onListen(Object o, final EventChannel.EventSink eventSink) {
|
||||||
this.eventSink = eventSink;
|
this.eventSink = eventSink;
|
||||||
this.handler = new Handler(Looper.getMainLooper());
|
this.handler = new Handler(Looper.getMainLooper());
|
||||||
Runnable onGranted = () -> success(PermissionManager.hasGrantedPermissionToVolumeRoot(activity, volumePath));
|
Runnable onGranted = () -> success(!PermissionManager.requireVolumeAccessDialog(activity, volumePath));
|
||||||
Runnable onDenied = () -> success(false);
|
Runnable onDenied = () -> success(false);
|
||||||
PermissionManager.requestVolumeAccess(activity, volumePath, onGranted, onDenied);
|
PermissionManager.requestVolumeAccess(activity, volumePath, onGranted, onDenied);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ package deckers.thibault.aves.model.provider;
|
||||||
|
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
import android.content.ContentResolver;
|
import android.content.ContentResolver;
|
||||||
|
import android.content.ContentUris;
|
||||||
import android.content.ContentValues;
|
import android.content.ContentValues;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.database.Cursor;
|
import android.database.Cursor;
|
||||||
|
@ -99,23 +100,33 @@ public abstract class ImageProvider {
|
||||||
MediaScannerConnection.scanFile(activity, new String[]{newFile.getPath()}, new String[]{mimeType}, (newPath, newUri) -> {
|
MediaScannerConnection.scanFile(activity, new String[]{newFile.getPath()}, new String[]{mimeType}, (newPath, newUri) -> {
|
||||||
Log.d(LOG_TAG, "onScanCompleted with newPath=" + newPath + ", newUri=" + newUri);
|
Log.d(LOG_TAG, "onScanCompleted with newPath=" + newPath + ", newUri=" + newUri);
|
||||||
if (newUri != null) {
|
if (newUri != null) {
|
||||||
// we retrieve updated fields as the renamed file became a new entry in the Media Store
|
// newURI is a file media URI (e.g. "content://media/12a9-8b42/file/62872")
|
||||||
String[] projection = {MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.TITLE};
|
// but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
|
||||||
try {
|
long contentId = ContentUris.parseId(newUri);
|
||||||
Cursor cursor = activity.getContentResolver().query(newUri, projection, null, null, null);
|
Uri contentUri = null;
|
||||||
if (cursor != null) {
|
if (mimeType.startsWith(MimeTypes.IMAGE)) {
|
||||||
if (cursor.moveToNext()) {
|
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId);
|
||||||
long contentId = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID));
|
} else if (mimeType.startsWith(MimeTypes.VIDEO)) {
|
||||||
newFields.put("uri", newUri.toString());
|
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId);
|
||||||
newFields.put("contentId", contentId);
|
}
|
||||||
newFields.put("path", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)));
|
if (contentUri != null) {
|
||||||
newFields.put("title", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.TITLE)));
|
// we retrieve updated fields as the renamed file became a new entry in the Media Store
|
||||||
|
String[] projection = {MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.TITLE};
|
||||||
|
try {
|
||||||
|
Cursor cursor = activity.getContentResolver().query(contentUri, projection, null, null, null);
|
||||||
|
if (cursor != null) {
|
||||||
|
if (cursor.moveToNext()) {
|
||||||
|
newFields.put("uri", contentUri.toString());
|
||||||
|
newFields.put("contentId", contentId);
|
||||||
|
newFields.put("path", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)));
|
||||||
|
newFields.put("title", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.TITLE)));
|
||||||
|
}
|
||||||
|
cursor.close();
|
||||||
}
|
}
|
||||||
cursor.close();
|
} catch (Exception e) {
|
||||||
|
callback.onFailure(e);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
|
||||||
callback.onFailure(e);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
callback.onSuccess(newFields);
|
callback.onSuccess(newFields);
|
||||||
|
@ -189,24 +200,26 @@ public abstract class ImageProvider {
|
||||||
Map<String, Object> newFields = new HashMap<>();
|
Map<String, Object> newFields = new HashMap<>();
|
||||||
newFields.put("orientationDegrees", orientationDegrees);
|
newFields.put("orientationDegrees", orientationDegrees);
|
||||||
|
|
||||||
ContentResolver contentResolver = activity.getContentResolver();
|
// ContentResolver contentResolver = activity.getContentResolver();
|
||||||
ContentValues values = new ContentValues();
|
// ContentValues values = new ContentValues();
|
||||||
// from Android Q, media store update needs to be flagged IS_PENDING first
|
// // from Android Q, media store update needs to be flagged IS_PENDING first
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
values.put(MediaStore.MediaColumns.IS_PENDING, 1);
|
// values.put(MediaStore.MediaColumns.IS_PENDING, 1);
|
||||||
contentResolver.update(uri, values, null, null);
|
// // TODO TLAD catch RecoverableSecurityException
|
||||||
values.clear();
|
// contentResolver.update(uri, values, null, null);
|
||||||
values.put(MediaStore.MediaColumns.IS_PENDING, 0);
|
// values.clear();
|
||||||
}
|
// values.put(MediaStore.MediaColumns.IS_PENDING, 0);
|
||||||
// uses MediaStore.Images.Media instead of MediaStore.MediaColumns for APIs < Q
|
// }
|
||||||
values.put(MediaStore.Images.Media.ORIENTATION, orientationDegrees);
|
// // uses MediaStore.Images.Media instead of MediaStore.MediaColumns for APIs < Q
|
||||||
int updatedRowCount = contentResolver.update(uri, values, null, null);
|
// values.put(MediaStore.Images.Media.ORIENTATION, orientationDegrees);
|
||||||
if (updatedRowCount > 0) {
|
// // TODO TLAD catch RecoverableSecurityException
|
||||||
MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (p, u) -> callback.onSuccess(newFields));
|
// int updatedRowCount = contentResolver.update(uri, values, null, null);
|
||||||
} else {
|
// if (updatedRowCount > 0) {
|
||||||
Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri);
|
MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (p, u) -> callback.onSuccess(newFields));
|
||||||
callback.onSuccess(newFields);
|
// } else {
|
||||||
}
|
// Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri);
|
||||||
|
// callback.onSuccess(newFields);
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void rotatePng(final Activity activity, final String path, final Uri uri, boolean clockwise, final ImageOpCallback callback) {
|
private void rotatePng(final Activity activity, final String path, final Uri uri, boolean clockwise, final ImageOpCallback callback) {
|
||||||
|
@ -259,24 +272,26 @@ public abstract class ImageProvider {
|
||||||
newFields.put("width", rotatedWidth);
|
newFields.put("width", rotatedWidth);
|
||||||
newFields.put("height", rotatedHeight);
|
newFields.put("height", rotatedHeight);
|
||||||
|
|
||||||
ContentResolver contentResolver = activity.getContentResolver();
|
// ContentResolver contentResolver = activity.getContentResolver();
|
||||||
ContentValues values = new ContentValues();
|
// ContentValues values = new ContentValues();
|
||||||
// from Android Q, media store update needs to be flagged IS_PENDING first
|
// // from Android Q, media store update needs to be flagged IS_PENDING first
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
values.put(MediaStore.MediaColumns.IS_PENDING, 1);
|
// values.put(MediaStore.MediaColumns.IS_PENDING, 1);
|
||||||
contentResolver.update(uri, values, null, null);
|
// // TODO TLAD catch RecoverableSecurityException
|
||||||
values.clear();
|
// contentResolver.update(uri, values, null, null);
|
||||||
values.put(MediaStore.MediaColumns.IS_PENDING, 0);
|
// values.clear();
|
||||||
}
|
// values.put(MediaStore.MediaColumns.IS_PENDING, 0);
|
||||||
values.put(MediaStore.MediaColumns.WIDTH, rotatedWidth);
|
// }
|
||||||
values.put(MediaStore.MediaColumns.HEIGHT, rotatedHeight);
|
// values.put(MediaStore.MediaColumns.WIDTH, rotatedWidth);
|
||||||
int updatedRowCount = contentResolver.update(uri, values, null, null);
|
// values.put(MediaStore.MediaColumns.HEIGHT, rotatedHeight);
|
||||||
if (updatedRowCount > 0) {
|
// // TODO TLAD catch RecoverableSecurityException
|
||||||
MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (p, u) -> callback.onSuccess(newFields));
|
// int updatedRowCount = contentResolver.update(uri, values, null, null);
|
||||||
} else {
|
// if (updatedRowCount > 0) {
|
||||||
Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri);
|
MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (p, u) -> callback.onSuccess(newFields));
|
||||||
callback.onSuccess(newFields);
|
// } else {
|
||||||
}
|
// Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri);
|
||||||
|
// callback.onSuccess(newFields);
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface ImageOpCallback {
|
public interface ImageOpCallback {
|
||||||
|
|
|
@ -276,6 +276,12 @@ public class MediaStoreImageProvider extends ImageProvider {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, List<AvesImageEntry> entries, @NonNull ImageOpCallback callback) {
|
public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, List<AvesImageEntry> entries, @NonNull ImageOpCallback callback) {
|
||||||
|
if (PermissionManager.requireVolumeAccessDialog(activity, destinationDir)) {
|
||||||
|
Runnable runnable = () -> moveMultiple(activity, copy, destinationDir, entries, callback);
|
||||||
|
new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showVolumeAccessDialog(activity, destinationDir, runnable));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
DocumentFileCompat destinationDirDocFile = StorageUtils.createDirectoryIfAbsent(activity, destinationDir);
|
DocumentFileCompat destinationDirDocFile = StorageUtils.createDirectoryIfAbsent(activity, destinationDir);
|
||||||
if (destinationDirDocFile == null) {
|
if (destinationDirDocFile == null) {
|
||||||
callback.onFailure(new Exception("failed to create directory at path=" + destinationDir));
|
callback.onFailure(new Exception("failed to create directory at path=" + destinationDir));
|
||||||
|
@ -330,6 +336,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
||||||
ContentValues contentValues = new ContentValues();
|
ContentValues contentValues = new ContentValues();
|
||||||
contentValues.put(MediaStore.MediaColumns.DATA, destinationPath);
|
contentValues.put(MediaStore.MediaColumns.DATA, destinationPath);
|
||||||
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
|
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
|
||||||
|
// TODO TLAD when not using legacy storage (~Q, R+), provide relative path (assess first whether the root is the "Pictures" folder or the root)
|
||||||
// contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, "");
|
// contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, "");
|
||||||
// contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, "");
|
// contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, "");
|
||||||
Uri tableUrl = mimeType.startsWith(MimeTypes.VIDEO) ? MediaStore.Video.Media.getContentUri(volumeName) : MediaStore.Images.Media.getContentUri(volumeName);
|
Uri tableUrl = mimeType.startsWith(MimeTypes.VIDEO) ? MediaStore.Video.Media.getContentUri(volumeName) : MediaStore.Images.Media.getContentUri(volumeName);
|
||||||
|
@ -337,7 +344,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
||||||
if (destinationUri == null) {
|
if (destinationUri == null) {
|
||||||
future.setException(new Exception("failed to insert row to content resolver"));
|
future.setException(new Exception("failed to insert row to content resolver"));
|
||||||
} else {
|
} else {
|
||||||
DocumentFileCompat source = DocumentFileCompat.fromFile(new File(sourcePath));
|
DocumentFileCompat source = DocumentFileCompat.fromSingleUri(activity, sourceUri);
|
||||||
DocumentFileCompat destination = DocumentFileCompat.fromSingleUri(activity, destinationUri);
|
DocumentFileCompat destination = DocumentFileCompat.fromSingleUri(activity, destinationUri);
|
||||||
source.copyTo(destination);
|
source.copyTo(destination);
|
||||||
|
|
||||||
|
@ -408,6 +415,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
||||||
MediaScannerConnection.scanFile(activity, new String[]{destinationPath}, new String[]{mimeType}, (newPath, newUri) -> {
|
MediaScannerConnection.scanFile(activity, new String[]{destinationPath}, new String[]{mimeType}, (newPath, newUri) -> {
|
||||||
Map<String, Object> newFields = new HashMap<>();
|
Map<String, Object> newFields = new HashMap<>();
|
||||||
if (newUri != null) {
|
if (newUri != null) {
|
||||||
|
// TODO TLAD check whether newURI is a file media URI (cf case in `rename`)
|
||||||
// we retrieve updated fields as the moved file became a new entry in the Media Store
|
// we retrieve updated fields as the moved file became a new entry in the Media Store
|
||||||
String[] projection = {MediaStore.MediaColumns._ID};
|
String[] projection = {MediaStore.MediaColumns._ID};
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -91,28 +91,6 @@ public class PermissionManager {
|
||||||
runnable.run();
|
runnable.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean hasGrantedPermissionToVolumeRoot(Context context, String path) {
|
|
||||||
boolean canAccess = false;
|
|
||||||
Stream<Uri> permittedUris = context.getContentResolver().getPersistedUriPermissions().stream().map(UriPermission::getUri);
|
|
||||||
// e.g. content://com.android.externalstorage.documents/tree/12A9-8B42%3A
|
|
||||||
StorageManager sm = context.getSystemService(StorageManager.class);
|
|
||||||
if (sm != null) {
|
|
||||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
|
|
||||||
StorageVolume volume = sm.getStorageVolume(new File(path));
|
|
||||||
if (volume != null) {
|
|
||||||
// primary storage doesn't have a UUID
|
|
||||||
String uuid = volume.isPrimary() ? "primary" : volume.getUuid();
|
|
||||||
Uri targetVolumeTreeUri = getVolumeTreeUriFromUuid(uuid);
|
|
||||||
canAccess = permittedUris.anyMatch(uri -> uri.equals(targetVolumeTreeUri));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// TODO TLAD find alternative for Android <N
|
|
||||||
canAccess = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return canAccess;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Uri getVolumeTreeUriFromUuid(String uuid) {
|
private static Uri getVolumeTreeUriFromUuid(String uuid) {
|
||||||
return DocumentsContract.buildTreeDocumentUri(
|
return DocumentsContract.buildTreeDocumentUri(
|
||||||
"com.android.externalstorage.documents",
|
"com.android.externalstorage.documents",
|
||||||
|
|
|
@ -18,14 +18,14 @@ class AndroidFileService {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> hasGrantedPermissionToVolumeRoot(String path) async {
|
static Future<bool> requireVolumeAccessDialog(String path) async {
|
||||||
try {
|
try {
|
||||||
final result = await platform.invokeMethod('hasGrantedPermissionToVolumeRoot', <String, dynamic>{
|
final result = await platform.invokeMethod('requireVolumeAccessDialog', <String, dynamic>{
|
||||||
'path': path,
|
'path': path,
|
||||||
});
|
});
|
||||||
return result as bool;
|
return result as bool;
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
debugPrint('hasGrantedPermissionToVolumeRoot failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
debugPrint('requireVolumeAccessDialog failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,16 +10,17 @@ mixin PermissionAwareMixin {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> checkStoragePermissionForPaths(BuildContext context, Iterable<String> paths) async {
|
Future<bool> checkStoragePermissionForPaths(BuildContext context, Iterable<String> paths) async {
|
||||||
final volumes = paths.map((path) => androidFileUtils.getStorageVolume(path)).toSet();
|
final volumes = paths.map(androidFileUtils.getStorageVolume).toSet();
|
||||||
final removableVolumes = volumes.where((v) => v.isRemovable);
|
final ungrantedVolumes = (await Future.wait<Tuple2<StorageVolume, bool>>(
|
||||||
final volumePermissions = await Future.wait<Tuple2<StorageVolume, bool>>(
|
volumes.map(
|
||||||
removableVolumes.map(
|
(volume) => AndroidFileService.requireVolumeAccessDialog(volume.path).then(
|
||||||
(volume) => AndroidFileService.hasGrantedPermissionToVolumeRoot(volume.path).then(
|
|
||||||
(granted) => Tuple2(volume, granted),
|
(granted) => Tuple2(volume, granted),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
))
|
||||||
final ungrantedVolumes = volumePermissions.where((t) => !t.item2).map((t) => t.item1).toList();
|
.where((t) => t.item2)
|
||||||
|
.map((t) => t.item1)
|
||||||
|
.toList();
|
||||||
while (ungrantedVolumes.isNotEmpty) {
|
while (ungrantedVolumes.isNotEmpty) {
|
||||||
final volume = ungrantedVolumes.first;
|
final volume = ungrantedVolumes.first;
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
|
|
|
@ -46,8 +46,8 @@ class DebugPageState extends State<DebugPage> {
|
||||||
_startDbReport();
|
_startDbReport();
|
||||||
_volumePermissionLoader = Future.wait<Tuple2<String, bool>>(
|
_volumePermissionLoader = Future.wait<Tuple2<String, bool>>(
|
||||||
androidFileUtils.storageVolumes.map(
|
androidFileUtils.storageVolumes.map(
|
||||||
(volume) => AndroidFileService.hasGrantedPermissionToVolumeRoot(volume.path).then(
|
(volume) => AndroidFileService.requireVolumeAccessDialog(volume.path).then(
|
||||||
(value) => Tuple2(volume.path, value),
|
(value) => Tuple2(volume.path, !value),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
Loading…
Reference in a new issue