copy/move: album creation
This commit is contained in:
parent
a437c2fe9a
commit
97e3fe62c0
15 changed files with 323 additions and 133 deletions
|
@ -85,8 +85,8 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onFailure() {
|
||||
result.error("getImageEntry-failure", "failed to get entry for uri=" + uriString, null);
|
||||
public void onFailure(Throwable throwable) {
|
||||
result.error("getImageEntry-failure", "failed to get entry for uri=" + uriString, throwable);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -114,8 +114,8 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onFailure() {
|
||||
new Handler(Looper.getMainLooper()).post(() -> result.error("rename-failure", "failed to rename", null));
|
||||
public void onFailure(Throwable throwable) {
|
||||
new Handler(Looper.getMainLooper()).post(() -> result.error("rename-failure", "failed to rename", throwable));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -143,8 +143,8 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onFailure() {
|
||||
new Handler(Looper.getMainLooper()).post(() -> result.error("rotate-failure", "failed to rotate", null));
|
||||
public void onFailure(Throwable throwable) {
|
||||
new Handler(Looper.getMainLooper()).post(() -> result.error("rotate-failure", "failed to rotate", throwable));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -11,7 +11,9 @@ import java.util.HashMap;
|
|||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import deckers.thibault.aves.model.ImageEntry;
|
||||
import deckers.thibault.aves.model.provider.ImageProvider;
|
||||
import deckers.thibault.aves.model.provider.ImageProviderFactory;
|
||||
import deckers.thibault.aves.utils.Utils;
|
||||
|
@ -91,25 +93,18 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
|
|||
String destinationDir = (String) argMap.get("destinationPath");
|
||||
if (copy == null || destinationDir == null) return;
|
||||
|
||||
for (Map<String, Object> entryMap : entryMapList) {
|
||||
String uriString = (String) entryMap.get("uri");
|
||||
Uri sourceUri = Uri.parse(uriString);
|
||||
String sourcePath = (String) entryMap.get("path");
|
||||
String mimeType = (String) entryMap.get("mimeType");
|
||||
|
||||
Map<String, Object> result = new HashMap<String, Object>() {{
|
||||
put("uri", uriString);
|
||||
}};
|
||||
try {
|
||||
Map<String, Object> newFields = provider.move(activity, sourcePath, sourceUri, destinationDir, mimeType, copy).get();
|
||||
result.put("success", true);
|
||||
result.put("newFields", newFields);
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
Log.w(LOG_TAG, "failed to move to destinationDir=" + destinationDir + " entry with sourcePath=" + sourcePath, e);
|
||||
result.put("success", false);
|
||||
ArrayList<ImageEntry> entries = entryMapList.stream().map(ImageEntry::new).collect(Collectors.toCollection(ArrayList::new));
|
||||
provider.moveMultiple(activity, copy, destinationDir, entries, new ImageProvider.ImageOpCallback() {
|
||||
@Override
|
||||
public void onSuccess(Map<String, Object> fields) {
|
||||
success(fields);
|
||||
}
|
||||
success(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Throwable throwable) {
|
||||
error("move-failure", "failed to move entries", throwable);
|
||||
}
|
||||
});
|
||||
endOfStream();
|
||||
}
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ class ContentImageProvider extends ImageProvider {
|
|||
if (entry.hasSize() || entry.isSvg()) {
|
||||
callback.onSuccess(entry.toMap());
|
||||
} else {
|
||||
callback.onFailure();
|
||||
callback.onFailure(new Exception("entry has no size"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@ package deckers.thibault.aves.model.provider;
|
|||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
|
@ -10,11 +9,8 @@ import java.io.File;
|
|||
|
||||
import deckers.thibault.aves.model.ImageEntry;
|
||||
import deckers.thibault.aves.utils.FileUtils;
|
||||
import deckers.thibault.aves.utils.Utils;
|
||||
|
||||
class FileImageProvider extends ImageProvider {
|
||||
private static final String LOG_TAG = Utils.createLogTag(FileImageProvider.class);
|
||||
|
||||
@Override
|
||||
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
|
||||
ImageEntry entry = new ImageEntry();
|
||||
|
@ -32,8 +28,7 @@ class FileImageProvider extends ImageProvider {
|
|||
entry.dateModifiedSecs = file.lastModified() / 1000;
|
||||
}
|
||||
} catch (SecurityException e) {
|
||||
Log.w(LOG_TAG, "failed to get path from file at uri=" + uri);
|
||||
callback.onFailure();
|
||||
callback.onFailure(e);
|
||||
}
|
||||
}
|
||||
entry.fillPreCatalogMetadata(context);
|
||||
|
@ -41,7 +36,7 @@ class FileImageProvider extends ImageProvider {
|
|||
if (entry.hasSize() || entry.isSvg()) {
|
||||
callback.onSuccess(entry.toMap());
|
||||
} else {
|
||||
callback.onFailure();
|
||||
callback.onFailure(new Exception("entry has no size"));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -33,9 +33,11 @@ import java.io.FileNotFoundException;
|
|||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import deckers.thibault.aves.model.ImageEntry;
|
||||
import deckers.thibault.aves.utils.Env;
|
||||
import deckers.thibault.aves.utils.MetadataHelper;
|
||||
import deckers.thibault.aves.utils.MimeTypes;
|
||||
|
@ -56,21 +58,20 @@ public abstract class ImageProvider {
|
|||
private static final String LOG_TAG = Utils.createLogTag(ImageProvider.class);
|
||||
|
||||
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
|
||||
callback.onFailure();
|
||||
callback.onFailure(new UnsupportedOperationException());
|
||||
}
|
||||
|
||||
public ListenableFuture<Object> delete(final Activity activity, final String path, final Uri uri) {
|
||||
return Futures.immediateFailedFuture(new UnsupportedOperationException());
|
||||
}
|
||||
|
||||
public ListenableFuture<Map<String, Object>> move(final Activity activity, final String sourcePath, final Uri sourceUri, String destinationDir, String mimeType, boolean copy) {
|
||||
return Futures.immediateFailedFuture(new UnsupportedOperationException());
|
||||
public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, ArrayList<ImageEntry> entries, @NonNull ImageOpCallback callback) {
|
||||
callback.onFailure(new UnsupportedOperationException());
|
||||
}
|
||||
|
||||
public void rename(final Activity activity, final String oldPath, final Uri oldMediaUri, final String mimeType, final String newFilename, final ImageOpCallback callback) {
|
||||
if (oldPath == null) {
|
||||
Log.w(LOG_TAG, "entry does not have a path, uri=" + oldMediaUri);
|
||||
callback.onFailure();
|
||||
callback.onFailure(new IllegalArgumentException("entry does not have a path, uri=" + oldMediaUri));
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -96,13 +97,11 @@ public abstract class ImageProvider {
|
|||
try {
|
||||
boolean renamed = df != null && df.renameTo(newFilename);
|
||||
if (!renamed) {
|
||||
Log.w(LOG_TAG, "failed to rename entry at path=" + oldPath);
|
||||
callback.onFailure();
|
||||
callback.onFailure(new Exception("failed to rename entry at path=" + oldPath));
|
||||
return;
|
||||
}
|
||||
} catch (FileNotFoundException e) {
|
||||
Log.w(LOG_TAG, "failed to rename entry at path=" + oldPath, e);
|
||||
callback.onFailure();
|
||||
callback.onFailure(e);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -125,8 +124,7 @@ public abstract class ImageProvider {
|
|||
cursor.close();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(LOG_TAG, "failed to update Media Store after renaming entry at path=" + oldPath, e);
|
||||
callback.onFailure();
|
||||
callback.onFailure(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@ -157,8 +155,11 @@ public abstract class ImageProvider {
|
|||
public void rotate(final Activity activity, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) {
|
||||
// the reported `mimeType` (e.g. from Media Store) is sometimes incorrect
|
||||
// so we retrieve it again from the file metadata
|
||||
String metadataMimeType = getMimeType(activity, uri);
|
||||
switch (metadataMimeType != null ? metadataMimeType : mimeType) {
|
||||
String actualMimeType = getMimeType(activity, uri);
|
||||
if (actualMimeType == null) {
|
||||
actualMimeType = mimeType;
|
||||
}
|
||||
switch (actualMimeType) {
|
||||
case MimeTypes.JPEG:
|
||||
rotateJpeg(activity, path, uri, clockwise, callback);
|
||||
break;
|
||||
|
@ -166,7 +167,7 @@ public abstract class ImageProvider {
|
|||
rotatePng(activity, path, uri, clockwise, callback);
|
||||
break;
|
||||
default:
|
||||
callback.onFailure();
|
||||
callback.onFailure(new UnsupportedOperationException("unsupported mimeType=" + actualMimeType));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -182,8 +183,7 @@ public abstract class ImageProvider {
|
|||
}
|
||||
}
|
||||
|
||||
boolean rotated = false;
|
||||
int newOrientationCode = 0;
|
||||
int newOrientationCode;
|
||||
try {
|
||||
ExifInterface exif = new ExifInterface(editablePath);
|
||||
switch (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) {
|
||||
|
@ -205,12 +205,8 @@ public abstract class ImageProvider {
|
|||
|
||||
// copy the edited temporary file to the original DocumentFile
|
||||
DocumentFileCompat.fromFile(new File(editablePath)).copyTo(DocumentFileCompat.fromSingleUri(activity, uri));
|
||||
rotated = true;
|
||||
} catch (IOException e) {
|
||||
Log.w(LOG_TAG, "failed to edit EXIF to rotate image at path=" + path, e);
|
||||
}
|
||||
if (!rotated) {
|
||||
callback.onFailure();
|
||||
callback.onFailure(e);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -253,8 +249,7 @@ public abstract class ImageProvider {
|
|||
|
||||
Bitmap originalImage = BitmapFactory.decodeFile(path);
|
||||
if (originalImage == null) {
|
||||
Log.e(LOG_TAG, "failed to decode image at path=" + path);
|
||||
callback.onFailure();
|
||||
callback.onFailure(new Exception("failed to decode image at path=" + path));
|
||||
return;
|
||||
}
|
||||
Matrix matrix = new Matrix();
|
||||
|
@ -263,18 +258,13 @@ public abstract class ImageProvider {
|
|||
matrix.setRotate(clockwise ? 90 : -90, originalWidth >> 1, originalHeight >> 1);
|
||||
Bitmap rotatedImage = Bitmap.createBitmap(originalImage, 0, 0, originalWidth, originalHeight, matrix, true);
|
||||
|
||||
boolean rotated = false;
|
||||
try (FileOutputStream fos = new FileOutputStream(editablePath)) {
|
||||
rotatedImage.compress(Bitmap.CompressFormat.PNG, 100, fos);
|
||||
|
||||
// copy the edited temporary file to the original DocumentFile
|
||||
DocumentFileCompat.fromFile(new File(editablePath)).copyTo(DocumentFileCompat.fromSingleUri(activity, uri));
|
||||
rotated = true;
|
||||
} catch (IOException e) {
|
||||
Log.e(LOG_TAG, "failed to save rotated image to path=" + path, e);
|
||||
}
|
||||
if (!rotated) {
|
||||
callback.onFailure();
|
||||
callback.onFailure(e);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -306,8 +296,8 @@ public abstract class ImageProvider {
|
|||
}
|
||||
|
||||
public interface ImageOpCallback {
|
||||
void onSuccess(Map<String, Object> newFields);
|
||||
void onSuccess(Map<String, Object> fields);
|
||||
|
||||
void onFailure();
|
||||
void onFailure(Throwable throwable);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@ import com.google.common.util.concurrent.SettableFuture;
|
|||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
@ -88,7 +89,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
entryCount = fetchFrom(context, onSuccess, contentUri, VIDEO_PROJECTION);
|
||||
}
|
||||
if (entryCount == 0) {
|
||||
callback.onFailure();
|
||||
callback.onFailure(new Exception("failed to fetch entry at uri=" + uri));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -224,9 +225,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
}
|
||||
|
||||
@Override
|
||||
public ListenableFuture<Map<String, Object>> move(final Activity activity, final String sourcePath, final Uri sourceUri, String destinationDir, String mimeType, boolean copy) {
|
||||
SettableFuture<Map<String, Object>> future = SettableFuture.create();
|
||||
|
||||
public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, ArrayList<ImageEntry> entries, ImageOpCallback callback) {
|
||||
String volumeName = "external";
|
||||
StorageManager sm = activity.getSystemService(StorageManager.class);
|
||||
if (sm != null) {
|
||||
|
@ -241,6 +240,34 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
}
|
||||
}
|
||||
|
||||
if (!StorageUtils.createDirectoryIfAbsent(activity, destinationDir)) {
|
||||
callback.onFailure(new Exception("failed to create directory at path=" + destinationDir));
|
||||
return;
|
||||
}
|
||||
|
||||
for (ImageEntry entry : entries) {
|
||||
Uri sourceUri = entry.uri;
|
||||
String sourcePath = entry.path;
|
||||
String mimeType = entry.mimeType;
|
||||
|
||||
Map<String, Object> result = new HashMap<String, Object>() {{
|
||||
put("uri", sourceUri.toString());
|
||||
}};
|
||||
try {
|
||||
Map<String, Object> newFields = moveSingle(activity, volumeName, sourcePath, sourceUri, destinationDir, mimeType, copy).get();
|
||||
result.put("success", true);
|
||||
result.put("newFields", newFields);
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
Log.w(LOG_TAG, "failed to move to destinationDir=" + destinationDir + " entry with sourcePath=" + sourcePath, e);
|
||||
result.put("success", false);
|
||||
}
|
||||
callback.onSuccess(result);
|
||||
}
|
||||
}
|
||||
|
||||
private ListenableFuture<Map<String, Object>> moveSingle(final Activity activity, final String volumeName, final String sourcePath, final Uri sourceUri, String destinationDir, String mimeType, boolean copy) {
|
||||
SettableFuture<Map<String, Object>> future = SettableFuture.create();
|
||||
|
||||
try {
|
||||
// from API 29, changing MediaColumns.RELATIVE_PATH can move files on disk (same storage device)
|
||||
// from API 26, retrieve document URI from mediastore URI with `MediaStore.getDocumentUri(...)`
|
||||
|
|
|
@ -59,20 +59,24 @@ public class StorageUtils {
|
|||
public static MediaMetadataRetriever openMetadataRetriever(Context context, Uri uri, String path) {
|
||||
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
|
||||
|
||||
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);
|
||||
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);
|
||||
return retriever;
|
||||
}
|
||||
retriever.setDataSource(context, uri);
|
||||
return retriever;
|
||||
}
|
||||
|
||||
// on Android <Q, we directly work with file paths if possible
|
||||
if (path != null) {
|
||||
retriever.setDataSource(path);
|
||||
} else {
|
||||
retriever.setDataSource(context, uri);
|
||||
// on Android <Q, we directly work with file paths if possible
|
||||
if (path != null) {
|
||||
retriever.setDataSource(path);
|
||||
} else {
|
||||
retriever.setDataSource(context, uri);
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
Log.w(LOG_TAG, "failed to open MediaMetadataRetriever for uri=" + uri + ", path=" + path);
|
||||
}
|
||||
return retriever;
|
||||
}
|
||||
|
@ -189,17 +193,13 @@ public class StorageUtils {
|
|||
return Optional.empty();
|
||||
}
|
||||
|
||||
PathSegments pathSegments = new PathSegments(path, storageVolumeRoots);
|
||||
ArrayList<String> pathSteps = Lists.newArrayList(Splitter.on(File.separatorChar)
|
||||
.trimResults().omitEmptyStrings().split(pathSegments.getRelativePath()));
|
||||
pathSteps.add(pathSegments.getFilename());
|
||||
Iterator<String> pathIterator = pathSteps.iterator();
|
||||
|
||||
DocumentFileCompat documentFile = DocumentFileCompat.fromTreeUri(context, rootTreeUri);
|
||||
if (documentFile == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// follow the entry path down the document tree
|
||||
Iterator<String> pathIterator = getPathStepIterator(storageVolumeRoots, path);
|
||||
while (pathIterator.hasNext()) {
|
||||
documentFile = documentFile.findFile(pathIterator.next());
|
||||
if (documentFile == null) {
|
||||
|
@ -209,9 +209,20 @@ public class StorageUtils {
|
|||
return Optional.of(documentFile);
|
||||
}
|
||||
|
||||
private static Iterator<String> getPathStepIterator(String[] storageVolumeRoots, String path) {
|
||||
PathSegments pathSegments = new PathSegments(path, storageVolumeRoots);
|
||||
ArrayList<String> pathSteps = Lists.newArrayList(Splitter.on(File.separatorChar)
|
||||
.trimResults().omitEmptyStrings().split(pathSegments.getRelativePath()));
|
||||
String filename = pathSegments.getFilename();
|
||||
if (filename != null && filename.length() > 0) {
|
||||
pathSteps.add(filename);
|
||||
}
|
||||
return pathSteps.iterator();
|
||||
}
|
||||
|
||||
|
||||
@Nullable
|
||||
public static DocumentFileCompat getDocumentFile(Activity activity, @NonNull String path, @NonNull Uri mediaUri) {
|
||||
public static DocumentFileCompat getDocumentFile(@NonNull Activity activity, @NonNull String path, @NonNull Uri mediaUri) {
|
||||
if (Env.requireAccessPermission(path)) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Uri docUri = MediaStore.getDocumentUri(activity, mediaUri);
|
||||
|
@ -227,6 +238,40 @@ public class StorageUtils {
|
|||
}
|
||||
}
|
||||
|
||||
public static boolean createDirectoryIfAbsent(@NonNull Activity activity, @NonNull String directoryPath) {
|
||||
if (Env.requireAccessPermission(directoryPath)) {
|
||||
Uri rootTreeUri = PermissionManager.getSdCardTreeUri(activity);
|
||||
DocumentFileCompat parentFile = DocumentFileCompat.fromTreeUri(activity, rootTreeUri);
|
||||
if (parentFile == null) return false;
|
||||
|
||||
String[] storageVolumeRoots = Env.getStorageVolumeRoots(activity);
|
||||
if (!directoryPath.endsWith(File.separator)) {
|
||||
directoryPath += File.separator;
|
||||
}
|
||||
Iterator<String> pathIterator = getPathStepIterator(storageVolumeRoots, directoryPath);
|
||||
while (pathIterator.hasNext()) {
|
||||
String dirName = pathIterator.next();
|
||||
DocumentFileCompat dirFile = parentFile.findFile(dirName);
|
||||
if (dirFile == null || !dirFile.exists()) {
|
||||
try {
|
||||
dirFile = parentFile.createDirectory(dirName);
|
||||
if (dirFile != null) {
|
||||
parentFile = dirFile;
|
||||
}
|
||||
} catch (FileNotFoundException e) {
|
||||
Log.e(LOG_TAG, "failed to create directory with name=" + dirName + " from parent=" + parentFile, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
File directory = new File(directoryPath);
|
||||
if (directory.exists()) return true;
|
||||
return directory.mkdirs();
|
||||
}
|
||||
}
|
||||
|
||||
public static String copyFileToTemp(String path) {
|
||||
try {
|
||||
String extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(new File(path)).toString());
|
||||
|
|
|
@ -121,6 +121,7 @@ class MetadataDb {
|
|||
}
|
||||
|
||||
void _batchInsertMetadata(Batch batch, CatalogMetadata metadata) {
|
||||
if (metadata == null) return;
|
||||
if (metadata.dateMillis != 0) {
|
||||
batch.insert(
|
||||
dateTakenTable,
|
||||
|
@ -171,6 +172,7 @@ class MetadataDb {
|
|||
}
|
||||
|
||||
void _batchInsertAddress(Batch batch, AddressDetails address) {
|
||||
if (address == null) return;
|
||||
batch.insert(
|
||||
addressTable,
|
||||
address.toMap(),
|
||||
|
@ -214,6 +216,7 @@ class MetadataDb {
|
|||
}
|
||||
|
||||
void _batchInsertFavourite(Batch batch, FavouriteRow row) {
|
||||
if (row == null) return;
|
||||
batch.insert(
|
||||
favouriteTable,
|
||||
row.toMap(),
|
||||
|
|
|
@ -5,21 +5,20 @@ import 'package:path/path.dart';
|
|||
final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
|
||||
|
||||
class AndroidFileUtils {
|
||||
String externalStorage, dcimPath, downloadPath, moviesPath, picturesPath;
|
||||
|
||||
static List<StorageVolume> storageVolumes = [];
|
||||
static Map appNameMap = {};
|
||||
String primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath;
|
||||
Set<StorageVolume> storageVolumes = {};
|
||||
Map appNameMap = {};
|
||||
|
||||
AndroidFileUtils._private();
|
||||
|
||||
Future<void> init() async {
|
||||
storageVolumes = (await AndroidFileService.getStorageVolumes()).map((map) => StorageVolume.fromMap(map)).toList();
|
||||
storageVolumes = (await AndroidFileService.getStorageVolumes()).map((map) => StorageVolume.fromMap(map)).toSet();
|
||||
// path_provider getExternalStorageDirectory() gives '/storage/emulated/0/Android/data/deckers.thibault.aves/files'
|
||||
externalStorage = storageVolumes.firstWhere((volume) => volume.isPrimary).path;
|
||||
dcimPath = join(externalStorage, 'DCIM');
|
||||
downloadPath = join(externalStorage, 'Download');
|
||||
moviesPath = join(externalStorage, 'Movies');
|
||||
picturesPath = join(externalStorage, 'Pictures');
|
||||
primaryStorage = storageVolumes.firstWhere((volume) => volume.isPrimary).path;
|
||||
dcimPath = join(primaryStorage, 'DCIM');
|
||||
downloadPath = join(primaryStorage, 'Download');
|
||||
moviesPath = join(primaryStorage, 'Movies');
|
||||
picturesPath = join(primaryStorage, 'Pictures');
|
||||
appNameMap = await AndroidAppService.getAppNames()
|
||||
..addAll({'KakaoTalkDownload': 'com.kakao.talk'});
|
||||
}
|
||||
|
@ -38,20 +37,20 @@ class AndroidFileUtils {
|
|||
|
||||
AlbumType getAlbumType(String albumDirectory) {
|
||||
if (albumDirectory != null) {
|
||||
if (androidFileUtils.isCameraPath(albumDirectory)) return AlbumType.camera;
|
||||
if (androidFileUtils.isDownloadPath(albumDirectory)) return AlbumType.download;
|
||||
if (androidFileUtils.isScreenRecordingsPath(albumDirectory)) return AlbumType.screenRecordings;
|
||||
if (androidFileUtils.isScreenshotsPath(albumDirectory)) return AlbumType.screenshots;
|
||||
if (isCameraPath(albumDirectory)) return AlbumType.camera;
|
||||
if (isDownloadPath(albumDirectory)) return AlbumType.download;
|
||||
if (isScreenRecordingsPath(albumDirectory)) return AlbumType.screenRecordings;
|
||||
if (isScreenshotsPath(albumDirectory)) return AlbumType.screenshots;
|
||||
|
||||
final parts = albumDirectory.split(separator);
|
||||
if (albumDirectory.startsWith(androidFileUtils.externalStorage) && appNameMap.keys.contains(parts.last)) return AlbumType.app;
|
||||
if (albumDirectory.startsWith(primaryStorage) && appNameMap.keys.contains(parts.last)) return AlbumType.app;
|
||||
}
|
||||
return AlbumType.regular;
|
||||
}
|
||||
|
||||
String getAlbumAppPackageName(String albumDirectory) {
|
||||
final parts = albumDirectory.split(separator);
|
||||
return AndroidFileUtils.appNameMap[parts.last];
|
||||
return appNameMap[parts.last];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -64,6 +64,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
void dispose() {
|
||||
_unregisterWidget(widget);
|
||||
_browseToSelectAnimation.dispose();
|
||||
_searchFieldController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
92
lib/widgets/common/action_delegates/create_album_dialog.dart
Normal file
92
lib/widgets/common/action_delegates/create_album_dialog.dart
Normal file
|
@ -0,0 +1,92 @@
|
|||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
||||
class CreateAlbumDialog extends StatefulWidget {
|
||||
@override
|
||||
_CreateAlbumDialogState createState() => _CreateAlbumDialogState();
|
||||
}
|
||||
|
||||
class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
Set<StorageVolume> allVolumes;
|
||||
StorageVolume primaryVolume, selectedVolume;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// TODO TLAD improve new album default name
|
||||
_nameController.text = 'Album 1';
|
||||
|
||||
allVolumes = androidFileUtils.storageVolumes;
|
||||
primaryVolume = allVolumes.firstWhere((volume) => volume.isPrimary, orElse: () => allVolumes.first);
|
||||
selectedVolume = primaryVolume;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('New Album'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
// autofocus: true,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Storage:'),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: DropdownButton<StorageVolume>(
|
||||
isExpanded: true,
|
||||
items: allVolumes
|
||||
.map((volume) => DropdownMenuItem(
|
||||
value: volume,
|
||||
child: Text(
|
||||
volume.description,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
value: selectedVolume,
|
||||
onChanged: (volume) => setState(() => selectedVolume = volume),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
|
||||
actions: [
|
||||
FlatButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('Cancel'.toUpperCase()),
|
||||
),
|
||||
FlatButton(
|
||||
onPressed: () => Navigator.pop(context, _buildAlbumPath()),
|
||||
child: Text('Create'.toUpperCase()),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _buildAlbumPath() {
|
||||
final newName = _nameController.text;
|
||||
if (newName == null || newName.isEmpty) return null;
|
||||
return join(selectedVolume == primaryVolume ? androidFileUtils.dcimPath : selectedVolume.path, newName);
|
||||
}
|
||||
}
|
|
@ -4,6 +4,7 @@ import 'package:aves/model/collection_lens.dart';
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/services/android_app_service.dart';
|
||||
import 'package:aves/services/image_file_service.dart';
|
||||
import 'package:aves/widgets/common/action_delegates/rename_entry_dialog.dart';
|
||||
import 'package:aves/widgets/common/action_delegates/permission_aware.dart';
|
||||
import 'package:aves/widgets/common/entry_actions.dart';
|
||||
import 'package:aves/widgets/common/image_providers/uri_image_provider.dart';
|
||||
|
@ -167,28 +168,10 @@ class EntryActionDelegate with PermissionAwareMixin {
|
|||
}
|
||||
|
||||
Future<void> _showRenameDialog(BuildContext context, ImageEntry entry) async {
|
||||
final currentName = entry.filenameWithoutExtension ?? entry.sourceTitle;
|
||||
final controller = TextEditingController(text: currentName);
|
||||
final newName = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
),
|
||||
actions: [
|
||||
FlatButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('Cancel'.toUpperCase()),
|
||||
),
|
||||
FlatButton(
|
||||
onPressed: () => Navigator.pop(context, controller.text),
|
||||
child: Text('Apply'.toUpperCase()),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
context: context,
|
||||
builder: (context) => RenameEntryDialog(entry),
|
||||
);
|
||||
if (newName == null || newName.isEmpty) return;
|
||||
|
||||
if (!await checkStoragePermission(context, [entry])) return;
|
||||
|
|
48
lib/widgets/common/action_delegates/rename_entry_dialog.dart
Normal file
48
lib/widgets/common/action_delegates/rename_entry_dialog.dart
Normal file
|
@ -0,0 +1,48 @@
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class RenameEntryDialog extends StatefulWidget {
|
||||
final ImageEntry entry;
|
||||
|
||||
const RenameEntryDialog(this.entry);
|
||||
|
||||
@override
|
||||
_RenameEntryDialogState createState() => _RenameEntryDialogState();
|
||||
}
|
||||
|
||||
class _RenameEntryDialogState extends State<RenameEntryDialog> {
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final entry = widget.entry;
|
||||
_nameController.text = entry.filenameWithoutExtension ?? entry.sourceTitle;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
content: TextField(
|
||||
controller: _nameController,
|
||||
autofocus: true,
|
||||
),
|
||||
actions: [
|
||||
FlatButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('Cancel'.toUpperCase()),
|
||||
),
|
||||
FlatButton(
|
||||
onPressed: () => Navigator.pop(context, _nameController.text),
|
||||
child: Text('Apply'.toUpperCase()),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -8,6 +8,7 @@ import 'package:aves/model/metadata_db.dart';
|
|||
import 'package:aves/services/android_app_service.dart';
|
||||
import 'package:aves/services/image_file_service.dart';
|
||||
import 'package:aves/widgets/album/app_bar.dart';
|
||||
import 'package:aves/widgets/common/action_delegates/create_album_dialog.dart';
|
||||
import 'package:aves/widgets/common/action_delegates/permission_aware.dart';
|
||||
import 'package:aves/widgets/common/entry_actions.dart';
|
||||
import 'package:aves/widgets/common/icons.dart';
|
||||
|
@ -16,6 +17,7 @@ import 'package:collection/collection.dart';
|
|||
import 'package:flushbar/flushbar.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:percent_indicator/circular_percent_indicator.dart';
|
||||
|
||||
|
@ -53,9 +55,9 @@ class SelectionActionDelegate with PermissionAwareMixin {
|
|||
}
|
||||
|
||||
Future _moveSelection(BuildContext context, {@required bool copy}) async {
|
||||
final filter = await Navigator.push(
|
||||
final destinationAlbum = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<AlbumFilter>(
|
||||
MaterialPageRoute<String>(
|
||||
builder: (context) {
|
||||
final source = collection.source;
|
||||
return FilterGridPage(
|
||||
|
@ -64,10 +66,17 @@ class SelectionActionDelegate with PermissionAwareMixin {
|
|||
leading: const BackButton(),
|
||||
title: Text(copy ? 'Copy to Album' : 'Move to Album'),
|
||||
actions: [
|
||||
const IconButton(
|
||||
IconButton(
|
||||
icon: Icon(AIcons.createAlbum),
|
||||
// TODO TLAD album creation
|
||||
onPressed: null,
|
||||
onPressed: () async {
|
||||
final newAlbum = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => CreateAlbumDialog(),
|
||||
);
|
||||
if (newAlbum != null && newAlbum.isNotEmpty) {
|
||||
Navigator.pop<String>(context, newAlbum);
|
||||
}
|
||||
},
|
||||
tooltip: 'Create album',
|
||||
),
|
||||
],
|
||||
|
@ -75,12 +84,12 @@ class SelectionActionDelegate with PermissionAwareMixin {
|
|||
),
|
||||
filterEntries: source.getAlbumEntries(),
|
||||
filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)),
|
||||
onPressed: (filter) => Navigator.pop<AlbumFilter>(context, filter),
|
||||
onPressed: (filter) => Navigator.pop<String>(context, (filter as AlbumFilter)?.album),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
if (filter == null) return;
|
||||
if (destinationAlbum == null || destinationAlbum.isEmpty) return;
|
||||
|
||||
final selection = collection.selection.toList();
|
||||
if (!await checkStoragePermission(context, selection)) return;
|
||||
|
@ -88,7 +97,7 @@ class SelectionActionDelegate with PermissionAwareMixin {
|
|||
_showOpReport(
|
||||
context: context,
|
||||
selection: selection,
|
||||
opStream: ImageFileService.move(selection, copy: copy, destinationAlbum: filter.album),
|
||||
opStream: ImageFileService.move(selection, copy: copy, destinationAlbum: destinationAlbum),
|
||||
onDone: (Set<MoveOpEvent> processed) {
|
||||
debugPrint('$runtimeType _moveSelection onDone');
|
||||
final movedOps = processed.where((e) => e.success);
|
||||
|
@ -97,6 +106,9 @@ class SelectionActionDelegate with PermissionAwareMixin {
|
|||
if (movedCount < selectionCount) {
|
||||
final count = selectionCount - movedCount;
|
||||
_showFeedback(context, 'Failed to move ${Intl.plural(count, one: '${count} item', other: '${count} items')}');
|
||||
} else {
|
||||
final count = movedCount;
|
||||
_showFeedback(context, '${copy ? 'Copied' : 'Moved'} ${Intl.plural(count, one: '${count} item', other: '${count} items')}');
|
||||
}
|
||||
if (movedCount > 0) {
|
||||
final source = collection.source;
|
||||
|
|
|
@ -43,7 +43,7 @@ class DebugPageState extends State<DebugPage> {
|
|||
super.initState();
|
||||
_startDbReport();
|
||||
_volumePermissionLoader = Future.wait<Tuple2<String, bool>>(
|
||||
AndroidFileUtils.storageVolumes.map(
|
||||
androidFileUtils.storageVolumes.map(
|
||||
(volume) => AndroidFileService.hasGrantedPermissionToVolumeRoot(volume.path).then(
|
||||
(value) => Tuple2(volume.path, value),
|
||||
),
|
||||
|
@ -259,7 +259,7 @@ class DebugPageState extends State<DebugPage> {
|
|||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...AndroidFileUtils.storageVolumes.expand((v) => [
|
||||
...androidFileUtils.storageVolumes.expand((v) => [
|
||||
Text(v.path),
|
||||
InfoRowGroup({
|
||||
'description': '${v.description}',
|
||||
|
|
Loading…
Reference in a new issue