diff --git a/android/app/build.gradle b/android/app/build.gradle index 710034859..ac8a02d8f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -35,7 +35,7 @@ android { applicationId "deckers.thibault.aves" // some Java 8 APIs (java.util.stream, etc.) require minSdkVersion 24 // but Android Studio 4.0 desugaring features allow targeting older SDKs - minSdkVersion 23 + minSdkVersion 24 targetSdkVersion 29 // same as compileSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/android/app/src/main/java/deckers/thibault/aves/model/ImageEntry.java b/android/app/src/main/java/deckers/thibault/aves/model/ImageEntry.java index c39e0f213..b1b45f541 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/ImageEntry.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/ImageEntry.java @@ -40,8 +40,6 @@ public class ImageEntry { @Nullable public String title; @Nullable - private String bucketDisplayName; - @Nullable public Integer width, height, orientationDegrees; @Nullable public Long sizeBytes; @@ -66,7 +64,6 @@ public class ImageEntry { this.title = (String) map.get("title"); this.dateModifiedSecs = toLong(map.get("dateModifiedSecs")); this.sourceDateTakenMillis = toLong(map.get("sourceDateTakenMillis")); - this.bucketDisplayName = (String) map.get("bucketDisplayName"); this.durationMillis = toLong(map.get("durationMillis")); } @@ -82,7 +79,6 @@ public class ImageEntry { put("title", title); put("dateModifiedSecs", dateModifiedSecs); put("sourceDateTakenMillis", sourceDateTakenMillis); - put("bucketDisplayName", bucketDisplayName); put("durationMillis", durationMillis); // only for map export put("contentId", getContentId()); 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 87db96235..acc01d19e 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 @@ -10,6 +10,8 @@ import android.net.Uri; import android.os.Build; import android.os.Handler; import android.os.Looper; +import android.os.storage.StorageManager; +import android.os.storage.StorageVolume; import android.provider.MediaStore; import android.util.Log; @@ -51,7 +53,6 @@ public class MediaStoreImageProvider extends ImageProvider { private static final String[] IMAGE_PROJECTION = Stream.of(BASE_PROJECTION, new String[]{ // uses MediaStore.Images.Media instead of MediaStore.MediaColumns for APIs < Q MediaStore.Images.Media.DATE_TAKEN, - MediaStore.Images.Media.BUCKET_DISPLAY_NAME, MediaStore.Images.Media.ORIENTATION, }).flatMap(Stream::of).toArray(String[]::new); @@ -59,7 +60,6 @@ public class MediaStoreImageProvider extends ImageProvider { private static final String[] VIDEO_PROJECTION = Stream.of(BASE_PROJECTION, new String[]{ // uses MediaStore.Video.Media instead of MediaStore.MediaColumns for APIs < Q MediaStore.Video.Media.DATE_TAKEN, - MediaStore.Video.Media.BUCKET_DISPLAY_NAME, MediaStore.Video.Media.DURATION, }, (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ? new String[]{ @@ -111,7 +111,6 @@ public class MediaStoreImageProvider extends ImageProvider { int heightColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT); int dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED); int dateTakenColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_TAKEN); - int bucketDisplayNameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_DISPLAY_NAME); // image & video for API >= Q, only for images for API < Q int orientationColumn = cursor.getColumnIndex(MediaStore.MediaColumns.ORIENTATION); @@ -138,7 +137,6 @@ public class MediaStoreImageProvider extends ImageProvider { put("title", cursor.getString(titleColumn)); put("dateModifiedSecs", cursor.getLong(dateModifiedColumn)); put("sourceDateTakenMillis", cursor.getLong(dateTakenColumn)); - put("bucketDisplayName", cursor.getString(bucketDisplayNameColumn)); // only for map export put("contentId", contentId); }}; @@ -228,21 +226,19 @@ public class MediaStoreImageProvider extends ImageProvider { public ListenableFuture> move(final Activity activity, final String sourcePath, final Uri sourceUri, String destinationDir, String mimeType, boolean copy) { SettableFuture> future = SettableFuture.create(); -// if (Env.isOnSdCard(activity, path)) { -// Uri sdCardTreeUri = PermissionManager.getSdCardTreeUri(activity); -// if (sdCardTreeUri == null) { -// Runnable runnable = () -> move(activity, path, uri, copy, destinationPath, callback); -// new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showSdCardAccessDialog(activity, runnable)); -// return; -// } -// -// // if the file is on SD card, calling the content resolver delete() removes the entry from the Media Store -// // but it doesn't delete the file, even if the app has the permission -// StorageUtils.deleteFromSdCard(activity, sdCardTreeUri, Env.getStorageVolumes(activity), path); -// Log.d(LOG_TAG, "deleted from SD card at path=" + uri); -// callback.onSuccess(null); -// return; -// } + String volumeName = "external"; + StorageManager sm = activity.getSystemService(StorageManager.class); + if (sm != null) { + StorageVolume volume = sm.getStorageVolume(new File(destinationDir)); + if (volume != null && !volume.isPrimary()) { + String uuid = volume.getUuid(); + if (uuid != null) { + // the UUID returned may be uppercase + // but it should be lowercase to work with the MediaStore + volumeName = volume.getUuid().toLowerCase(); + } + } + } try { // from API 29, changing MediaColumns.RELATIVE_PATH can move files on disk (same storage device) @@ -251,9 +247,6 @@ public class MediaStoreImageProvider extends ImageProvider { // DocumentFile.getParentFile() is null without picking a tree first // DocumentsContract.copyDocument() and moveDocument() need parent doc uri - // TODO TLAD copy/move - // TODO TLAD cannot copy to SD card, even with the permission to the volume root, by inserting to MediaStore - PathComponents sourcePathComponents = new PathComponents(sourcePath, Env.getStorageVolumes(activity)); String destinationPath = destinationDir + File.separator + sourcePathComponents.getFilename(); @@ -262,9 +255,8 @@ public class MediaStoreImageProvider extends ImageProvider { contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType); // contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, ""); // contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, ""); - Uri tableUrl = mimeType.startsWith(MimeTypes.VIDEO) ? MediaStore.Video.Media.EXTERNAL_CONTENT_URI : MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + Uri tableUrl = mimeType.startsWith(MimeTypes.VIDEO) ? MediaStore.Video.Media.getContentUri(volumeName) : MediaStore.Images.Media.getContentUri(volumeName); Uri destinationUri = activity.getContentResolver().insert(tableUrl, contentValues); -// Log.d("TLAD", "move copy from=" + sourcePath + " to=" + destinationPath + " (destinationUri=" + destinationUri + ")"); if (destinationUri == null) { future.setException(new Exception("failed to insert row to content resolver")); } else { @@ -272,6 +264,8 @@ public class MediaStoreImageProvider extends ImageProvider { DocumentFileCompat destination = DocumentFileCompat.fromSingleUri(activity, destinationUri); source.copyTo(destination); + // TODO TLAD delete source when it is a `move` + Map newFields = new HashMap<>(); newFields.put("uri", destinationUri.toString()); newFields.put("contentId", ContentUris.parseId(destinationUri)); diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 91faa3678..830ba1897 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -28,7 +28,6 @@ class ImageEntry { String sourceTitle; final int dateModifiedSecs; final int sourceDateTakenMillis; - final String bucketDisplayName; final int durationMillis; int _catalogDateMillis; CatalogMetadata _catalogMetadata; @@ -49,12 +48,38 @@ class ImageEntry { this.sourceTitle, this.dateModifiedSecs, this.sourceDateTakenMillis, - this.bucketDisplayName, this.durationMillis, }) : directory = path != null ? dirname(path) : null { isFavouriteNotifier.value = isFavourite; } + ImageEntry copyWith({ + @required String uri, + @required String path, + @required int contentId, + }) { + final copyContentId = contentId ?? this.contentId; + final copied = ImageEntry( + uri: uri ?? uri, + path: path ?? this.path, + contentId: copyContentId, + mimeType: mimeType, + width: width, + height: height, + orientationDegrees: orientationDegrees, + sizeBytes: sizeBytes, + sourceTitle: sourceTitle, + dateModifiedSecs: dateModifiedSecs, + sourceDateTakenMillis: sourceDateTakenMillis, + durationMillis: durationMillis, + ) + .._catalogDateMillis = _catalogDateMillis + .._catalogMetadata = _catalogMetadata?.copyWith(contentId: copyContentId) + .._addressDetails = _addressDetails?.copyWith(contentId: copyContentId); + + return copied; + } + factory ImageEntry.fromMap(Map map) { return ImageEntry( uri: map['uri'] as String, @@ -68,7 +93,6 @@ class ImageEntry { sourceTitle: map['title'] as String, dateModifiedSecs: map['dateModifiedSecs'] as int, sourceDateTakenMillis: map['sourceDateTakenMillis'] as int, - bucketDisplayName: map['bucketDisplayName'] as String, durationMillis: map['durationMillis'] as int, ); } @@ -86,7 +110,6 @@ class ImageEntry { 'title': sourceTitle, 'dateModifiedSecs': dateModifiedSecs, 'sourceDateTakenMillis': sourceDateTakenMillis, - 'bucketDisplayName': bucketDisplayName, 'durationMillis': durationMillis, }; } diff --git a/lib/model/image_metadata.dart b/lib/model/image_metadata.dart index db9489ba7..e8f65e56e 100644 --- a/lib/model/image_metadata.dart +++ b/lib/model/image_metadata.dart @@ -43,11 +43,26 @@ class CatalogMetadata { this.xmpTitleDescription, double latitude, double longitude, - }) + }) // Geocoder throws an IllegalArgumentException when a coordinate has a funky values like 1.7056881853375E7 : latitude = latitude == null || latitude < -90.0 || latitude > 90.0 ? null : latitude, longitude = longitude == null || longitude < -180.0 || longitude > 180.0 ? null : longitude; + CatalogMetadata copyWith({ + @required int contentId, + }) { + return CatalogMetadata( + contentId: contentId ?? this.contentId, + dateMillis: dateMillis, + isAnimated: isAnimated, + videoRotation: videoRotation, + xmpSubjects: xmpSubjects, + xmpTitleDescription: xmpTitleDescription, + latitude: latitude, + longitude: longitude, + ); + } + factory CatalogMetadata.fromMap(Map map, {bool boolAsInteger = false}) { final isAnimated = map['isAnimated'] ?? (boolAsInteger ? 0 : false); return CatalogMetadata( @@ -121,6 +136,19 @@ class AddressDetails { this.locality, }); + AddressDetails copyWith({ + @required int contentId, + }) { + return AddressDetails( + contentId: contentId ?? this.contentId, + addressLine: addressLine, + countryCode: countryCode, + countryName: countryName, + adminArea: adminArea, + locality: locality, + ); + } + factory AddressDetails.fromMap(Map map) { return AddressDetails( contentId: map['contentId'], diff --git a/lib/widgets/album/app_bar.dart b/lib/widgets/album/app_bar.dart index f442c008e..a6f4673a6 100644 --- a/lib/widgets/album/app_bar.dart +++ b/lib/widgets/album/app_bar.dart @@ -188,6 +188,8 @@ class _CollectionAppBarState extends State with SingleTickerPr ), const PopupMenuItem( value: CollectionAction.move, + // TODO TLAD enable when handled on native side + enabled: false, child: MenuRow(text: 'Move to album'), ), const PopupMenuItem( diff --git a/lib/widgets/common/action_delegates/selection_action_delegate.dart b/lib/widgets/common/action_delegates/selection_action_delegate.dart index 94df3d5f9..e4660279b 100644 --- a/lib/widgets/common/action_delegates/selection_action_delegate.dart +++ b/lib/widgets/common/action_delegates/selection_action_delegate.dart @@ -62,11 +62,10 @@ class SelectionActionDelegate with PermissionAwareMixin { leading: const BackButton(), title: Text(copy ? 'Copy to Album' : 'Move to Album'), actions: [ - IconButton( - icon: const Icon(AIcons.createAlbum), - onPressed: () { - // TODO TLAD album creation - }, + const IconButton( + icon: Icon(AIcons.createAlbum), + // TODO TLAD album creation + onPressed: null, tooltip: 'Create album', ), ], @@ -90,19 +89,31 @@ class SelectionActionDelegate with PermissionAwareMixin { opStream: ImageFileService.move(selection, copy: copy, destinationPath: filter.album), onDone: (Set processed) { debugPrint('$runtimeType _moveSelection onDone'); - final movedUris = processed.where((e) => e.success).map((e) => e.uri); - final movedCount = movedUris.length; + final movedOps = processed.where((e) => e.success); + final movedCount = movedOps.length; final selectionCount = selection.length; if (movedCount < selectionCount) { final count = selectionCount - movedCount; _showFeedback(context, 'Failed to move ${Intl.plural(count, one: '${count} item', other: '${count} items')}'); } if (movedCount > 0) { - processed.forEach((event) { - debugPrint('$runtimeType _moveSelection moved entry uri=${event.uri} newFields=${event.newFields}'); - // TODO TLAD update source - }); + if (copy) { + collection.source.addAll(movedOps.map((movedOp) { + final sourceUri = movedOp.uri; + final newFields = movedOp.newFields; + final sourceEntry = selection.firstWhere((entry) => entry.uri == sourceUri, orElse: () => null); + return sourceEntry?.copyWith( + uri: newFields['uri'] as String, + path: newFields['path'] as String, + contentId: newFields['contentId'] as int, + ); + })); + } else { + // TODO TLAD update old entries path/dir/ID + } + // TODO TLAD update DB for catalog/address/fav } + collection.clearSelection(); collection.browse(); }, ); @@ -149,6 +160,7 @@ class SelectionActionDelegate with PermissionAwareMixin { if (deletedCount > 0) { collection.source.removeEntries(selection.where((e) => deletedUris.contains(e.uri))); } + collection.clearSelection(); collection.browse(); }, );