diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageFileHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageFileHandler.java index ebe81b15d..b1702a11a 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageFileHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageFileHandler.java @@ -58,6 +58,9 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { case "rotate": new Thread(() -> rotate(call, new MethodResultWrapper(result))).start(); break; + case "renameDirectory": + new Thread(() -> renameDirectory(call, new MethodResultWrapper(result))).start(); + break; default: result.notImplemented(); break; @@ -179,4 +182,26 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { } }); } + + private void renameDirectory(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { + String dirPath = call.argument("path"); + String newName = call.argument("newName"); + if (dirPath == null || newName == null) { + result.error("renameDirectory-args", "failed because of missing arguments", null); + return; + } + + ImageProvider provider = new MediaStoreImageProvider(); + provider.renameDirectory(activity, dirPath, newName, new ImageProvider.AlbumRenameOpCallback() { + @Override + public void onSuccess(List> fieldsByEntry) { + result.success(fieldsByEntry); + } + + @Override + public void onFailure(Throwable throwable) { + result.error("renameDirectory-failure", "failed to rename directory", throwable.getMessage()); + } + }); + } } \ No newline at end of file diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java index 1d60a4541..def2cf250 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java @@ -22,6 +22,7 @@ import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -86,6 +87,91 @@ public abstract class ImageProvider { scanNewPath(context, newFile.getPath(), mimeType, callback); } + public void renameDirectory(Context context, String oldDirPath, String newDirName, final AlbumRenameOpCallback callback) { + if (!oldDirPath.endsWith(File.separator)) { + oldDirPath += File.separator; + } + + DocumentFileCompat destinationDirDocFile = StorageUtils.createDirectoryIfAbsent(context, oldDirPath); + if (destinationDirDocFile == null) { + callback.onFailure(new Exception("failed to find directory at path=" + oldDirPath)); + return; + } + + final ArrayList> entries = new ArrayList<>(); + entries.addAll(listContentEntries(context, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, oldDirPath)); + entries.addAll(listContentEntries(context, MediaStore.Video.Media.EXTERNAL_CONTENT_URI, oldDirPath)); + + boolean renamed; + try { + renamed = destinationDirDocFile.renameTo(newDirName); + } catch (FileNotFoundException e) { + callback.onFailure(new Exception("failed to rename to name=" + newDirName + " directory at path=" + oldDirPath, e)); + return; + } + + if (!renamed) { + callback.onFailure(new Exception("failed to rename to name=" + newDirName + " directory at path=" + oldDirPath)); + return; + } + + String newDirPath = new File(oldDirPath).getParent() + File.separator + newDirName + File.separator; + for (Map entry : entries) { + String displayName = (String) entry.get("displayName"); + String mimeType = (String) entry.get("mimeType"); + + String oldEntryPath = oldDirPath + displayName; + MediaScannerConnection.scanFile(context, new String[]{oldEntryPath}, new String[]{mimeType}, null); + + String newEntryPath = newDirPath + displayName; + scanNewPath(context, newEntryPath, mimeType, new ImageProvider.ImageOpCallback() { + @Override + public void onSuccess(Map newFields) { + // TODO TLAD process ID and report success + entry.putAll(newFields); + Log.d(LOG_TAG, "success with entry=" + entry); + } + + @Override + public void onFailure(Throwable throwable) { + // TODO TLAD report failure + } + }); + } + + callback.onSuccess(entries); + } + + private List> listContentEntries(Context context, Uri contentUri, String dirPath) { + final ArrayList> entries = new ArrayList<>(); + String[] projection = { + MediaStore.MediaColumns._ID, + MediaStore.MediaColumns.DISPLAY_NAME, + MediaStore.MediaColumns.MIME_TYPE, + }; + String selection = MediaStore.MediaColumns.DATA + " like ?"; + + try { + Cursor cursor = context.getContentResolver().query(contentUri, projection, selection, new String[]{dirPath + "%"}, null); + if (cursor != null) { + int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID); + int displayNameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME); + int mimeTypeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE); + while (cursor.moveToNext()) { + entries.add(new HashMap() {{ + put("oldContentId", cursor.getInt(idColumn)); + put("displayName", cursor.getString(displayNameColumn)); + put("mimeType", cursor.getString(mimeTypeColumn)); + }}); + } + cursor.close(); + } + } catch (Exception e) { + Log.e(LOG_TAG, "failed to list entries in contentUri=" + contentUri, e); + } + return entries; + } + public void rotate(final Context context, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) { switch (mimeType) { case MimeTypes.JPEG: @@ -261,6 +347,7 @@ public abstract class ImageProvider { Map newFields = new HashMap<>(); // we retrieve updated fields as the renamed file became a new entry in the Media Store String[] projection = { + MediaStore.MediaColumns.DISPLAY_NAME, MediaStore.MediaColumns.TITLE, MediaStore.MediaColumns.DATE_MODIFIED, }; @@ -271,6 +358,7 @@ public abstract class ImageProvider { newFields.put("uri", contentUri.toString()); newFields.put("contentId", contentId); newFields.put("path", path); + newFields.put("displayName", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME))); newFields.put("title", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.TITLE))); newFields.put("dateModifiedSecs", cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED))); } @@ -294,4 +382,10 @@ public abstract class ImageProvider { void onFailure(Throwable throwable); } + + public interface AlbumRenameOpCallback { + void onSuccess(List> fieldsByEntry); + + void onFailure(Throwable throwable); + } } diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/MimeTypes.java b/android/app/src/main/java/deckers/thibault/aves/utils/MimeTypes.java index 676c28217..debb8a3c3 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/MimeTypes.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/MimeTypes.java @@ -2,14 +2,14 @@ package deckers.thibault.aves.utils; public class MimeTypes { public static final String IMAGE = "image"; - public static final String DNG = "image/x-adobe-dng"; + public static final String DNG = "image/x-adobe-dng"; // .dng public static final String GIF = "image/gif"; public static final String HEIC = "image/heic"; public static final String HEIF = "image/heif"; public static final String JPEG = "image/jpeg"; public static final String PNG = "image/png"; - public static final String PSD = "image/x-photoshop"; - public static final String SVG = "image/svg+xml"; + public static final String PSD = "image/x-photoshop"; // .psd + public static final String SVG = "image/svg+xml"; // .svg public static final String WEBP = "image/webp"; public static final String VIDEO = "video"; diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index 1b4c6df7e..684026a30 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -40,7 +40,7 @@ class ImageFileService { } } - static Future getObsoleteEntries(List knownContentIds) async { + static Future> getObsoleteEntries(List knownContentIds) async { try { final result = await platform.invokeMethod('getObsoleteEntries', { 'knownContentIds': knownContentIds, @@ -194,6 +194,19 @@ class ImageFileService { } return {}; } + + static Future> renameDirectory(String path, String newName) async { + try { + final result = await platform.invokeMethod('renameDirectory', { + 'path': path, + 'newName': newName, + }); + return (result as List).cast(); + } on PlatformException catch (e) { + debugPrint('renameDirectory failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return []; + } } @immutable diff --git a/lib/widgets/common/action_delegates/create_album_dialog.dart b/lib/widgets/common/action_delegates/create_album_dialog.dart index 8f2ad8ee5..6d9d394c6 100644 --- a/lib/widgets/common/action_delegates/create_album_dialog.dart +++ b/lib/widgets/common/action_delegates/create_album_dialog.dart @@ -132,9 +132,11 @@ class _CreateAlbumDialogState extends State { } Future _validate() async { - final path = _buildAlbumPath(_nameController.text); - _existsNotifier.value = path.isEmpty ? false : await Directory(path).exists(); - _isValidNotifier.value = (_nameController.text ?? '').isNotEmpty; + final newName = _nameController.text ?? ''; + final path = _buildAlbumPath(newName); + final exists = newName.isNotEmpty && await Directory(path).exists(); + _existsNotifier.value = exists; + _isValidNotifier.value = newName.isNotEmpty; } void _submit(BuildContext context) => Navigator.pop(context, _buildAlbumPath(_nameController.text)); diff --git a/lib/widgets/common/action_delegates/rename_album_dialog.dart b/lib/widgets/common/action_delegates/rename_album_dialog.dart new file mode 100644 index 000000000..99bd36467 --- /dev/null +++ b/lib/widgets/common/action_delegates/rename_album_dialog.dart @@ -0,0 +1,88 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as path; + +import '../aves_dialog.dart'; + +class RenameAlbumDialog extends StatefulWidget { + final String album; + + const RenameAlbumDialog(this.album); + + @override + _RenameAlbumDialogState createState() => _RenameAlbumDialogState(); +} + +class _RenameAlbumDialogState extends State { + final TextEditingController _nameController = TextEditingController(); + final ValueNotifier _existsNotifier = ValueNotifier(false); + final ValueNotifier _isValidNotifier = ValueNotifier(false); + + String get album => widget.album; + + String get initialValue => path.basename(album); + + @override + void initState() { + super.initState(); + _nameController.text = initialValue; + _validate(); + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AvesDialog( + content: ValueListenableBuilder( + valueListenable: _existsNotifier, + builder: (context, exists, child) { + return TextField( + controller: _nameController, + decoration: InputDecoration( + labelText: 'New name', + helperText: exists ? 'Album already exists' : '', + ), + autofocus: true, + onChanged: (_) => _validate(), + onSubmitted: (_) => _submit(context), + ); + }), + actions: [ + FlatButton( + onPressed: () => Navigator.pop(context), + child: Text('Cancel'.toUpperCase()), + ), + ValueListenableBuilder( + valueListenable: _isValidNotifier, + builder: (context, isValid, child) { + return FlatButton( + onPressed: isValid ? () => _submit(context) : null, + child: Text('Apply'.toUpperCase()), + ); + }, + ) + ], + ); + } + + String _buildAlbumPath(String name) { + if (name == null || name.isEmpty) return ''; + return path.join(path.dirname(album), name); + } + + Future _validate() async { + final newName = _nameController.text ?? ''; + final path = _buildAlbumPath(newName); + final exists = newName.isNotEmpty && await FileSystemEntity.type(path) != FileSystemEntityType.notFound; + _existsNotifier.value = exists && newName != initialValue; + _isValidNotifier.value = newName.isNotEmpty && !exists; + } + + void _submit(BuildContext context) => Navigator.pop(context, _nameController.text); +} diff --git a/lib/widgets/common/action_delegates/rename_entry_dialog.dart b/lib/widgets/common/action_delegates/rename_entry_dialog.dart index b42f14180..8c143d5b7 100644 --- a/lib/widgets/common/action_delegates/rename_entry_dialog.dart +++ b/lib/widgets/common/action_delegates/rename_entry_dialog.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:aves/model/image_entry.dart'; import 'package:flutter/material.dart'; -import 'package:path/path.dart'; +import 'package:path/path.dart' as path; import '../aves_dialog.dart'; @@ -65,13 +65,17 @@ class _RenameEntryDialogState extends State { ); } + String _buildEntryPath(String name) { + if (name == null || name.isEmpty) return ''; + return path.join(entry.directory, name + entry.extension); + } + Future _validate() async { - var newName = _nameController.text ?? ''; - if (newName.isNotEmpty) { - newName += entry.extension; - } - final type = await FileSystemEntity.type(join(entry.directory, newName)); - _isValidNotifier.value = type == FileSystemEntityType.notFound; + final newName = _nameController.text ?? ''; + final path = _buildEntryPath(newName); + final exists = newName.isNotEmpty && await FileSystemEntity.type(path) != FileSystemEntityType.notFound; + debugPrint('TLAD path=$path exists=$exists'); + _isValidNotifier.value = newName.isNotEmpty && !exists; } void _submit(BuildContext context) => Navigator.pop(context, _nameController.text); diff --git a/lib/widgets/filter_grids/common/chip_action_delegate.dart b/lib/widgets/filter_grids/common/chip_action_delegate.dart index 0a1af6670..5909a1de0 100644 --- a/lib/widgets/filter_grids/common/chip_action_delegate.dart +++ b/lib/widgets/filter_grids/common/chip_action_delegate.dart @@ -1,9 +1,15 @@ +import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/settings/settings.dart'; +import 'package:aves/services/image_file_service.dart'; import 'package:aves/utils/durations.dart'; +import 'package:aves/widgets/common/action_delegates/feedback.dart'; +import 'package:aves/widgets/common/action_delegates/permission_aware.dart'; +import 'package:aves/widgets/common/action_delegates/rename_album_dialog.dart'; import 'package:aves/widgets/filter_grids/common/chip_actions.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +import 'package:pedantic/pedantic.dart'; class ChipActionDelegate { Future onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) async { @@ -25,16 +31,31 @@ class ChipActionDelegate { } } -class AlbumChipActionDelegate extends ChipActionDelegate { +class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, PermissionAwareMixin { @override Future onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) async { await super.onActionSelected(context, filter, action); + final album = (filter as AlbumFilter).album; switch (action) { case ChipAction.rename: - // TODO TLAD rename album + unawaited(_showRenameDialog(context, album)); break; default: break; } } + + Future _showRenameDialog(BuildContext context, String album) async { + final newName = await showDialog( + context: context, + builder: (context) => RenameAlbumDialog(album), + ); + if (newName == null || newName.isEmpty) return; + + if (!await checkStoragePermissionForAlbums(context, {album})) return; + + // TODO TLAD rename album + final result = await ImageFileService.renameDirectory(album, newName); + showFeedback(context, result != null ? 'Done!' : 'Failed'); + } }